commit
5644bfde61
@ -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 @@
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
@ -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>
|
||||
|
After Width: | Height: | Size: 544 B |
|
After Width: | Height: | Size: 442 B |
|
After Width: | Height: | Size: 721 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
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…
Reference in new issue