Skip to content
Closed
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
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
42 changes: 31 additions & 11 deletions learning/curriculum.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<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
- ☑ **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')`.
Expand All @@ -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`.
Expand Down Expand Up @@ -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.**
18 changes: 18 additions & 0 deletions lib/features/cards/domain/cards_providers.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -37,3 +38,20 @@ Future<List<CardWithCollection>> cardsWithCollection(Ref ref) async {
)
.toList();
}

/// Favorite user cards, derived from the in-memory favorites set.
@riverpod
Future<List<CoffeeCardModel>> 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<CoffeeCardModel?> cardById(Ref ref, String cardId) async {
final cards = await ref.watch(contentRepositoryProvider).getCards();

return cards.where((card) => card.id == cardId).firstOrNull;
}
130 changes: 130 additions & 0 deletions lib/features/cards/domain/cards_providers.g.dart

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

2 changes: 1 addition & 1 deletion lib/features/cards/domain/favorite_cards_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
19 changes: 16 additions & 3 deletions lib/features/cards/domain/favorite_cards_provider.g.dart

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

78 changes: 78 additions & 0 deletions lib/features/cards/presentation/card_detail_screen.dart
Original file line number Diff line number Diff line change
@@ -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),
],
),
),
),
);
}
}
Loading
Loading