Project and state
สิ่งที่จะสร้าง
หัวข้อที่มีชื่อว่า “สิ่งที่จะสร้าง”mobile/ — Flutter app และเป็น client หลักแบบ mobile-first ของ FitTrack บทนี้ยังไม่ทำอะไรที่เจาะจง FitTrack เกี่ยวกับข้อมูลเลย แค่ตั้งโครง Flutter project ที่ client อีกสอง module จะต่อยอด คุณรัน flutter create ในไดเรกทอรี mobile/ ที่ repo layout → กันไว้ให้ เพิ่ม flutter_riverpod สำหรับจัดการ state และ go_router สำหรับ navigation ครอบทั้งแอปด้วย ProviderScope จัด lib/ เป็น feature folder แทนที่จะยัดทุกอย่างในไฟล์เดียว แล้วนิยาม router ที่บทถัด ๆ ไปเอาหน้า sign-in กับ tracking มาแขวน
พอจบบท คุณจะได้ Flutter app ที่ analyze ผ่านสะอาด ผ่าน widget test ตัวแรก และรันลงที่ home route แบบ placeholder ได้ — พร้อมโครงสร้างพื้นฐาน (provider container ที่ root, router provider, layout แบบ feature-first) ที่บท Supabase auth และ API client เสียบเข้ามาใช้ได้ทันที
การตัดสินใจสองเรื่องกำหนดว่าทุกหน้าจอหลังจากนี้จะเขียนยังไง: state ไหลยังไง กับ navigation ทำงานยังไง
State — Riverpod. แอป workout tracker เต็มไปด้วย state ที่ share กันและเป็น async: auth session ปัจจุบัน, exercise catalog, workout ที่กำลัง log อยู่, ค่า progress stats ที่ดึงจาก API setState ที่ Flutter มีมาให้ขัง state ไว้ใน widget เดียว ส่งต่อผ่าน constructor ลงไปเรื่อย ๆ ก็เริ่มเทอะทะเร็ว และ InheritedWidget ก็ยาวเยิ่นเวลาเขียนเอง Riverpod ให้ provider กับคุณ — หน่วยของ state และ logic ที่ประกาศแบบ declarative, test ได้ และ widget ไหนก็อ่านได้ด้วย ref.watch(...) แล้ว rebuild เฉพาะ widget ที่พึ่ง state นั้นจริง ๆ จุดสำคัญคือ provider ไม่ผูกกับ BuildContext ดังนั้น API client และ auth logic อยู่เป็น provider ธรรมดาที่ unit-test ได้โดยไม่ต้อง pump widget provider ยัง compose กันได้ด้วย: API-client provider จะอ่าน auth provider เพื่อเอา token ปัจจุบัน แล้ว Riverpod ต่อสาย dependency นั้นให้คุณเอง
Navigation — go_router. FitTrack มีความต้องการด้าน navigation จริง: auth gate (คนที่ยังไม่ sign-in เห็นหน้า sign-in, คนที่ sign-in แล้วเห็นตัวแอป), named route สำหรับ push หน้า log workout และในอนาคตคือ deep link Navigator.push แบบ imperative ของ Flutter กระจายการตัดสินใจเรื่อง route ไปทั่ว codebase และทำให้ “redirect ไป sign-in เมื่อ session เป็น null” ทำได้อย่างงุ่มง่าม go_router เป็น declarative: คุณอธิบาย route table ครั้งเดียว แล้ว redirect callback ตัวเดียว (เพิ่มในบทถัดไป) gate ทั้งแอปบน auth state ได้ และยังเข้ากับ Riverpod ได้อย่างเป็นธรรมชาติ — ตัว router เองเป็น provider ที่ watch session ได้
Structure — feature-first. lib/main.dart เล็กมาก: แค่ติดตั้ง ProviderScope แล้วส่งต่อให้ MaterialApp.router ที่เหลือทั้งหมดอยู่ใต้ lib/src/ จัดกลุ่มตาม feature (features/auth/, features/workouts/) พร้อมโฟลเดอร์ core/ สำหรับชิ้นส่วนที่ใช้ข้ามกัน (router, API client, config) layout แบบ feature-first หมายความว่า UI, state และ model ของ feature เดียวอยู่ด้วยกัน และแอปขยายได้โดยไม่มี main.dart ยาว 2000 บรรทัด
ข้อดีข้อเสีย
หัวข้อที่มีชื่อว่า “ข้อดีข้อเสีย”Riverpod vs. plain setState / InheritedWidget (or the older provider package)
- Pros: state อยู่นอก widget tree จึง share ได้โดยไม่ต้อง prop-drilling และ unit-test ได้โดยไม่ต้องมี widget; provider rebuild เฉพาะตัวที่พึ่งค่านั้น (update แบบ fine-grained, performant); ปลอดภัยตอน compile — provider ที่หายไปเป็น build error ไม่ใช่
nullตอน runtime; และ provider compose กันได้ API client จึงพึ่ง auth session แบบ declarative ได้ - Cons: นี่คือ mental model ใหม่ (provider,
ref,WidgetRef) ที่มีต้นทุนการเรียนรู้ล่วงหน้าจริง; แอปเล็ก ๆ ที่ไม่เคย share state เลยจะเบากว่าถ้าใช้setState; และ ecosystem มีหลายรสชาติ (Notifier,AsyncNotifier, code-gen แบบ optional) ที่คุณต้องเลือก สำหรับแอปที่มี auth, catalog และ API state ข้ามหน้าจอ โครงสร้างนั้นคุ้มทันที
go_router (declarative routing) vs. imperative Navigator.push / Navigator.pop
- Pros: route table กลางตัวเดียวแทน route logic ที่โปรยไปทั่ว widget;
redirectcallback ตัวเดียวเขียน auth gate ทั้งหมดได้; รองรับ deep-link และ URL แบบ first-class (ที่ Svelte companion ได้ฟรีแต่ mobile ต้องเลือกใช้เอง); และ integrate กับ Riverpod ได้ router จึงตอบสนองการเปลี่ยน session ได้ - Cons: พิธีรีตองมากกว่าสำหรับแค่หน้าจอสองหน้า ที่
Navigator.pushเปล่า ๆ ก็พอ; โมเดล redirect/refresh มี edge case ที่ควรเข้าใจก่อนใช้จริง; และเป็น dependency ที่ต้องคอยอัปเดต สำหรับแอปที่ต้อง gate บน auth และจะโตขึ้นอีกหลายหน้าจอ route table แบบ declarative ชนะ
ติดตั้ง
หัวข้อที่มีชื่อว่า “ติดตั้ง”1. flutter create ตัวแอป
หัวข้อที่มีชื่อว่า “1. flutter create ตัวแอป”จาก repo root (โฟลเดอร์ fittrack/ ที่มี api/) สร้างแอป เข้าไปใน ไดเรกทอรี mobile/ ที่มีอยู่แล้ว:
flutter create --org com.avetavos.fittrack --project-name fittrack mobilecd mobile--org ตั้ง bundle identifier แบบ reverse-DNS (ใช้ตอน packaging iOS/Android และภายหลังคือ OAuth redirect scheme); --project-name ตั้งชื่อ Dart package ยืนยันว่า toolchain สมบูรณ์ดี:
flutter doctor2. เพิ่ม dependency
หัวข้อที่มีชื่อว่า “2. เพิ่ม dependency”flutter pub add flutter_riverpod go_routerคำสั่งนี้เขียนลงใน pubspec.yaml บรรทัดที่เกี่ยวข้อง:
dependencies: flutter: sdk: flutter flutter_riverpod: ^2.6.1 # state management go_router: ^14.6.2 # declarative routing3. วางโครง lib/src/
หัวข้อที่มีชื่อว่า “3. วางโครง lib/src/”แทนที่ demo counter ของ Flutter ด้วยโครงสร้างแบบ feature-first สร้างโฟลเดอร์และไฟล์เหล่านี้:
mobile/lib/├── main.dart # ProviderScope + MaterialApp.router — stays tiny└── src/ ├── router.dart # the go_router route table (a provider) ├── core/ # cross-cutting: config, the API client (later lessons) └── features/ ├── auth/ # sign-in / session (next lesson) └── workouts/ # logging + history (Module 10) └── home_screen.dartmkdir -p lib/src/core lib/src/features/auth lib/src/features/workouts4. หน้าจอแรก — lib/src/features/workouts/home_screen.dart
หัวข้อที่มีชื่อว่า “4. หน้าจอแรก — lib/src/features/workouts/home_screen.dart”หน้า placeholder ที่ router จะลงมาได้ ประกาศเป็น ConsumerWidget (widget base ของ Riverpod) ตั้งแต่แรก การเสียบ provider เข้ามาภายหลังจึงเป็นแค่การแก้บรรทัดเดียว:
import 'package:flutter/material.dart';import 'package:flutter_riverpod/flutter_riverpod.dart';
/// The app's landing screen after sign-in. For now it's a placeholder;/// Module 10 turns this into the workout list. It's already a/// ConsumerWidget so it can `ref.watch(...)` providers without a rewrite.class HomeScreen extends ConsumerWidget { const HomeScreen({super.key});
@override Widget build(BuildContext context, WidgetRef ref) { return Scaffold( appBar: AppBar(title: const Text('FitTrack')), body: const Center(child: Text('Your workouts will appear here.')), ); }}5. ตัว router — lib/src/router.dart
หัวข้อที่มีชื่อว่า “5. ตัว router — lib/src/router.dart”expose GoRouter เป็น Riverpod provider เรื่องนี้สำคัญ: ในบทถัดไป provider ตัวเดียวกันนี้จะ ref.watch auth session แล้วเพิ่ม redirect โดยไม่ต้องแตะ main.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';import 'package:go_router/go_router.dart';
import 'features/workouts/home_screen.dart';
/// The app's route table, exposed as a provider so it can later depend on/// auth state (a `redirect` that watches the session is added next lesson).final routerProvider = Provider<GoRouter>((ref) { return GoRouter( initialLocation: '/', routes: [ GoRoute( path: '/', name: 'home', builder: (context, state) => const HomeScreen(), ), ], );});6. ต่อสายให้ครบ — lib/main.dart
หัวข้อที่มีชื่อว่า “6. ต่อสายให้ครบ — lib/main.dart”main.dart ทำสองอย่างแล้วหยุด: ติดตั้ง ProviderScope (root container ของ Riverpod ที่ถือ state ของทุก provider) และขับ MaterialApp.router จาก router provider
import 'package:flutter/material.dart';import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'src/router.dart';
void main() { // ProviderScope stores the state of all providers — it must sit above // every widget that reads one, so it wraps the entire app. runApp(const ProviderScope(child: FitTrackApp()));}
class FitTrackApp extends ConsumerWidget { const FitTrackApp({super.key});
@override Widget build(BuildContext context, WidgetRef ref) { final router = ref.watch(routerProvider); return MaterialApp.router( title: 'FitTrack', theme: ThemeData(colorSchemeSeed: const Color(0xFF009688)), routerConfig: router, ); }}ตรวจสอบผล
หัวข้อที่มีชื่อว่า “ตรวจสอบผล”ดึง package แล้วรัน static analyzer — ทั้ง Riverpod และ go_router พึ่ง analyzer ในการจับ error แต่เนิ่น ๆ:
flutter pub getflutter analyzeAnalyzing mobile...No issues found!แทนที่ test/widget_test.dart เดิม (ไฟล์เดิมอ้าง counter demo ที่ลบไปแล้ว) ด้วยตัวที่ pump แอปภายใน ProviderScope แล้ว assert ว่า home screen render ออกมา:
import 'package:flutter/material.dart';import 'package:flutter_riverpod/flutter_riverpod.dart';import 'package:flutter_test/flutter_test.dart';import 'package:fittrack/main.dart';
void main() { testWidgets('app boots to the home screen', (tester) async { await tester.pumpWidget(const ProviderScope(child: FitTrackApp())); await tester.pumpAndSettle();
expect(find.text('FitTrack'), findsOneWidget); expect(find.text('Your workouts will appear here.'), findsOneWidget); });}flutter test00:02 +1: All tests passed!สุดท้าย รันบน device หรือ simulator แล้วยืนยันว่าแอปลงที่ home route:
flutter runแอปเปิดขึ้นมาที่ app bar อ่านว่า FitTrack พร้อม body แบบ placeholder flutter analyze ที่สะอาด, flutter test ที่เขียว และแอปที่รันอยู่บน route / แปลว่าโครงสร้างพื้นฐาน — provider ที่ root, router ที่เป็น provider, layout แบบ feature-first — พร้อมแล้ว
ตรวจสอบความเข้าใจ:
- ทำไมถึง expose
GoRouterเป็น Riverpod provider แทนที่จะสร้างแบบ inline ในMaterialApp.routerการทำแบบนั้นเปิดทางให้บทถัดไปทำอะไรได้โดยไม่ต้องแก้main.dart? ProviderScopeครอบทั้งแอปทั้งในmain()และใน widget test อะไรจะพังถ้า widget พยายามref.watchprovider ที่ไม่มีProviderScopeอยู่เหนือขึ้นไป?- ยกตัวอย่าง state สองแบบใน FitTrack ที่ share ข้ามหน้าจอและจะจัดการได้ยากถ้าใช้
setStateอย่างเดียว - home screen เป็น
ConsumerWidgetทั้งที่ยังไม่ได้อ่าน provider เลย ทำไมถึงเริ่มแบบนั้นแทนที่จะเป็นStatelessWidgetธรรมดา?
ตอนนี้ mobile/ เป็นแอปที่ scaffold ด้วย flutter create พร้อมการตัดสินใจสองเรื่องที่กำหนดทุกหน้าจอหลังจากนี้: Riverpod สำหรับ state (ProviderScope ที่ root, หน้าจอเป็น ConsumerWidget) และ go_router สำหรับ navigation (route table ที่ expose เป็น routerProvider เพื่อให้ gate บน auth ได้ภายหลัง) lib/main.dart อยู่ตัวเล็ก — scope กับ MaterialApp.router — และที่เหลืออยู่แบบ feature-first ใต้ lib/src/ โดยกัน core/ ไว้ให้ router และ API client flutter analyze สะอาด, flutter test ผ่าน widget test แบบ boot-to-home และ flutter run ลงที่ route / ต่อไป Supabase auth → initialize supabase_flutter, สร้างหน้า sign-in/sign-up, expose session เป็น provider และเปลี่ยน routerProvider ให้เป็น auth gate จริง