The logging UI
สิ่งที่จะสร้าง
หัวข้อที่มีชื่อว่า “สิ่งที่จะสร้าง”หัวใจของ FitTrack: หน้าจอที่ผู้ใช้ log workout ผู้ใช้เริ่ม session, เลือก exercise จาก catalog (GET /exercises), เพิ่ม set — แต่ละอันเป็นจำนวน reps กับ weight_kg — ก่อรายการขึ้นมา แล้ว save ทั้งหมดในครั้งเดียว ด้วย POST /workouts ตัวเดียว ตรงตามที่ Workouts API → คาดไว้ (workout และ set ทั้งหมดสร้างขึ้นใน transaction เดียว) workout ที่กำลังทำอยู่อยู่ใน Riverpod Notifier เป็น immutable state; หน้าจอ watch draft นั้นและ exercise catalog แล้วเรียก API client → จาก module ที่แล้วเพื่อ persist draft
พอจบบท ผู้ใช้เปิดหน้า logging เพิ่ม “Bench Press — 5 reps @ 60 kg” เพิ่ม set ที่สอง แล้วแตะ Save เพื่อ POST session; 201/200 พร้อม workout ที่สร้างแล้วยืนยันว่าข้อมูลลงถึง Supabase ผ่าน FastAPI
workout ที่ log แล้ว ก่อขึ้นมาทีละส่วนแต่ save แบบ atomic ผู้ใช้เพิ่ม set ทีละอันในช่วงนาทีสองนาที — แต่ backend โมเดล workout กับ set เป็นหน่วยเดียวที่สร้างใน database transaction เดียว (นั่นคือเหตุผลที่ POST /workouts ตาม contract รับ set ซ้อนอยู่ใน body ไม่ใช่ทีละ set) งานของ client จึงคือ สะสม draft ไว้ที่ local แล้วส่งเป็น payload เดียวเมื่อผู้ใช้ทำเสร็จ draft นั้นคือ state แบบ share-กัน-และ-เปลี่ยนแปลง-ตลอด ที่ Riverpod เกิดมาเพื่อทำ: Notifier ถือรายการ set, expose method ที่ตั้งชื่อตาม intent (addSet, removeSet, clear) และหน้าจอก็ ref.watch ตัว Notifier นั้น UI จึงสะท้อน draft ปัจจุบันเสมอ
การเก็บ draft ให้ immutable — ทุกการเปลี่ยนแทนที่ state ด้วย object ใหม่แทนที่จะแก้ในที่เดิม — คือสิ่งที่ทำให้ Riverpod rebuild ได้เชื่อถือได้: Riverpod เทียบ state เก่ากับใหม่ด้วย identity list ใหม่จึง trigger repaint ส่วน .add() แบบ in-place อาจไม่ trigger และยังทำให้ flow คาดเดาได้และ test ได้: ให้ draft กับ action มา draft ถัดไปเป็น pure function ของสองสิ่งนั้น
exercise catalog มาจาก backend จึงเป็น async state — FutureProvider ครอบ api.listExercises() แบบธรรมชาติ Riverpod ยื่น loading และ error state ให้ฟรี (AsyncValue) picker จึงโชว์ spinner ระหว่าง catalog โหลดและโชว์ error ถ้าโหลดไม่ได้ โดยไม่ต้องจดบัญชี isLoading เอง เมื่อผู้ใช้ save draft ถูก serialize เป็น shape ตาม contract แล้วยื่นให้ API client; สำเร็จแล้ว draft ถูก clear และแอปไปต่อได้
ข้อดีข้อเสีย
หัวข้อที่มีชื่อว่า “ข้อดีข้อเสีย”A client-side draft saved with one POST /workouts vs. POSTing each set as the user adds it
- Pros: ตรงกับโมเดล transactional ของ backend — workout กับ set สำเร็จหรือล้มเหลวด้วยกัน ไม่มี session ที่ save ค้างครึ่งทาง; ทำงานแบบ offline-แล้วค่อย-save ได้ตามธรรมชาติ; และมี round-trip น้อยกว่า ผู้ใช้แก้หรือลบ set ได้อิสระก่อน commit อะไร
- Cons: draft ที่ยังไม่ save อยู่แค่ใน memory crash กลาง log ก็หายไป (บรรเทาภายหลังด้วย local persistence); และ client ต้องสร้าง nested payload ที่ API คาดไว้ให้ตรงเป๊ะ สำหรับ workout ที่โดยเนื้อแท้เป็น session เดียว atomic save คือโมเดลที่ถูกต้อง
A Riverpod Notifier holding immutable draft state vs. local setState in the logging screen
- Pros: draft รอดข้าม navigation (เปิด exercise picker บนอีก route แล้วกลับมา set ยังอยู่), unit-test ได้โดยไม่ต้องมี widget และ immutability ทำให้ rebuild ถูกต้องและคาดเดาได้; widget อื่น (ตัวนับ set ใน app bar) watch draft เดียวกันได้
- Cons: มีโครงสร้างมากกว่าหน้าจอที่ขับด้วย
setStateตัวเดียว; และคุณต้องมีวินัยว่าจะแทนที่ state เสมอ ไม่ mutate ของเดิม เพราะการ log กินพื้นที่ทั้ง picker และ list และต้องไม่หายตอน navigationNotifierจึงคุ้ม
ติดตั้ง
หัวข้อที่มีชื่อว่า “ติดตั้ง”1. Model — lib/src/features/workouts/models.dart
หัวข้อที่มีชื่อว่า “1. Model — lib/src/features/workouts/models.dart”model เล็ก ๆ แบบ immutable ที่ตรงกับ shape ของ API SetInput สะท้อน nested set ตาม contract; WorkoutDraft.toJson ผลิต body ของ POST /workouts
/// An exercise from GET /exercises.class Exercise { const Exercise({required this.id, required this.name, required this.muscleGroup});
final String id; final String name; final String muscleGroup;
factory Exercise.fromJson(Map<String, dynamic> json) => Exercise( id: json['id'] as String, name: json['name'] as String, muscleGroup: json['muscle_group'] as String, );}
/// One set in the draft: an exercise plus reps and weight.class SetInput { const SetInput({ required this.exercise, required this.reps, required this.weightKg, });
final Exercise exercise; final int reps; final double weightKg;}
/// The in-progress workout. Immutable: every change returns a new draft.class WorkoutDraft { const WorkoutDraft({this.notes, this.sets = const []});
final String? notes; final List<SetInput> sets;
WorkoutDraft copyWith({String? notes, List<SetInput>? sets}) => WorkoutDraft(notes: notes ?? this.notes, sets: sets ?? this.sets);
/// Serialize to the POST /workouts body the backend expects: the workout /// with its sets nested, each carrying its 0-based position (set_index). Map<String, dynamic> toJson() => { if (notes != null && notes!.isNotEmpty) 'notes': notes, 'sets': [ for (final (index, s) in sets.indexed) { 'exercise_id': s.exercise.id, 'set_index': index, 'reps': s.reps, 'weight_kg': s.weightKg, }, ], };}2. catalog provider — lib/src/features/workouts/exercises_provider.dart
หัวข้อที่มีชื่อว่า “2. catalog provider — lib/src/features/workouts/exercises_provider.dart”import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/api/api_providers.dart';import 'models.dart';
/// The exercise catalog (public + the user's own), from GET /exercises./// FutureProvider gives loading/error/data states for free.final exercisesProvider = FutureProvider<List<Exercise>>((ref) async { final rows = await ref.watch(apiProvider).listExercises(); return rows .map((e) => Exercise.fromJson(e as Map<String, dynamic>)) .toList();});3. draft notifier — lib/src/features/workouts/logging_controller.dart
หัวข้อที่มีชื่อว่า “3. draft notifier — lib/src/features/workouts/logging_controller.dart”ถือ draft และ action สำหรับ save saveWorkout serialize แล้วเรียก API client คืน id ของ workout ที่สร้างเมื่อสำเร็จ
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/api/api_providers.dart';import 'models.dart';
/// Owns the in-progress workout draft. Every mutation replaces `state`/// with a new WorkoutDraft so Riverpod reliably rebuilds watchers.class LoggingController extends Notifier<WorkoutDraft> { @override WorkoutDraft build() => const WorkoutDraft();
void addSet(SetInput set) { state = state.copyWith(sets: [...state.sets, set]); }
void removeSet(int index) { final next = [...state.sets]..removeAt(index); state = state.copyWith(sets: next); }
void setNotes(String notes) => state = state.copyWith(notes: notes);
void clear() => state = const WorkoutDraft();
/// Persist the draft in one POST /workouts. Returns the new workout id. Future<String> saveWorkout() async { final created = await ref.read(apiProvider).createWorkout(state.toJson()); clear(); return created['id'] as String; }}
final loggingControllerProvider = NotifierProvider<LoggingController, WorkoutDraft>(LoggingController.new);4. ตัวหน้าจอ — lib/src/features/workouts/log_workout_screen.dart
หัวข้อที่มีชื่อว่า “4. ตัวหน้าจอ — lib/src/features/workouts/log_workout_screen.dart”watch draft และ catalog “Add set” เปิด dialog เล็ก ๆ ให้เลือก exercise และกรอก reps + weight; “Save” commit ทั้ง session
import 'package:flutter/material.dart';import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/api/api_exception.dart';import 'exercises_provider.dart';import 'logging_controller.dart';import 'models.dart';
class LogWorkoutScreen extends ConsumerWidget { const LogWorkoutScreen({super.key});
@override Widget build(BuildContext context, WidgetRef ref) { final draft = ref.watch(loggingControllerProvider); final controller = ref.read(loggingControllerProvider.notifier);
Future<void> save() async { try { await controller.saveWorkout(); if (context.mounted) { ScaffoldMessenger.of(context) .showSnackBar(const SnackBar(content: Text('Workout saved'))); } } on ApiException catch (e) { if (context.mounted) { ScaffoldMessenger.of(context) .showSnackBar(SnackBar(content: Text('Save failed: ${e.message}'))); } } }
return Scaffold( appBar: AppBar(title: const Text('Log workout')), body: draft.sets.isEmpty ? const Center(child: Text('No sets yet. Add your first set.')) : ListView.builder( itemCount: draft.sets.length, itemBuilder: (context, i) { final s = draft.sets[i]; return ListTile( title: Text(s.exercise.name), subtitle: Text('${s.reps} reps × ${s.weightKg} kg'), trailing: IconButton( icon: const Icon(Icons.delete_outline), onPressed: () => controller.removeSet(i), ), ); }, ), floatingActionButton: FloatingActionButton.extended( onPressed: () => _showAddSet(context, ref), icon: const Icon(Icons.add), label: const Text('Add set'), ), bottomNavigationBar: Padding( padding: const EdgeInsets.all(12), child: FilledButton( onPressed: draft.sets.isEmpty ? null : save, child: const Text('Save workout'), ), ), ); }
Future<void> _showAddSet(BuildContext context, WidgetRef ref) async { final exercisesAsync = ref.read(exercisesProvider); final exercises = exercisesAsync.valueOrNull ?? const <Exercise>[]; if (exercises.isEmpty) return;
Exercise selected = exercises.first; final repsCtrl = TextEditingController(text: '5'); final weightCtrl = TextEditingController(text: '20');
await showDialog<void>( context: context, builder: (context) => AlertDialog( title: const Text('Add set'), content: Column( mainAxisSize: MainAxisSize.min, children: [ DropdownButtonFormField<Exercise>( initialValue: selected, items: [ for (final e in exercises) DropdownMenuItem(value: e, child: Text(e.name)), ], onChanged: (e) => selected = e ?? selected, ), TextField( controller: repsCtrl, keyboardType: TextInputType.number, decoration: const InputDecoration(labelText: 'Reps'), ), TextField( controller: weightCtrl, keyboardType: TextInputType.number, decoration: const InputDecoration(labelText: 'Weight (kg)'), ), ], ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('Cancel'), ), FilledButton( onPressed: () { final reps = int.tryParse(repsCtrl.text) ?? 0; final weight = double.tryParse(weightCtrl.text) ?? 0; if (reps > 0 && weight > 0) { ref.read(loggingControllerProvider.notifier).addSet( SetInput( exercise: selected, reps: reps, weightKg: weight), ); } Navigator.pop(context); }, child: const Text('Add'), ), ], ), ); }}5. ทำ route เข้าหน้านี้ — แก้ lib/src/router.dart
หัวข้อที่มีชื่อว่า “5. ทำ route เข้าหน้านี้ — แก้ lib/src/router.dart”เพิ่ม route /log และปุ่มบน home screen เพื่อเข้าหน้านั้น:
// lib/src/router.dart — inside routes: [ ... ]GoRoute( path: '/log', name: 'log', builder: (context, state) => const LogWorkoutScreen(),),// lib/src/features/workouts/home_screen.dart — in the bodyFilledButton.icon( onPressed: () => context.go('/log'), icon: const Icon(Icons.add), label: const Text('Log a workout'),),ตรวจสอบผล
หัวข้อที่มีชื่อว่า “ตรวจสอบผล”โดยมี backend รันอยู่และมีอย่างน้อยหนึ่ง exercise ใน catalog (seed หนึ่งตัวผ่าน POST /exercises หรือ seed ของ module Exercises API) analyze แล้วรัน:
flutter analyzeflutter run \ --dart-define=SUPABASE_URL=https://your-project-ref.supabase.co \ --dart-define=SUPABASE_ANON_KEY=your-anon-key \ --dart-define=API_BASE_URL=http://10.0.2.2:8000sign in แตะ Log a workout แล้ว Add set: เลือก exercise กรอก 5 reps และ 60 kg แล้ว Add ทำซ้ำสำหรับ set ที่สอง list โชว์ทั้งคู่ แตะ Save workout — snackbar อ่านว่า Workout saved และ draft ถูก clear ยืนยันว่าข้อมูล persist จริงด้วยการถาม history ที่ save เพิ่งสร้างจาก backend:
curl -s localhost:8000/workouts \ -H "Authorization: Bearer <your-jwt>" | jq '.[0].sets | length'2สอง set บน workout ล่าสุดแปลว่าทั้ง draft serialize และ save ใน transaction เดียว รักษา guard ให้เขียว:
flutter test00:02 +1: All tests passed!ตรวจสอบความเข้าใจ:
- ทำไมแอปถึงสะสม draft แล้วส่ง
POST /workoutsครั้งเดียวแทนที่จะ POST แต่ละ set ตอนเพิ่ม อันนี้สอดคล้องกับการรับประกันอะไรของ backend? LoggingController.addSetสร้าง list ใหม่ด้วย[...state.sets, set]แทนที่จะเรียกstate.sets.add(set)ทำไม immutability ถึงสำคัญต่อการที่ Riverpod จะ rebuild หน้าจอ?WorkoutDraft.toJsonกำหนดset_indexจากตำแหน่งใน list index นั้นมาจากไหนในโค้ด และทำไม backend ถึงอยากได้ค่านั้น?- catalog เป็น
exercisesProvider(ตัวFutureProvider) provider ตัวนี้ให้ UI state สองอย่างอะไรฟรี ๆ ที่การ fetch เองจะทำให้คุณต้องตามจดเอง?
หน้า logging คือ flow หลักของ FitTrack: Riverpod Notifier (LoggingController) ถือ WorkoutDraft แบบ immutable, ผู้ใช้เพิ่ม set (exercise + reps + weight_kg) ที่ดึงมาจาก exercisesProvider ซึ่งป้อนด้วย GET /exercises และ Save serialize draft เป็น nested body ตาม contract แล้วส่ง POST /workouts ตัวเดียวผ่าน API client — workout และ set ทั้งหมด commit ใน backend transaction เดียว immutable state ทำให้ rebuild ถูกต้อง และ ApiException โชว์การ save ที่ล้มเหลวให้ผู้ใช้เห็น ต่อไป History and progress → อ่าน session กลับด้วย GET /workouts และแสดง trend จาก endpoint GET /progress/*