master
gitea 3 months ago
commit 5644bfde61

45
.gitignore vendored

@ -0,0 +1,45 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release

@ -0,0 +1,30 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "b45fa18946ecc2d9b4009952c636ba7e2ffbb787"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787
base_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787
- platform: android
create_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787
base_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'

@ -0,0 +1,3 @@
# decky_app
A new Flutter project.

@ -0,0 +1 @@
include: package:flutter_lints/flutter.yaml

14
android/.gitignore vendored

@ -0,0 +1,14 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks

@ -0,0 +1,44 @@
plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "com.example.decky_app"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.example.decky_app"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
}
}
}
flutter {
source = "../.."
}

@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

@ -0,0 +1,45 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="decky_app"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>

@ -0,0 +1,5 @@
package com.example.decky_app
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

@ -0,0 +1,24 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}

@ -0,0 +1,2 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true

@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip

@ -0,0 +1,26 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
}
include(":app")

@ -0,0 +1,25 @@
import 'package:flutter/material.dart';
import 'routes.dart';
import 'theme.dart';
void main() {
runApp(const DeckyApp());
}
class DeckyApp extends StatelessWidget {
const DeckyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Decky - Practice Engine',
theme: AppTheme.lightTheme,
darkTheme: AppTheme.darkTheme,
themeMode: ThemeMode.system,
initialRoute: Routes.deckImport,
routes: Routes.routes,
debugShowCheckedModeBanner: false,
);
}
}

@ -0,0 +1,25 @@
import 'package:flutter/material.dart';
import 'screens/deck_import_screen.dart';
import 'screens/deck_overview_screen.dart';
import 'screens/deck_config_screen.dart';
import 'screens/attempt_screen.dart';
import 'screens/attempt_result_screen.dart';
class Routes {
static const String deckImport = '/';
static const String deckOverview = '/deck-overview';
static const String deckConfig = '/deck-config';
static const String attempt = '/attempt';
static const String attemptResult = '/attempt-result';
static Map<String, WidgetBuilder> get routes {
return {
deckImport: (context) => const DeckImportScreen(),
deckOverview: (context) => const DeckOverviewScreen(),
deckConfig: (context) => const DeckConfigScreen(),
attempt: (context) => const AttemptScreen(),
attemptResult: (context) => const AttemptResultScreen(),
};
}
}

@ -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,
),
],
);
}
}

@ -0,0 +1,62 @@
import 'package:flutter/material.dart';
class AppTheme {
static ThemeData get lightTheme {
return ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue,
brightness: Brightness.light,
),
cardTheme: CardThemeData(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
chipTheme: ChipThemeData(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
);
}
static ThemeData get darkTheme {
return ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue,
brightness: Brightness.dark,
),
cardTheme: CardThemeData(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
chipTheme: ChipThemeData(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
);
}
}

@ -0,0 +1,81 @@
import 'package:flutter/material.dart';
class AnswerOption extends StatelessWidget {
final String text;
final bool isSelected;
final VoidCallback onTap;
final bool? isCorrect;
const AnswerOption({
super.key,
required this.text,
required this.isSelected,
required this.onTap,
this.isCorrect,
});
@override
Widget build(BuildContext context) {
Color? backgroundColor;
Color? borderColor;
IconData? icon;
if (isCorrect != null) {
if (isCorrect == true) {
backgroundColor = Colors.green.withValues(alpha: 0.1);
borderColor = Colors.green;
icon = Icons.check_circle;
} else {
backgroundColor = Colors.red.withValues(alpha: 0.1);
borderColor = Colors.red;
icon = Icons.cancel;
}
} else if (isSelected) {
backgroundColor = Theme.of(context).colorScheme.primaryContainer;
borderColor = Theme.of(context).colorScheme.primary;
}
return Card(
color: backgroundColor,
elevation: isSelected ? 4 : 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: borderColor != null
? BorderSide(color: borderColor, width: 2)
: BorderSide.none,
),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
if (icon != null) ...[
Icon(icon, color: borderColor),
const SizedBox(width: 12),
],
Expanded(
child: Text(
text,
style: Theme.of(context).textTheme.bodyLarge,
),
),
if (isSelected && icon == null)
Icon(
Icons.radio_button_checked,
color: Theme.of(context).colorScheme.primary,
)
else if (!isSelected)
Icon(
Icons.radio_button_unchecked,
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5),
),
],
),
),
),
);
}
}

@ -0,0 +1,28 @@
import 'package:flutter/material.dart';
class PracticeProgressIndicator extends StatelessWidget {
final double percentage;
const PracticeProgressIndicator({
super.key,
required this.percentage,
});
@override
Widget build(BuildContext context) {
return Column(
children: [
CircularProgressIndicator(
value: percentage / 100,
strokeWidth: 8,
),
const SizedBox(height: 8),
Text(
'${percentage.toStringAsFixed(1)}%',
style: Theme.of(context).textTheme.titleLarge,
),
],
);
}
}

@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
import 'package:practice_engine/practice_engine.dart';
class QuestionCard extends StatelessWidget {
final Question question;
const QuestionCard({
super.key,
required this.question,
});
@override
Widget build(BuildContext context) {
return Card(
elevation: 4,
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.quiz,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Text(
'Question',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 16),
Text(
question.prompt,
style: Theme.of(context).textTheme.titleLarge,
),
],
),
),
);
}
}

@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import 'package:practice_engine/practice_engine.dart';
class StatusChip extends StatelessWidget {
final QuestionStatusChange statusChange;
const StatusChip({
super.key,
required this.statusChange,
});
@override
Widget build(BuildContext context) {
String label;
IconData icon;
Color color;
switch (statusChange) {
case QuestionStatusChange.improved:
label = 'Improved';
icon = Icons.trending_up;
color = Colors.green;
break;
case QuestionStatusChange.regressed:
label = 'Regressed';
icon = Icons.trending_down;
color = Colors.red;
break;
case QuestionStatusChange.unchanged:
label = 'Unchanged';
icon = Icons.remove;
color = Colors.grey;
break;
}
return Chip(
label: Text(label),
avatar: Icon(icon, size: 18, color: color),
backgroundColor: color.withValues(alpha: 0.1),
side: BorderSide(color: color),
);
}
}

@ -0,0 +1 @@
Subproject commit cdf0cdf35021931b0789552e8ba650483168700e

@ -0,0 +1,220 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
async:
dependency: transitive
description:
name: async
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
url: "https://pub.dev"
source: hosted
version: "2.13.0"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
characters:
dependency: transitive
description:
name: characters
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
url: "https://pub.dev"
source: hosted
version: "1.4.0"
clock:
dependency: transitive
description:
name: clock
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
url: "https://pub.dev"
source: hosted
version: "1.1.2"
collection:
dependency: transitive
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.19.1"
cupertino_icons:
dependency: "direct main"
description:
name: cupertino_icons
sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6
url: "https://pub.dev"
source: hosted
version: "1.0.8"
fake_async:
dependency: transitive
description:
name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
source: hosted
version: "1.3.3"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_lints:
dependency: "direct dev"
description:
name: flutter_lints
sha256: "9e8c3858111da373efc5aa341de011d9bd23e2c5c5e0c62bccf32438e192d7b1"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
leak_tracker:
dependency: transitive
description:
name: leak_tracker
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev"
source: hosted
version: "11.0.2"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev"
source: hosted
version: "3.0.10"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
lints:
dependency: transitive
description:
name: lints
sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290
url: "https://pub.dev"
source: hosted
version: "3.0.0"
matcher:
dependency: transitive
description:
name: matcher
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
url: "https://pub.dev"
source: hosted
version: "0.12.17"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
url: "https://pub.dev"
source: hosted
version: "0.11.1"
meta:
dependency: transitive
description:
name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
source: hosted
version: "1.17.0"
path:
dependency: transitive
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.1"
practice_engine:
dependency: "direct main"
description:
path: "packages/practice_engine"
relative: true
source: path
version: "1.0.0"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
source_span:
dependency: transitive
description:
name: source_span
sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c"
url: "https://pub.dev"
source: hosted
version: "1.10.1"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev"
source: hosted
version: "1.12.1"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
test_api:
dependency: transitive
description:
name: test_api
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev"
source: hosted
version: "0.7.7"
vector_math:
dependency: transitive
description:
name: vector_math
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev"
source: hosted
version: "2.2.0"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
url: "https://pub.dev"
source: hosted
version: "15.0.2"
sdks:
dart: ">=3.8.0-0 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54"

@ -0,0 +1,23 @@
name: decky_app
description: A Flutter app for practicing with spaced repetition
publish_to: 'none'
version: 1.0.0+1
environment:
sdk: '>=3.0.0 <4.0.0'
dependencies:
flutter:
sdk: flutter
practice_engine:
path: packages/practice_engine
cupertino_icons: ^1.0.6
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^3.0.0
flutter:
uses-material-design: true
Loading…
Cancel
Save

Powered by TurnKey Linux.