Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,5 @@ app.*.map.json
.DS_Store
# Widget Preview related
.widget_preview/

task.md
6 changes: 3 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions learning/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
54 changes: 39 additions & 15 deletions learning/curriculum.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,31 +20,36 @@ 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<CoffeeCardModel>`.
- ☐ **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)

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`.
Expand Down Expand Up @@ -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.**
114 changes: 75 additions & 39 deletions lib/app/app_router.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -25,6 +27,19 @@ part 'app_router.g.dart';
final _rootKey = GlobalKey<NavigatorState>();

/// 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
Expand All @@ -37,50 +52,66 @@ 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: [
AnalyticsNavigatorObserver(ref.watch(analyticsServiceProvider)),
],
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(
Expand All @@ -89,22 +120,22 @@ 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']!,
),
),
// 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']!,
Expand All @@ -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']!,
Expand All @@ -134,17 +165,17 @@ 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']!,
practice: true,
),
),
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']!,
Expand All @@ -157,22 +188,27 @@ GoRouter appRouter(Ref ref) {
StatefulShellBranch(
routes: [
GoRoute(
path: '/path',
name: 'path',
path: AppRoutes.path.path,
name: AppRoutes.path.name,
builder: (context, state) => const PathScreen(),
),
],
),
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']!,
),
Expand All @@ -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(),
),
Expand Down
2 changes: 1 addition & 1 deletion lib/app/app_router.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading