From 604ee8b0ef746bd57728de183c95e364ab96f0fa Mon Sep 17 00:00:00 2001 From: maximsan Date: Tue, 16 Jun 2026 10:02:43 +0200 Subject: [PATCH 1/3] feat: add favorite/unfavorite logic --- .gitignore | 2 + CLAUDE.md | 6 +- learning/README.md | 4 +- learning/curriculum.md | 24 +++-- .../domain/favorite_cards_provider.g.dart | 19 +++- .../presentation/card_detail_screen.dart | 93 +++++++++++++++++++ .../presentation/onboarding_providers.g.dart | 4 +- .../profile/domain/settings_providers.g.dart | 4 +- task.md | 1 - 9 files changed, 137 insertions(+), 20 deletions(-) create mode 100644 lib/features/cards/presentation/card_detail_screen.dart delete mode 100644 task.md 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..34afae5 100644 --- a/learning/curriculum.md +++ b/learning/curriculum.md @@ -20,12 +20,16 @@ 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`. +- ☑ **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.** In `cards_providers.dart`: (a) `favoriteCardsList` + (function-style `@riverpod`) combining the favorites set with + `contentRepository.getCards()` → `List` (for B4's screen); and + (b) `cardById` (family) to replace the inline-Future antipattern in + `card_detail_screen.dart` and kill the toggle flicker. - ☐ **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. @@ -44,7 +48,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 +76,9 @@ 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.** diff --git a/lib/features/cards/domain/favorite_cards_provider.g.dart b/lib/features/cards/domain/favorite_cards_provider.g.dart index bd547cd..db4874d 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 favorited 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 favorited 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 favorited 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 favorited 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..d8336f7 --- /dev/null +++ b/lib/features/cards/presentation/card_detail_screen.dart @@ -0,0 +1,93 @@ +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/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/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 repo = ref.watch(contentRepositoryProvider); + final isFavorite = ref.watch(favoriteCardsProvider).contains(cardId); + + 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: FutureBuilder( + future: repo.getCards().then( + (cards) => cards.where((c) => c.id == cardId).firstOrNull, + ), + builder: (context, snap) { + if (snap.connectionState != ConnectionState.done) { + return const LoadingIndicator(); + } + + if (snap.hasError) { + return ErrorView(message: '${snap.error}'); + } + + final card = snap.data; + if (card == null) { + return const ErrorView(message: 'Card not found'); + } + + final theme = Theme.of(context); + final colors = theme.colorScheme; + + return 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/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 From 558dcf2133982509dac960dcd7bceae094fedf1f Mon Sep 17 00:00:00 2001 From: maximsan Date: Tue, 16 Jun 2026 11:59:12 +0200 Subject: [PATCH 2/3] feat: get favorite card by id without UI flickering --- learning/curriculum.md | 18 ++- .../cards/domain/cards_providers.dart | 18 +++ .../cards/domain/cards_providers.g.dart | 139 ++++++++++++++++++ .../cards/domain/favorite_cards_provider.dart | 2 +- .../presentation/card_detail_screen.dart | 89 +++++------ 5 files changed, 206 insertions(+), 60 deletions(-) diff --git a/learning/curriculum.md b/learning/curriculum.md index 34afae5..8fc0481 100644 --- a/learning/curriculum.md +++ b/learning/curriculum.md @@ -25,13 +25,12 @@ derived providers, `AsyncValue` UI, empty/loading/error states. 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.** In `cards_providers.dart`: (a) `favoriteCardsList` - (function-style `@riverpod`) combining the favorites set with - `contentRepository.getCards()` → `List` (for B4's screen); and - (b) `cardById` (family) to replace the inline-Future antipattern in - `card_detail_screen.dart` and kill the toggle flicker. -- ☐ **B4 — Favorites screen.** New `FavoritesScreen` (`ConsumerWidget`) rendering - the list, with proper loading / empty / error states. Use +- ☑ **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.** New `FavoritesScreen` (`ConsumerWidget`) + rendering `favoriteCardsList`, 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 @@ -82,3 +81,8 @@ invalidation. 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`.** 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..5bdd18a 100644 --- a/lib/features/cards/domain/cards_providers.g.dart +++ b/lib/features/cards/domain/cards_providers.g.dart @@ -69,3 +69,142 @@ final class CardsWithCollectionProvider String _$cardsWithCollectionHash() => r'e90b2194edb08dcedb9d9014f5086725bf05c085'; + +/// Watches two things: ref.watch(favoriteCardsProvider) (the Set) +/// and ref.watch(contentRepositoryProvider).getCards() (the card content). +/// Returns Future> — +/// the cards whose id is in the favorites set. + +@ProviderFor(favoriteCardsList) +final favoriteCardsListProvider = FavoriteCardsListProvider._(); + +/// Watches two things: ref.watch(favoriteCardsProvider) (the Set) +/// and ref.watch(contentRepositoryProvider).getCards() (the card content). +/// Returns Future> — +/// the cards whose id is in the favorites set. + +final class FavoriteCardsListProvider + extends + $FunctionalProvider< + AsyncValue>, + List, + FutureOr> + > + with + $FutureModifier>, + $FutureProvider> { + /// Watches two things: ref.watch(favoriteCardsProvider) (the Set) + /// and ref.watch(contentRepositoryProvider).getCards() (the card content). + /// Returns Future> — + /// the cards whose id is in the 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/presentation/card_detail_screen.dart b/lib/features/cards/presentation/card_detail_screen.dart index d8336f7..b263240 100644 --- a/lib/features/cards/presentation/card_detail_screen.dart +++ b/lib/features/cards/presentation/card_detail_screen.dart @@ -1,9 +1,8 @@ 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/models/coffee_card_model.dart'; -import 'package:coffee_quest/shared/repositories/content_repository.dart'; import 'package:coffee_quest/shared/theme/app_spacing.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -22,8 +21,11 @@ class CardDetailScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final repo = ref.watch(contentRepositoryProvider); 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( @@ -37,56 +39,39 @@ class CardDetailScreen extends ConsumerWidget { ), ], ), - body: FutureBuilder( - future: repo.getCards().then( - (cards) => cards.where((c) => c.id == cardId).firstOrNull, - ), - builder: (context, snap) { - if (snap.connectionState != ConnectionState.done) { - return const LoadingIndicator(); - } - - if (snap.hasError) { - return ErrorView(message: '${snap.error}'); - } - - final card = snap.data; - if (card == null) { - return const ErrorView(message: 'Card not found'); - } - - final theme = Theme.of(context); - final colors = theme.colorScheme; - - return 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, - ), + 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), + ], ), - 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), - ], - ), - ); - }, + ), ), ); } From fbd936f49b8835f320d9a9afc0a0c1bc283e125a Mon Sep 17 00:00:00 2001 From: maximsan Date: Tue, 16 Jun 2026 20:15:51 +0200 Subject: [PATCH 3/3] feat: implement favorites screen --- learning/curriculum.md | 14 ++- .../cards/domain/cards_providers.g.dart | 15 +-- .../domain/favorite_cards_provider.g.dart | 8 +- .../cards/presentation/favorites_screen.dart | 103 ++++++++++++++++++ .../presentation/module_detail_screen.dart | 14 ++- 5 files changed, 132 insertions(+), 22 deletions(-) create mode 100644 lib/features/cards/presentation/favorites_screen.dart diff --git a/learning/curriculum.md b/learning/curriculum.md index 8fc0481..bf18db0 100644 --- a/learning/curriculum.md +++ b/learning/curriculum.md @@ -29,10 +29,12 @@ derived providers, `AsyncValue` UI, empty/loading/error states. `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.** New `FavoritesScreen` (`ConsumerWidget`) - rendering `favoriteCardsList`, 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 +- ☑ **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')`. @@ -86,3 +88,7 @@ invalidation. 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.g.dart b/lib/features/cards/domain/cards_providers.g.dart index 5bdd18a..82d5381 100644 --- a/lib/features/cards/domain/cards_providers.g.dart +++ b/lib/features/cards/domain/cards_providers.g.dart @@ -70,18 +70,12 @@ final class CardsWithCollectionProvider String _$cardsWithCollectionHash() => r'e90b2194edb08dcedb9d9014f5086725bf05c085'; -/// Watches two things: ref.watch(favoriteCardsProvider) (the Set) -/// and ref.watch(contentRepositoryProvider).getCards() (the card content). -/// Returns Future> — -/// the cards whose id is in the favorites set. +/// Favorite user cards, derived from the in-memory favorites set. @ProviderFor(favoriteCardsList) final favoriteCardsListProvider = FavoriteCardsListProvider._(); -/// Watches two things: ref.watch(favoriteCardsProvider) (the Set) -/// and ref.watch(contentRepositoryProvider).getCards() (the card content). -/// Returns Future> — -/// the cards whose id is in the favorites set. +/// Favorite user cards, derived from the in-memory favorites set. final class FavoriteCardsListProvider extends @@ -93,10 +87,7 @@ final class FavoriteCardsListProvider with $FutureModifier>, $FutureProvider> { - /// Watches two things: ref.watch(favoriteCardsProvider) (the Set) - /// and ref.watch(contentRepositoryProvider).getCards() (the card content). - /// Returns Future> — - /// the cards whose id is in the favorites set. + /// Favorite user cards, derived from the in-memory favorites set. FavoriteCardsListProvider._() : super( from: null, diff --git a/lib/features/cards/domain/favorite_cards_provider.g.dart b/lib/features/cards/domain/favorite_cards_provider.g.dart index db4874d..caa9866 100644 --- a/lib/features/cards/domain/favorite_cards_provider.g.dart +++ b/lib/features/cards/domain/favorite_cards_provider.g.dart @@ -8,19 +8,19 @@ part of 'favorite_cards_provider.dart'; // GENERATED CODE - DO NOT MODIFY BY HAND // ignore_for_file: type=lint, type=warning -/// 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. @ProviderFor(FavoriteCards) final favoriteCardsProvider = FavoriteCardsProvider._(); -/// 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. final class FavoriteCardsProvider extends $NotifierProvider> { - /// 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. FavoriteCardsProvider._() @@ -52,7 +52,7 @@ final class FavoriteCardsProvider String _$favoriteCardsHash() => r'85779f39d3a950074a09ad32f9563e233a427922'; -/// 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. 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); } }