Skip to content
Merged
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
7 changes: 7 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,13 @@ You can always edit this file by hand instead — the helpers just save effort.
answer that latches the card, and reduced motion lands it in a single frame
with the colours and the words still changing.

- **The course ending counts what the spec asked for.** The completion moment
now shows the learner's *longest* streak and the number of Module Rewards
they earned, in place of the current streak and the total card count it
stood in with. Both are derived — the longest run comes off the same fold as
the current one, and a Module Reward is a card the content bank points at a
module — so nothing new is stored and a reset still leaves nothing behind.

- **The Today tour is the one the design draws.** A bordered frame travels
between the four things it introduces instead of a spotlight snapping
between them, the card carries its own counter, dots, Skip and Next, and
Expand Down
12 changes: 6 additions & 6 deletions lib/features/cards/domain/cards_providers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,12 @@ class CardWithCollection {
final bool isCollected;
}

/// Reads the collected ids **off the snapshot directly** rather than chaining
/// through `collectedCardsProvider.future`. The chained form hits a Riverpod
/// 3.2.1 internal-pause-state assertion (issue #4709) when the
/// `StatefulShellRoute` toggles `TickerMode` after the lesson-completion
/// screen invalidates the inner provider. Callers that mutate collected
/// cards must invalidate this provider alongside `collectedCardsProvider`.
/// Every card the bank holds, paired with whether the learner owns it.
///
/// Reads the collected ids **off the snapshot directly**: chaining through a
/// provider hit a Riverpod 3.2.1 pause-state assertion (issue #4709) under the
/// `StatefulShellRoute`. So a caller that collects a card invalidates this,
/// and everything showing a collection hangs off it.
@riverpod
Future<List<CardWithCollection>> cardsWithCollection(Ref ref) async {
final content = ref.watch(contentRepositoryProvider);
Expand Down
36 changes: 18 additions & 18 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.

13 changes: 13 additions & 0 deletions lib/features/cards/domain/module_rewards.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/// The completion moment's Module Reward count.
library;

import 'package:brew_path/features/cards/domain/cards_providers.dart';

/// How many Module Rewards the learner owns, out of [collection].
///
/// Derived on every read, never marked when a card is earned: a stored marker
/// would need a merge rule and a reset path of its own (#149).
int collectedModuleRewards(Iterable<CardWithCollection> collection) =>
collection
.where((entry) => entry.isCollected && entry.card.isModuleReward)
.length;
27 changes: 18 additions & 9 deletions lib/features/learn/presentation/course_completion_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import 'dart:async';
import 'package:brew_path/core/constants/app_routes.dart';
import 'package:brew_path/core/widgets/loading_indicator.dart';
import 'package:brew_path/core/widgets/sticky_action_bar.dart';
import 'package:brew_path/features/cards/domain/cards_providers.dart';
import 'package:brew_path/features/cards/domain/module_rewards.dart';
import 'package:brew_path/features/companion/domain/companion_reaction.dart';
import 'package:brew_path/features/companion/presentation/companion_celebration.dart';
import 'package:brew_path/features/learn/domain/course_completion_providers.dart';
Expand Down Expand Up @@ -70,17 +72,17 @@ class _CourseCompletionScreenState
final mood = context.mood;

final lessons = ref.watch(completedLessonsProvider);
final cards = ref.watch(collectedCardsProvider);
final streak = ref.watch(streakProvider);
final cards = ref.watch(cardsWithCollectionProvider);
final streak = ref.watch(streakStatusProvider);
// The ending must never paint "0 lessons" for a loading frame; the
// moment waits for its own numbers.
if (!lessons.hasValue || !cards.hasValue || !streak.hasValue) {
return const Scaffold(body: LoadingIndicator());
}
final stats = (
lessons: lessons.value?.count ?? 0,
cards: cards.value?.length ?? 0,
streak: streak.value ?? 0,
moduleRewards: collectedModuleRewards(cards.value ?? const []),
longestStreak: streak.value?.longestStreak ?? 0,
);
return Scaffold(
// The bar takes the bottom inset itself, so this must not consume it
Expand Down Expand Up @@ -140,25 +142,32 @@ class _CourseCompletionScreenState
);
}

/// The three stats the ending reports.
///
/// #149 rules the last two off the design's own *Cards collected* and
/// *Day streak*, so each label says what its number actually is.
Widget _statsSummary(_Stats stats) {
return Semantics(
label:
'What you did: ${stats.lessons} lessons completed, '
'${stats.cards} cards collected, '
'a ${stats.streak} day streak.',
'${stats.moduleRewards} Module Rewards earned, '
'a longest streak of ${stats.longestStreak} days.',
child: Column(
children: [
_StatRow(label: 'Lessons completed', value: '${stats.lessons}'),
_StatRow(label: 'Cards collected', value: '${stats.cards}'),
_StatRow(label: 'Day streak', value: '${stats.streak}'),
_StatRow(
label: 'Module Rewards',
value: '${stats.moduleRewards}',
),
_StatRow(label: 'Longest streak', value: '${stats.longestStreak}'),
],
),
);
}
}

/// The three derived completion stats, travelling together.
typedef _Stats = ({int lessons, int cards, int streak});
typedef _Stats = ({int lessons, int moduleRewards, int longestStreak});

class _StatRow extends StatelessWidget {
const _StatRow({required this.label, required this.value});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,7 @@ class _LessonCompletionScreenState
ref.invalidate(modulesWithProgressProvider);
ref.invalidate(totalPointsProvider);
ref.invalidate(completedLessonsProvider);
ref.invalidate(collectedCardsProvider);
// `cardsWithCollectionProvider` no longer chains through
// `collectedCardsProvider`, so invalidate it explicitly.
// The cards grid and the Module Reward count both hang off this one.
ref.invalidate(cardsWithCollectionProvider);
// A finished lesson can unlock a Coffee Challenge, and Today and Profile
// stay mounted behind this screen — so neither would notice on its own.
Expand Down
1 change: 0 additions & 1 deletion lib/features/profile/domain/settings_providers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,6 @@ Future<void> resetProgress(WidgetRef ref) async {
ref.invalidate(totalPointsProvider);
ref.invalidate(streakStatusProvider);
ref.invalidate(completedLessonsProvider);
ref.invalidate(collectedCardsProvider);
ref.invalidate(cardsWithCollectionProvider);
ref.invalidate(modulesWithProgressProvider);
ref.invalidate(todayLessonProvider);
Expand Down
65 changes: 17 additions & 48 deletions lib/features/progress/domain/progress_providers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,10 @@ part 'progress_providers.g.dart';

/// The learner's points total — **derived, never stored**.
///
/// Two payouts exist and both leave a record: a finished lesson is worth the
/// flat ten it authors, and a challenge's five is implied by its id sitting in
/// the completed set. Summing them here means the total cannot drift from what
/// was actually earned, and Reset Progress needs no rule of its own — clearing
/// the completions clears the total by construction.
///
/// **The payout is read off the course, not off a copy of it.** The old
/// completions table banked the points on the row; the snapshot stores which
/// lessons are finished and nothing about what they paid, because what a
/// lesson is worth is a fact about the lesson. A finished lesson the content
/// no longer carries therefore pays nothing, which is the same answer a
/// dropped row would have given.
/// Summed from the two records that exist: a finished lesson is worth the flat
/// ten it authors, and a challenge's five is implied by its id in the completed
/// set. The payout is read off the course rather than a banked copy, so a
/// finished lesson the content no longer carries pays nothing.
@riverpod
Future<int> totalPoints(Ref ref) async {
// Every watch resolved before the first await: a rebuild mid-flight must not
Expand All @@ -49,18 +41,12 @@ Future<int> totalPoints(Ref ref) async {
return fromLessons + challengesLogged * PointsValues.challengeCompletion;
}

/// The streak, the freeze and the covered days, derived from the snapshot.
///
/// Read against `DateTime.now()`, so it is only as fresh as the last time it
/// was built — which is why `DayRolloverWatcher` invalidates this on a resume
/// that crossed midnight, rather than letting a value computed before it stand.
/// The qualifying-day set every streak surface folds over — one derivation, so
/// the engine, the save notice and the week strip can never disagree on which
/// days count.
///
/// The day set it folds is assembled by [streakDaySet], which also backfills
/// a learner whose completions predate the day set — see it for why the three
/// sources are unioned rather than ranked.
/// The qualifying-day set every streak surface folds over — one derivation,
/// so the engine, the save notice and the week strip can never disagree on
/// which days count.
/// Assembled by [streakDaySet], which also backfills a learner whose
/// completions predate the day set.
@riverpod
Future<Set<int>> activeDaySet(Ref ref) async {
final completedFuture = ref.watch(completedLessonsProvider.future);
Expand Down Expand Up @@ -129,25 +115,12 @@ Future<CompletedLessons> completedLessons(Ref ref) async {
Future<Set<String>> completedLessonIds(Ref ref) async =>
(await ref.watch(completedLessonsProvider.future)).ids;

/// The ids of all cards the user has collected, off the progress snapshot.
///
/// Stored in full rather than derived from the finished lessons: the lesson id
/// space has been rewritten once already on this project, and a derived set
/// would have silently revoked every card the rename touched.
@riverpod
Future<List<String>> collectedCards(Ref ref) async {
final snapshot = await ref.watch(snapshotRepositoryProvider).read();
return snapshot.clearedByReset.ownedCollectibles.toList();
}

/// Highest tree stage ever reached: `max(stored, derived)`, as the field has
/// always described itself.
///
/// The stored half is written by first-time lesson completion and never goes
/// down. The derived half is what the *current* course size implies, and it
/// is here to heal a learner whose stored stage predates the writer — taking
/// the max is what stops it doing harm, because a grown course derives lower
/// for the same learner and the stored floor wins.
/// The derived half heals a learner whose stored stage predates the writer;
/// taking the max is what stops it doing harm, because a grown course derives
/// lower for the same learner and the stored floor wins.
@riverpod
Future<int> treeStage(Ref ref) async {
// Every watch resolved before the first await, and **one read** of the
Expand All @@ -172,10 +145,8 @@ Future<int> treeStage(Ref ref) async {
///
/// *Core* means every lesson in every module — the design's `CORE_LESSON_IDS`
/// is `MODULES.flatMap(m => m.lessons)`, and the app has no lesson outside a
/// module, so this is not a new content concept and needs no flag on
/// `LessonModel`. It is the very pair [treeStage] already folds over, named
/// once here so the tree screen's counter and the stage it sits under can never
/// disagree about what they are counting.
/// module. The very pair [treeStage] folds over, named once so the tree
/// screen's counter and the stage it sits under cannot disagree.
typedef CoreLessonProgress = ({int completed, int total});

/// The learner's progress through the core course.
Expand All @@ -188,11 +159,9 @@ Future<CoreLessonProgress> coreLessonProgress(Ref ref) async {

/// The month the Profile's closing line names, or null before there is one.
///
/// The rule is [deriveJoinedDate]'s: the install stamp when the database
/// recorded one, and the earliest active day for every device created before
/// it did. The active-day set is read either way rather than only on the
/// fallback, so a stamp arriving later cannot change which providers this one
/// depends on mid-session.
/// The rule is [deriveJoinedDate]'s. The active-day set is read either way
/// rather than only on the fallback, so a stamp arriving later cannot change
/// which providers this one depends on mid-session.
@riverpod
Future<DateTime?> joinedDate(Ref ref) async {
final daysFuture = ref.watch(activeDaySetProvider.future);
Expand Down
Loading
Loading