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.
724 lines
24 KiB
724 lines
24 KiB
import 'package:flutter/material.dart';
|
|
import 'package:practice_engine/practice_engine.dart';
|
|
import '../routes.dart';
|
|
import '../services/deck_storage.dart';
|
|
import '../utils/top_snackbar.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) {
|
|
showTopSnackBar(
|
|
context,
|
|
message: 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';
|
|
}
|
|
}
|
|
|
|
Widget _buildProgressStats(BuildContext context) {
|
|
if (_deck == null) return const SizedBox.shrink();
|
|
|
|
final totalQuestions = _deck!.numberOfQuestions;
|
|
final knownCount = _deck!.knownCount;
|
|
final unknownCount = totalQuestions - knownCount;
|
|
final unknownPercentage = totalQuestions > 0
|
|
? (unknownCount / totalQuestions * 100.0)
|
|
: 0.0;
|
|
final knownPercentage = totalQuestions > 0
|
|
? (knownCount / totalQuestions * 100.0)
|
|
: 0.0;
|
|
|
|
// Get progress trend if we have history
|
|
// Attempt history is stored chronologically (oldest first), so first is oldest, last is newest
|
|
String? trendText;
|
|
if (_deck!.attemptHistory.isNotEmpty) {
|
|
final oldestAttempt = _deck!.attemptHistory.first;
|
|
final newestAttempt = _deck!.attemptHistory.last;
|
|
final oldestUnknown = oldestAttempt.unknownPercentage;
|
|
final newestUnknown = newestAttempt.unknownPercentage;
|
|
final improvement = oldestUnknown - newestUnknown;
|
|
|
|
if (improvement > 0) {
|
|
trendText = 'Improved by ${improvement.toStringAsFixed(1)}% since first attempt';
|
|
} else if (improvement < 0) {
|
|
trendText = '${improvement.abs().toStringAsFixed(1)}% more to learn since first attempt';
|
|
} else {
|
|
trendText = 'No change since first attempt';
|
|
}
|
|
}
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Main stat: Unknown percentage
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Questions to Learn',
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7),
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
'${unknownPercentage.toStringAsFixed(1)}%',
|
|
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
color: Theme.of(context).colorScheme.primary,
|
|
),
|
|
),
|
|
Text(
|
|
'$unknownCount of $totalQuestions questions',
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
),
|
|
],
|
|
),
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
children: [
|
|
Text(
|
|
'Questions Known',
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7),
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
'${knownPercentage.toStringAsFixed(1)}%',
|
|
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
color: Theme.of(context).colorScheme.secondary,
|
|
),
|
|
),
|
|
Text(
|
|
'$knownCount of $totalQuestions questions',
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
// Progress bar
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(
|
|
'Progress',
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7),
|
|
),
|
|
),
|
|
Text(
|
|
'${knownPercentage.toStringAsFixed(1)}%',
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: LinearProgressIndicator(
|
|
value: knownPercentage / 100.0,
|
|
minHeight: 12,
|
|
backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest,
|
|
valueColor: AlwaysStoppedAnimation<Color>(
|
|
Theme.of(context).colorScheme.primary,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
if (trendText != null) ...[
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
trendText,
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
|
|
@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!.progressPercentage / 100,
|
|
strokeWidth: 6,
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'${_deck!.progressPercentage.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(
|
|
'Learning Progress',
|
|
style: Theme.of(context).textTheme.titleSmall,
|
|
),
|
|
const SizedBox(height: 12),
|
|
// Current progress
|
|
_buildProgressStats(context),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
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),
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
OutlinedButton.icon(
|
|
onPressed: () {
|
|
Navigator.pushNamed(
|
|
context,
|
|
Routes.flaggedQuestions,
|
|
arguments: _deck,
|
|
).then((_) => _refreshDeck());
|
|
},
|
|
icon: Icon(
|
|
Icons.flag,
|
|
color: _deck!.flaggedCount > 0 ? Colors.orange : null,
|
|
),
|
|
label: Text(
|
|
'Flagged questions (${_deck!.flaggedCount})',
|
|
),
|
|
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,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
|