Skip to content
Open
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
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
109 changes: 109 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,112 @@ 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 asOfDay = _dateOnly(asOf ?? DateTime.now());
final today = _formatDateOnly(asOfDay);
final yesterday = _formatDateOnly(
asOfDay.subtract(const Duration(days: 1)),
);

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 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
''',
[anchorDay],
);
current = currentRows.first['current_streak'] as int;
}

return ReviewStreak(
currentStreak: current,
longestStreak: longest,
lastCompletedDate: lastCompleted,
);
}

static DateTime _dateOnly(DateTime d) => DateTime(d.year, d.month, d.day);

static String _formatDateOnly(DateTime date) {
return '${date.year.toString().padLeft(4, '0')}-'
'${date.month.toString().padLeft(2, '0')}-'
'${date.day.toString().padLeft(2, '0')}';
}
}
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];
}
Loading