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