Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
137 changes: 127 additions & 10 deletions lib/features/decks/presentation/screens/deck_list_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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});
Expand All @@ -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',
Expand Down Expand Up @@ -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<double> _scale;
late final Animation<double> _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<double>(begin: 0.92, end: 1.10).animate(curve);
_glow = Tween<double>(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<DeckWithCounts> decks;

Expand All @@ -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];
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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();
Expand All @@ -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');
}
}
}
Expand Down
93 changes: 93 additions & 0 deletions lib/features/study/data/review_session_summary_repository.dart
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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<ReviewStreak> 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<DateTime> 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<DateTime> days, DateTime endDay) {
var count = 0;
var cursor = endDay;
while (days.contains(cursor)) {
count++;
cursor = cursor.subtract(const Duration(days: 1));
}
return count;
}
}
Original file line number Diff line number Diff line change
@@ -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<ReviewSessionSummaryRepository>((ref) {
return ReviewSessionSummaryRepository();
});
3 changes: 2 additions & 1 deletion lib/features/study/domain/review_session_summary.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
21 changes: 21 additions & 0 deletions lib/features/study/domain/review_streak.dart
Original file line number Diff line number Diff line change
@@ -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<Object?> get props => [currentStreak, longestStreak, lastCompletedDate];
}
Original file line number Diff line number Diff line change
@@ -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<ReviewStreak>((ref) async {
final repo = ref.watch(reviewSessionSummaryRepositoryProvider);
return repo.getStreak();
});
Loading