From 890bfba84fbceacd84074fb2c68dc6db09ae74dc Mon Sep 17 00:00:00 2001 From: Devam Date: Mon, 6 Apr 2026 16:39:05 -0400 Subject: [PATCH 1/4] feat: add review streak tracking and home indicator --- .../screens/deck_list_screen.dart | 137 ++++++++++++++++-- .../review_session_summary_repository.dart | 93 ++++++++++++ ...w_session_summary_repository_provider.dart | 7 + .../study/domain/review_session_summary.dart | 3 +- lib/features/study/domain/review_streak.dart | 21 +++ .../providers/review_streak_provider.dart | 8 + .../screens/review_stats_screen.dart | 109 ++++++++++++-- .../screens/study_session_screen.dart | 52 +++++++ ...eview_session_summary_repository_test.dart | 101 +++++++++++++ 9 files changed, 509 insertions(+), 22 deletions(-) create mode 100644 lib/features/study/data/review_session_summary_repository_provider.dart create mode 100644 lib/features/study/domain/review_streak.dart create mode 100644 lib/features/study/presentation/providers/review_streak_provider.dart diff --git a/lib/features/decks/presentation/screens/deck_list_screen.dart b/lib/features/decks/presentation/screens/deck_list_screen.dart index 2f229c7..8703350 100644 --- a/lib/features/decks/presentation/screens/deck_list_screen.dart +++ b/lib/features/decks/presentation/screens/deck_list_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:lapse/core/routing/routes.dart'; +import 'package:lapse/core/theme/app_colors.dart'; import 'package:lapse/core/theme/spacing.dart'; import 'package:lapse/core/widgets/app_snack_bar.dart'; import 'package:lapse/core/widgets/confirm_dialog.dart'; @@ -16,6 +17,7 @@ import 'package:lapse/features/decks/domain/deck_with_counts.dart'; import 'package:lapse/features/decks/presentation/providers/deck_list_provider.dart'; import 'package:lapse/features/decks/presentation/widgets/deck_card.dart'; import 'package:lapse/features/decks/presentation/widgets/empty_deck_state.dart'; +import 'package:lapse/features/study/presentation/providers/review_streak_provider.dart'; class DeckListScreen extends ConsumerWidget { const DeckListScreen({super.key}); @@ -34,6 +36,7 @@ class DeckListScreen extends ConsumerWidget { ), title: const Text('Decks'), actions: [ + const _StreakAppBarAction(), IconButton( icon: const Icon(Icons.view_list), tooltip: 'View all cards', @@ -65,6 +68,122 @@ class DeckListScreen extends ConsumerWidget { } } +class _StreakAppBarAction extends ConsumerWidget { + const _StreakAppBarAction(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final streakAsync = ref.watch(reviewStreakProvider); + + return streakAsync.when( + data: (streak) { + if (streak.currentStreak <= 0) { + return const SizedBox.shrink(); + } + + return Padding( + padding: const EdgeInsets.symmetric(vertical: Spacing.sm), + child: Container( + margin: const EdgeInsets.only(right: Spacing.xs), + padding: const EdgeInsets.symmetric( + horizontal: Spacing.sm, + vertical: Spacing.xs, + ), + decoration: BoxDecoration( + color: AppColors.surfaceElevated, + borderRadius: BorderRadius.circular(Spacing.radiusMd), + border: Border.all(color: AppColors.outline), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const _AnimatedFlameIcon(), + const SizedBox(width: Spacing.xs), + Text( + '${streak.currentStreak}', + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: AppColors.textPrimary, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ); + }, + loading: () => const SizedBox.shrink(), + error: (_, _) => const SizedBox.shrink(), + ); + } +} + +class _AnimatedFlameIcon extends StatefulWidget { + const _AnimatedFlameIcon(); + + @override + State<_AnimatedFlameIcon> createState() => _AnimatedFlameIconState(); +} + +class _AnimatedFlameIconState extends State<_AnimatedFlameIcon> + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final Animation _scale; + late final Animation _glow; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 900), + )..repeat(reverse: true); + + final curve = CurvedAnimation(parent: _controller, curve: Curves.easeInOut); + _scale = Tween(begin: 0.92, end: 1.10).animate(curve); + _glow = Tween(begin: 0.15, end: 0.55).animate(curve); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + builder: (context, child) { + return Transform.scale( + scale: _scale.value, + child: DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Color.lerp( + Colors.transparent, + AppColors.warning, + _glow.value, + )!, + blurRadius: 8, + spreadRadius: 0.5, + ), + ], + ), + child: child, + ), + ); + }, + child: const Icon( + Icons.local_fire_department_rounded, + size: 16, + color: AppColors.warning, + ), + ); + } +} + class _DeckList extends ConsumerWidget { final List decks; @@ -82,10 +201,7 @@ class _DeckList extends ConsumerWidget { } return ListView.builder( - padding: const EdgeInsets.only( - top: Spacing.sm, - bottom: Spacing.sm + 80, - ), + padding: const EdgeInsets.only(top: Spacing.sm, bottom: Spacing.sm + 80), itemCount: decks.length, itemBuilder: (context, index) { final item = decks[index]; @@ -135,8 +251,9 @@ class _DeckList extends ConsumerWidget { case ContextMenuAction.move: final deckRepo = ref.read(deckRepositoryProvider); final allDecks = await deckRepo.getAll(); - final excludeIds = - (await deckRepo.getDescendantIds(deck.deckId)).toSet(); + final excludeIds = (await deckRepo.getDescendantIds( + deck.deckId, + )).toSet(); if (!context.mounted) return; final targetId = await DeckPickerDialog.show( @@ -164,8 +281,9 @@ class _DeckList extends ConsumerWidget { return; } - final deckToMove = - deck.copyWith(parentId: Optional.value(newParentId)); + final deckToMove = deck.copyWith( + parentId: Optional.value(newParentId), + ); await deckRepo.update(deckToMove); ref.invalidate(deckListProvider); ref.read(syncServiceProvider.notifier).schedulePush(); @@ -175,8 +293,7 @@ class _DeckList extends ConsumerWidget { } } catch (e) { if (context.mounted) { - AppSnackBar.show(context, 'Action failed: $e', -); + AppSnackBar.show(context, 'Action failed: $e'); } } } diff --git a/lib/features/study/data/review_session_summary_repository.dart b/lib/features/study/data/review_session_summary_repository.dart index 8647707..05b523f 100644 --- a/lib/features/study/data/review_session_summary_repository.dart +++ b/lib/features/study/data/review_session_summary_repository.dart @@ -1,6 +1,7 @@ import 'package:lapse/core/database/database_helper.dart'; import 'package:lapse/core/database/database_constants.dart'; import 'package:lapse/core/domain/sync_status.dart'; +import 'package:lapse/features/study/domain/review_streak.dart'; import 'package:lapse/features/study/domain/review_session_summary.dart'; class ReviewSessionSummaryRepository { @@ -115,4 +116,96 @@ class ReviewSessionSummaryRepository { [startDate, endDate], ); } + + /// Returns streak stats derived from completed review sessions. + /// + /// Rules: + /// - A day counts only if at least one session that day has total_reviews > 0. + /// - Current streak is consecutive days ending at: + /// - today, if completed today + /// - yesterday, if not completed today but yesterday is completed + /// - otherwise 0 + /// - Longest streak is the max consecutive run across all completed days. + Future getStreak({DateTime? asOf}) async { + final db = await _dbHelper.database; + final rows = await db.rawQuery(''' + SELECT DISTINCT ${DatabaseConstants.colDate} AS ${DatabaseConstants.colDate} + FROM ${DatabaseConstants.tableReviewSessionSummary} + WHERE ${DatabaseConstants.colTotalReviews} > 0 + ORDER BY ${DatabaseConstants.colDate} ASC + '''); + + if (rows.isEmpty) return const ReviewStreak.empty(); + + final days = + rows + .map( + (row) => _parseDateOnly(row[DatabaseConstants.colDate] as String), + ) + .toList() + ..sort(); + + final longest = _computeLongest(days); + final today = _dateOnly(asOf ?? DateTime.now()); + final yesterday = today.subtract(const Duration(days: 1)); + final daySet = days.toSet(); + + int current = 0; + if (daySet.contains(today)) { + current = _countBackwardsFrom(daySet, today); + } else if (daySet.contains(yesterday)) { + current = _countBackwardsFrom(daySet, yesterday); + } + + final lastCompleted = _formatDateOnly(days.last); + + return ReviewStreak( + currentStreak: current, + longestStreak: longest, + lastCompletedDate: lastCompleted, + ); + } + + static DateTime _dateOnly(DateTime d) => DateTime(d.year, d.month, d.day); + + static DateTime _parseDateOnly(String date) { + final parsed = DateTime.parse(date); + return _dateOnly(parsed); + } + + static String _formatDateOnly(DateTime date) { + return '${date.year.toString().padLeft(4, '0')}-' + '${date.month.toString().padLeft(2, '0')}-' + '${date.day.toString().padLeft(2, '0')}'; + } + + static int _computeLongest(List daysSortedAsc) { + if (daysSortedAsc.isEmpty) return 0; + var longest = 1; + var run = 1; + + for (var i = 1; i < daysSortedAsc.length; i++) { + final prev = daysSortedAsc[i - 1]; + final curr = daysSortedAsc[i]; + final delta = curr.difference(prev).inDays; + + if (delta == 1) { + run++; + if (run > longest) longest = run; + } else if (delta > 1) { + run = 1; + } + } + return longest; + } + + static int _countBackwardsFrom(Set days, DateTime endDay) { + var count = 0; + var cursor = endDay; + while (days.contains(cursor)) { + count++; + cursor = cursor.subtract(const Duration(days: 1)); + } + return count; + } } diff --git a/lib/features/study/data/review_session_summary_repository_provider.dart b/lib/features/study/data/review_session_summary_repository_provider.dart new file mode 100644 index 0000000..4b0f87b --- /dev/null +++ b/lib/features/study/data/review_session_summary_repository_provider.dart @@ -0,0 +1,7 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lapse/features/study/data/review_session_summary_repository.dart'; + +final reviewSessionSummaryRepositoryProvider = + Provider((ref) { + return ReviewSessionSummaryRepository(); + }); diff --git a/lib/features/study/domain/review_session_summary.dart b/lib/features/study/domain/review_session_summary.dart index a24fbe9..a09690d 100644 --- a/lib/features/study/domain/review_session_summary.dart +++ b/lib/features/study/domain/review_session_summary.dart @@ -60,8 +60,9 @@ class ReviewSessionSummary extends Equatable { required int reviewCount, String userId = '', }) { + // Streaks are completion-based: count the day the session finishes. final date = - '${startedAt.year}-${startedAt.month.toString().padLeft(2, '0')}-${startedAt.day.toString().padLeft(2, '0')}'; + '${endedAt.year}-${endedAt.month.toString().padLeft(2, '0')}-${endedAt.day.toString().padLeft(2, '0')}'; return ReviewSessionSummary( userId: userId, date: date, diff --git a/lib/features/study/domain/review_streak.dart b/lib/features/study/domain/review_streak.dart new file mode 100644 index 0000000..b496c4d --- /dev/null +++ b/lib/features/study/domain/review_streak.dart @@ -0,0 +1,21 @@ +import 'package:equatable/equatable.dart'; + +class ReviewStreak extends Equatable { + final int currentStreak; + final int longestStreak; + final String? lastCompletedDate; // YYYY-MM-DD + + const ReviewStreak({ + required this.currentStreak, + required this.longestStreak, + required this.lastCompletedDate, + }); + + const ReviewStreak.empty() + : currentStreak = 0, + longestStreak = 0, + lastCompletedDate = null; + + @override + List get props => [currentStreak, longestStreak, lastCompletedDate]; +} diff --git a/lib/features/study/presentation/providers/review_streak_provider.dart b/lib/features/study/presentation/providers/review_streak_provider.dart new file mode 100644 index 0000000..7bcd9b0 --- /dev/null +++ b/lib/features/study/presentation/providers/review_streak_provider.dart @@ -0,0 +1,8 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lapse/features/study/data/review_session_summary_repository_provider.dart'; +import 'package:lapse/features/study/domain/review_streak.dart'; + +final reviewStreakProvider = FutureProvider((ref) async { + final repo = ref.watch(reviewSessionSummaryRepositoryProvider); + return repo.getStreak(); +}); diff --git a/lib/features/study/presentation/screens/review_stats_screen.dart b/lib/features/study/presentation/screens/review_stats_screen.dart index ec3fc37..4378e09 100644 --- a/lib/features/study/presentation/screens/review_stats_screen.dart +++ b/lib/features/study/presentation/screens/review_stats_screen.dart @@ -7,6 +7,9 @@ import 'package:lapse/core/widgets/app_snack_bar.dart'; import 'package:lapse/core/theme/spacing.dart'; import 'package:lapse/core/widgets/loading_indicator.dart'; import 'package:lapse/features/cards/data/card_repository_provider.dart'; +import 'package:lapse/features/study/data/review_session_summary_repository.dart'; +import 'package:lapse/features/study/data/review_session_summary_repository_provider.dart'; +import 'package:lapse/features/study/domain/review_streak.dart'; class ReviewStatsScreen extends ConsumerStatefulWidget { const ReviewStatsScreen({super.key}); @@ -17,10 +20,13 @@ class ReviewStatsScreen extends ConsumerStatefulWidget { class _ReviewStatsScreenState extends ConsumerState { CardRepository get _cardRepo => ref.read(cardRepositoryProvider); + ReviewSessionSummaryRepository get _summaryRepo => + ref.read(reviewSessionSummaryRepositoryProvider); Map _dueCounts = {}; // dayOffset → count bool _isLoading = true; int _maxDay = 7; + ReviewStreak _streak = const ReviewStreak.empty(); @override void initState() { @@ -30,7 +36,10 @@ class _ReviewStatsScreenState extends ConsumerState { Future _loadData() async { try { - final raw = await _cardRepo.getDueDateCounts(); + final (raw, streak) = await ( + _cardRepo.getDueDateCounts(), + _summaryRepo.getStreak(), + ).wait; final today = DateUtils.dateOnly(DateTime.now()); // Convert calendar dates to day offsets from today. @@ -50,6 +59,7 @@ class _ReviewStatsScreenState extends ConsumerState { setState(() { _dueCounts = byOffset; _maxDay = maxDay; + _streak = streak; _isLoading = false; }); } @@ -74,20 +84,13 @@ class _ReviewStatsScreenState extends ConsumerState { } Widget _buildBody() { - if (_dueCounts.isEmpty) { - return const Center( - child: Text( - 'No cards yet — study some cards first.', - style: TextStyle(color: AppColors.textTertiary), - ), - ); - } - return Padding( padding: const EdgeInsets.all(Spacing.lg), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + _buildStreakRow(), + const SizedBox(height: Spacing.lg), Text( 'Due Date Forecast', style: Theme.of(context).textTheme.titleMedium, @@ -100,7 +103,91 @@ class _ReviewStatsScreenState extends ConsumerState { ).textTheme.bodySmall?.copyWith(color: AppColors.textTertiary), ), const SizedBox(height: Spacing.lg), - Expanded(child: _buildChart()), + Expanded( + child: _dueCounts.isEmpty + ? const Center( + child: Text( + 'No cards yet — study some cards first.', + style: TextStyle(color: AppColors.textTertiary), + ), + ) + : _buildChart(), + ), + ], + ), + ); + } + + Widget _buildStreakRow() { + return Row( + children: [ + Expanded( + child: _buildStreakCard( + label: 'Current Streak', + value: '${_streak.currentStreak}', + caption: _streak.currentStreak == 1 ? 'day' : 'days', + color: AppColors.primary, + ), + ), + const SizedBox(width: Spacing.md), + Expanded( + child: _buildStreakCard( + label: 'Longest Streak', + value: '${_streak.longestStreak}', + caption: _streak.longestStreak == 1 ? 'day' : 'days', + color: AppColors.secondary, + ), + ), + const SizedBox(width: Spacing.md), + Expanded( + child: _buildStreakCard( + label: 'Last Completed', + value: _streak.lastCompletedDate ?? '—', + caption: 'date', + color: AppColors.textSecondary, + ), + ), + ], + ); + } + + Widget _buildStreakCard({ + required String label, + required String value, + required String caption, + required Color color, + }) { + return Container( + padding: const EdgeInsets.all(Spacing.md), + decoration: BoxDecoration( + color: AppColors.surfaceElevated, + borderRadius: BorderRadius.circular(Spacing.radiusMd), + border: Border.all(color: AppColors.outline), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: Theme.of( + context, + ).textTheme.labelMedium?.copyWith(color: AppColors.textTertiary), + ), + const SizedBox(height: Spacing.xs), + Text( + value, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: color, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + caption, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: AppColors.textSecondary), + ), ], ), ); diff --git a/lib/features/study/presentation/screens/study_session_screen.dart b/lib/features/study/presentation/screens/study_session_screen.dart index 53fadff..9eee956 100644 --- a/lib/features/study/presentation/screens/study_session_screen.dart +++ b/lib/features/study/presentation/screens/study_session_screen.dart @@ -14,9 +14,13 @@ import 'package:lapse/features/cards/data/card_repository_provider.dart'; import 'package:lapse/features/cards/domain/flashcard.dart'; import 'package:lapse/features/study/domain/rating.dart'; import 'package:lapse/features/study/data/review_repository_provider.dart'; +import 'package:lapse/features/study/data/review_session_summary_repository.dart'; +import 'package:lapse/features/study/data/review_session_summary_repository_provider.dart'; import 'package:lapse/features/study/application/study_session_service.dart'; +import 'package:lapse/features/study/domain/review_session_summary.dart'; import 'package:lapse/core/sync/sync_service.dart'; import 'package:lapse/features/study/domain/study_session.dart'; +import 'package:lapse/features/study/presentation/providers/review_streak_provider.dart'; import 'package:lapse/features/study/presentation/widgets/card_stack.dart'; import 'package:lapse/features/study/presentation/widgets/flip_card.dart'; import 'package:lapse/features/study/presentation/widgets/swipeable_card.dart'; @@ -103,6 +107,8 @@ class _StudySessionScreenState extends ConsumerState with SingleTickerProviderStateMixin { CardRepository get _cardRepo => ref.read(cardRepositoryProvider); ReviewRepository get _reviewRepo => ref.read(reviewRepositoryProvider); + ReviewSessionSummaryRepository get _summaryRepo => + ref.read(reviewSessionSummaryRepositoryProvider); final _studySessionService = StudySessionService(); late StudySession _session; late final SyncServiceNotifier _syncNotifier; @@ -140,6 +146,7 @@ class _StudySessionScreenState extends ConsumerState // Debug panel state bool _showDebugPanel = false; final List<_ReviewLogEntry> _reviewLog = []; + bool _sessionSummarySaved = false; Flashcard get _currentCard => _cards[_currentIndex]; bool get _isSessionComplete => @@ -257,6 +264,49 @@ class _StudySessionScreenState extends ConsumerState ref.read(syncServiceProvider.notifier).schedulePush(); } + Future _persistSessionSummaryIfNeeded() async { + if (_sessionSummarySaved) return; + + final totalReviewed = _ratingCounts.values.fold(0, (sum, v) => sum + v); + if (totalReviewed <= 0) return; + + var newCount = 0; + var learningCount = 0; + var reviewCount = 0; + + for (final entry in _reviewLog) { + switch (entry.before.cardState) { + case CardState.newCard: + newCount++; + break; + case CardState.learning: + case CardState.relearning: + learningCount++; + break; + case CardState.review: + reviewCount++; + break; + } + } + + final summary = ReviewSessionSummary.fromSession( + startedAt: _session.startedAt, + endedAt: DateTime.now(), + againCount: _ratingCounts[Rating.again] ?? 0, + hardCount: _ratingCounts[Rating.hard] ?? 0, + goodCount: _ratingCounts[Rating.good] ?? 0, + easyCount: _ratingCounts[Rating.easy] ?? 0, + newCount: newCount, + learningCount: learningCount, + reviewCount: reviewCount, + ); + + await _summaryRepo.add(summary); + ref.invalidate(reviewStreakProvider); + ref.read(syncServiceProvider.notifier).schedulePush(); + _sessionSummarySaved = true; + } + /// Undoes the most recent rating by discarding the pending DB write /// and restoring local state to before that rating. /// Only one level of undo — button is disabled when [_pendingRating] is null. @@ -885,6 +935,7 @@ class _StudySessionScreenState extends ConsumerState child: ElevatedButton( onPressed: () async { await _flushPendingWrite(); + await _persistSessionSummaryIfNeeded(); _invalidateDeckProviders(); if (mounted) context.pop(); }, @@ -946,6 +997,7 @@ class _StudySessionScreenState extends ConsumerState ); if (shouldExit == true && context.mounted) { await _flushPendingWrite(); + await _persistSessionSummaryIfNeeded(); _invalidateDeckProviders(); if (context.mounted) context.pop(); } diff --git a/test/features/study/review_session_summary_repository_test.dart b/test/features/study/review_session_summary_repository_test.dart index 1994a97..e12bd81 100644 --- a/test/features/study/review_session_summary_repository_test.dart +++ b/test/features/study/review_session_summary_repository_test.dart @@ -116,6 +116,107 @@ void main() { expect(summary.date, '2026-03-14'); }); + test('fromSession uses completion day for date', () { + final start = DateTime(2026, 3, 14, 23, 55); + final end = DateTime(2026, 3, 15, 0, 5); + final summary = ReviewSessionSummary.fromSession( + startedAt: start, + endedAt: end, + againCount: 1, + hardCount: 1, + goodCount: 1, + easyCount: 1, + newCount: 1, + learningCount: 1, + reviewCount: 2, + ); + + expect(summary.date, '2026-03-15'); + }); + + group('streaks', () { + test( + 'getStreak returns empty values when no completed sessions exist', + () async { + final streak = await repo.getStreak(asOf: DateTime(2026, 3, 20)); + expect(streak.currentStreak, 0); + expect(streak.longestStreak, 0); + expect(streak.lastCompletedDate, isNull); + }, + ); + + test( + 'getStreak computes current and longest from completed dates', + () async { + await repo.add(makeSummary(id: 's1', date: '2026-03-14')); + await repo.add(makeSummary(id: 's2', date: '2026-03-15')); + await repo.add(makeSummary(id: 's3', date: '2026-03-16')); + await repo.add(makeSummary(id: 's4', date: '2026-03-18')); + await repo.add(makeSummary(id: 's5', date: '2026-03-19')); + + final streak = await repo.getStreak(asOf: DateTime(2026, 3, 19, 12)); + expect(streak.currentStreak, 2); // 18-19 + expect(streak.longestStreak, 3); // 14-16 + expect(streak.lastCompletedDate, '2026-03-19'); + }, + ); + + test( + 'getStreak keeps streak if yesterday is completed and today is not yet', + () async { + await repo.add(makeSummary(id: 's1', date: '2026-03-18')); + await repo.add(makeSummary(id: 's2', date: '2026-03-19')); + + final streak = await repo.getStreak(asOf: DateTime(2026, 3, 20, 8)); + expect(streak.currentStreak, 2); + expect(streak.longestStreak, 2); + expect(streak.lastCompletedDate, '2026-03-19'); + }, + ); + + test('getStreak resets current streak after a missed day', () async { + await repo.add(makeSummary(id: 's1', date: '2026-03-18')); + await repo.add(makeSummary(id: 's2', date: '2026-03-19')); + + final streak = await repo.getStreak(asOf: DateTime(2026, 3, 21, 8)); + expect(streak.currentStreak, 0); + expect(streak.longestStreak, 2); + expect(streak.lastCompletedDate, '2026-03-19'); + }); + + test('getStreak ignores session rows with zero reviews', () async { + await repo.add(makeSummary(id: 's1', date: '2026-03-18')); + await repo.add(makeSummary(id: 's2', date: '2026-03-19')); + await repo.add( + makeSummary( + id: 's3', + date: '2026-03-20', + againCount: 0, + hardCount: 0, + goodCount: 0, + easyCount: 0, + ), + ); + + final streak = await repo.getStreak(asOf: DateTime(2026, 3, 20, 12)); + expect(streak.currentStreak, 2); // 18-19 only + expect(streak.longestStreak, 2); + expect(streak.lastCompletedDate, '2026-03-19'); + }); + + test('getStreak treats multiple sessions in one day as one streak day', () async { + await repo.add(makeSummary(id: 's1', date: '2026-03-18')); + await repo.add(makeSummary(id: 's2', date: '2026-03-18')); + await repo.add(makeSummary(id: 's3', date: '2026-03-19')); + await repo.add(makeSummary(id: 's4', date: '2026-03-19')); + + final streak = await repo.getStreak(asOf: DateTime(2026, 3, 19, 18)); + expect(streak.currentStreak, 2); + expect(streak.longestStreak, 2); + expect(streak.lastCompletedDate, '2026-03-19'); + }); + }); + group('sync status', () { test('add sets syncStatus to pending', () async { await repo.add(makeSummary(id: 's1')); From ab72bee5005234c706c60c3f7e6f5543e3d6b8bc Mon Sep 17 00:00:00 2001 From: Devam Date: Sat, 18 Apr 2026 18:10:51 -0400 Subject: [PATCH 2/4] fix(study): refresh streak state and optimize streak queries --- .../review_session_summary_repository.dart | 148 ++++++++++-------- .../providers/review_streak_provider.dart | 46 +++++- 2 files changed, 130 insertions(+), 64 deletions(-) diff --git a/lib/features/study/data/review_session_summary_repository.dart b/lib/features/study/data/review_session_summary_repository.dart index 05b523f..18fa5c6 100644 --- a/lib/features/study/data/review_session_summary_repository.dart +++ b/lib/features/study/data/review_session_summary_repository.dart @@ -128,36 +128,93 @@ class ReviewSessionSummaryRepository { /// - Longest streak is the max consecutive run across all completed days. Future getStreak({DateTime? asOf}) async { final db = await _dbHelper.database; - final rows = await db.rawQuery(''' - SELECT DISTINCT ${DatabaseConstants.colDate} AS ${DatabaseConstants.colDate} - FROM ${DatabaseConstants.tableReviewSessionSummary} - WHERE ${DatabaseConstants.colTotalReviews} > 0 - ORDER BY ${DatabaseConstants.colDate} ASC - '''); + final asOfDay = _dateOnly(asOf ?? DateTime.now()); + final today = _formatDateOnly(asOfDay); + final yesterday = _formatDateOnly( + asOfDay.subtract(const Duration(days: 1)), + ); - if (rows.isEmpty) return const ReviewStreak.empty(); - - final days = - rows - .map( - (row) => _parseDateOnly(row[DatabaseConstants.colDate] as String), - ) - .toList() - ..sort(); - - final longest = _computeLongest(days); - final today = _dateOnly(asOf ?? DateTime.now()); - final yesterday = today.subtract(const Duration(days: 1)); - final daySet = days.toSet(); - - int current = 0; - if (daySet.contains(today)) { - current = _countBackwardsFrom(daySet, today); - } else if (daySet.contains(yesterday)) { - current = _countBackwardsFrom(daySet, yesterday); - } + final summaryRows = await db.rawQuery( + ''' + WITH completed_days AS ( + SELECT DISTINCT ${DatabaseConstants.colDate} AS day + FROM ${DatabaseConstants.tableReviewSessionSummary} + WHERE ${DatabaseConstants.colTotalReviews} > 0 + ) + SELECT + MAX(day) AS last_completed_date, + COALESCE(MAX(CASE WHEN day = ? THEN 1 ELSE 0 END), 0) AS has_today, + COALESCE(MAX(CASE WHEN day = ? THEN 1 ELSE 0 END), 0) AS has_yesterday + FROM completed_days + ''', + [today, yesterday], + ); - final lastCompleted = _formatDateOnly(days.last); + final summary = summaryRows.first; + final lastCompleted = summary['last_completed_date'] as String?; + if (lastCompleted == null) return const ReviewStreak.empty(); + + final hasToday = (summary['has_today'] as int) == 1; + final hasYesterday = (summary['has_yesterday'] as int) == 1; + final anchorDay = hasToday + ? today + : hasYesterday + ? yesterday + : null; + + final longestRows = await db.rawQuery(''' + WITH completed_days AS ( + SELECT DISTINCT ${DatabaseConstants.colDate} AS day + FROM ${DatabaseConstants.tableReviewSessionSummary} + WHERE ${DatabaseConstants.colTotalReviews} > 0 + ), + ranked AS ( + SELECT + day, + ROW_NUMBER() OVER (ORDER BY day) AS rn + FROM completed_days + ), + runs AS ( + SELECT + (JULIANDAY(day) - rn) AS grp, + COUNT(*) AS streak_len + FROM ranked + GROUP BY grp + ) + SELECT COALESCE(MAX(streak_len), 0) AS longest_streak + FROM runs + '''); + final longest = longestRows.first['longest_streak'] as int; + + var current = 0; + if (anchorDay != null) { + final currentRows = await db.rawQuery( + ''' + WITH RECURSIVE streak(day) AS ( + SELECT ? + UNION ALL + SELECT DATE(day, '-1 day') + FROM streak + WHERE EXISTS ( + SELECT 1 + FROM ${DatabaseConstants.tableReviewSessionSummary} + WHERE ${DatabaseConstants.colDate} = DATE(day, '-1 day') + AND ${DatabaseConstants.colTotalReviews} > 0 + ) + ) + SELECT COUNT(*) AS current_streak + FROM streak + WHERE EXISTS ( + SELECT 1 + FROM ${DatabaseConstants.tableReviewSessionSummary} + WHERE ${DatabaseConstants.colDate} = streak.day + AND ${DatabaseConstants.colTotalReviews} > 0 + ) + ''', + [anchorDay], + ); + current = currentRows.first['current_streak'] as int; + } return ReviewStreak( currentStreak: current, @@ -168,44 +225,9 @@ class ReviewSessionSummaryRepository { static DateTime _dateOnly(DateTime d) => DateTime(d.year, d.month, d.day); - static DateTime _parseDateOnly(String date) { - final parsed = DateTime.parse(date); - return _dateOnly(parsed); - } - static String _formatDateOnly(DateTime date) { return '${date.year.toString().padLeft(4, '0')}-' '${date.month.toString().padLeft(2, '0')}-' '${date.day.toString().padLeft(2, '0')}'; } - - static int _computeLongest(List daysSortedAsc) { - if (daysSortedAsc.isEmpty) return 0; - var longest = 1; - var run = 1; - - for (var i = 1; i < daysSortedAsc.length; i++) { - final prev = daysSortedAsc[i - 1]; - final curr = daysSortedAsc[i]; - final delta = curr.difference(prev).inDays; - - if (delta == 1) { - run++; - if (run > longest) longest = run; - } else if (delta > 1) { - run = 1; - } - } - return longest; - } - - static int _countBackwardsFrom(Set days, DateTime endDay) { - var count = 0; - var cursor = endDay; - while (days.contains(cursor)) { - count++; - cursor = cursor.subtract(const Duration(days: 1)); - } - return count; - } } diff --git a/lib/features/study/presentation/providers/review_streak_provider.dart b/lib/features/study/presentation/providers/review_streak_provider.dart index 7bcd9b0..0e68fe4 100644 --- a/lib/features/study/presentation/providers/review_streak_provider.dart +++ b/lib/features/study/presentation/providers/review_streak_provider.dart @@ -1,8 +1,52 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lapse/features/study/data/review_session_summary_repository_provider.dart'; import 'package:lapse/features/study/domain/review_streak.dart'; -final reviewStreakProvider = FutureProvider((ref) async { +/// Emits on first watch and again whenever the local day rolls over. +final _streakDayRefreshProvider = StreamProvider.autoDispose((ref) async* { + var tick = 0; + yield tick; + + while (true) { + final now = DateTime.now(); + final nextMidnight = DateTime(now.year, now.month, now.day + 1); + final delay = nextMidnight.difference(now) + const Duration(seconds: 1); + await Future.delayed(delay); + tick++; + yield tick; + } +}); + +/// Emits when the app resumes so stale cached values refresh after background. +final _streakResumeRefreshProvider = StreamProvider.autoDispose((ref) { + final controller = StreamController(); + var tick = 0; + controller.add(tick); + final listener = AppLifecycleListener( + onResume: () { + tick++; + if (!controller.isClosed) { + controller.add(tick); + } + }, + ); + + ref.onDispose(() { + listener.dispose(); + controller.close(); + }); + + return controller.stream; +}); + +final reviewStreakProvider = FutureProvider.autoDispose(( + ref, +) async { + ref.watch(_streakDayRefreshProvider); + ref.watch(_streakResumeRefreshProvider); final repo = ref.watch(reviewSessionSummaryRepositoryProvider); return repo.getStreak(); }); From bab748247b9424000d64d2ab8eae8b5461e77a3c Mon Sep 17 00:00:00 2001 From: Devam Date: Sat, 25 Apr 2026 09:52:37 -0400 Subject: [PATCH 3/4] fix(study): address PR #172 streak query and overflow feedback --- .../study/data/review_session_summary_repository.dart | 6 ------ .../study/presentation/screens/review_stats_screen.dart | 2 ++ 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/lib/features/study/data/review_session_summary_repository.dart b/lib/features/study/data/review_session_summary_repository.dart index 18fa5c6..357a736 100644 --- a/lib/features/study/data/review_session_summary_repository.dart +++ b/lib/features/study/data/review_session_summary_repository.dart @@ -204,12 +204,6 @@ class ReviewSessionSummaryRepository { ) SELECT COUNT(*) AS current_streak FROM streak - WHERE EXISTS ( - SELECT 1 - FROM ${DatabaseConstants.tableReviewSessionSummary} - WHERE ${DatabaseConstants.colDate} = streak.day - AND ${DatabaseConstants.colTotalReviews} > 0 - ) ''', [anchorDay], ); diff --git a/lib/features/study/presentation/screens/review_stats_screen.dart b/lib/features/study/presentation/screens/review_stats_screen.dart index 4378e09..c200325 100644 --- a/lib/features/study/presentation/screens/review_stats_screen.dart +++ b/lib/features/study/presentation/screens/review_stats_screen.dart @@ -176,6 +176,8 @@ class _ReviewStatsScreenState extends ConsumerState { const SizedBox(height: Spacing.xs), Text( value, + maxLines: 1, + overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.titleLarge?.copyWith( color: color, fontWeight: FontWeight.w700, From 1e6e886ea47b18e6f577e4696f4a119730fdf1d3 Mon Sep 17 00:00:00 2001 From: Devam Patel <142634740+DevamPatel22@users.noreply.github.com> Date: Sat, 25 Apr 2026 10:05:40 -0400 Subject: [PATCH 4/4] Update lib/features/study/presentation/screens/review_stats_screen.dart Co-authored-by: Austin Gause --- .../study/presentation/screens/review_stats_screen.dart | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/features/study/presentation/screens/review_stats_screen.dart b/lib/features/study/presentation/screens/review_stats_screen.dart index c200325..34fcb89 100644 --- a/lib/features/study/presentation/screens/review_stats_screen.dart +++ b/lib/features/study/presentation/screens/review_stats_screen.dart @@ -174,6 +174,8 @@ class _ReviewStatsScreenState extends ConsumerState { ).textTheme.labelMedium?.copyWith(color: AppColors.textTertiary), ), const SizedBox(height: Spacing.xs), + Text( + value, Text( value, maxLines: 1,