commit 5644bfde6103894285194a95e88d046759a16e80 Author: gitea Date: Fri Dec 12 14:06:56 2025 +0100 first diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..defd946 --- /dev/null +++ b/.metadata @@ -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' diff --git a/README.md b/README.md new file mode 100644 index 0000000..bfff5df --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# decky_app + +A new Flutter project. diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..f9b3034 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1 @@ +include: package:flutter_lints/flutter.yaml diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/android/.gitignore @@ -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 diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..b35cf94 --- /dev/null +++ b/android/app/build.gradle.kts @@ -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 = "../.." +} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..3b88ad4 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/example/decky_app/MainActivity.kt b/android/app/src/main/kotlin/com/example/decky_app/MainActivity.kt new file mode 100644 index 0000000..cd2bdd1 --- /dev/null +++ b/android/app/src/main/kotlin/com/example/decky_app/MainActivity.kt @@ -0,0 +1,5 @@ +package com.example.decky_app + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/android/build.gradle.kts @@ -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("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..fbee1d8 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e4ef43f --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..ca7fe06 --- /dev/null +++ b/android/settings.gradle.kts @@ -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") diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..1e868b8 --- /dev/null +++ b/lib/main.dart @@ -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, + ); + } +} + diff --git a/lib/routes.dart b/lib/routes.dart new file mode 100644 index 0000000..7c42bb3 --- /dev/null +++ b/lib/routes.dart @@ -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 get routes { + return { + deckImport: (context) => const DeckImportScreen(), + deckOverview: (context) => const DeckOverviewScreen(), + deckConfig: (context) => const DeckConfigScreen(), + attempt: (context) => const AttemptScreen(), + attemptResult: (context) => const AttemptResultScreen(), + }; + } +} + diff --git a/lib/screens/attempt_result_screen.dart b/lib/screens/attempt_result_screen.dart new file mode 100644 index 0000000..30e8ed2 --- /dev/null +++ b/lib/screens/attempt_result_screen.dart @@ -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 createState() => _AttemptResultScreenState(); +} + +class _AttemptResultScreenState extends State { + 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?; + _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, + ), + ], + ); + } +} + diff --git a/lib/screens/attempt_screen.dart b/lib/screens/attempt_screen.dart new file mode 100644 index 0000000..4ea7135 --- /dev/null +++ b/lib/screens/attempt_screen.dart @@ -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 createState() => _AttemptScreenState(); +} + +class _AttemptScreenState extends State { + Deck? _deck; + Attempt? _attempt; + AttemptService? _attemptService; + int _currentQuestionIndex = 0; + int? _selectedAnswerIndex; + final Map _answers = {}; + final Map _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'), + ), + ), + ], + ), + ); + } +} + diff --git a/lib/screens/deck_config_screen.dart b/lib/screens/deck_config_screen.dart new file mode 100644 index 0000000..013181b --- /dev/null +++ b/lib/screens/deck_config_screen.dart @@ -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 createState() => _DeckConfigScreenState(); +} + +class _DeckConfigScreenState extends State { + 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'), + ), + ), + ], + ), + ], + ), + ), + ); + } +} + diff --git a/lib/screens/deck_import_screen.dart b/lib/screens/deck_import_screen.dart new file mode 100644 index 0000000..2f813fb --- /dev/null +++ b/lib/screens/deck_import_screen.dart @@ -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 createState() => _DeckImportScreenState(); +} + +class _DeckImportScreenState extends State { + final TextEditingController _jsonController = TextEditingController(); + String? _errorMessage; + bool _isLoading = false; + + @override + void dispose() { + _jsonController.dispose(); + super.dispose(); + } + + Deck? _parseDeckFromJson(String jsonString) { + try { + final Map json = jsonDecode(jsonString); + + // Parse config + final configJson = json['config'] as Map? ?? {}; + 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? ?? []; + final questions = questionsJson.map((qJson) { + final questionMap = qJson as Map; + return Question( + id: questionMap['id'] as String? ?? '', + prompt: questionMap['prompt'] as String? ?? '', + answers: (questionMap['answers'] as List?) + ?.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'), + ], + ), + ), + ], + ), + ], + ), + ), + ); + } +} + diff --git a/lib/screens/deck_overview_screen.dart b/lib/screens/deck_overview_screen.dart new file mode 100644 index 0000000..5c2baf8 --- /dev/null +++ b/lib/screens/deck_overview_screen.dart @@ -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 createState() => _DeckOverviewScreenState(); +} + +class _DeckOverviewScreenState extends State { + 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 _resetDeck() async { + final confirmed = await showDialog( + 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, + ), + ], + ); + } +} + diff --git a/lib/theme.dart b/lib/theme.dart new file mode 100644 index 0000000..49cbcc4 --- /dev/null +++ b/lib/theme.dart @@ -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), + ), + ), + ); + } +} + diff --git a/lib/widgets/answer_option.dart b/lib/widgets/answer_option.dart new file mode 100644 index 0000000..e84106f --- /dev/null +++ b/lib/widgets/answer_option.dart @@ -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), + ), + ], + ), + ), + ), + ); + } +} + diff --git a/lib/widgets/progress_indicator.dart b/lib/widgets/progress_indicator.dart new file mode 100644 index 0000000..78680a4 --- /dev/null +++ b/lib/widgets/progress_indicator.dart @@ -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, + ), + ], + ); + } +} + diff --git a/lib/widgets/question_card.dart b/lib/widgets/question_card.dart new file mode 100644 index 0000000..9160301 --- /dev/null +++ b/lib/widgets/question_card.dart @@ -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, + ), + ], + ), + ), + ); + } +} + diff --git a/lib/widgets/status_chip.dart b/lib/widgets/status_chip.dart new file mode 100644 index 0000000..8144f4e --- /dev/null +++ b/lib/widgets/status_chip.dart @@ -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), + ); + } +} + diff --git a/packages/practice_engine b/packages/practice_engine new file mode 160000 index 0000000..cdf0cdf --- /dev/null +++ b/packages/practice_engine @@ -0,0 +1 @@ +Subproject commit cdf0cdf35021931b0789552e8ba650483168700e diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..1d28a18 --- /dev/null +++ b/pubspec.lock @@ -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" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..9455d56 --- /dev/null +++ b/pubspec.yaml @@ -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 +