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..bf18db0 100644 --- a/learning/curriculum.md +++ b/learning/curriculum.md @@ -20,16 +20,21 @@ 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 +- ☑ **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 `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')`. @@ -44,7 +49,7 @@ invalidation. `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,18 @@ 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.** 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..b65e52d 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) 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/favorites_screen.dart b/lib/features/cards/presentation/favorites_screen.dart new file mode 100644 index 0000000..0e94454 --- /dev/null +++ b/lib/features/cards/presentation/favorites_screen.dart @@ -0,0 +1,103 @@ +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('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/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/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/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/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