diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8c570b3..2796a96 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,8 @@ jobs: flutter-version: ${{ env.FLUTTER_VERSION }} channel: stable cache: true + - name: Install dependencies + run: flutter pub get # Scoped to source dirs β€” `dart format .` would descend into build/ # (Firebase plugins copy example apps there with broken includes). - name: Check formatting @@ -39,8 +41,10 @@ jobs: - name: Install dependencies run: flutter pub get + # Info-level lints (e.g. public_member_api_docs) report but don't fail CI; + # warnings and errors still do. flutter analyze defaults --fatal-infos on. - name: Analyze - run: flutter analyze + run: flutter analyze --no-fatal-infos # dart_code_linter's per-function metrics (source-lines-of-code, # cyclomatic-complexity, nesting, parameter/method counts) aren't surfaced diff --git a/.gitignore b/.gitignore index efc2f39..7fb1111 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,5 @@ app.*.map.json .DS_Store # Widget Preview related .widget_preview/ + +task.md diff --git a/CLAUDE.md b/CLAUDE.md index e4c998e..5a8fed6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,10 +12,10 @@ brewpath/ ← git root, CLAUDE.md lives here ## Learning track A hands-on, learn-by-doing Flutter course for this app lives in -[`../learning/`](../learning/). When the user asks to "continue the lesson" / +[`learning/`](learning/). When the user asks to "continue the lesson" / "continue the Flutter onboarding", read -[`../learning/README.md`](../learning/README.md) (teaching contract) and -[`../learning/curriculum.md`](../learning/curriculum.md) (current step, marked πŸ‘‰) +[`learning/README.md`](learning/README.md) (teaching contract) and +[`learning/curriculum.md`](learning/curriculum.md) (current step, marked πŸ‘‰) first, then resume. ## Architecture diff --git a/learning/README.md b/learning/README.md index 39cc49b..43b3d87 100644 --- a/learning/README.md +++ b/learning/README.md @@ -24,7 +24,7 @@ curriculum, and resume from there following the contract below. 3. **Verify every step before moving on:** - Read the changed files / `git diff`. - If codegen was touched (`@riverpod`, `@freezed`, a Drift table), run - `dart run build_runner build` from `coffee_quest/`. + `dart run build_runner build` from the repo root. - Run `flutter analyze` and the relevant tests; report results honestly, including failures and their output. - Give specific, teaching-oriented feedback (what's right, what's wrong, and @@ -36,7 +36,7 @@ curriculum, and resume from there following the contract below. 5. **Be accurate.** Verify claims against the actual code and pub.dev; never invent APIs or behavior. If unsure, check before asserting. 6. **Respect project conventions** in - [`../coffee_quest/CLAUDE.md`](../coffee_quest/CLAUDE.md): `package:coffee_quest/…` + [`../CLAUDE.md`](../CLAUDE.md): `package:coffee_quest/…` imports inside `lib/`; no magic numbers (lint-enforced by `dart_code_linter`, only `0/1/2` allowed); navigate by route `name` (`context.goNamed(...)`), not hardcoded paths; function-style `@riverpod` for derived reads, class-style only diff --git a/learning/curriculum.md b/learning/curriculum.md index 8b0abdc..d3c6dc0 100644 --- a/learning/curriculum.md +++ b/learning/curriculum.md @@ -20,19 +20,24 @@ derived providers, `AsyncValue` UI, empty/loading/error states. Notifier's `state` as the single source of truth, `@Riverpod(keepAlive: true)`, `void toggle` with immutable spread updates, renamed to `FavoriteCards` β†’ `favoriteCardsProvider`._ -- πŸ‘‰ ☐ **B2 β€” Toggle UI.** Add a heart `IconButton` to the `card_detail_screen.dart` - AppBar: `ref.watch(favoriteCardsProvider)` to choose the filled/outline icon; - `ref.read(favoriteCardsProvider.notifier).toggle(cardId)` on press. -- ☐ **B3 β€” Derived provider.** `favoriteCardsList` (function-style `@riverpod`) - combining the favorites set with `contentRepository.getCards()` β†’ - `List`. -- ☐ **B4 β€” Favorites screen.** New `FavoritesScreen` (`ConsumerWidget`) rendering - the list, with proper loading / empty / error states. Use - `/flutter-mobile-design`; add `Semantics` labels and respect reduced motion. -- ☐ **B5 β€” Wire the route.** Nested `GoRoute` under `/cards` β€” declare the static - `favorites` route **before** `:cardId` so the param doesn't capture - "favorites". Add `RouteNames.favorites` + an `AppStrings` label; reach it via an - AppBar action on `CardsScreen` using `context.goNamed('favorites')`. +- β˜‘ **B2 β€” Toggle UI.** Heart `IconButton` on `card_detail_screen.dart` AppBar. + _Done 2026-06-16 β€” correct `watch state` / `read notifier` split, tooltip a11y, + extracted badge constants. Known follow-up: inline `FutureBuilder` Future in + `build` flickers the body on each toggle now that the screen rebuilds β€” fixed + in B3 via a `cardById` provider._ +- β˜‘ **B3 β€” Derived providers.** `favoriteCardsList` + `cardById` (family) in + `cards_providers.dart`. _Done 2026-06-16 β€” correct reactive chain; rewired + `card_detail_screen.dart` to `ref.watch(cardByIdProvider(cardId)).when(...)`, + which removed the inline-Future flicker; cleaned up unused imports._ +- β˜‘ **B4 β€” Favorites screen.** `FavoritesScreen` + `_FavoritesGrid` + + `_FavoritesEmpty` in `favorites_screen.dart`. _Done 2026-06-16 (via + `/flutter-mobile-design`) β€” reuses `CardGridItemWidget`, all three async states, + accessible empty state (`Semantics` + `excludeSemantics`), vertical-centering + bug fixed (`mainAxisSize.min`), tokens throughout._ +- β˜‘ **B5 β€” Wire the route.** Nested `favorites` `GoRoute` under `/cards`, + declared before `:cardId`; `RouteNames.favorites` + `AppStrings.favorites`; + AppBar entry point on `CardsScreen` via `context.goNamed('favorites')`. + _Done 2026-06-16 β€” **Phase B complete.** Full loop works end-to-end._ ## Phase A β€” "Persist favorites" (persistence slice) @@ -40,11 +45,11 @@ Teaches: Drift schema change, migration, `drift_dev` codegen + schema snapshots, repository + DTO mapping, a mutable controller backed by storage, provider invalidation. -- ☐ **A1 β€” Schema.** Add a `FavoriteCardRecords` table (unique `cardId`) in +- πŸ‘‰ ☐ **A1 β€” Schema.** Add a `FavoriteCardRecords` table (unique `cardId`) in `shared/storage/app_database.dart`; bump `schemaVersion`; add the `onUpgrade` migration step. - ☐ **A2 β€” Codegen + schema snapshot.** `dart run build_runner build`, then the - drift schema `dump`/`generate` workflow documented in `coffee_quest/README.md`. + drift schema `dump`/`generate` workflow documented in `README.md`. - ☐ **A3 β€” Repository.** `FavoriteCardRepository` mirroring `card_repository.dart` (list IDs, insert-or-ignore toggle, delete-all); register it in `repository_providers.dart`. @@ -72,3 +77,22 @@ invalidation. - **2026-06-13** β€” B1 reworked and verified correct (generated `favoriteCardsProvider`, `isAutoDispose: false`). Covered the identity-based `updateShouldNotify` mechanism behind immutable `state` updates. **Next: B2 (toggle UI).** +- **2026-06-16** β€” Project restructured: Flutter app moved from `coffee_quest/` to + the repo root (`lib/`, `pubspec.yaml`, `ios/` at `brewpath/`). Fixed the + `coffee_quest/` path references in the learning docs + the `CLAUDE.md` learning + pointer. (Root `CLAUDE.md` "Project Layout" / "run commands from coffee_quest/" + still stale β€” flagged to user.) B2 verified done; covered `AppSpacing` token + swap + the "never build a Future inside `build()`" rule. **Next: B3.** +- **2026-06-16** β€” B3 verified done (codegen emitted `favoriteCardsListProvider` + + `cardByIdProvider` family). Flicker fixed via cached provider read. Covered + family providers (arg as cache key) and why `ref.watch` of a provider beats an + inline `FutureBuilder`. **Next: B4 (Favorites screen) β€” pull in + `/flutter-mobile-design`.** +- **2026-06-16** β€” B4 done. Built `FavoritesScreen` piece-by-piece; taught + `super.key` / widget keys, `const` constructors, `Column` + `Center` sizing + (`mainAxisSize.min`), and `Semantics(excludeSemantics:)` for a one-sentence + empty-state announcement. **Next: B5 (route wiring) β€” the go_router step.** +- **2026-06-16** β€” **B5 done β†’ Phase B complete.** Wired the nested `favorites` + route (static-before-dynamic ordering, the key go_router lesson), constants, + and the Cards AppBar entry point with `goNamed`. Full favorites loop works + end-to-end. **Next: Phase A (persist favorites with Drift) β€” start at A1.** diff --git a/lib/app/app_router.dart b/lib/app/app_router.dart index c8ac021..da2d971 100644 --- a/lib/app/app_router.dart +++ b/lib/app/app_router.dart @@ -1,7 +1,9 @@ import 'package:coffee_quest/app/analytics_navigator_observer.dart'; import 'package:coffee_quest/app/app_shell.dart'; +import 'package:coffee_quest/core/constants/app_routes.dart'; import 'package:coffee_quest/features/cards/presentation/card_detail_screen.dart'; import 'package:coffee_quest/features/cards/presentation/cards_screen.dart'; +import 'package:coffee_quest/features/cards/presentation/favorites_screen.dart'; import 'package:coffee_quest/features/learn/presentation/game_type_practice_screen.dart'; import 'package:coffee_quest/features/learn/presentation/learn_screen.dart'; import 'package:coffee_quest/features/learn/presentation/module_detail_screen.dart'; @@ -25,6 +27,19 @@ part 'app_router.g.dart'; final _rootKey = GlobalKey(); /// Provides the app's [GoRouter] (rebuilds on onboarding-gate changes). +/// +/// Adding a route: +/// 1. Add an [AppRoute] to [AppRoutes] β€” an absolute `path` for a top-level +/// route, or a relative segment for one nested under a parent. +/// 2. Add a [GoRoute] here from `AppRoutes.x.path` / `AppRoutes.x.name`; pass +/// `parentNavigatorKey: _rootKey` if it should cover the bottom-nav shell. +/// 3. Navigate via `context.goNamed(AppRoutes.x.name, …)` and assert its +/// location in `test/unit/app_routes_test.dart`. +/// 4. New tab? Also add a `NavigationDestination` to [AppShell] in the SAME +/// order as the branches below (they pair by index), with an `AppLabels` +/// label. +/// 5. Visibility for gated/onboarding routes is decided in `redirect`, not on +/// the [GoRoute]. @riverpod GoRouter appRouter(Ref ref) { // Ticks whenever the async onboarding gate resolves; passed to the router @@ -37,26 +52,42 @@ GoRouter appRouter(Ref ref) { }); return GoRouter( navigatorKey: _rootKey, - initialLocation: '/loading', + initialLocation: AppRoutes.loading.path, refreshListenable: refresh, // Funnels the root (platform initial route, error-page "Home") to Loading - // and gates the rest of the app behind the onboarding flow. + // and gates the rest of the app behind the onboarding flow. Locations are + // resolved from route names so this never hardcodes a URL. redirect: (context, state) { + final loadingLocation = state.namedLocation(AppRoutes.loading.name); + final welcomeLocation = state.namedLocation(AppRoutes.welcome.name); + final goalLocation = state.namedLocation(AppRoutes.onboardingGoal.name); + final brewerLocation = state.namedLocation( + AppRoutes.onboardingBrewer.name, + ); final path = state.uri.path; + if (path == '/') { - return '/loading'; + return loadingLocation; } + final completed = ref.read(onboardingCompletedProvider).value ?? false; final isOnboardingRoute = - path == '/loading' || - path == '/welcome' || - path.startsWith('/onboarding'); + path == loadingLocation || + path == welcomeLocation || + path == goalLocation || + path == brewerLocation; + if (!completed && !isOnboardingRoute) { - return '/welcome'; + return welcomeLocation; } - if (completed && (path == '/welcome' || path.startsWith('/onboarding'))) { - return '/learn'; + + if (completed && + (path == welcomeLocation || + path == goalLocation || + path == brewerLocation)) { + return state.namedLocation(AppRoutes.learn.name); } + return null; }, observers: [ @@ -64,23 +95,23 @@ GoRouter appRouter(Ref ref) { ], routes: [ GoRoute( - path: '/loading', - name: 'loading', + path: AppRoutes.loading.path, + name: AppRoutes.loading.name, builder: (context, state) => const LoadingScreen(), ), GoRoute( - path: '/welcome', - name: 'welcome', + path: AppRoutes.welcome.path, + name: AppRoutes.welcome.name, builder: (context, state) => const WelcomeScreen(), ), GoRoute( - path: '/onboarding/goal', - name: 'onboardingGoal', + path: AppRoutes.onboardingGoal.path, + name: AppRoutes.onboardingGoal.name, builder: (context, state) => const GoalScreen(), ), GoRoute( - path: '/onboarding/brewer', - name: 'onboardingBrewer', + path: AppRoutes.onboardingBrewer.path, + name: AppRoutes.onboardingBrewer.name, builder: (context, state) => const BrewerScreen(), ), StatefulShellRoute.indexedStack( @@ -89,13 +120,13 @@ GoRouter appRouter(Ref ref) { StatefulShellBranch( routes: [ GoRoute( - path: '/learn', - name: 'learn', + path: AppRoutes.learn.path, + name: AppRoutes.learn.name, builder: (context, state) => const LearnScreen(), routes: [ GoRoute( - path: 'module/:moduleId', - name: 'moduleDetail', + path: AppRoutes.moduleDetail.path, + name: AppRoutes.moduleDetail.name, builder: (context, state) => ModuleDetailScreen( moduleId: state.pathParameters['moduleId']!, ), @@ -103,8 +134,8 @@ GoRouter appRouter(Ref ref) { // Immersive lesson flow: pushed on the root navigator so it // covers the bottom-nav shell. GoRoute( - path: 'lesson/:lessonId', - name: 'lesson', + path: AppRoutes.lesson.path, + name: AppRoutes.lesson.name, parentNavigatorKey: _rootKey, builder: (context, state) => LessonScreen( lessonId: state.pathParameters['lessonId']!, @@ -113,8 +144,8 @@ GoRouter appRouter(Ref ref) { ), routes: [ GoRoute( - path: 'complete', - name: 'lessonComplete', + path: AppRoutes.lessonComplete.path, + name: AppRoutes.lessonComplete.name, parentNavigatorKey: _rootKey, builder: (context, state) => LessonCompletionScreen( lessonId: state.pathParameters['lessonId']!, @@ -134,8 +165,8 @@ GoRouter appRouter(Ref ref) { // returns to the Learn tab. Both push on the root navigator // to cover the bottom-nav shell (same as lessons). GoRoute( - path: 'practice/lesson/:lessonId', - name: 'practiceLesson', + path: AppRoutes.practiceLesson.path, + name: AppRoutes.practiceLesson.name, parentNavigatorKey: _rootKey, builder: (context, state) => LessonScreen( lessonId: state.pathParameters['lessonId']!, @@ -143,8 +174,8 @@ GoRouter appRouter(Ref ref) { ), ), GoRoute( - path: 'practice/game-type/:gameType', - name: 'practiceGameType', + path: AppRoutes.practiceGameType.path, + name: AppRoutes.practiceGameType.name, parentNavigatorKey: _rootKey, builder: (context, state) => GameTypePracticeScreen( gameType: state.pathParameters['gameType']!, @@ -157,8 +188,8 @@ GoRouter appRouter(Ref ref) { StatefulShellBranch( routes: [ GoRoute( - path: '/path', - name: 'path', + path: AppRoutes.path.path, + name: AppRoutes.path.name, builder: (context, state) => const PathScreen(), ), ], @@ -166,13 +197,18 @@ GoRouter appRouter(Ref ref) { StatefulShellBranch( routes: [ GoRoute( - path: '/cards', - name: 'cards', + path: AppRoutes.cards.path, + name: AppRoutes.cards.name, builder: (context, state) => const CardsScreen(), routes: [ GoRoute( - path: ':cardId', - name: 'cardDetail', + path: AppRoutes.favorites.path, + name: AppRoutes.favorites.name, + builder: (context, state) => const FavoritesScreen(), + ), + GoRoute( + path: AppRoutes.cardDetail.path, + name: AppRoutes.cardDetail.name, builder: (context, state) => CardDetailScreen( cardId: state.pathParameters['cardId']!, ), @@ -184,13 +220,13 @@ GoRouter appRouter(Ref ref) { StatefulShellBranch( routes: [ GoRoute( - path: '/profile', - name: 'profile', + path: AppRoutes.profile.path, + name: AppRoutes.profile.name, builder: (context, state) => const ProfileScreen(), routes: [ GoRoute( - path: 'settings', - name: 'profileSettings', + path: AppRoutes.profileSettings.path, + name: AppRoutes.profileSettings.name, parentNavigatorKey: _rootKey, builder: (context, state) => const SettingsScreen(), ), diff --git a/lib/app/app_router.g.dart b/lib/app/app_router.g.dart index 3ac522e..0e16a94 100644 --- a/lib/app/app_router.g.dart +++ b/lib/app/app_router.g.dart @@ -52,4 +52,4 @@ final class AppRouterProvider } } -String _$appRouterHash() => r'3febbd5746e406a532b6c1c21f991f1f31d3c770'; +String _$appRouterHash() => r'7c01f5ee6d7001520125aef506affb0f5349bd3a'; diff --git a/lib/app/app_shell.dart b/lib/app/app_shell.dart index 2176b7d..323115a 100644 --- a/lib/app/app_shell.dart +++ b/lib/app/app_shell.dart @@ -1,4 +1,4 @@ -import 'package:coffee_quest/core/constants/app_strings.dart'; +import 'package:coffee_quest/core/constants/app_labels.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; @@ -31,22 +31,22 @@ class AppShell extends StatelessWidget { NavigationDestination( icon: Icon(Icons.school_outlined), selectedIcon: Icon(Icons.school), - label: AppStrings.tabLearn, + label: AppLabels.tabLearn, ), NavigationDestination( icon: Icon(Icons.route_outlined), selectedIcon: Icon(Icons.route), - label: AppStrings.tabPath, + label: AppLabels.tabPath, ), NavigationDestination( icon: Icon(Icons.style_outlined), selectedIcon: Icon(Icons.style), - label: AppStrings.tabCards, + label: AppLabels.tabCards, ), NavigationDestination( icon: Icon(Icons.person_outline), selectedIcon: Icon(Icons.person), - label: AppStrings.tabProfile, + label: AppLabels.tabProfile, ), ], ), diff --git a/lib/core/constants/app_strings.dart b/lib/core/constants/app_labels.dart similarity index 88% rename from lib/core/constants/app_strings.dart rename to lib/core/constants/app_labels.dart index 658fdb5..30333b1 100644 --- a/lib/core/constants/app_strings.dart +++ b/lib/core/constants/app_labels.dart @@ -2,7 +2,7 @@ // ignore_for_file: public_member_api_docs /// User-facing string constants. -abstract class AppStrings { +abstract class AppLabels { static const appName = 'Coffee Quest'; static const tabLearn = 'Learn'; static const tabPath = 'Path'; @@ -12,4 +12,5 @@ abstract class AppStrings { 'Complete the previous module to unlock this one.'; static const continueLabel = 'Continue'; static const tryAgainLabel = 'Try Again'; + static const favorites = 'Favorites'; } diff --git a/lib/core/constants/app_routes.dart b/lib/core/constants/app_routes.dart new file mode 100644 index 0000000..1bebc65 --- /dev/null +++ b/lib/core/constants/app_routes.dart @@ -0,0 +1,45 @@ +// Self-descriptive route catalog β€” no per-member docs needed. +// ignore_for_file: public_member_api_docs + +/// A single app route: its go_router [name] (the stable navigation handle) and +/// its [path] (the value the router registers β€” absolute for top-level routes, +/// relative for nested ones). Declaring both on one entry keeps a route's name +/// and path together so they can never silently drift apart. +class AppRoute { + const AppRoute(this.name, this.path); + + final String name; + final String path; +} + +/// Canonical catalog of every route in the app. The router builds each +/// `GoRoute` from these entries (`path:` ← [AppRoute.path], `name:` ← +/// [AppRoute.name]); navigation refers to a route by `AppRoutes.x.name` via +/// `context.goNamed`, so go_router builds the URL from the route's own path. +abstract class AppRoutes { + static const loading = AppRoute('loading', '/loading'); + static const welcome = AppRoute('welcome', '/welcome'); + static const onboardingGoal = AppRoute('onboardingGoal', '/onboarding/goal'); + static const onboardingBrewer = AppRoute( + 'onboardingBrewer', + '/onboarding/brewer', + ); + static const learn = AppRoute('learn', '/learn'); + static const moduleDetail = AppRoute('moduleDetail', 'module/:moduleId'); + static const lesson = AppRoute('lesson', 'lesson/:lessonId'); + static const lessonComplete = AppRoute('lessonComplete', 'complete'); + static const practiceLesson = AppRoute( + 'practiceLesson', + 'practice/lesson/:lessonId', + ); + static const practiceGameType = AppRoute( + 'practiceGameType', + 'practice/game-type/:gameType', + ); + static const path = AppRoute('path', '/path'); + static const cards = AppRoute('cards', '/cards'); + static const favorites = AppRoute('favorites', 'favorites'); + static const cardDetail = AppRoute('cardDetail', ':cardId'); + static const profile = AppRoute('profile', '/profile'); + static const profileSettings = AppRoute('profileSettings', 'settings'); +} diff --git a/lib/core/constants/route_names.dart b/lib/core/constants/route_names.dart deleted file mode 100644 index 665cbb2..0000000 --- a/lib/core/constants/route_names.dart +++ /dev/null @@ -1,23 +0,0 @@ -/// Named-route path constants for the app's go_router configuration. -abstract class RouteNames { - /// Learn tab root β€” the module list. - static const learn = '/learn'; - - /// A single learning module's detail screen. - static const moduleDetail = '/learn/module/:moduleId'; - - /// An immersive single-lesson flow. - static const lesson = '/learn/lesson/:lessonId'; - - /// Path tab β€” the progress journey. - static const path = '/path'; - - /// Cards tab β€” the collected-cards grid. - static const cards = '/cards'; - - /// A single collected card's detail screen. - static const cardDetail = '/cards/:cardId'; - - /// Profile tab. - static const profile = '/profile'; -} diff --git a/lib/features/cards/domain/cards_providers.dart b/lib/features/cards/domain/cards_providers.dart index 3d804dc..0b148f2 100644 --- a/lib/features/cards/domain/cards_providers.dart +++ b/lib/features/cards/domain/cards_providers.dart @@ -1,3 +1,4 @@ +import 'package:coffee_quest/features/cards/domain/favorite_cards_provider.dart'; import 'package:coffee_quest/shared/models/coffee_card_model.dart'; import 'package:coffee_quest/shared/repositories/content_repository.dart'; import 'package:coffee_quest/shared/repositories/repository_providers.dart'; @@ -37,3 +38,20 @@ Future> cardsWithCollection(Ref ref) async { ) .toList(); } + +/// Favorite user cards, derived from the in-memory favorites set. +@riverpod +Future> favoriteCardsList(Ref ref) async { + final favorites = ref.watch(favoriteCardsProvider); + final cards = await ref.watch(contentRepositoryProvider).getCards(); + + return cards.where((card) => favorites.contains(card.id)).toList(); +} + +/// Returns first equal card by Id +@riverpod +Future cardById(Ref ref, String cardId) async { + final cards = await ref.watch(contentRepositoryProvider).getCards(); + + return cards.where((card) => card.id == cardId).firstOrNull; +} diff --git a/lib/features/cards/domain/cards_providers.g.dart b/lib/features/cards/domain/cards_providers.g.dart index 0a30751..82d5381 100644 --- a/lib/features/cards/domain/cards_providers.g.dart +++ b/lib/features/cards/domain/cards_providers.g.dart @@ -69,3 +69,133 @@ final class CardsWithCollectionProvider String _$cardsWithCollectionHash() => r'e90b2194edb08dcedb9d9014f5086725bf05c085'; + +/// Favorite user cards, derived from the in-memory favorites set. + +@ProviderFor(favoriteCardsList) +final favoriteCardsListProvider = FavoriteCardsListProvider._(); + +/// Favorite user cards, derived from the in-memory favorites set. + +final class FavoriteCardsListProvider + extends + $FunctionalProvider< + AsyncValue>, + List, + FutureOr> + > + with + $FutureModifier>, + $FutureProvider> { + /// Favorite user cards, derived from the in-memory favorites set. + FavoriteCardsListProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'favoriteCardsListProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$favoriteCardsListHash(); + + @$internal + @override + $FutureProviderElement> $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr> create(Ref ref) { + return favoriteCardsList(ref); + } +} + +String _$favoriteCardsListHash() => r'172ed916dc81cd10f32063bedb6426795d433826'; + +/// Returns first equal card by Id + +@ProviderFor(cardById) +final cardByIdProvider = CardByIdFamily._(); + +/// Returns first equal card by Id + +final class CardByIdProvider + extends + $FunctionalProvider< + AsyncValue, + CoffeeCardModel?, + FutureOr + > + with $FutureModifier, $FutureProvider { + /// Returns first equal card by Id + CardByIdProvider._({ + required CardByIdFamily super.from, + required String super.argument, + }) : super( + retry: null, + name: r'cardByIdProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$cardByIdHash(); + + @override + String toString() { + return r'cardByIdProvider' + '' + '($argument)'; + } + + @$internal + @override + $FutureProviderElement $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + final argument = this.argument as String; + return cardById(ref, argument); + } + + @override + bool operator ==(Object other) { + return other is CardByIdProvider && other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$cardByIdHash() => r'aed02080a5b28c0c8812171f24d85e8ebbec072c'; + +/// Returns first equal card by Id + +final class CardByIdFamily extends $Family + with $FunctionalFamilyOverride, String> { + CardByIdFamily._() + : super( + retry: null, + name: r'cardByIdProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + /// Returns first equal card by Id + + CardByIdProvider call(String cardId) => + CardByIdProvider._(argument: cardId, from: this); + + @override + String toString() => r'cardByIdProvider'; +} diff --git a/lib/features/cards/domain/favorite_cards_provider.dart b/lib/features/cards/domain/favorite_cards_provider.dart index 8388665..6ea6c64 100644 --- a/lib/features/cards/domain/favorite_cards_provider.dart +++ b/lib/features/cards/domain/favorite_cards_provider.dart @@ -2,7 +2,7 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'favorite_cards_provider.g.dart'; -/// In-memory set of favorited card IDs. +/// In-memory set of favorite card IDs. /// Phase B keeps this in memory only; /// Phase A backs it with Drift so favorites survive a restart. @Riverpod(keepAlive: true) @@ -10,11 +10,13 @@ class FavoriteCards extends _$FavoriteCards { @override Set build() => {}; + /// Adds [cardId] to the favorites if absent, or removes it if present. void toggle(String cardId) { state = state.contains(cardId) ? ({...state}..remove(cardId)) // new set, minus the id : ({...state, cardId}); // new set, plus the id } + /// Whether [cardId] is currently marked as a favorite. bool isFavorite(String cardId) => state.contains(cardId); } diff --git a/lib/features/cards/domain/favorite_cards_provider.g.dart b/lib/features/cards/domain/favorite_cards_provider.g.dart index bd547cd..caa9866 100644 --- a/lib/features/cards/domain/favorite_cards_provider.g.dart +++ b/lib/features/cards/domain/favorite_cards_provider.g.dart @@ -8,12 +8,21 @@ part of 'favorite_cards_provider.dart'; // GENERATED CODE - DO NOT MODIFY BY HAND // ignore_for_file: type=lint, type=warning +/// In-memory set of favorite card IDs. +/// Phase B keeps this in memory only; +/// Phase A backs it with Drift so favorites survive a restart. @ProviderFor(FavoriteCards) final favoriteCardsProvider = FavoriteCardsProvider._(); +/// In-memory set of favorite card IDs. +/// Phase B keeps this in memory only; +/// Phase A backs it with Drift so favorites survive a restart. final class FavoriteCardsProvider extends $NotifierProvider> { + /// In-memory set of favorite card IDs. + /// Phase B keeps this in memory only; + /// Phase A backs it with Drift so favorites survive a restart. FavoriteCardsProvider._() : super( from: null, @@ -41,13 +50,17 @@ final class FavoriteCardsProvider } } -String _$favoriteCardsHash() => r'fc415c5f678fd604e52f0c9ecb65b43d86b3959e'; +String _$favoriteCardsHash() => r'85779f39d3a950074a09ad32f9563e233a427922'; + +/// In-memory set of favorite card IDs. +/// Phase B keeps this in memory only; +/// Phase A backs it with Drift so favorites survive a restart. abstract class _$FavoriteCards extends $Notifier> { Set build(); @$mustCallSuper @override - void runBuild() { + WhenComplete runBuild() { final ref = this.ref as $Ref, Set>; final element = ref.element @@ -57,6 +70,6 @@ abstract class _$FavoriteCards extends $Notifier> { Object?, Object? >; - element.handleCreate(ref, build); + return element.handleCreate(ref, build); } } diff --git a/lib/features/cards/presentation/card_detail_screen.dart b/lib/features/cards/presentation/card_detail_screen.dart new file mode 100644 index 0000000..b263240 --- /dev/null +++ b/lib/features/cards/presentation/card_detail_screen.dart @@ -0,0 +1,78 @@ +import 'package:coffee_quest/core/utils/module_icons.dart'; +import 'package:coffee_quest/core/widgets/error_view.dart'; +import 'package:coffee_quest/core/widgets/loading_indicator.dart'; +import 'package:coffee_quest/features/cards/domain/cards_providers.dart'; +import 'package:coffee_quest/features/cards/domain/favorite_cards_provider.dart'; +import 'package:coffee_quest/shared/theme/app_spacing.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// Detail screen for a single collected coffee card. +class CardDetailScreen extends ConsumerWidget { + /// Creates a [CardDetailScreen]. + const CardDetailScreen({required this.cardId, super.key}); + + /// Id of the card to display. + final String cardId; + + static const double _badgeSize = 80; + static const double _badgeRadius = 20; + static const double _iconSize = 40; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isFavorite = ref.watch(favoriteCardsProvider).contains(cardId); + final cardAsync = ref.watch(cardByIdProvider(cardId)); + + final theme = Theme.of(context); + final colors = theme.colorScheme; + + return Scaffold( + appBar: AppBar( + title: const Text('Card'), + actions: [ + IconButton( + icon: Icon(isFavorite ? Icons.favorite : Icons.favorite_border), + onPressed: () => + ref.read(favoriteCardsProvider.notifier).toggle(cardId), + tooltip: isFavorite ? 'Remove from favorites' : 'Add to favorites', + ), + ], + ), + body: cardAsync.when( + loading: () => const LoadingIndicator(), + error: (e, _) => ErrorView(message: '$e'), + data: (card) => card == null + ? const ErrorView(message: 'Card not found') + : SingleChildScrollView( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: _badgeSize, + height: _badgeSize, + alignment: Alignment.center, + decoration: BoxDecoration( + color: colors.primaryContainer, + borderRadius: BorderRadius.circular(_badgeRadius), + ), + child: Icon( + moduleIcon(card.iconName), + size: _iconSize, + color: colors.onPrimaryContainer, + ), + ), + const SizedBox(height: AppSpacing.lg), + Text(card.title, style: theme.textTheme.headlineSmall), + const SizedBox(height: AppSpacing.sm), + Chip(label: Text(card.moduleTag)), + const SizedBox(height: AppSpacing.md), + Text(card.description, style: theme.textTheme.bodyLarge), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/cards/presentation/card_grid_item_widget.dart b/lib/features/cards/presentation/card_grid_item_widget.dart index f1971c1..670040d 100644 --- a/lib/features/cards/presentation/card_grid_item_widget.dart +++ b/lib/features/cards/presentation/card_grid_item_widget.dart @@ -1,3 +1,4 @@ +import 'package:coffee_quest/core/constants/app_routes.dart'; import 'package:coffee_quest/core/utils/module_icons.dart'; import 'package:coffee_quest/features/cards/domain/cards_providers.dart'; import 'package:flutter/material.dart'; @@ -26,7 +27,12 @@ class CardGridItemWidget extends StatelessWidget { return Card( margin: EdgeInsets.zero, child: InkWell( - onTap: collected ? () => context.go('/cards/${item.card.id}') : null, + onTap: collected + ? () => context.goNamed( + AppRoutes.cardDetail.name, + pathParameters: {'cardId': item.card.id}, + ) + : null, borderRadius: BorderRadius.circular(_cornerRadius), child: Padding( padding: const EdgeInsets.all(12), diff --git a/lib/features/cards/presentation/cards_screen.dart b/lib/features/cards/presentation/cards_screen.dart index 77db50d..c0f4b3a 100644 --- a/lib/features/cards/presentation/cards_screen.dart +++ b/lib/features/cards/presentation/cards_screen.dart @@ -1,4 +1,5 @@ -import 'package:coffee_quest/core/constants/app_strings.dart'; +import 'package:coffee_quest/core/constants/app_labels.dart'; +import 'package:coffee_quest/core/constants/app_routes.dart'; import 'package:coffee_quest/core/widgets/error_view.dart'; import 'package:coffee_quest/core/widgets/loading_indicator.dart'; import 'package:coffee_quest/core/widgets/section_header.dart'; @@ -7,6 +8,7 @@ import 'package:coffee_quest/features/cards/presentation/card_grid_item_widget.d import 'package:coffee_quest/shared/theme/app_spacing.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; /// Cards tab: a grid of collectible coffee cards (locked until earned). class CardsScreen extends ConsumerWidget { @@ -18,7 +20,16 @@ class CardsScreen extends ConsumerWidget { final cards = ref.watch(cardsWithCollectionProvider); return Scaffold( - appBar: AppBar(title: const Text(AppStrings.tabCards)), + appBar: AppBar( + title: const Text(AppLabels.tabCards), + actions: [ + IconButton( + onPressed: () => context.goNamed(AppRoutes.favorites.name), + icon: const Icon(Icons.favorite), + tooltip: AppLabels.favorites, + ), + ], + ), body: cards.when( loading: () => const LoadingIndicator(), error: (e, _) => ErrorView(message: '$e'), diff --git a/lib/features/cards/presentation/favorites_screen.dart b/lib/features/cards/presentation/favorites_screen.dart new file mode 100644 index 0000000..e45dbf0 --- /dev/null +++ b/lib/features/cards/presentation/favorites_screen.dart @@ -0,0 +1,104 @@ +import 'package:coffee_quest/core/constants/app_labels.dart'; +import 'package:coffee_quest/core/widgets/error_view.dart'; +import 'package:coffee_quest/core/widgets/loading_indicator.dart'; +import 'package:coffee_quest/features/cards/domain/cards_providers.dart'; +import 'package:coffee_quest/features/cards/presentation/card_grid_item_widget.dart'; +import 'package:coffee_quest/shared/models/coffee_card_model.dart'; +import 'package:coffee_quest/shared/theme/app_spacing.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// Screen listing the coffee cards the user has favorited. +class FavoritesScreen extends ConsumerWidget { + /// Const favorite screen constructor + const FavoritesScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final favorites = ref.watch(favoriteCardsListProvider); + + return Scaffold( + appBar: AppBar( + title: const Text(AppLabels.favorites), + ), + body: favorites.when( + data: (cards) => cards.isEmpty + ? const _FavoritesEmpty() + : _FavoritesGrid(cards: cards), + error: (e, _) => ErrorView(message: '$e'), + loading: () => const LoadingIndicator(), + ), + ); + } +} + +class _FavoritesGrid extends StatelessWidget { + const _FavoritesGrid({required this.cards}); + + final List cards; + + static const _tileAspectRatio = 0.85; + + @override + Widget build(BuildContext context) { + return GridView.builder( + padding: const EdgeInsets.all(AppSpacing.md), + itemCount: cards.length, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + mainAxisSpacing: AppSpacing.sm, + crossAxisSpacing: AppSpacing.sm, + childAspectRatio: _tileAspectRatio, + crossAxisCount: 2, + ), + itemBuilder: (context, i) { + return CardGridItemWidget( + item: CardWithCollection(card: cards[i], isCollected: true), + ); + }, + ); + } +} + +class _FavoritesEmpty extends StatelessWidget { + const _FavoritesEmpty(); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xl), + child: Semantics( + label: 'No favorites yet. Tap the heart on a card to save it here.', + excludeSemantics: true, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.favorite_border, + size: AppSpacing.xxl, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(height: AppSpacing.md), + Text( + 'No favorites yet', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: AppSpacing.xs), + Text( + 'Tap the β™‘ on any card to save it here.', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/learn/presentation/game_type_practice_widgets.dart b/lib/features/learn/presentation/game_type_practice_widgets.dart index 79752a3..e08cd51 100644 --- a/lib/features/learn/presentation/game_type_practice_widgets.dart +++ b/lib/features/learn/presentation/game_type_practice_widgets.dart @@ -1,3 +1,4 @@ +import 'package:coffee_quest/core/constants/app_routes.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; @@ -39,7 +40,7 @@ class GameTypeEmptyState extends StatelessWidget { ), const SizedBox(height: 16), FilledButton( - onPressed: () => context.go('/learn'), + onPressed: () => context.goNamed(AppRoutes.learn.name), child: const Text('Back to Learn'), ), ], @@ -117,7 +118,7 @@ class GameTypeSummary extends StatelessWidget { ), const SizedBox(height: 32), FilledButton( - onPressed: () => context.go('/learn'), + onPressed: () => context.goNamed(AppRoutes.learn.name), child: const Text('Continue'), ), ], diff --git a/lib/features/learn/presentation/learn_screen.dart b/lib/features/learn/presentation/learn_screen.dart index e106047..50bc25b 100644 --- a/lib/features/learn/presentation/learn_screen.dart +++ b/lib/features/learn/presentation/learn_screen.dart @@ -1,4 +1,4 @@ -import 'package:coffee_quest/core/constants/app_strings.dart'; +import 'package:coffee_quest/core/constants/app_labels.dart'; import 'package:coffee_quest/core/widgets/error_view.dart'; import 'package:coffee_quest/core/widgets/loading_indicator.dart'; import 'package:coffee_quest/core/widgets/section_header.dart'; @@ -25,7 +25,7 @@ class LearnScreen extends ConsumerWidget { final completedLessons = ref.watch(completedLessonsProvider); return Scaffold( - appBar: AppBar(title: const Text(AppStrings.tabLearn)), + appBar: AppBar(title: const Text(AppLabels.tabLearn)), body: modules.when( loading: () => const LoadingIndicator(), error: (e, _) => ErrorView(message: '$e'), diff --git a/lib/features/learn/presentation/module_card_widget.dart b/lib/features/learn/presentation/module_card_widget.dart index 7f134d7..ff8aa8b 100644 --- a/lib/features/learn/presentation/module_card_widget.dart +++ b/lib/features/learn/presentation/module_card_widget.dart @@ -1,4 +1,5 @@ -import 'package:coffee_quest/core/constants/app_strings.dart'; +import 'package:coffee_quest/core/constants/app_labels.dart'; +import 'package:coffee_quest/core/constants/app_routes.dart'; import 'package:coffee_quest/core/utils/module_icons.dart'; import 'package:coffee_quest/features/learn/domain/learn_providers.dart'; import 'package:flutter/material.dart'; @@ -21,11 +22,14 @@ class ModuleCardWidget extends StatelessWidget { ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar( - const SnackBar(content: Text(AppStrings.lockedModuleMessage)), + const SnackBar(content: Text(AppLabels.lockedModuleMessage)), ); return; } - context.go('/learn/module/${item.module.id}'); + context.goNamed( + AppRoutes.moduleDetail.name, + pathParameters: {'moduleId': item.module.id}, + ); } @override diff --git a/lib/features/learn/presentation/module_detail_screen.dart b/lib/features/learn/presentation/module_detail_screen.dart index cbec944..0fd8cea 100644 --- a/lib/features/learn/presentation/module_detail_screen.dart +++ b/lib/features/learn/presentation/module_detail_screen.dart @@ -31,14 +31,20 @@ class ModuleDetailScreen extends ConsumerWidget { if (snap.connectionState != ConnectionState.done) { return const LoadingIndicator(); } - if (snap.hasError) return ErrorView(message: '${snap.error}'); + + if (snap.hasError) { + return ErrorView(message: '${snap.error}'); + } + final (module, lessons) = snap.data!; if (module == null) { return const ErrorView(message: 'Module not found'); } + final completedIds = completed.asData?.value.map((r) => r.lessonId).toSet() ?? const {}; + return ListView( padding: const EdgeInsets.all(16), physics: const AlwaysScrollableScrollPhysics(), @@ -67,13 +73,17 @@ class ModuleDetailScreen extends ConsumerWidget { ) async { final modules = await repo.getModules(); final module = modules.where((m) => m.id == moduleId).firstOrNull; - if (module == null) return (null, const []); + if (module == null) { + return (null, const []); + } + final all = await repo.getLessons(); final byId = {for (final l in all) l.id: l}; final lessons = [ for (final id in module.lessonIds) if (byId[id] != null) byId[id]!, ]; + return (module, lessons); } } diff --git a/lib/features/learn/presentation/module_lesson_card_widget.dart b/lib/features/learn/presentation/module_lesson_card_widget.dart index 1abee00..592e49a 100644 --- a/lib/features/learn/presentation/module_lesson_card_widget.dart +++ b/lib/features/learn/presentation/module_lesson_card_widget.dart @@ -1,3 +1,4 @@ +import 'package:coffee_quest/core/constants/app_routes.dart'; import 'package:coffee_quest/shared/models/lesson_model.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; @@ -28,14 +29,20 @@ class ModuleLessonCardWidget extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); final colors = theme.colorScheme; - final destination = isCompleted - ? '/learn/lesson/${lesson.id}?review=true' - : '/learn/lesson/${lesson.id}'; + final lessonParams = {'lessonId': lesson.id}; + // Completed lessons re-open in review mode; new ones start fresh. + final lessonQuery = isCompleted + ? const {'review': 'true'} + : const {}; return Card( margin: EdgeInsets.zero, child: InkWell( - onTap: () => context.go(destination), + onTap: () => context.goNamed( + AppRoutes.lesson.name, + pathParameters: lessonParams, + queryParameters: lessonQuery, + ), borderRadius: BorderRadius.circular(_cardRadius), child: Padding( padding: const EdgeInsets.all(14), @@ -66,7 +73,11 @@ class ModuleLessonCardWidget extends StatelessWidget { const SizedBox(width: 8), if (isCompleted) TextButton( - onPressed: () => context.go(destination), + onPressed: () => context.goNamed( + AppRoutes.lesson.name, + pathParameters: lessonParams, + queryParameters: lessonQuery, + ), child: const Text('Review'), ) else diff --git a/lib/features/learn/presentation/practice_any_lesson_widget.dart b/lib/features/learn/presentation/practice_any_lesson_widget.dart index 22f90e3..ac85ad0 100644 --- a/lib/features/learn/presentation/practice_any_lesson_widget.dart +++ b/lib/features/learn/presentation/practice_any_lesson_widget.dart @@ -1,3 +1,4 @@ +import 'package:coffee_quest/core/constants/app_routes.dart'; import 'package:coffee_quest/core/utils/module_icons.dart'; import 'package:coffee_quest/features/learn/domain/learn_providers.dart'; import 'package:flutter/material.dart'; @@ -127,7 +128,10 @@ class _LessonRow extends StatelessWidget { completed ? Icons.check_circle : Icons.fitness_center, color: completed ? colors.primary : colors.onSurfaceVariant, ), - onTap: () => context.go('/learn/practice/lesson/${entry.lesson.id}'), + onTap: () => context.goNamed( + AppRoutes.practiceLesson.name, + pathParameters: {'lessonId': entry.lesson.id}, + ), ); } } diff --git a/lib/features/learn/presentation/practice_by_game_type_widget.dart b/lib/features/learn/presentation/practice_by_game_type_widget.dart index 0271054..a92f8d8 100644 --- a/lib/features/learn/presentation/practice_by_game_type_widget.dart +++ b/lib/features/learn/presentation/practice_by_game_type_widget.dart @@ -1,3 +1,4 @@ +import 'package:coffee_quest/core/constants/app_routes.dart'; import 'package:coffee_quest/features/learn/domain/learn_providers.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; @@ -42,7 +43,10 @@ class PracticeByGameTypeWidget extends StatelessWidget { ), ), onPressed: enabled - ? () => context.go('/learn/practice/game-type/$key') + ? () => context.goNamed( + AppRoutes.practiceGameType.name, + pathParameters: {'gameType': key}, + ) : null, ); }(), diff --git a/lib/features/learn/presentation/today_card_widget.dart b/lib/features/learn/presentation/today_card_widget.dart index 3e13d0b..d319459 100644 --- a/lib/features/learn/presentation/today_card_widget.dart +++ b/lib/features/learn/presentation/today_card_widget.dart @@ -1,3 +1,4 @@ +import 'package:coffee_quest/core/constants/app_routes.dart'; import 'package:coffee_quest/shared/models/lesson_model.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; @@ -39,7 +40,10 @@ class TodayCardWidget extends StatelessWidget { LessonModel lesson, ) { return InkWell( - onTap: () => context.go('/learn/lesson/${lesson.id}'), + onTap: () => context.goNamed( + AppRoutes.lesson.name, + pathParameters: {'lessonId': lesson.id}, + ), borderRadius: BorderRadius.circular(_heroRadius), child: Padding( padding: const EdgeInsets.all(20), @@ -87,7 +91,10 @@ class TodayCardWidget extends StatelessWidget { _XpPill(xp: lesson.xpReward), const Spacer(), FilledButton.icon( - onPressed: () => context.go('/learn/lesson/${lesson.id}'), + onPressed: () => context.goNamed( + AppRoutes.lesson.name, + pathParameters: {'lessonId': lesson.id}, + ), icon: const Icon(Icons.play_arrow), label: const Text('Start'), ), diff --git a/lib/features/lessons/presentation/lesson_completion_body.dart b/lib/features/lessons/presentation/lesson_completion_body.dart index 058f6b0..d8217ec 100644 --- a/lib/features/lessons/presentation/lesson_completion_body.dart +++ b/lib/features/lessons/presentation/lesson_completion_body.dart @@ -1,4 +1,5 @@ -import 'package:coffee_quest/core/constants/app_strings.dart'; +import 'package:coffee_quest/core/constants/app_labels.dart'; +import 'package:coffee_quest/core/constants/app_routes.dart'; import 'package:coffee_quest/core/utils/module_icons.dart'; import 'package:coffee_quest/features/lessons/domain/lesson_completion_service.dart'; import 'package:coffee_quest/features/lessons/presentation/lesson_completion_reward.dart'; @@ -35,8 +36,8 @@ class LessonCompletionBody extends StatelessWidget { ..._content(context), const SizedBox(height: 32), FilledButton( - onPressed: () => context.go('/learn'), - child: const Text(AppStrings.continueLabel), + onPressed: () => context.goNamed(AppRoutes.learn.name), + child: const Text(AppLabels.continueLabel), ), ], ), diff --git a/lib/features/lessons/presentation/lesson_screen.dart b/lib/features/lessons/presentation/lesson_screen.dart index eebb1cd..7fb43e2 100644 --- a/lib/features/lessons/presentation/lesson_screen.dart +++ b/lib/features/lessons/presentation/lesson_screen.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:coffee_quest/core/constants/app_routes.dart'; import 'package:coffee_quest/core/widgets/error_view.dart'; import 'package:coffee_quest/core/widgets/loading_indicator.dart'; import 'package:coffee_quest/features/mini_games/domain/mini_game_result.dart'; @@ -75,9 +76,14 @@ class _LessonScreenState extends ConsumerState { // Mastery = first-try accuracy as a percentage (0–100). final score = (100 * _firstTryCorrectCount / lesson.steps.length) .round(); - context.go( - '/learn/lesson/${lesson.id}/complete' - '?review=${widget.review}&practice=${widget.practice}&score=$score', + context.goNamed( + AppRoutes.lessonComplete.name, + pathParameters: {'lessonId': lesson.id}, + queryParameters: { + 'review': '${widget.review}', + 'practice': '${widget.practice}', + 'score': '$score', + }, ); } else { setState(() { diff --git a/lib/features/mini_games/presentation/multiple_choice_game.dart b/lib/features/mini_games/presentation/multiple_choice_game.dart index 3d19786..64e462f 100644 --- a/lib/features/mini_games/presentation/multiple_choice_game.dart +++ b/lib/features/mini_games/presentation/multiple_choice_game.dart @@ -1,4 +1,4 @@ -import 'package:coffee_quest/core/constants/app_strings.dart'; +import 'package:coffee_quest/core/constants/app_labels.dart'; import 'package:coffee_quest/features/mini_games/domain/mini_game_result.dart'; import 'package:coffee_quest/shared/models/lesson_step_model.dart'; import 'package:flutter/material.dart'; @@ -85,7 +85,7 @@ class _MultipleChoiceGameState extends State { FilledButton( onPressed: _onContinue, child: Text( - isCorrect ? AppStrings.continueLabel : AppStrings.tryAgainLabel, + isCorrect ? AppLabels.continueLabel : AppLabels.tryAgainLabel, ), ), ], diff --git a/lib/features/mini_games/presentation/slider_game.dart b/lib/features/mini_games/presentation/slider_game.dart index 5de5bdd..f2f773e 100644 --- a/lib/features/mini_games/presentation/slider_game.dart +++ b/lib/features/mini_games/presentation/slider_game.dart @@ -1,4 +1,4 @@ -import 'package:coffee_quest/core/constants/app_strings.dart'; +import 'package:coffee_quest/core/constants/app_labels.dart'; import 'package:coffee_quest/features/mini_games/domain/mini_game_result.dart'; import 'package:coffee_quest/shared/models/lesson_step_model.dart'; import 'package:flutter/material.dart'; @@ -84,7 +84,7 @@ class _SliderGameState extends State { FilledButton( onPressed: _onContinue, child: Text( - _inRange ? AppStrings.continueLabel : AppStrings.tryAgainLabel, + _inRange ? AppLabels.continueLabel : AppLabels.tryAgainLabel, ), ), ], diff --git a/lib/features/mini_games/presentation/tap_order_game.dart b/lib/features/mini_games/presentation/tap_order_game.dart index 9a7b571..cb0ad23 100644 --- a/lib/features/mini_games/presentation/tap_order_game.dart +++ b/lib/features/mini_games/presentation/tap_order_game.dart @@ -1,4 +1,4 @@ -import 'package:coffee_quest/core/constants/app_strings.dart'; +import 'package:coffee_quest/core/constants/app_labels.dart'; import 'package:coffee_quest/features/mini_games/domain/mini_game_result.dart'; import 'package:coffee_quest/shared/models/lesson_step_model.dart'; import 'package:coffee_quest/shared/theme/app_spacing.dart'; @@ -113,7 +113,7 @@ class _TapOrderGameState extends State { if (_correct) FilledButton( onPressed: _onContinue, - child: const Text(AppStrings.continueLabel), + child: const Text(AppLabels.continueLabel), ) else Row( @@ -128,7 +128,7 @@ class _TapOrderGameState extends State { Expanded( child: FilledButton( onPressed: _onContinue, - child: const Text(AppStrings.tryAgainLabel), + child: const Text(AppLabels.tryAgainLabel), ), ), ], diff --git a/lib/features/onboarding/presentation/brewer/brewer_screen.dart b/lib/features/onboarding/presentation/brewer/brewer_screen.dart index e75e062..ebc0a0c 100644 --- a/lib/features/onboarding/presentation/brewer/brewer_screen.dart +++ b/lib/features/onboarding/presentation/brewer/brewer_screen.dart @@ -1,3 +1,4 @@ +import 'package:coffee_quest/core/constants/app_routes.dart'; import 'package:coffee_quest/core/widgets/pick_card.dart'; import 'package:coffee_quest/core/widgets/primary_button.dart'; import 'package:coffee_quest/core/widgets/smallcaps_label.dart'; @@ -53,7 +54,7 @@ class _BrewerScreenState extends ConsumerState { await draft.complete(); }, onFinished: () { - if (mounted) context.go('/learn'); + if (mounted) context.goNamed(AppRoutes.learn.name); }, )..addListener(_onControllerChanged); } diff --git a/lib/features/onboarding/presentation/goal/goal_screen.dart b/lib/features/onboarding/presentation/goal/goal_screen.dart index b7a664b..ccc8a32 100644 --- a/lib/features/onboarding/presentation/goal/goal_screen.dart +++ b/lib/features/onboarding/presentation/goal/goal_screen.dart @@ -1,3 +1,4 @@ +import 'package:coffee_quest/core/constants/app_routes.dart'; import 'package:coffee_quest/core/widgets/pick_card.dart'; import 'package:coffee_quest/core/widgets/primary_button.dart'; import 'package:coffee_quest/core/widgets/smallcaps_label.dart'; @@ -53,7 +54,7 @@ class _GoalScreenState extends ConsumerState { _controller = GoalController( onSubmit: (index) { ref.read(onboardingDraftProvider.notifier).setGoal(_options[index].key); - context.go('/onboarding/brewer'); + context.goNamed(AppRoutes.onboardingBrewer.name); }, )..addListener(_onControllerChanged); } diff --git a/lib/features/onboarding/presentation/loading/loading_screen.dart b/lib/features/onboarding/presentation/loading/loading_screen.dart index 7735e83..5134fc9 100644 --- a/lib/features/onboarding/presentation/loading/loading_screen.dart +++ b/lib/features/onboarding/presentation/loading/loading_screen.dart @@ -1,3 +1,4 @@ +import 'package:coffee_quest/core/constants/app_routes.dart'; import 'package:coffee_quest/features/onboarding/presentation/loading/loading_animation.dart'; import 'package:coffee_quest/features/onboarding/presentation/loading/wake_sequence_controller.dart'; import 'package:coffee_quest/features/onboarding/presentation/loading/widgets/pulsing_dots.dart'; @@ -71,8 +72,11 @@ class _LoadingScreenState extends ConsumerState { /// (`appRouter`) owns the gateβ†’destination policy and bounces returning /// users on to `/learn`, so this screen does not duplicate that decision. void _advance() { - if (!mounted) return; - context.goNamed('welcome'); + if (!mounted) { + return; + } + + context.goNamed(AppRoutes.welcome.name); } @override diff --git a/lib/features/onboarding/presentation/onboarding_providers.g.dart b/lib/features/onboarding/presentation/onboarding_providers.g.dart index 7714500..5f2739c 100644 --- a/lib/features/onboarding/presentation/onboarding_providers.g.dart +++ b/lib/features/onboarding/presentation/onboarding_providers.g.dart @@ -163,7 +163,7 @@ abstract class _$OnboardingDraft ({String? brewer, String? goal}) build(); @$mustCallSuper @override - void runBuild() { + WhenComplete runBuild() { final ref = this.ref as $Ref< @@ -181,6 +181,6 @@ abstract class _$OnboardingDraft Object?, Object? >; - element.handleCreate(ref, build); + return element.handleCreate(ref, build); } } diff --git a/lib/features/onboarding/presentation/welcome/welcome_screen.dart b/lib/features/onboarding/presentation/welcome/welcome_screen.dart index a2c11f2..b3ecda0 100644 --- a/lib/features/onboarding/presentation/welcome/welcome_screen.dart +++ b/lib/features/onboarding/presentation/welcome/welcome_screen.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:coffee_quest/core/constants/app_routes.dart'; import 'package:coffee_quest/core/widgets/link_button.dart'; import 'package:coffee_quest/core/widgets/primary_button.dart'; import 'package:coffee_quest/core/widgets/smallcaps_label.dart'; @@ -69,7 +70,7 @@ class WelcomeScreen extends ConsumerWidget { const SizedBox(height: AppSpacing.lg + 4), PrimaryButton( label: 'Plant your seed', - onPressed: () => context.go('/onboarding/goal'), + onPressed: () => context.goNamed(AppRoutes.onboardingGoal.name), ), Center( child: LinkButton( diff --git a/lib/features/path/presentation/path_module_node_widget.dart b/lib/features/path/presentation/path_module_node_widget.dart index 21ddb98..d34e5ab 100644 --- a/lib/features/path/presentation/path_module_node_widget.dart +++ b/lib/features/path/presentation/path_module_node_widget.dart @@ -1,4 +1,5 @@ -import 'package:coffee_quest/core/constants/app_strings.dart'; +import 'package:coffee_quest/core/constants/app_labels.dart'; +import 'package:coffee_quest/core/constants/app_routes.dart'; import 'package:coffee_quest/features/learn/domain/learn_providers.dart'; import 'package:coffee_quest/features/path/presentation/path_node_card.dart'; import 'package:coffee_quest/features/path/presentation/path_node_rail.dart'; @@ -33,11 +34,14 @@ class PathModuleNodeWidget extends StatelessWidget { ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar( - const SnackBar(content: Text(AppStrings.lockedModuleMessage)), + const SnackBar(content: Text(AppLabels.lockedModuleMessage)), ); return; } - context.go('/learn/module/${item.module.id}'); + context.goNamed( + AppRoutes.moduleDetail.name, + pathParameters: {'moduleId': item.module.id}, + ); } @override diff --git a/lib/features/path/presentation/path_screen.dart b/lib/features/path/presentation/path_screen.dart index 25e9c3f..2023f96 100644 --- a/lib/features/path/presentation/path_screen.dart +++ b/lib/features/path/presentation/path_screen.dart @@ -1,4 +1,4 @@ -import 'package:coffee_quest/core/constants/app_strings.dart'; +import 'package:coffee_quest/core/constants/app_labels.dart'; import 'package:coffee_quest/core/widgets/error_view.dart'; import 'package:coffee_quest/core/widgets/loading_indicator.dart'; import 'package:coffee_quest/features/learn/domain/learn_providers.dart'; @@ -17,7 +17,7 @@ class PathScreen extends ConsumerWidget { final modules = ref.watch(modulesWithProgressProvider); return Scaffold( - appBar: AppBar(title: const Text(AppStrings.tabPath)), + appBar: AppBar(title: const Text(AppLabels.tabPath)), body: modules.when( loading: () => const LoadingIndicator(), error: (e, _) => ErrorView(message: '$e'), diff --git a/lib/features/profile/domain/settings_providers.g.dart b/lib/features/profile/domain/settings_providers.g.dart index b86713e..00343f9 100644 --- a/lib/features/profile/domain/settings_providers.g.dart +++ b/lib/features/profile/domain/settings_providers.g.dart @@ -53,7 +53,7 @@ abstract class _$SettingsController extends $AsyncNotifier { FutureOr build(); @$mustCallSuper @override - void runBuild() { + WhenComplete runBuild() { final ref = this.ref as $Ref, UserSettingsRecord>; final element = @@ -64,7 +64,7 @@ abstract class _$SettingsController extends $AsyncNotifier { Object?, Object? >; - element.handleCreate(ref, build); + return element.handleCreate(ref, build); } } diff --git a/lib/features/profile/presentation/profile_screen.dart b/lib/features/profile/presentation/profile_screen.dart index 218267b..0093619 100644 --- a/lib/features/profile/presentation/profile_screen.dart +++ b/lib/features/profile/presentation/profile_screen.dart @@ -1,3 +1,4 @@ +import 'package:coffee_quest/core/constants/app_routes.dart'; import 'package:coffee_quest/features/profile/domain/settings_providers.dart'; import 'package:coffee_quest/features/profile/presentation/widgets/preference_tile.dart'; import 'package:coffee_quest/features/profile/presentation/widgets/premium_card.dart'; @@ -31,8 +32,9 @@ class ProfileScreen extends ConsumerWidget { pinned: true, delegate: ProfileHeaderDelegate( title: 'Profile', - onClose: () => context.go('/learn'), - onSettings: () => context.go('/profile/settings'), + onClose: () => context.goNamed(AppRoutes.learn.name), + onSettings: () => + context.goNamed(AppRoutes.profileSettings.name), ), ), SliverPadding( diff --git a/lib/features/profile/presentation/settings_screen.dart b/lib/features/profile/presentation/settings_screen.dart index 08b7fe8..b2c3103 100644 --- a/lib/features/profile/presentation/settings_screen.dart +++ b/lib/features/profile/presentation/settings_screen.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:coffee_quest/core/constants/app_routes.dart'; import 'package:coffee_quest/features/onboarding/presentation/onboarding_providers.dart'; import 'package:coffee_quest/features/profile/domain/settings_providers.dart'; import 'package:flutter/material.dart'; @@ -217,6 +218,6 @@ class _ResetOnboardingTile extends ConsumerWidget { await ref.read(onboardingRepositoryProvider).resetOnboarding(); ref.invalidate(onboardingCompletedProvider); if (!context.mounted) return; - context.go('/welcome'); + context.goNamed(AppRoutes.welcome.name); } } diff --git a/task.md b/task.md deleted file mode 100644 index 7f109d6..0000000 --- a/task.md +++ /dev/null @@ -1 +0,0 @@ -Add loading screen with animation. The animation is the coffee bean that cracks in 2 peaces, the animation is cycled. User sees it until the application is finally loaded diff --git a/test/unit/app_routes_test.dart b/test/unit/app_routes_test.dart new file mode 100644 index 0000000..a7aa601 --- /dev/null +++ b/test/unit/app_routes_test.dart @@ -0,0 +1,103 @@ +import 'package:coffee_quest/app/app_router.dart'; +import 'package:coffee_quest/core/constants/app_routes.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; + +import '../support/widget_harness.dart'; + +/// Guards name↔path sync: each [AppRoute] declares its name and path together, +/// and the router builds every `GoRoute` from them. These assertions resolve +/// each name back to its location so the two can never silently drift. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(useInMemoryDatabase); + + late ProviderContainer container; + late GoRouter router; + + setUp(() { + container = ProviderContainer(); + addTearDown(container.dispose); + router = container.read(appRouterProvider); + }); + + String locationOf( + String name, { + Map pathParameters = const {}, + Map queryParameters = const {}, + }) => router.namedLocation( + name, + pathParameters: pathParameters, + queryParameters: queryParameters, + ); + + test('param-less routes resolve to their canonical paths', () { + expect(locationOf(AppRoutes.loading.name), '/loading'); + expect(locationOf(AppRoutes.welcome.name), '/welcome'); + expect(locationOf(AppRoutes.onboardingGoal.name), '/onboarding/goal'); + expect(locationOf(AppRoutes.onboardingBrewer.name), '/onboarding/brewer'); + expect(locationOf(AppRoutes.learn.name), '/learn'); + expect(locationOf(AppRoutes.path.name), '/path'); + expect(locationOf(AppRoutes.cards.name), '/cards'); + expect(locationOf(AppRoutes.favorites.name), '/cards/favorites'); + expect(locationOf(AppRoutes.profile.name), '/profile'); + expect(locationOf(AppRoutes.profileSettings.name), '/profile/settings'); + }); + + test('parametrized routes interpolate path parameters', () { + expect( + locationOf( + AppRoutes.moduleDetail.name, + pathParameters: {'moduleId': 'beans'}, + ), + '/learn/module/beans', + ); + expect( + locationOf(AppRoutes.lesson.name, pathParameters: {'lessonId': 'l1'}), + '/learn/lesson/l1', + ); + expect( + locationOf( + AppRoutes.practiceLesson.name, + pathParameters: {'lessonId': 'l1'}, + ), + '/learn/practice/lesson/l1', + ); + expect( + locationOf( + AppRoutes.practiceGameType.name, + pathParameters: {'gameType': 'slider'}, + ), + '/learn/practice/game-type/slider', + ); + expect( + locationOf(AppRoutes.cardDetail.name, pathParameters: {'cardId': 'c1'}), + '/cards/c1', + ); + }); + + test('lesson and lessonComplete carry their query parameters', () { + expect( + locationOf( + AppRoutes.lesson.name, + pathParameters: {'lessonId': 'l1'}, + queryParameters: {'review': 'true'}, + ), + '/learn/lesson/l1?review=true', + ); + expect( + locationOf( + AppRoutes.lessonComplete.name, + pathParameters: {'lessonId': 'l1'}, + queryParameters: { + 'review': 'false', + 'practice': 'false', + 'score': '80', + }, + ), + '/learn/lesson/l1/complete?review=false&practice=false&score=80', + ); + }); +} diff --git a/test/widget/features/onboarding/welcome_screen_test.dart b/test/widget/features/onboarding/welcome_screen_test.dart index 2f028f1..d140e1c 100644 --- a/test/widget/features/onboarding/welcome_screen_test.dart +++ b/test/widget/features/onboarding/welcome_screen_test.dart @@ -1,3 +1,4 @@ +import 'package:coffee_quest/core/constants/app_routes.dart'; import 'package:coffee_quest/features/onboarding/presentation/welcome/welcome_screen.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -13,9 +14,14 @@ void main() { final router = GoRouter( initialLocation: '/welcome', routes: [ - GoRoute(path: '/welcome', builder: (_, _) => const WelcomeScreen()), + GoRoute( + path: '/welcome', + name: AppRoutes.welcome.name, + builder: (_, _) => const WelcomeScreen(), + ), GoRoute( path: '/onboarding/goal', + name: AppRoutes.onboardingGoal.name, builder: (_, _) => const Scaffold(body: Text('goal-stub')), ), ], diff --git a/test/widget/learn_screen_test.dart b/test/widget/learn_screen_test.dart index 5354b56..ca45119 100644 --- a/test/widget/learn_screen_test.dart +++ b/test/widget/learn_screen_test.dart @@ -1,4 +1,4 @@ -import 'package:coffee_quest/core/constants/app_strings.dart'; +import 'package:coffee_quest/core/constants/app_labels.dart'; import 'package:coffee_quest/features/learn/domain/learn_providers.dart'; import 'package:coffee_quest/features/learn/presentation/learn_screen.dart'; import 'package:coffee_quest/features/learn/presentation/module_card_widget.dart'; @@ -98,6 +98,6 @@ void main() { await tester.tap(find.byIcon(Icons.lock_outline).first); await tester.pump(); // let the SnackBar appear - expect(find.text(AppStrings.lockedModuleMessage), findsOneWidget); + expect(find.text(AppLabels.lockedModuleMessage), findsOneWidget); }); }