You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
decky/lib/screens/deck_overview_screen.dart

573 lines
19 KiB

import 'package:flutter/material.dart';
import 'package:practice_engine/practice_engine.dart';
import '../routes.dart';
import '../services/deck_storage.dart';
import '../widgets/attempts_chart.dart';
class DeckOverviewScreen extends StatefulWidget {
const DeckOverviewScreen({super.key});
@override
State<DeckOverviewScreen> createState() => _DeckOverviewScreenState();
}
class _DeckOverviewScreenState extends State<DeckOverviewScreen> {
Deck? _deck;
final DeckStorage _deckStorage = DeckStorage();
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Get deck from route arguments
final args = ModalRoute.of(context)?.settings.arguments;
if (args is Deck) {
// ALWAYS load the latest version from storage to ensure we have the most up-to-date deck
// This is critical for getting the latest incomplete attempt state
// Don't rely on route arguments - they might be stale
final storedDeck = _deckStorage.getDeckSync(args.id);
final deckToUse = storedDeck ?? args;
// Always update to get the latest state from storage
// This ensures we always have the most up-to-date incomplete attempt state
setState(() {
_deck = deckToUse;
});
}
}
void _saveDeck() {
if (_deck != null) {
_deckStorage.saveDeckSync(_deck!);
}
}
@override
void initState() {
super.initState();
}
void _startAttempt() async {
// Force reload from storage before checking for incomplete attempts to ensure we have latest state
Deck? deckToCheck = _deck;
if (_deck != null) {
final freshDeck = _deckStorage.getDeckSync(_deck!.id);
if (freshDeck != null) {
setState(() {
_deck = freshDeck;
});
deckToCheck = freshDeck; // Use the fresh deck for the check
}
}
if (deckToCheck == null || deckToCheck.questions.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Cannot start attempt: No questions in deck'),
backgroundColor: Colors.red,
),
);
return;
}
// Check for incomplete attempt first - use the fresh deck we just loaded
if (deckToCheck.incompleteAttempt != null) {
final incomplete = deckToCheck.incompleteAttempt!;
final progress = incomplete.currentQuestionIndex + 1;
final total = incomplete.questionIds.length;
final action = await showDialog<String>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Incomplete Attempt Found'),
content: Text(
'You have an incomplete attempt (Question $progress of $total).\n\n'
'Would you like to continue where you left off, or start a new attempt?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, 'ignore'),
child: const Text('Start Fresh'),
),
FilledButton(
onPressed: () => Navigator.pop(context, 'continue'),
child: const Text('Continue'),
),
],
),
);
if (action == 'continue') {
// Continue the incomplete attempt
if (!mounted) return;
Navigator.pushNamed(
context,
Routes.attempt,
arguments: {
'deck': deckToCheck,
'resumeAttempt': true,
},
).then((_) {
// Refresh deck state when returning from attempt
_refreshDeck();
});
return;
} else if (action == 'ignore') {
// Clear incomplete attempt and start fresh
// Clear incomplete attempt - create a fresh copy without it
final updatedDeck = deckToCheck.copyWith(clearIncompleteAttempt: true);
// Save to storage multiple times to ensure it's persisted
_deckStorage.saveDeckSync(updatedDeck);
_deckStorage.saveDeckSync(updatedDeck); // Save twice to be sure
// Update local state immediately with cleared deck
setState(() {
_deck = updatedDeck;
});
// Force reload from storage multiple times to verify it's cleared
Deck? finalClearedDeck = updatedDeck;
for (int i = 0; i < 3; i++) {
final verifiedDeck = _deckStorage.getDeckSync(updatedDeck.id);
if (verifiedDeck != null) {
// Ensure incomplete attempt is null even if storage had it
if (verifiedDeck.incompleteAttempt != null) {
final clearedDeck = verifiedDeck.copyWith(clearIncompleteAttempt: true);
_deckStorage.saveDeckSync(clearedDeck);
setState(() {
_deck = clearedDeck;
});
finalClearedDeck = clearedDeck;
} else {
// Already cleared, update state
setState(() {
_deck = verifiedDeck;
});
finalClearedDeck = verifiedDeck;
break; // Exit loop if already cleared
}
}
}
// CRITICAL: Update deckToCheck to use the cleared deck for the rest of the flow
deckToCheck = finalClearedDeck ?? updatedDeck;
// Continue to normal flow below - the deck should now have no incomplete attempt
} else {
// User cancelled
return;
}
}
// Check if all questions are known and includeKnownInAttempts is disabled
final allKnown = deckToCheck.knownCount == deckToCheck.numberOfQuestions && deckToCheck.numberOfQuestions > 0;
final includeKnownDisabled = !deckToCheck.config.includeKnownInAttempts;
if (allKnown && includeKnownDisabled) {
// Show confirmation dialog
final shouldIncludeKnown = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('All Questions Known'),
content: const Text(
'All questions are known, attempt with known questions?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('No'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Yes'),
),
],
),
);
if (shouldIncludeKnown == null || !shouldIncludeKnown) {
// User cancelled or said No
return;
}
// Pass the deck with a flag to include known questions
// Ensure we're using a deck without incomplete attempt
if (!mounted) return;
final deckToUse = deckToCheck.copyWith(clearIncompleteAttempt: true);
Navigator.pushNamed(
context,
Routes.attempt,
arguments: {
'deck': deckToUse,
'includeKnown': true,
},
).then((_) {
// Refresh deck state when returning from attempt
_refreshDeck();
});
} else {
// Normal flow - ensure we're using a deck without incomplete attempt
if (!mounted) return;
// Always use a fresh deck copy without incomplete attempt to prevent stale state
// Also ensure storage has the cleared state
final deckToUse = deckToCheck.copyWith(clearIncompleteAttempt: true);
// Ensure storage also has the cleared state
_deckStorage.saveDeckSync(deckToUse);
Navigator.pushNamed(
context,
Routes.attempt,
arguments: deckToUse,
).then((_) {
// Refresh deck state when returning from attempt
_refreshDeck();
});
}
}
void _refreshDeck() {
if (_deck != null) {
// Reload the deck from storage to get the latest state (including incomplete attempts)
final refreshedDeck = _deckStorage.getDeckSync(_deck!.id);
if (refreshedDeck != null && mounted) {
setState(() {
_deck = refreshedDeck;
});
}
}
}
void _openConfig() {
if (_deck == null) return;
Navigator.pushNamed(context, Routes.deckConfig, arguments: _deck)
.then((updatedDeck) {
if (updatedDeck != null && updatedDeck is Deck) {
final wasReset = updatedDeck.attemptHistory.isEmpty &&
updatedDeck.currentAttemptIndex == 0 &&
_deck != null &&
_deck!.attemptHistory.isNotEmpty;
setState(() {
_deck = updatedDeck;
});
_saveDeck(); // Save to storage
// Show confirmation message
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(wasReset
? 'Deck has been reset successfully'
: 'Deck settings have been updated'),
backgroundColor: Colors.green,
duration: const Duration(seconds: 2),
),
);
}
}
});
}
String _formatTime(int milliseconds) {
final seconds = milliseconds ~/ 1000;
final minutes = seconds ~/ 60;
final hours = minutes ~/ 60;
final remainingMinutes = minutes % 60;
final remainingSeconds = seconds % 60;
if (hours > 0) {
return '${hours}h ${remainingMinutes}m';
} else if (minutes > 0) {
return '${minutes}m ${remainingSeconds}s';
} else {
return '${remainingSeconds}s';
}
}
@override
Widget build(BuildContext context) {
if (_deck == null) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
return Scaffold(
appBar: AppBar(
title: Text(_deck!.title),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Deck Description
Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Description',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 4),
Text(
_deck!.description,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
),
const SizedBox(height: 12),
// Practice Progress
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
SizedBox(
width: 80,
height: 80,
child: CircularProgressIndicator(
value: _deck!.practicePercentage / 100,
strokeWidth: 6,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${_deck!.practicePercentage.toStringAsFixed(1)}%',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
'${_deck!.knownCount} of ${_deck!.numberOfQuestions} questions known',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
],
),
),
),
const SizedBox(height: 12),
// Statistics
Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_StatItem(
label: 'Total',
value: '${_deck!.numberOfQuestions}',
icon: Icons.quiz,
),
_StatItem(
label: 'Known',
value: '${_deck!.knownCount}',
icon: Icons.check_circle,
),
_StatItem(
label: 'Practice',
value: '${_deck!.numberOfQuestions - _deck!.knownCount}',
icon: Icons.school,
),
],
),
),
),
const SizedBox(height: 12),
// Attempts History
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.history,
size: 20,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Text(
'Attempts History',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 16),
if (_deck!.attemptHistory.isEmpty)
Padding(
padding: const EdgeInsets.all(16),
child: Center(
child: Column(
children: [
Icon(
Icons.quiz_outlined,
size: 48,
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.3),
),
const SizedBox(height: 8),
Text(
'No attempts yet',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
),
),
],
),
),
)
else ...[
// Summary Statistics
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_HistoryStatItem(
label: 'Attempts',
value: '${_deck!.attemptCount}',
icon: Icons.quiz,
),
_HistoryStatItem(
label: 'Avg. Score',
value: '${_deck!.averagePercentageCorrect.toStringAsFixed(1)}%',
icon: Icons.trending_up,
),
_HistoryStatItem(
label: 'Total Time',
value: _formatTime(_deck!.totalTimeSpent),
icon: Icons.timer,
),
],
),
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 8),
Text(
'Progress Chart',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
// Progress chart
AttemptsChart(
attempts: _deck!.attemptHistory,
maxDisplayItems: 15,
),
],
],
),
),
),
const SizedBox(height: 12),
// Action Buttons
FilledButton.icon(
onPressed: _startAttempt,
icon: const Icon(Icons.play_arrow),
label: const Text('Start Attempt'),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: _openConfig,
icon: const Icon(Icons.settings),
label: const Text('Configure Deck'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
],
),
),
);
}
}
class _StatItem extends StatelessWidget {
final String label;
final String value;
final IconData icon;
const _StatItem({
required this.label,
required this.value,
required this.icon,
});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 24),
const SizedBox(height: 4),
Text(
value,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
Text(
label,
style: Theme.of(context).textTheme.bodySmall,
),
],
);
}
}
class _HistoryStatItem extends StatelessWidget {
final String label;
final String value;
final IconData icon;
const _HistoryStatItem({
required this.label,
required this.value,
required this.icon,
});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 20, color: Theme.of(context).colorScheme.primary),
const SizedBox(height: 4),
Text(
value,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
Text(
label,
style: Theme.of(context).textTheme.bodySmall,
),
],
);
}
}

Powered by TurnKey Linux.