ข้ามไปยังเนื้อหา

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; redirect callback ตัวเดียวเขียน 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 ชนะ

จาก repo root (โฟลเดอร์ fittrack/ ที่มี api/) สร้างแอป เข้าไปใน ไดเรกทอรี mobile/ ที่มีอยู่แล้ว:

Terminal window
flutter create --org com.avetavos.fittrack --project-name fittrack mobile
cd mobile

--org ตั้ง bundle identifier แบบ reverse-DNS (ใช้ตอน packaging iOS/Android และภายหลังคือ OAuth redirect scheme); --project-name ตั้งชื่อ Dart package ยืนยันว่า toolchain สมบูรณ์ดี:

Terminal window
flutter doctor
Terminal window
flutter pub add flutter_riverpod go_router

คำสั่งนี้เขียนลงใน pubspec.yaml บรรทัดที่เกี่ยวข้อง:

pubspec.yaml
dependencies:
flutter:
sdk: flutter
flutter_riverpod: ^2.6.1 # state management
go_router: ^14.6.2 # declarative routing

แทนที่ 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.dart
Terminal window
mkdir -p lib/src/core lib/src/features/auth lib/src/features/workouts

หน้า placeholder ที่ router จะลงมาได้ ประกาศเป็น ConsumerWidget (widget base ของ Riverpod) ตั้งแต่แรก การเสียบ provider เข้ามาภายหลังจึงเป็นแค่การแก้บรรทัดเดียว:

lib/src/features/workouts/home_screen.dart
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.')),
);
}
}

expose GoRouter เป็น Riverpod provider เรื่องนี้สำคัญ: ในบทถัดไป provider ตัวเดียวกันนี้จะ ref.watch auth session แล้วเพิ่ม redirect โดยไม่ต้องแตะ main.dart

lib/src/router.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(),
),
],
);
});

main.dart ทำสองอย่างแล้วหยุด: ติดตั้ง ProviderScope (root container ของ Riverpod ที่ถือ state ของทุก provider) และขับ MaterialApp.router จาก router provider

lib/main.dart
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 แต่เนิ่น ๆ:

Terminal window
flutter pub get
flutter analyze
Analyzing mobile...
No issues found!

แทนที่ test/widget_test.dart เดิม (ไฟล์เดิมอ้าง counter demo ที่ลบไปแล้ว) ด้วยตัวที่ pump แอปภายใน ProviderScope แล้ว assert ว่า home screen render ออกมา:

test/widget_test.dart
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);
});
}
Terminal window
flutter test
00:02 +1: All tests passed!

สุดท้าย รันบน device หรือ simulator แล้วยืนยันว่าแอปลงที่ home route:

Terminal window
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.watch provider ที่ไม่มี 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 จริง