first
This commit is contained in:
@@ -0,0 +1,284 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:practice_engine/practice_engine.dart';
|
||||
import '../routes.dart';
|
||||
import '../widgets/status_chip.dart';
|
||||
|
||||
class AttemptResultScreen extends StatefulWidget {
|
||||
const AttemptResultScreen({super.key});
|
||||
|
||||
@override
|
||||
State<AttemptResultScreen> createState() => _AttemptResultScreenState();
|
||||
}
|
||||
|
||||
class _AttemptResultScreenState extends State<AttemptResultScreen> {
|
||||
Deck? _deck;
|
||||
AttemptResult? _result;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_deck == null) {
|
||||
// Get data from route arguments
|
||||
final args = ModalRoute.of(context)?.settings.arguments as Map<String, dynamic>?;
|
||||
_deck = args?['deck'] as Deck? ?? _createSampleDeck();
|
||||
_result = args?['result'] as AttemptResult? ?? _createSampleResult();
|
||||
}
|
||||
}
|
||||
|
||||
Deck _createSampleDeck() {
|
||||
return Deck(
|
||||
id: 'sample',
|
||||
title: 'Sample',
|
||||
description: 'Sample',
|
||||
questions: [],
|
||||
config: const DeckConfig(),
|
||||
);
|
||||
}
|
||||
|
||||
AttemptResult _createSampleResult() {
|
||||
return AttemptResult(
|
||||
totalQuestions: 10,
|
||||
correctCount: 7,
|
||||
percentageCorrect: 70.0,
|
||||
timeSpent: 300000,
|
||||
incorrectQuestions: [],
|
||||
allResults: [],
|
||||
);
|
||||
}
|
||||
|
||||
String _formatTime(int milliseconds) {
|
||||
final seconds = milliseconds ~/ 1000;
|
||||
final minutes = seconds ~/ 60;
|
||||
final remainingSeconds = seconds % 60;
|
||||
return '${minutes}m ${remainingSeconds}s';
|
||||
}
|
||||
|
||||
void _repeatSameAttempt() {
|
||||
if (_deck == null) return;
|
||||
Navigator.pushReplacementNamed(context, Routes.attempt, arguments: _deck);
|
||||
}
|
||||
|
||||
void _newAttempt() {
|
||||
if (_deck == null) return;
|
||||
Navigator.pushReplacementNamed(context, Routes.attempt, arguments: _deck);
|
||||
}
|
||||
|
||||
void _done() {
|
||||
if (_deck == null) return;
|
||||
// Navigate back to deck overview with updated deck
|
||||
Navigator.pushNamedAndRemoveUntil(
|
||||
context,
|
||||
Routes.deckOverview,
|
||||
(route) => false,
|
||||
arguments: _deck,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_deck == null || _result == null) {
|
||||
return const Scaffold(
|
||||
body: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Attempt Results'),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Summary Card
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
'Results',
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
CircularProgressIndicator(
|
||||
value: _result!.percentageCorrect / 100,
|
||||
strokeWidth: 8,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'${_result!.percentageCorrect.toStringAsFixed(1)}%',
|
||||
style: Theme.of(context).textTheme.displayMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'${_result!.correctCount} of ${_result!.totalQuestions} correct',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
_StatItem(
|
||||
label: 'Time',
|
||||
value: _formatTime(_result!.timeSpent),
|
||||
icon: Icons.timer,
|
||||
),
|
||||
_StatItem(
|
||||
label: 'Correct',
|
||||
value: '${_result!.correctCount}',
|
||||
icon: Icons.check_circle,
|
||||
),
|
||||
_StatItem(
|
||||
label: 'Incorrect',
|
||||
value: '${_result!.totalQuestions - _result!.correctCount}',
|
||||
icon: Icons.cancel,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Incorrect Questions
|
||||
if (_result!.incorrectQuestions.isNotEmpty) ...[
|
||||
Text(
|
||||
'Incorrect Questions',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
..._result!.incorrectQuestions.map((answerResult) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
answerResult.question.prompt,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
StatusChip(statusChange: answerResult.statusChange),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'Your answer: ${answerResult.question.answers[answerResult.userAnswerIndex]}',
|
||||
style: TextStyle(
|
||||
color: Colors.red,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Correct answer: ${answerResult.question.answers[answerResult.question.correctAnswerIndex]}',
|
||||
style: TextStyle(
|
||||
color: Colors.green,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
|
||||
// All Results with Status
|
||||
Text(
|
||||
'Question Status Changes',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
..._result!.allResults.map((answerResult) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: ListTile(
|
||||
title: Text(answerResult.question.prompt),
|
||||
subtitle: Text(
|
||||
answerResult.isCorrect
|
||||
? 'Correct'
|
||||
: 'Incorrect - Selected: ${answerResult.question.answers[answerResult.userAnswerIndex]}',
|
||||
),
|
||||
trailing: StatusChip(statusChange: answerResult.statusChange),
|
||||
),
|
||||
);
|
||||
}),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Action Buttons
|
||||
FilledButton.icon(
|
||||
onPressed: _repeatSameAttempt,
|
||||
icon: const Icon(Icons.repeat),
|
||||
label: const Text('Repeat Same Attempt'),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _newAttempt,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('New Attempt'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _done,
|
||||
icon: const Icon(Icons.check),
|
||||
label: const Text('Done'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
children: [
|
||||
Icon(icon, size: 32),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
value,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:practice_engine/practice_engine.dart';
|
||||
import '../routes.dart';
|
||||
import '../widgets/question_card.dart';
|
||||
import '../widgets/answer_option.dart';
|
||||
|
||||
class AttemptScreen extends StatefulWidget {
|
||||
const AttemptScreen({super.key});
|
||||
|
||||
@override
|
||||
State<AttemptScreen> createState() => _AttemptScreenState();
|
||||
}
|
||||
|
||||
class _AttemptScreenState extends State<AttemptScreen> {
|
||||
Deck? _deck;
|
||||
Attempt? _attempt;
|
||||
AttemptService? _attemptService;
|
||||
int _currentQuestionIndex = 0;
|
||||
int? _selectedAnswerIndex;
|
||||
final Map<String, int> _answers = {};
|
||||
final Map<String, bool> _manualOverrides = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_attemptService = AttemptService();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_deck == null) {
|
||||
// Get deck from route arguments
|
||||
final args = ModalRoute.of(context)?.settings.arguments;
|
||||
_deck = args is Deck ? args : _createSampleDeck();
|
||||
_attempt = _attemptService!.createAttempt(deck: _deck!);
|
||||
}
|
||||
}
|
||||
|
||||
Deck _createSampleDeck() {
|
||||
const config = DeckConfig();
|
||||
final questions = List.generate(10, (i) {
|
||||
return Question(
|
||||
id: 'q$i',
|
||||
prompt: 'Sample Question $i?',
|
||||
answers: ['A', 'B', 'C', 'D'],
|
||||
correctAnswerIndex: i % 4,
|
||||
);
|
||||
});
|
||||
return Deck(
|
||||
id: 'sample',
|
||||
title: 'Sample',
|
||||
description: 'Sample',
|
||||
questions: questions,
|
||||
config: config,
|
||||
);
|
||||
}
|
||||
|
||||
Question get _currentQuestion => _attempt!.questions[_currentQuestionIndex];
|
||||
bool get _isLastQuestion => _currentQuestionIndex == _attempt!.questions.length - 1;
|
||||
bool get _hasAnswer => _selectedAnswerIndex != null;
|
||||
|
||||
void _selectAnswer(int index) {
|
||||
setState(() {
|
||||
_selectedAnswerIndex = index;
|
||||
});
|
||||
|
||||
if (_deck != null && _deck!.config.immediateFeedbackEnabled) {
|
||||
// Show feedback immediately
|
||||
}
|
||||
}
|
||||
|
||||
void _submitAnswer() {
|
||||
if (_selectedAnswerIndex == null) return;
|
||||
|
||||
_answers[_currentQuestion.id] = _selectedAnswerIndex!;
|
||||
|
||||
if (_isLastQuestion) {
|
||||
_completeAttempt();
|
||||
} else {
|
||||
setState(() {
|
||||
_currentQuestionIndex++;
|
||||
_selectedAnswerIndex = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _markAsKnown() {
|
||||
if (_deck == null) return;
|
||||
setState(() {
|
||||
_manualOverrides[_currentQuestion.id] = false; // Not needs practice
|
||||
_deck = DeckService.markQuestionAsKnown(
|
||||
deck: _deck!,
|
||||
questionId: _currentQuestion.id,
|
||||
);
|
||||
});
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Question marked as Known'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _markAsNeedsPractice() {
|
||||
if (_deck == null) return;
|
||||
setState(() {
|
||||
_manualOverrides[_currentQuestion.id] = true;
|
||||
_deck = DeckService.markQuestionAsNeedsPractice(
|
||||
deck: _deck!,
|
||||
questionId: _currentQuestion.id,
|
||||
);
|
||||
});
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Question marked as Needs Practice'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _completeAttempt() {
|
||||
if (_deck == null || _attempt == null || _attemptService == null) return;
|
||||
|
||||
final result = _attemptService!.processAttempt(
|
||||
deck: _deck!,
|
||||
attempt: _attempt!,
|
||||
answers: _answers,
|
||||
manualOverrides: _manualOverrides,
|
||||
endTime: DateTime.now().millisecondsSinceEpoch,
|
||||
);
|
||||
|
||||
Navigator.pushReplacementNamed(
|
||||
context,
|
||||
Routes.attemptResult,
|
||||
arguments: {
|
||||
'deck': result.updatedDeck,
|
||||
'result': result.result,
|
||||
'attempt': _attempt,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_deck == null || _attempt == null) {
|
||||
return const Scaffold(
|
||||
body: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Attempt - ${_deck!.title}'),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// Progress Indicator
|
||||
LinearProgressIndicator(
|
||||
value: (_currentQuestionIndex + 1) / _attempt!.questions.length,
|
||||
minHeight: 4,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Question ${_currentQuestionIndex + 1} of ${_attempt!.questions.length}',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
if (_currentQuestion.isKnown)
|
||||
Chip(
|
||||
label: const Text('Known'),
|
||||
avatar: const Icon(Icons.check_circle, size: 18),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Question Card
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
QuestionCard(question: _currentQuestion),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Answer Options
|
||||
...List.generate(
|
||||
_currentQuestion.answers.length,
|
||||
(index) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: AnswerOption(
|
||||
text: _currentQuestion.answers[index],
|
||||
isSelected: _selectedAnswerIndex == index,
|
||||
onTap: () => _selectAnswer(index),
|
||||
isCorrect: _deck != null && _deck!.config.immediateFeedbackEnabled &&
|
||||
_selectedAnswerIndex == index
|
||||
? index == _currentQuestion.correctAnswerIndex
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Manual Override Buttons
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'Manual Override',
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _markAsKnown,
|
||||
icon: const Icon(Icons.check_circle),
|
||||
label: const Text('Mark as Known'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _markAsNeedsPractice,
|
||||
icon: const Icon(Icons.school),
|
||||
label: const Text('Needs Practice'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Submit/Next Button
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: FilledButton(
|
||||
onPressed: _hasAnswer ? _submitAnswer : null,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
),
|
||||
child: Text(_isLastQuestion ? 'Complete Attempt' : 'Next Question'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:practice_engine/practice_engine.dart';
|
||||
|
||||
class DeckConfigScreen extends StatefulWidget {
|
||||
const DeckConfigScreen({super.key});
|
||||
|
||||
@override
|
||||
State<DeckConfigScreen> createState() => _DeckConfigScreenState();
|
||||
}
|
||||
|
||||
class _DeckConfigScreenState extends State<DeckConfigScreen> {
|
||||
Deck? _deck;
|
||||
DeckConfig? _config;
|
||||
late TextEditingController _consecutiveController;
|
||||
late TextEditingController _attemptSizeController;
|
||||
late TextEditingController _priorityIncreaseController;
|
||||
late TextEditingController _priorityDecreaseController;
|
||||
late bool _immediateFeedback;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_deck = null;
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_deck == null) {
|
||||
// Get deck from route arguments
|
||||
final args = ModalRoute.of(context)?.settings.arguments;
|
||||
_deck = args is Deck ? args : _createSampleDeck();
|
||||
_config = _deck!.config;
|
||||
|
||||
_consecutiveController = TextEditingController(
|
||||
text: _config!.requiredConsecutiveCorrect.toString(),
|
||||
);
|
||||
_attemptSizeController = TextEditingController(
|
||||
text: _config!.defaultAttemptSize.toString(),
|
||||
);
|
||||
_priorityIncreaseController = TextEditingController(
|
||||
text: _config!.priorityIncreaseOnIncorrect.toString(),
|
||||
);
|
||||
_priorityDecreaseController = TextEditingController(
|
||||
text: _config!.priorityDecreaseOnCorrect.toString(),
|
||||
);
|
||||
_immediateFeedback = _config!.immediateFeedbackEnabled;
|
||||
}
|
||||
}
|
||||
|
||||
Deck _createSampleDeck() {
|
||||
return Deck(
|
||||
id: 'sample',
|
||||
title: 'Sample',
|
||||
description: 'Sample',
|
||||
questions: [],
|
||||
config: const DeckConfig(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_consecutiveController.dispose();
|
||||
_attemptSizeController.dispose();
|
||||
_priorityIncreaseController.dispose();
|
||||
_priorityDecreaseController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _save() {
|
||||
if (_deck == null || _config == null) return;
|
||||
|
||||
final consecutive = int.tryParse(_consecutiveController.text);
|
||||
final attemptSize = int.tryParse(_attemptSizeController.text);
|
||||
final priorityIncrease = int.tryParse(_priorityIncreaseController.text);
|
||||
final priorityDecrease = int.tryParse(_priorityDecreaseController.text);
|
||||
|
||||
if (consecutive == null ||
|
||||
attemptSize == null ||
|
||||
priorityIncrease == null ||
|
||||
priorityDecrease == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Please enter valid numbers for all fields'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (consecutive < 1 ||
|
||||
attemptSize < 1 ||
|
||||
priorityIncrease < 0 ||
|
||||
priorityDecrease < 0) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Values must be positive (priority changes >= 0)'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final updatedConfig = _config!.copyWith(
|
||||
requiredConsecutiveCorrect: consecutive,
|
||||
defaultAttemptSize: attemptSize,
|
||||
priorityIncreaseOnIncorrect: priorityIncrease,
|
||||
priorityDecreaseOnCorrect: priorityDecrease,
|
||||
immediateFeedbackEnabled: _immediateFeedback,
|
||||
);
|
||||
|
||||
final updatedDeck = _deck!.copyWith(config: updatedConfig);
|
||||
|
||||
Navigator.pop(context, updatedDeck);
|
||||
}
|
||||
|
||||
void _cancel() {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_deck == null || _config == null) {
|
||||
return const Scaffold(
|
||||
body: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Deck Configuration'),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Practice Settings',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Required Consecutive Correct
|
||||
TextField(
|
||||
controller: _consecutiveController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Required Consecutive Correct',
|
||||
helperText: 'Number of correct answers in a row to mark as known',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Default Attempt Size
|
||||
TextField(
|
||||
controller: _attemptSizeController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Default Questions Per Attempt',
|
||||
helperText: 'Number of questions to include in each attempt',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Priority Increase
|
||||
TextField(
|
||||
controller: _priorityIncreaseController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Priority Increase on Incorrect',
|
||||
helperText: 'Priority points added when answered incorrectly',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Priority Decrease
|
||||
TextField(
|
||||
controller: _priorityDecreaseController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Priority Decrease on Correct',
|
||||
helperText: 'Priority points removed when answered correctly',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Immediate Feedback Toggle
|
||||
SwitchListTile(
|
||||
title: const Text('Immediate Feedback'),
|
||||
subtitle: const Text(
|
||||
'Show correct/incorrect immediately after answering',
|
||||
),
|
||||
value: _immediateFeedback,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_immediateFeedback = value;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Action Buttons
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: _cancel,
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: _save,
|
||||
child: const Text('Save'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:practice_engine/practice_engine.dart';
|
||||
import '../routes.dart';
|
||||
|
||||
class DeckImportScreen extends StatefulWidget {
|
||||
const DeckImportScreen({super.key});
|
||||
|
||||
@override
|
||||
State<DeckImportScreen> createState() => _DeckImportScreenState();
|
||||
}
|
||||
|
||||
class _DeckImportScreenState extends State<DeckImportScreen> {
|
||||
final TextEditingController _jsonController = TextEditingController();
|
||||
String? _errorMessage;
|
||||
bool _isLoading = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_jsonController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Deck? _parseDeckFromJson(String jsonString) {
|
||||
try {
|
||||
final Map<String, dynamic> json = jsonDecode(jsonString);
|
||||
|
||||
// Parse config
|
||||
final configJson = json['config'] as Map<String, dynamic>? ?? {};
|
||||
final config = DeckConfig(
|
||||
requiredConsecutiveCorrect: configJson['requiredConsecutiveCorrect'] as int? ?? 3,
|
||||
defaultAttemptSize: configJson['defaultAttemptSize'] as int? ?? 10,
|
||||
priorityIncreaseOnIncorrect: configJson['priorityIncreaseOnIncorrect'] as int? ?? 5,
|
||||
priorityDecreaseOnCorrect: configJson['priorityDecreaseOnCorrect'] as int? ?? 2,
|
||||
immediateFeedbackEnabled: configJson['immediateFeedbackEnabled'] as bool? ?? true,
|
||||
);
|
||||
|
||||
// Parse questions
|
||||
final questionsJson = json['questions'] as List<dynamic>? ?? [];
|
||||
final questions = questionsJson.map((qJson) {
|
||||
final questionMap = qJson as Map<String, dynamic>;
|
||||
return Question(
|
||||
id: questionMap['id'] as String? ?? '',
|
||||
prompt: questionMap['prompt'] as String? ?? '',
|
||||
answers: (questionMap['answers'] as List<dynamic>?)
|
||||
?.map((e) => e.toString())
|
||||
.toList() ??
|
||||
[],
|
||||
correctAnswerIndex: questionMap['correctAnswerIndex'] as int? ?? 0,
|
||||
consecutiveCorrect: questionMap['consecutiveCorrect'] as int? ?? 0,
|
||||
isKnown: questionMap['isKnown'] as bool? ?? false,
|
||||
priorityPoints: questionMap['priorityPoints'] as int? ?? 0,
|
||||
lastAttemptIndex: questionMap['lastAttemptIndex'] as int? ?? -1,
|
||||
totalCorrectAttempts: questionMap['totalCorrectAttempts'] as int? ?? 0,
|
||||
totalAttempts: questionMap['totalAttempts'] as int? ?? 0,
|
||||
);
|
||||
}).toList();
|
||||
|
||||
// Create deck
|
||||
return Deck(
|
||||
id: json['id'] as String? ?? DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
title: json['title'] as String? ?? 'Imported Deck',
|
||||
description: json['description'] as String? ?? '',
|
||||
questions: questions,
|
||||
config: config,
|
||||
currentAttemptIndex: json['currentAttemptIndex'] as int? ?? 0,
|
||||
);
|
||||
} catch (e) {
|
||||
throw FormatException('Invalid JSON format: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _importDeck() {
|
||||
setState(() {
|
||||
_errorMessage = null;
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
if (_jsonController.text.trim().isEmpty) {
|
||||
throw FormatException('Please enter JSON data');
|
||||
}
|
||||
|
||||
final deck = _parseDeckFromJson(_jsonController.text.trim());
|
||||
|
||||
if (deck == null) {
|
||||
throw FormatException('Failed to parse deck');
|
||||
}
|
||||
|
||||
if (deck.questions.isEmpty) {
|
||||
throw FormatException('Deck must contain at least one question');
|
||||
}
|
||||
|
||||
// Navigate to deck overview with the imported deck
|
||||
Navigator.pushReplacementNamed(
|
||||
context,
|
||||
Routes.deckOverview,
|
||||
arguments: deck,
|
||||
);
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_errorMessage = e.toString();
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _loadSampleDeck() {
|
||||
final sampleJson = {
|
||||
'id': 'sample-deck',
|
||||
'title': 'Sample Practice Deck',
|
||||
'description': 'This is a sample deck for practicing. It contains various questions to help you learn.',
|
||||
'config': {
|
||||
'requiredConsecutiveCorrect': 3,
|
||||
'defaultAttemptSize': 10,
|
||||
'priorityIncreaseOnIncorrect': 5,
|
||||
'priorityDecreaseOnCorrect': 2,
|
||||
'immediateFeedbackEnabled': true,
|
||||
},
|
||||
'questions': List.generate(20, (i) {
|
||||
return {
|
||||
'id': 'q$i',
|
||||
'prompt': 'Sample Question $i?',
|
||||
'answers': ['Answer A', 'Answer B', 'Answer C', 'Answer D'],
|
||||
'correctAnswerIndex': i % 4,
|
||||
'isKnown': i < 5,
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
_jsonController.text = const JsonEncoder.withIndent(' ').convert(sampleJson);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Import Deck'),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Import Deck from JSON',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Paste your deck JSON below. The format should include id, title, description, config, and questions.',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// JSON Input
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Deck JSON',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: _loadSampleDeck,
|
||||
icon: const Icon(Icons.description),
|
||||
label: const Text('Load Sample'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _jsonController,
|
||||
maxLines: 15,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Paste JSON here...',
|
||||
border: const OutlineInputBorder(),
|
||||
errorText: _errorMessage,
|
||||
),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Error Message
|
||||
if (_errorMessage != null)
|
||||
Card(
|
||||
color: Colors.red.shade50,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.error, color: Colors.red.shade700),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_errorMessage!,
|
||||
style: TextStyle(color: Colors.red.shade700),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (_errorMessage != null) const SizedBox(height: 16),
|
||||
|
||||
// Import Button
|
||||
FilledButton.icon(
|
||||
onPressed: _isLoading ? null : _importDeck,
|
||||
icon: _isLoading
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.upload),
|
||||
label: Text(_isLoading ? 'Importing...' : 'Import Deck'),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// JSON Format Help
|
||||
ExpansionTile(
|
||||
title: const Text('JSON Format Help'),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Required fields:',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text('• id: string'),
|
||||
const Text('• title: string'),
|
||||
const Text('• description: string'),
|
||||
const Text('• questions: array of question objects'),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Question object format:',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text('• id: string (required)'),
|
||||
const Text('• prompt: string (required)'),
|
||||
const Text('• answers: array of strings (required)'),
|
||||
const Text('• correctAnswerIndex: number (required)'),
|
||||
const Text('• isKnown: boolean (optional)'),
|
||||
const Text('• consecutiveCorrect: number (optional)'),
|
||||
const Text('• priorityPoints: number (optional)'),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Config object (optional):',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text('• requiredConsecutiveCorrect: number'),
|
||||
const Text('• defaultAttemptSize: number'),
|
||||
const Text('• priorityIncreaseOnIncorrect: number'),
|
||||
const Text('• priorityDecreaseOnCorrect: number'),
|
||||
const Text('• immediateFeedbackEnabled: boolean'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:practice_engine/practice_engine.dart';
|
||||
import '../routes.dart';
|
||||
|
||||
class DeckOverviewScreen extends StatefulWidget {
|
||||
const DeckOverviewScreen({super.key});
|
||||
|
||||
@override
|
||||
State<DeckOverviewScreen> createState() => _DeckOverviewScreenState();
|
||||
}
|
||||
|
||||
class _DeckOverviewScreenState extends State<DeckOverviewScreen> {
|
||||
Deck? _deck;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
// Get deck from route arguments or create sample
|
||||
final args = ModalRoute.of(context)?.settings.arguments;
|
||||
if (args is Deck) {
|
||||
setState(() {
|
||||
_deck = args;
|
||||
});
|
||||
} else if (_deck == null) {
|
||||
// Only create sample if we don't have a deck yet
|
||||
setState(() {
|
||||
_deck = _createSampleDeck();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
}
|
||||
|
||||
Deck _createSampleDeck() {
|
||||
const config = DeckConfig();
|
||||
final questions = List.generate(20, (i) {
|
||||
return Question(
|
||||
id: 'q$i',
|
||||
prompt: 'Sample Question $i?',
|
||||
answers: ['Answer A', 'Answer B', 'Answer C', 'Answer D'],
|
||||
correctAnswerIndex: i % 4,
|
||||
isKnown: i < 5, // First 5 are known
|
||||
);
|
||||
});
|
||||
|
||||
return Deck(
|
||||
id: 'sample-deck',
|
||||
title: 'Sample Practice Deck',
|
||||
description: 'This is a sample deck for practicing. It contains various questions to help you learn.',
|
||||
questions: questions,
|
||||
config: config,
|
||||
);
|
||||
}
|
||||
|
||||
void _startAttempt() {
|
||||
if (_deck == null || _deck!.questions.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Cannot start attempt: No questions in deck'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Navigator.pushNamed(
|
||||
context,
|
||||
Routes.attempt,
|
||||
arguments: _deck!,
|
||||
);
|
||||
}
|
||||
|
||||
void _openConfig() {
|
||||
if (_deck == null) return;
|
||||
|
||||
Navigator.pushNamed(context, Routes.deckConfig, arguments: _deck)
|
||||
.then((updatedDeck) {
|
||||
if (updatedDeck != null && updatedDeck is Deck) {
|
||||
setState(() {
|
||||
_deck = updatedDeck;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _resetDeck() async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Reset Deck Progress'),
|
||||
content: const Text(
|
||||
'Are you sure you want to reset all progress? This will reset streaks, known status, and priorities for all questions.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
child: const Text('Reset'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true && _deck != null) {
|
||||
setState(() {
|
||||
_deck = DeckService.resetDeck(deck: _deck!, resetAttemptCounts: false);
|
||||
});
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Deck progress has been reset'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_deck == null) {
|
||||
return const Scaffold(
|
||||
body: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(_deck!.title),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
tooltip: 'Reset Progress',
|
||||
onPressed: _resetDeck,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Deck Description
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Description',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_deck!.description,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Practice Progress
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
'Practice Progress',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
CircularProgressIndicator(
|
||||
value: _deck!.practicePercentage / 100,
|
||||
strokeWidth: 8,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'${_deck!.practicePercentage.toStringAsFixed(1)}%',
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'${_deck!.knownCount} of ${_deck!.numberOfQuestions} questions known',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Statistics
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Statistics',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
_StatItem(
|
||||
label: 'Total Questions',
|
||||
value: '${_deck!.numberOfQuestions}',
|
||||
icon: Icons.quiz,
|
||||
),
|
||||
_StatItem(
|
||||
label: 'Known',
|
||||
value: '${_deck!.knownCount}',
|
||||
icon: Icons.check_circle,
|
||||
),
|
||||
_StatItem(
|
||||
label: 'Needs Practice',
|
||||
value: '${_deck!.numberOfQuestions - _deck!.knownCount}',
|
||||
icon: Icons.school,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 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: 16),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _openConfig,
|
||||
icon: const Icon(Icons.settings),
|
||||
label: const Text('Configure Deck'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
children: [
|
||||
Icon(icon, size: 32),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
value,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user