diff --git a/issues.json b/issues.json new file mode 100644 index 0000000..b2abe79 --- /dev/null +++ b/issues.json @@ -0,0 +1,1157 @@ +[{ + "resource": "/Users/znup/projects/decky/lib/screens/attempt_result_screen.dart", + "owner": "_generated_diagnostic_collection_name_#0", + "code": { + "value": "argument_type_not_assignable", + "target": { + "$mid": 1, + "path": "/diagnostics/argument_type_not_assignable", + "scheme": "https", + "authority": "dart.dev" + } + }, + "severity": 8, + "message": "The argument type 'int?' can't be assigned to the parameter type 'int'. ", + "source": "dart", + "startLineNumber": 193, + "startColumn": 76, + "endLineNumber": 193, + "endColumn": 116, + "origin": "extHost1" + },{ + "resource": "/Users/znup/projects/decky/lib/screens/attempt_result_screen.dart", + "owner": "_generated_diagnostic_collection_name_#0", + "code": { + "value": "deprecated_member_use", + "target": { + "$mid": 1, + "path": "/diagnostics/deprecated_member_use", + "scheme": "https", + "authority": "dart.dev" + } + }, + "severity": 2, + "message": "'userAnswerIndex' is deprecated and shouldn't be used. Use userAnswerIndices instead.\nTry replacing the use of the deprecated member with the replacement.", + "source": "dart", + "startLineNumber": 183, + "startColumn": 90, + "endLineNumber": 183, + "endColumn": 105, + "tags": [ + 2 + ], + "origin": "extHost1" + },{ + "resource": "/Users/znup/projects/decky/lib/screens/attempt_result_screen.dart", + "owner": "_generated_diagnostic_collection_name_#0", + "code": { + "value": "deprecated_member_use", + "target": { + "$mid": 1, + "path": "/diagnostics/deprecated_member_use", + "scheme": "https", + "authority": "dart.dev" + } + }, + "severity": 2, + "message": "'correctAnswerIndex' is deprecated and shouldn't be used. Use correctAnswerIndices instead.\nTry replacing the use of the deprecated member with the replacement.", + "source": "dart", + "startLineNumber": 193, + "startColumn": 98, + "endLineNumber": 193, + "endColumn": 116, + "tags": [ + 2 + ], + "origin": "extHost1" + },{ + "resource": "/Users/znup/projects/decky/lib/screens/attempt_result_screen.dart", + "owner": "_generated_diagnostic_collection_name_#0", + "code": { + "value": "deprecated_member_use", + "target": { + "$mid": 1, + "path": "/diagnostics/deprecated_member_use", + "scheme": "https", + "authority": "dart.dev" + } + }, + "severity": 2, + "message": "'userAnswerIndex' is deprecated and shouldn't be used. Use userAnswerIndices instead.\nTry replacing the use of the deprecated member with the replacement.", + "source": "dart", + "startLineNumber": 221, + "startColumn": 95, + "endLineNumber": 221, + "endColumn": 110, + "tags": [ + 2 + ], + "origin": "extHost1" + },{ + "resource": "/Users/znup/projects/decky/packages/practice_engine/test/attempt_result_test.dart", + "owner": "_generated_diagnostic_collection_name_#0", + "code": { + "value": "missing_required_argument", + "target": { + "$mid": 1, + "path": "/diagnostics/missing_required_argument", + "scheme": "https", + "authority": "dart.dev" + } + }, + "severity": 8, + "message": "The named parameter 'userAnswerIndices' is required, but there's no corresponding argument.\nTry adding the required argument.", + "source": "dart", + "startLineNumber": 130, + "startColumn": 22, + "endLineNumber": 130, + "endColumn": 34, + "origin": "extHost1" + },{ + "resource": "/Users/znup/projects/decky/packages/practice_engine/test/attempt_result_test.dart", + "owner": "_generated_diagnostic_collection_name_#0", + "code": { + "value": "undefined_named_parameter", + "target": { + "$mid": 1, + "path": "/diagnostics/undefined_named_parameter", + "scheme": "https", + "authority": "dart.dev" + } + }, + "severity": 8, + "message": "The named parameter 'userAnswerIndex' isn't defined.\nTry correcting the name to an existing named parameter's name, or defining a named parameter with the name 'userAnswerIndex'.", + "source": "dart", + "startLineNumber": 132, + "startColumn": 9, + "endLineNumber": 132, + "endColumn": 24, + "origin": "extHost1" + },{ + "resource": "/Users/znup/projects/decky/packages/practice_engine/test/attempt_result_test.dart", + "owner": "_generated_diagnostic_collection_name_#0", + "code": { + "value": "deprecated_member_use_from_same_package", + "target": { + "$mid": 1, + "path": "/diagnostics/deprecated_member_use_from_same_package", + "scheme": "https", + "authority": "dart.dev" + } + }, + "severity": 2, + "message": "'userAnswerIndex' is deprecated and shouldn't be used. Use userAnswerIndices instead.\nTry replacing the use of the deprecated member with the replacement.", + "source": "dart", + "startLineNumber": 138, + "startColumn": 21, + "endLineNumber": 138, + "endColumn": 36, + "tags": [ + 2 + ], + "origin": "extHost1" + },{ + "resource": "/Users/znup/projects/decky/lib/screens/attempt_screen.dart", + "owner": "_generated_diagnostic_collection_name_#0", + "code": { + "value": "unused_element", + "target": { + "$mid": 1, + "path": "/diagnostics/unused_element", + "scheme": "https", + "authority": "dart.dev" + } + }, + "severity": 4, + "message": "The declaration '_selectAnswer' isn't referenced.\nTry removing the declaration of '_selectAnswer'.", + "source": "dart", + "startLineNumber": 174, + "startColumn": 8, + "endLineNumber": 174, + "endColumn": 21, + "origin": "extHost1" + },{ + "resource": "/Users/znup/projects/decky/lib/screens/attempt_screen.dart", + "owner": "_generated_diagnostic_collection_name_#0", + "code": { + "value": "unused_element", + "target": { + "$mid": 1, + "path": "/diagnostics/unused_element", + "scheme": "https", + "authority": "dart.dev" + } + }, + "severity": 4, + "message": "The declaration '_markAsKnown' isn't referenced.\nTry removing the declaration of '_markAsKnown'.", + "source": "dart", + "startLineNumber": 273, + "startColumn": 8, + "endLineNumber": 273, + "endColumn": 20, + "origin": "extHost1" + },{ + "resource": "/Users/znup/projects/decky/lib/screens/attempt_screen.dart", + "owner": "_generated_diagnostic_collection_name_#0", + "code": { + "value": "unused_element", + "target": { + "$mid": 1, + "path": "/diagnostics/unused_element", + "scheme": "https", + "authority": "dart.dev" + } + }, + "severity": 4, + "message": "The declaration '_markAsNeedsPractice' isn't referenced.\nTry removing the declaration of '_markAsNeedsPractice'.", + "source": "dart", + "startLineNumber": 291, + "startColumn": 8, + "endLineNumber": 291, + "endColumn": 28, + "origin": "extHost1" + },{ + "resource": "/Users/znup/projects/decky/lib/screens/attempt_screen.dart", + "owner": "_generated_diagnostic_collection_name_#0", + "code": { + "value": "deprecated_member_use", + "target": { + "$mid": 1, + "path": "/diagnostics/deprecated_member_use", + "scheme": "https", + "authority": "dart.dev" + } + }, + "severity": 2, + "message": "'WillPopScope' is deprecated and shouldn't be used. Use PopScope instead. The Android predictive back feature will not work with WillPopScope. This feature was deprecated after v3.12.0-1.0.pre.\nTry replacing the use of the deprecated member with the replacement.", + "source": "dart", + "startLineNumber": 390, + "startColumn": 12, + "endLineNumber": 390, + "endColumn": 24, + "tags": [ + 2 + ], + "origin": "extHost1" + },{ + "resource": "/Users/znup/projects/decky/lib/screens/attempt_screen.dart", + "owner": "_generated_diagnostic_collection_name_#0", + "code": { + "value": "prefer_const_constructors", + "target": { + "$mid": 1, + "path": "/diagnostics/prefer_const_constructors", + "scheme": "https", + "authority": "dart.dev" + } + }, + "severity": 2, + "message": "Use 'const' with the constructor to improve performance.\nTry adding the 'const' keyword to the constructor invocation.", + "source": "dart", + "startLineNumber": 458, + "startColumn": 19, + "endLineNumber": 461, + "endColumn": 20, + "origin": "extHost1" + },{ + "resource": "/Users/znup/projects/decky/lib/screens/attempt_screen.dart", + "owner": "_generated_diagnostic_collection_name_#0", + "code": { + "value": "deprecated_member_use", + "target": { + "$mid": 1, + "path": "/diagnostics/deprecated_member_use", + "scheme": "https", + "authority": "dart.dev" + } + }, + "severity": 2, + "message": "'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss.\nTry replacing the use of the deprecated member with the replacement.", + "source": "dart", + "startLineNumber": 655, + "startColumn": 39, + "endLineNumber": 655, + "endColumn": 50, + "tags": [ + 2 + ], + "origin": "extHost1" + },{ + "resource": "/Users/znup/projects/decky/lib/screens/deck_overview_screen.dart", + "owner": "_generated_diagnostic_collection_name_#0", + "code": { + "value": "unused_element", + "target": { + "$mid": 1, + "path": "/diagnostics/unused_element", + "scheme": "https", + "authority": "dart.dev" + } + }, + "severity": 4, + "message": "The declaration '_resetDeck' isn't referenced.\nTry removing the declaration of '_resetDeck'.", + "source": "dart", + "startLineNumber": 286, + "startColumn": 16, + "endLineNumber": 286, + "endColumn": 26, + "origin": "extHost1" + },{ + "resource": "/Users/znup/projects/decky/lib/screens/deck_overview_screen.dart", + "owner": "_generated_diagnostic_collection_name_#0", + "code": { + "value": "use_build_context_synchronously", + "target": { + "$mid": 1, + "path": "/diagnostics/use_build_context_synchronously", + "scheme": "https", + "authority": "dart.dev" + } + }, + "severity": 2, + "message": "Don't use 'BuildContext's across async gaps.\nTry rewriting the code to not use the 'BuildContext', or guard the use with a 'mounted' check.", + "source": "dart", + "startLineNumber": 168, + "startColumn": 9, + "endLineNumber": 168, + "endColumn": 25, + "origin": "extHost1" + },{ + "resource": "/Users/znup/projects/decky/lib/data/default_deck.dart", + "owner": "_generated_diagnostic_collection_name_#0", + "code": { + "value": "deprecated_member_use", + "target": { + "$mid": 1, + "path": "/diagnostics/deprecated_member_use", + "scheme": "https", + "authority": "dart.dev" + } + }, + "severity": 2, + "message": "'correctAnswerIndex' is deprecated and shouldn't be used. Use correctAnswerIndices instead.\nTry replacing the use of the deprecated member with the replacement.", + "source": "dart", + "startLineNumber": 21, + "startColumn": 9, + "endLineNumber": 21, + "endColumn": 27, + "tags": [ + 2 + ], + "origin": "extHost1" + },{ + "resource": "/Users/znup/projects/decky/lib/data/default_deck.dart", + "owner": "_generated_diagnostic_collection_name_#0", + "code": { + "value": "deprecated_member_use", + "target": { + "$mid": 1, + "path": "/diagnostics/deprecated_member_use", + "scheme": "https", + "authority": "dart.dev" + } + }, + "severity": 2, + "message": "'correctAnswerIndex' is deprecated and shouldn't be used. Use correctAnswerIndices instead.\nTry replacing the use of the deprecated member with the replacement.", + "source": "dart", + "startLineNumber": 27, + "startColumn": 9, + "endLineNumber": 27, + "endColumn": 27, + "tags": [ + 2 + ], + "origin": "extHost1" + },{ + "resource": "/Users/znup/projects/decky/lib/data/default_deck.dart", + "owner": "_generated_diagnostic_collection_name_#0", + "code": { + "value": "deprecated_member_use", + "target": { + "$mid": 1, + "path": "/diagnostics/deprecated_member_use", + "scheme": "https", + "authority": "dart.dev" + } + }, + "severity": 2, + "message": "'correctAnswerIndex' is deprecated and shouldn't be used. Use correctAnswerIndices instead.\nTry replacing the use of the deprecated member with the replacement.", + "source": "dart", + "startLineNumber": 33, + "startColumn": 9, + "endLineNumber": 33, + "endColumn": 27, + "tags": [ + 2 + ], + "origin": "extHost1" + },{ + "resource": "/Users/znup/projects/decky/lib/data/default_deck.dart", + "owner": "_generated_diagnostic_collection_name_#0", + "code": { + "value": "deprecated_member_use", + "target": { + "$mid": 1, + "path": "/diagnostics/deprecated_member_use", + "scheme": "https", + "authority": "dart.dev" + } + }, + "severity": 2, + "message": "'correctAnswerIndex' is deprecated and shouldn't be used. Use correctAnswerIndices instead.\nTry replacing the use of the deprecated member with the replacement.", + "source": "dart", + "startLineNumber": 39, + "startColumn": 9, + "endLineNumber": 39, + "endColumn": 27, + "tags": [ + 2 + ], + "origin": "extHost1" + },{ + "resource": "/Users/znup/projects/decky/lib/data/default_deck.dart", + "owner": "_generated_diagnostic_collection_name_#0", + "code": { + "value": "deprecated_member_use", + "target": { + "$mid": 1, + "path": "/diagnostics/deprecated_member_use", + "scheme": "https", + "authority": "dart.dev" + } + }, + "severity": 2, + "message": "'correctAnswerIndex' is deprecated and shouldn't be used. Use correctAnswerIndices instead.\nTry replacing the use of the deprecated member with the replacement.", + "source": "dart", + "startLineNumber": 45, + "startColumn": 9, + "endLineNumber": 45, + "endColumn": 27, + "tags": [ + 2 + ], + "origin": "extHost1" + },{ + "resource": "/Users/znup/projects/decky/lib/data/default_deck.dart", + "owner": "_generated_diagnostic_collection_name_#0", + "code": { + "value": "deprecated_member_use", + "target": { + "$mid": 1, + "path": "/diagnostics/deprecated_member_use", + "scheme": "https", + "authority": "dart.dev" + } + }, + "severity": 2, + "message": "'correctAnswerIndex' is deprecated and shouldn't be used. Use correctAnswerIndices instead.\nTry replacing the use of the deprecated member with the replacement.", + "source": "dart", + "startLineNumber": 51, + "startColumn": 9, + "endLineNumber": 51, + "endColumn": 27, + "tags": [ + 2 + ], + "origin": "extHost1" + },{ + "resource": "/Users/znup/projects/decky/lib/data/default_deck.dart", + "owner": "_generated_diagnostic_collection_name_#0", + "code": { + "value": "deprecated_member_use", + "target": { + "$mid": 1, + "path": "/diagnostics/deprecated_member_use", + "scheme": "https", + "authority": "dart.dev" + } + }, + "severity": 2, + "message": "'correctAnswerIndex' is deprecated and shouldn't be used. Use correctAnswerIndices instead.\nTry replacing the use of the deprecated member with the replacement.", + "source": "dart", + "startLineNumber": 57, + "startColumn": 9, + "endLineNumber": 57, + "endColumn": 27, + "tags": [ + 2 + ], + "origin": "extHost1" + },{ + "resource": "/Users/znup/projects/decky/lib/data/default_deck.dart", + "owner": "_generated_diagnostic_collection_name_#0", + "code": { + "value": "deprecated_member_use", + "target": { + "$mid": 1, + "path": "/diagnostics/deprecated_member_use", + "scheme": "https", + "authority": "dart.dev" + } + }, + "severity": 2, + "message": "'correctAnswerIndex' is deprecated and shouldn't be used. Use correctAnswerIndices instead.\nTry replacing the use of the deprecated member with the replacement.", + "source": "dart", + "startLineNumber": 63, + "startColumn": 9, + "endLineNumber": 63, + "endColumn": 27, + "tags": [ + 2 + ], + "origin": "extHost1" + },{ + "resource": "/Users/znup/projects/decky/lib/data/default_deck.dart", + "owner": "_generated_diagnostic_collection_name_#0", + "code": { + "value": "deprecated_member_use", + "target": { + "$mid": 1, + "path": "/diagnostics/deprecated_member_use", + "scheme": "https", + "authority": "dart.dev" + } + }, + "severity": 2, + "message": "'correctAnswerIndex' is deprecated and shouldn't be used. Use correctAnswerIndices instead.\nTry replacing the use of the deprecated member with the replacement.", + "source": "dart", + "startLineNumber": 69, + "startColumn": 9, + "endLineNumber": 69, + "endColumn": 27, + "tags": [ + 2 + ], + "origin": "extHost1" + },{ + "resource": "/Users/znup/projects/decky/lib/data/default_deck.dart", + "owner": "_generated_diagnostic_collection_name_#0", + "code": { + "value": "deprecated_member_use", + "target": { + "$mid": 1, + "path": "/diagnostics/deprecated_member_use", + "scheme": "https", + "authority": "dart.dev" + } + }, + "severity": 2, + "message": "'correctAnswerIndex' is deprecated and shouldn't be used. Use correctAnswerIndices instead.\nTry replacing the use of the deprecated member with the replacement.", + "source": "dart", + "startLineNumber": 75, + "startColumn": 9, + "endLineNumber": 75, + "endColumn": 27, + "tags": [ + 2 + ], + "origin": "extHost1" + },{ + "resource": "/Users/znup/projects/decky/lib/data/default_deck.dart", + "owner": "_generated_diagnostic_collection_name_#0", + "code": { + "value": "deprecated_member_use", + "target": { + "$mid": 1, + "path": "/diagnostics/deprecated_member_use", + "scheme": "https", + "authority": "dart.dev" + } + }, + "severity": 2, + "message": "'correctAnswerIndex' is deprecated and shouldn't be used. Use correctAnswerIndices instead.\nTry replacing the use of the deprecated member with the replacement.", + "source": "dart", + "startLineNumber": 81, + "startColumn": 9, + "endLineNumber": 81, + "endColumn": 27, + "tags": [ + 2 + ], + "origin": "extHost1" + },{ + "resource": "/Users/znup/projects/decky/lib/data/default_deck.dart", + "owner": "_generated_diagnostic_collection_name_#0", + "code": { + "value": "deprecated_member_use", + "target": { + "$mid": 1, + "path": "/diagnostics/deprecated_member_use", + "scheme": "https", + "authority": "dart.dev" + } + }, + "severity": 2, + "message": "'correctAnswerIndex' is deprecated and shouldn't be used. Use correctAnswerIndices instead.\nTry replacing the use of the deprecated member with the replacement.", + "source": "dart", + "startLineNumber": 87, + "startColumn": 9, + "endLineNumber": 87, + "endColumn": 27, + "tags": [ + 2 + ], + "origin": "extHost1" + },{ + "resource": "/Users/znup/projects/decky/lib/data/default_deck.dart", + "owner": "_generated_diagnostic_collection_name_#0", + "code": { + "value": "deprecated_member_use", + "target": { + "$mid": 1, + "path": "/diagnostics/deprecated_member_use", + "scheme": "https", + "authority": "dart.dev" + } + }, + "severity": 2, + "message": "'correctAnswerIndex' is deprecated and shouldn't be used. Use correctAnswerIndices instead.\nTry replacing the use of the deprecated member with the replacement.", + "source": "dart", + "startLineNumber": 93, + "startColumn": 9, + "endLineNumber": 93, + "endColumn": 27, + "tags": [ + 2 + ], + "origin": "extHost1" + },{ + "resource": "/Users/znup/projects/decky/lib/data/default_deck.dart", + "owner": "_generated_diagnostic_collection_name_#0", + "code": { + "value": "deprecated_member_use", + "target": { + "$mid": 1, + "path": "/diagnostics/deprecated_member_use", + "scheme": "https", + "authority": "dart.dev" + } + }, + "severity": 2, + "message": "'correctAnswerIndex' is deprecated and shouldn't be used. Use correctAnswerIndices instead.\nTry replacing the use of the deprecated member with the replacement.", + "source": "dart", + "startLineNumber": 99, + "startColumn": 9, + "endLineNumber": 99, + "endColumn": 27, + "tags": [ + 2 + ], + "origin": "extHost1" + },{ + "resource": "/Users/znup/projects/decky/lib/data/default_deck.dart", + "owner": "_generated_diagnostic_collection_name_#0", + "code": { + "value": "deprecated_member_use", + "target": { + "$mid": 1, + "path": "/diagnostics/deprecated_member_use", + "scheme": "https", + "authority": "dart.dev" + } + }, + "severity": 2, + "message": "'correctAnswerIndex' is deprecated and shouldn't be used. Use correctAnswerIndices instead.\nTry replacing the use of the deprecated member with the replacement.", + "source": "dart", + "startLineNumber": 105, + "startColumn": 9, + "endLineNumber": 105, + "endColumn": 27, + "tags": [ + 2 + ], + "origin": "extHost1" + },{ + "resource": "/Users/znup/projects/decky/lib/data/default_deck.dart", + "owner": "_generated_diagnostic_collection_name_#0", + "code": { + "value": "deprecated_member_use", + "target": { + "$mid": 1, + "path": "/diagnostics/deprecated_member_use", + "scheme": "https", + "authority": "dart.dev" + } + }, + "severity": 2, + "message": "'correctAnswerIndex' is deprecated and shouldn't be used. Use correctAnswerIndices instead.\nTry replacing the use of the deprecated member with the replacement.", + "source": "dart", + "startLineNumber": 111, + "startColumn": 9, + "endLineNumber": 111, + "endColumn": 27, + "tags": [ + 2 + ], + "origin": "extHost1" + },{ + "resource": "/Users/znup/projects/decky/lib/data/default_deck.dart", + "owner": "_generated_diagnostic_collection_name_#0", + "code": { + "value": "deprecated_member_use", + "target": { + "$mid": 1, + "path": "/diagnostics/deprecated_member_use", + "scheme": "https", + "authority": "dart.dev" + } + }, + "severity": 2, + "message": "'correctAnswerIndex' is deprecated and shouldn't be used. Use correctAnswerIndices instead.\nTry replacing the use of the deprecated member with the replacement.", + "source": "dart", + "startLineNumber": 117, + "startColumn": 9, + "endLineNumber": 117, + "endColumn": 27, + "tags": [ + 2 + ], + "origin": "extHost1" + },{ + "resource": "/Users/znup/projects/decky/lib/data/default_deck.dart", + "owner": "_generated_diagnostic_collection_name_#0", + "code": { + "value": "deprecated_member_use", + "target": { + "$mid": 1, + "path": "/diagnostics/deprecated_member_use", + "scheme": "https", + "authority": "dart.dev" + } + }, + "severity": 2, + "message": "'correctAnswerIndex' is deprecated and shouldn't be used. Use correctAnswerIndices instead.\nTry replacing the use of the deprecated member with the replacement.", + "source": "dart", + "startLineNumber": 123, + "startColumn": 9, + "endLineNumber": 123, + "endColumn": 27, + "tags": [ + 2 + ], + "origin": "extHost1" + },{ + "resource": "/Users/znup/projects/decky/lib/data/default_deck.dart", + "owner": "_generated_diagnostic_collection_name_#0", + "code": { + "value": "deprecated_member_use", + "target": { + "$mid": 1, + "path": "/diagnostics/deprecated_member_use", + "scheme": "https", + "authority": "dart.dev" + } + }, + "severity": 2, + "message": "'correctAnswerIndex' is deprecated and shouldn't be used. Use correctAnswerIndices instead.\nTry replacing the use of the deprecated member with the replacement.", + "source": "dart", + "startLineNumber": 129, + "startColumn": 9, + "endLineNumber": 129, + "endColumn": 27, + "tags": [ + 2 + ], + "origin": "extHost1" + },{ + "resource": "/Users/znup/projects/decky/lib/data/default_deck.dart", + "owner": "_generated_diagnostic_collection_name_#0", + "code": { + "value": "deprecated_member_use", + "target": { + "$mid": 1, + "path": "/diagnostics/deprecated_member_use", + "scheme": "https", + "authority": "dart.dev" + } + }, + "severity": 2, + "message": "'correctAnswerIndex' is deprecated and shouldn't be used. Use correctAnswerIndices instead.\nTry replacing the use of the deprecated member with the replacement.", + "source": "dart", + "startLineNumber": 135, + "startColumn": 9, + "endLineNumber": 135, + "endColumn": 27, + "tags": [ + 2 + ], + "origin": "extHost1" + },{ + "resource": "/Users/znup/projects/decky/lib/screens/deck_config_screen.dart", + "owner": "_generated_diagnostic_collection_name_#0", + "code": { + "value": "prefer_const_constructors", + "target": { + "$mid": 1, + "path": "/diagnostics/prefer_const_constructors", + "scheme": "https", + "authority": "dart.dev" + } + }, + "severity": 2, + "message": "Use 'const' with the constructor to improve performance.\nTry adding the 'const' keyword to the constructor invocation.", + "source": "dart", + "startLineNumber": 83, + "startColumn": 12, + "endLineNumber": 89, + "endColumn": 6, + "origin": "extHost1" + },{ + "resource": "/Users/znup/projects/decky/lib/screens/deck_edit_screen.dart", + "owner": "_generated_diagnostic_collection_name_#0", + "code": { + "value": "prefer_is_empty", + "target": { + "$mid": 1, + "path": "/diagnostics/prefer_is_empty", + "scheme": "https", + "authority": "dart.dev" + } + }, + "severity": 2, + "message": "Use 'isNotEmpty' instead of 'length' to test whether the collection is empty.\nTry rewriting the expression to use 'isNotEmpty'.", + "source": "dart", + "startLineNumber": 320, + "startColumn": 33, + "endLineNumber": 320, + "endColumn": 75, + "origin": "extHost1" + },{ + "resource": "/Users/znup/projects/decky/lib/screens/deck_import_screen.dart", + "owner": "_generated_diagnostic_collection_name_#0", + "code": { + "value": "prefer_const_constructors", + "target": { + "$mid": 1, + "path": "/diagnostics/prefer_const_constructors", + "scheme": "https", + "authority": "dart.dev" + } + }, + "severity": 2, + "message": "Use 'const' with the constructor to improve performance.\nTry adding the 'const' keyword to the constructor invocation.", + "source": "dart", + "startLineNumber": 97, + "startColumn": 15, + "endLineNumber": 97, + "endColumn": 56, + "origin": "extHost1" + },{ + "resource": "/Users/znup/projects/decky/lib/screens/deck_import_screen.dart", + "owner": "_generated_diagnostic_collection_name_#0", + "code": { + "value": "prefer_const_constructors", + "target": { + "$mid": 1, + "path": "/diagnostics/prefer_const_constructors", + "scheme": "https", + "authority": "dart.dev" + } + }, + "severity": 2, + "message": "Use 'const' with the constructor to improve performance.\nTry adding the 'const' keyword to the constructor invocation.", + "source": "dart", + "startLineNumber": 103, + "startColumn": 15, + "endLineNumber": 103, + "endColumn": 54, + "origin": "extHost1" + },{ + "resource": "/Users/znup/projects/decky/lib/screens/deck_import_screen.dart", + "owner": "_generated_diagnostic_collection_name_#0", + "code": { + "value": "prefer_const_constructors", + "target": { + "$mid": 1, + "path": "/diagnostics/prefer_const_constructors", + "scheme": "https", + "authority": "dart.dev" + } + }, + "severity": 2, + "message": "Use 'const' with the constructor to improve performance.\nTry adding the 'const' keyword to the constructor invocation.", + "source": "dart", + "startLineNumber": 107, + "startColumn": 15, + "endLineNumber": 107, + "endColumn": 73, + "origin": "extHost1" + },{ + "resource": "/Users/znup/projects/decky/lib/screens/deck_import_screen.dart", + "owner": "_generated_diagnostic_collection_name_#0", + "code": { + "value": "prefer_const_constructors", + "target": { + "$mid": 1, + "path": "/diagnostics/prefer_const_constructors", + "scheme": "https", + "authority": "dart.dev" + } + }, + "severity": 2, + "message": "Use 'const' with the constructor to improve performance.\nTry adding the 'const' keyword to the constructor invocation.", + "source": "dart", + "startLineNumber": 368, + "startColumn": 13, + "endLineNumber": 415, + "endColumn": 14, + "origin": "extHost1" + },{ + "resource": "/Users/znup/projects/decky/lib/screens/deck_import_screen.dart", + "owner": "_generated_diagnostic_collection_name_#0", + "code": { + "value": "prefer_const_literals_to_create_immutables", + "target": { + "$mid": 1, + "path": "/diagnostics/prefer_const_literals_to_create_immutables", + "scheme": "https", + "authority": "dart.dev" + } + }, + "severity": 2, + "message": "Use 'const' literals as arguments to constructors of '@immutable' classes.\nTry adding 'const' before the literal.", + "source": "dart", + "startLineNumber": 370, + "startColumn": 25, + "endLineNumber": 414, + "endColumn": 16, + "origin": "extHost1" + },{ + "resource": "/Users/znup/projects/decky/lib/screens/deck_import_screen.dart", + "owner": "_generated_diagnostic_collection_name_#0", + "code": { + "value": "prefer_const_constructors", + "target": { + "$mid": 1, + "path": "/diagnostics/prefer_const_constructors", + "scheme": "https", + "authority": "dart.dev" + } + }, + "severity": 2, + "message": "Use 'const' with the constructor to improve performance.\nTry adding the 'const' keyword to the constructor invocation.", + "source": "dart", + "startLineNumber": 371, + "startColumn": 17, + "endLineNumber": 413, + "endColumn": 18, + "origin": "extHost1" + },{ + "resource": "/Users/znup/projects/decky/lib/screens/deck_import_screen.dart", + "owner": "_generated_diagnostic_collection_name_#0", + "code": { + "value": "prefer_const_constructors", + "target": { + "$mid": 1, + "path": "/diagnostics/prefer_const_constructors", + "scheme": "https", + "authority": "dart.dev" + } + }, + "severity": 2, + "message": "Use 'const' with the constructor to improve performance.\nTry adding the 'const' keyword to the constructor invocation.", + "source": "dart", + "startLineNumber": 373, + "startColumn": 26, + "endLineNumber": 412, + "endColumn": 20, + "origin": "extHost1" + },{ + "resource": "/Users/znup/projects/decky/lib/screens/deck_import_screen.dart", + "owner": "_generated_diagnostic_collection_name_#0", + "code": { + "value": "prefer_const_literals_to_create_immutables", + "target": { + "$mid": 1, + "path": "/diagnostics/prefer_const_literals_to_create_immutables", + "scheme": "https", + "authority": "dart.dev" + } + }, + "severity": 2, + "message": "Use 'const' literals as arguments to constructors of '@immutable' classes.\nTry adding 'const' before the literal.", + "source": "dart", + "startLineNumber": 375, + "startColumn": 31, + "endLineNumber": 411, + "endColumn": 22, + "origin": "extHost1" + },{ + "resource": "/Users/znup/projects/decky/packages/practice_engine/lib/models/question.dart", + "owner": "_generated_diagnostic_collection_name_#0", + "code": { + "value": "deprecated_member_use_from_same_package", + "target": { + "$mid": 1, + "path": "/diagnostics/deprecated_member_use_from_same_package", + "scheme": "https", + "authority": "dart.dev" + } + }, + "severity": 2, + "message": "'correctAnswerIndex' is deprecated and shouldn't be used. Use correctAnswerIndices instead.\nTry replacing the use of the deprecated member with the replacement.", + "source": "dart", + "startLineNumber": 13, + "startColumn": 60, + "endLineNumber": 13, + "endColumn": 78, + "tags": [ + 2 + ], + "origin": "extHost1" + },{ + "resource": "/Users/znup/projects/decky/packages/practice_engine/lib/models/question.dart", + "owner": "_generated_diagnostic_collection_name_#0", + "code": { + "value": "deprecated_member_use_from_same_package", + "target": { + "$mid": 1, + "path": "/diagnostics/deprecated_member_use_from_same_package", + "scheme": "https", + "authority": "dart.dev" + } + }, + "severity": 2, + "message": "'correctAnswerIndex' is deprecated and shouldn't be used. Use correctAnswerIndices instead.\nTry replacing the use of the deprecated member with the replacement.", + "source": "dart", + "startLineNumber": 53, + "startColumn": 9, + "endLineNumber": 53, + "endColumn": 27, + "tags": [ + 2 + ], + "origin": "extHost1" + },{ + "resource": "/Users/znup/projects/decky/packages/practice_engine/lib/models/question.dart", + "owner": "_generated_diagnostic_collection_name_#0", + "code": { + "value": "prefer_initializing_formals", + "target": { + "$mid": 1, + "path": "/diagnostics/prefer_initializing_formals", + "scheme": "https", + "authority": "dart.dev" + } + }, + "severity": 2, + "message": "Use an initializing formal to assign a parameter to a field.\nTry using an initialing formal ('this.correctAnswerIndex') to initialize the field.", + "source": "dart", + "startLineNumber": 53, + "startColumn": 9, + "endLineNumber": 53, + "endColumn": 48, + "origin": "extHost1" + },{ + "resource": "/Users/znup/projects/decky/packages/practice_engine/lib/models/question.dart", + "owner": "_generated_diagnostic_collection_name_#0", + "code": { + "value": "deprecated_member_use_from_same_package", + "target": { + "$mid": 1, + "path": "/diagnostics/deprecated_member_use_from_same_package", + "scheme": "https", + "authority": "dart.dev" + } + }, + "severity": 2, + "message": "'correctAnswerIndex' is deprecated and shouldn't be used. Use correctAnswerIndices instead.\nTry replacing the use of the deprecated member with the replacement.", + "source": "dart", + "startLineNumber": 74, + "startColumn": 7, + "endLineNumber": 74, + "endColumn": 25, + "tags": [ + 2 + ], + "origin": "extHost1" + },{ + "resource": "/Users/znup/projects/decky/packages/practice_engine/lib/models/question.dart", + "owner": "_generated_diagnostic_collection_name_#0", + "code": { + "value": "deprecated_member_use_from_same_package", + "target": { + "$mid": 1, + "path": "/diagnostics/deprecated_member_use_from_same_package", + "scheme": "https", + "authority": "dart.dev" + } + }, + "severity": 2, + "message": "'correctAnswerIndex' is deprecated and shouldn't be used. Use correctAnswerIndices instead.\nTry replacing the use of the deprecated member with the replacement.", + "source": "dart", + "startLineNumber": 74, + "startColumn": 54, + "endLineNumber": 74, + "endColumn": 72, + "tags": [ + 2 + ], + "origin": "extHost1" + },{ + "resource": "/Users/znup/projects/decky/packages/practice_engine/lib/models/question.dart", + "owner": "_generated_diagnostic_collection_name_#0", + "code": { + "value": "deprecated_member_use_from_same_package", + "target": { + "$mid": 1, + "path": "/diagnostics/deprecated_member_use_from_same_package", + "scheme": "https", + "authority": "dart.dev" + } + }, + "severity": 2, + "message": "'correctAnswerIndex' is deprecated and shouldn't be used. Use correctAnswerIndices instead.\nTry replacing the use of the deprecated member with the replacement.", + "source": "dart", + "startLineNumber": 90, + "startColumn": 9, + "endLineNumber": 90, + "endColumn": 27, + "tags": [ + 2 + ], + "origin": "extHost1" + },{ + "resource": "/Users/znup/projects/decky/packages/practice_engine/lib/models/question.dart", + "owner": "_generated_diagnostic_collection_name_#0", + "code": { + "value": "deprecated_member_use_from_same_package", + "target": { + "$mid": 1, + "path": "/diagnostics/deprecated_member_use_from_same_package", + "scheme": "https", + "authority": "dart.dev" + } + }, + "severity": 2, + "message": "'correctAnswerIndex' is deprecated and shouldn't be used. Use correctAnswerIndices instead.\nTry replacing the use of the deprecated member with the replacement.", + "source": "dart", + "startLineNumber": 91, + "startColumn": 15, + "endLineNumber": 91, + "endColumn": 33, + "tags": [ + 2 + ], + "origin": "extHost1" + },{ + "resource": "/Users/znup/projects/decky/packages/practice_engine/test/attempt_flow_test.dart", + "owner": "_generated_diagnostic_collection_name_#0", + "code": { + "value": "prefer_const_declarations", + "target": { + "$mid": 1, + "path": "/diagnostics/prefer_const_declarations", + "scheme": "https", + "authority": "dart.dev" + } + }, + "severity": 2, + "message": "Use 'const' for final variables initialized to a constant value.\nTry replacing 'final' with 'const'.", + "source": "dart", + "startLineNumber": 13, + "startColumn": 7, + "endLineNumber": 16, + "endColumn": 8, + "origin": "extHost1" + }] \ No newline at end of file diff --git a/lib/data/default_deck.dart b/lib/data/default_deck.dart new file mode 100644 index 0000000..241394f --- /dev/null +++ b/lib/data/default_deck.dart @@ -0,0 +1,146 @@ +import 'package:practice_engine/practice_engine.dart'; + +/// Default deck with 20 general knowledge questions for practice. +class DefaultDeck { + static Deck get deck { + const config = DeckConfig( + requiredConsecutiveCorrect: 3, + defaultAttemptSize: 10, + priorityIncreaseOnIncorrect: 5, + priorityDecreaseOnCorrect: 2, + immediateFeedbackEnabled: true, + ); + + final questions = [ + Question( + id: 'gk_1', + prompt: 'What is the capital city of Australia?', + answers: ['Sydney', 'Melbourne', 'Canberra', 'Perth'], + correctAnswerIndices: [2], + ), + Question( + id: 'gk_2', + prompt: 'Which planet is known as the Red Planet?', + answers: ['Venus', 'Mars', 'Jupiter', 'Saturn'], + correctAnswerIndices: [1], + ), + Question( + id: 'gk_3', + prompt: 'What is the largest ocean on Earth?', + answers: ['Atlantic Ocean', 'Indian Ocean', 'Arctic Ocean', 'Pacific Ocean'], + correctAnswerIndices: [3], + ), + Question( + id: 'gk_4', + prompt: 'Who wrote the novel "1984"?', + answers: ['George Orwell', 'Aldous Huxley', 'Ray Bradbury', 'J.D. Salinger'], + correctAnswerIndices: [0], + ), + Question( + id: 'gk_5', + prompt: 'What is the chemical symbol for gold?', + answers: ['Go', 'Gd', 'Au', 'Ag'], + correctAnswerIndices: [2], + ), + Question( + id: 'gk_6', + prompt: 'In which year did World War II end?', + answers: ['1943', '1944', '1945', '1946'], + correctAnswerIndices: [2], + ), + Question( + id: 'gk_7', + prompt: 'What is the smallest prime number?', + answers: ['0', '1', '2', '3'], + correctAnswerIndices: [2], + ), + Question( + id: 'gk_8', + prompt: 'Which gas makes up approximately 78% of Earth\'s atmosphere?', + answers: ['Oxygen', 'Carbon Dioxide', 'Nitrogen', 'Argon'], + correctAnswerIndices: [2], + ), + Question( + id: 'gk_9', + prompt: 'What is the longest river in the world?', + answers: ['Amazon River', 'Nile River', 'Yangtze River', 'Mississippi River'], + correctAnswerIndices: [1], + ), + Question( + id: 'gk_10', + prompt: 'Who painted the Mona Lisa?', + answers: ['Vincent van Gogh', 'Pablo Picasso', 'Leonardo da Vinci', 'Michelangelo'], + correctAnswerIndices: [2], + ), + Question( + id: 'gk_11', + prompt: 'What is the speed of light in a vacuum (approximately)?', + answers: ['300,000 km/s', '150,000 km/s', '450,000 km/s', '600,000 km/s'], + correctAnswerIndices: [0], + ), + Question( + id: 'gk_12', + prompt: 'Which country is home to the kangaroo?', + answers: ['New Zealand', 'Australia', 'South Africa', 'Brazil'], + correctAnswerIndices: [1], + ), + Question( + id: 'gk_13', + prompt: 'What is the hardest natural substance on Earth?', + answers: ['Gold', 'Diamond', 'Platinum', 'Titanium'], + correctAnswerIndices: [1], + ), + Question( + id: 'gk_14', + prompt: 'How many continents are there on Earth?', + answers: ['5', '6', '7', '8'], + correctAnswerIndices: [2], + ), + Question( + id: 'gk_15', + prompt: 'What is the largest mammal in the world?', + answers: ['African Elephant', 'Blue Whale', 'Giraffe', 'Polar Bear'], + correctAnswerIndices: [1], + ), + Question( + id: 'gk_16', + prompt: 'In which city is the Eiffel Tower located?', + answers: ['London', 'Berlin', 'Paris', 'Rome'], + correctAnswerIndices: [2], + ), + Question( + id: 'gk_17', + prompt: 'What is the square root of 64?', + answers: ['6', '7', '8', '9'], + correctAnswerIndices: [2], + ), + Question( + id: 'gk_18', + prompt: 'Which element has the atomic number 1?', + answers: ['Helium', 'Hydrogen', 'Lithium', 'Carbon'], + correctAnswerIndices: [1], + ), + Question( + id: 'gk_19', + prompt: 'What is the largest desert in the world?', + answers: ['Gobi Desert', 'Sahara Desert', 'Antarctic Desert', 'Arabian Desert'], + correctAnswerIndices: [2], + ), + Question( + id: 'gk_20', + prompt: 'Who invented the telephone?', + answers: ['Thomas Edison', 'Alexander Graham Bell', 'Nikola Tesla', 'Guglielmo Marconi'], + correctAnswerIndices: [1], + ), + ]; + + return Deck( + id: 'default-general-knowledge', + title: 'General Knowledge Quiz', + description: 'A collection of 20 general knowledge questions covering science, geography, history, and more. Perfect for testing your knowledge!', + questions: questions, + config: config, + ); + } +} + diff --git a/lib/main.dart b/lib/main.dart index 1e868b8..0ecca89 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -15,8 +15,8 @@ class DeckyApp extends StatelessWidget { title: 'Decky - Practice Engine', theme: AppTheme.lightTheme, darkTheme: AppTheme.darkTheme, - themeMode: ThemeMode.system, - initialRoute: Routes.deckImport, + themeMode: ThemeMode.dark, + initialRoute: Routes.deckList, routes: Routes.routes, debugShowCheckedModeBanner: false, ); diff --git a/lib/routes.dart b/lib/routes.dart index 7c42bb3..cf5c9d9 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -1,22 +1,31 @@ import 'package:flutter/material.dart'; +import 'screens/deck_list_screen.dart'; import 'screens/deck_import_screen.dart'; import 'screens/deck_overview_screen.dart'; import 'screens/deck_config_screen.dart'; +import 'screens/deck_edit_screen.dart'; +import 'screens/deck_create_screen.dart'; import 'screens/attempt_screen.dart'; import 'screens/attempt_result_screen.dart'; class Routes { - static const String deckImport = '/'; + static const String deckList = '/'; + static const String deckImport = '/deck-import'; static const String deckOverview = '/deck-overview'; static const String deckConfig = '/deck-config'; + static const String deckEdit = '/deck-edit'; + static const String deckCreate = '/deck-create'; static const String attempt = '/attempt'; static const String attemptResult = '/attempt-result'; static Map get routes { return { + deckList: (context) => const DeckListScreen(), deckImport: (context) => const DeckImportScreen(), deckOverview: (context) => const DeckOverviewScreen(), deckConfig: (context) => const DeckConfigScreen(), + deckEdit: (context) => const DeckEditScreen(), + deckCreate: (context) => const DeckCreateScreen(), attempt: (context) => const AttemptScreen(), attemptResult: (context) => const AttemptResultScreen(), }; diff --git a/lib/screens/attempt_result_screen.dart b/lib/screens/attempt_result_screen.dart index 30e8ed2..17bddde 100644 --- a/lib/screens/attempt_result_screen.dart +++ b/lib/screens/attempt_result_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:practice_engine/practice_engine.dart'; import '../routes.dart'; import '../widgets/status_chip.dart'; +import '../services/deck_storage.dart'; class AttemptResultScreen extends StatefulWidget { const AttemptResultScreen({super.key}); @@ -13,6 +14,7 @@ class AttemptResultScreen extends StatefulWidget { class _AttemptResultScreenState extends State { Deck? _deck; AttemptResult? _result; + final DeckStorage _deckStorage = DeckStorage(); @override void initState() { @@ -70,12 +72,13 @@ class _AttemptResultScreenState extends State { void _done() { if (_deck == null) return; - // Navigate back to deck overview with updated deck + // Save the updated deck to storage + _deckStorage.saveDeck(_deck!); + // Navigate back to deck list Navigator.pushNamedAndRemoveUntil( context, - Routes.deckOverview, + Routes.deckList, (route) => false, - arguments: _deck, ); } @@ -174,25 +177,25 @@ class _AttemptResultScreenState extends State { 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]}', + 'Your answer${answerResult.userAnswerIndices.length > 1 ? 's' : ''}: ${answerResult.userAnswerIndices.map((idx) => answerResult.question.answers[idx]).join(', ')}', style: TextStyle( - color: Colors.green, + color: Colors.red, fontWeight: FontWeight.bold, ), ), ], ), + const SizedBox(height: 8), + Text( + 'Correct answer${answerResult.question.correctIndices.length > 1 ? 's' : ''}: ${answerResult.question.correctIndices.map((idx) => answerResult.question.answers[idx]).join(', ')}', + style: TextStyle( + color: Colors.green, + fontWeight: FontWeight.bold, + ), + ), + ], + ), ), ); }), @@ -213,7 +216,7 @@ class _AttemptResultScreenState extends State { subtitle: Text( answerResult.isCorrect ? 'Correct' - : 'Incorrect - Selected: ${answerResult.question.answers[answerResult.userAnswerIndex]}', + : 'Incorrect - Selected: ${answerResult.userAnswerIndices.map((idx) => answerResult.question.answers[idx]).join(', ')}', ), trailing: StatusChip(statusChange: answerResult.statusChange), ), diff --git a/lib/screens/attempt_screen.dart b/lib/screens/attempt_screen.dart index 4ea7135..72df306 100644 --- a/lib/screens/attempt_screen.dart +++ b/lib/screens/attempt_screen.dart @@ -1,8 +1,65 @@ import 'package:flutter/material.dart'; +import 'dart:math' as math; +import 'dart:async'; +import 'dart:ui'; import 'package:practice_engine/practice_engine.dart'; import '../routes.dart'; import '../widgets/question_card.dart'; import '../widgets/answer_option.dart'; +import '../services/deck_storage.dart'; + +/// Custom 3D cube page transformer +class CubePageTransitionsBuilder extends PageTransitionsBuilder { + const CubePageTransitionsBuilder(); + + @override + Widget buildTransitions( + PageRoute route, + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + return _CubeTransition( + animation: animation, + child: child, + ); + } +} + +class _CubeTransition extends StatelessWidget { + final Animation animation; + final Widget child; + + const _CubeTransition({ + required this.animation, + required this.child, + }); + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: animation, + builder: (context, child) { + final value = animation.value; + final angle = (1.0 - value) * math.pi / 2; + final opacity = value < 0.5 ? 0.0 : (value - 0.5) * 2; + + return Transform( + alignment: Alignment.centerLeft, + transform: Matrix4.identity() + ..setEntry(3, 2, 0.001) + ..rotateY(angle), + child: Opacity( + opacity: opacity.clamp(0.0, 1.0), + child: child, + ), + ); + }, + child: child, + ); + } +} class AttemptScreen extends StatefulWidget { const AttemptScreen({super.key}); @@ -16,14 +73,21 @@ class _AttemptScreenState extends State { Attempt? _attempt; AttemptService? _attemptService; int _currentQuestionIndex = 0; - int? _selectedAnswerIndex; - final Map _answers = {}; - final Map _manualOverrides = {}; + int? _selectedAnswerIndex; // For single answer questions (backward compatibility) + Set _selectedAnswerIndices = {}; // For multiple answer questions + Map _answers = {}; // Can store int or List + Map _manualOverrides = {}; + final DeckStorage _deckStorage = DeckStorage(); + late PageController _pageController; + Timer? _timer; + int _remainingSeconds = 0; + int _startTime = 0; @override void initState() { super.initState(); _attemptService = AttemptService(); + _pageController = PageController(); } @override @@ -32,11 +96,100 @@ class _AttemptScreenState extends State { 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!); + bool? includeKnown; + bool? resumeAttempt; + + if (args is Map) { + _deck = args['deck'] as Deck? ?? _createSampleDeck(); + includeKnown = args['includeKnown'] as bool?; + resumeAttempt = args['resumeAttempt'] as bool? ?? false; + } else if (args is Deck) { + _deck = args; + resumeAttempt = false; + } else { + _deck = _createSampleDeck(); + resumeAttempt = false; + } + + // Check if we should resume an incomplete attempt + if (resumeAttempt == true && _deck!.incompleteAttempt != null) { + final incomplete = _deck!.incompleteAttempt!; + _attempt = incomplete.toAttempt(_deck!.questions); + _currentQuestionIndex = incomplete.currentQuestionIndex; + _answers = Map.from(incomplete.answers); + _manualOverrides = Map.from(incomplete.manualOverrides); + + // Restore selected answer for current question + _loadQuestionState(); + + // Initialize PageController to the current question index + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_pageController.hasClients) { + _pageController.jumpToPage(_currentQuestionIndex); + } + }); + } else { + _attempt = _attemptService!.createAttempt( + deck: _deck!, + includeKnown: includeKnown, + ); + } + + // Initialize timer if time limit is set + if (_deck!.config.timeLimitSeconds != null) { + if (resumeAttempt == true && _deck!.incompleteAttempt != null) { + // Use remaining time from incomplete attempt + if (_deck!.incompleteAttempt!.remainingSeconds != null) { + _remainingSeconds = _deck!.incompleteAttempt!.remainingSeconds!; + } else { + // Fallback: calculate remaining time when resuming (for backward compatibility) + final pausedAt = _deck!.incompleteAttempt!.pausedAt; + final elapsedSeconds = (DateTime.now().millisecondsSinceEpoch - pausedAt) ~/ 1000; + _remainingSeconds = (_deck!.config.timeLimitSeconds! - elapsedSeconds).clamp(0, _deck!.config.timeLimitSeconds!); + } + } else { + _startTime = DateTime.now().millisecondsSinceEpoch; + _remainingSeconds = _deck!.config.timeLimitSeconds!; + } + _startTimer(); + } } } + void _startTimer() { + _timer?.cancel(); + _timer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (!mounted) { + timer.cancel(); + return; + } + + setState(() { + if (_remainingSeconds > 0) { + _remainingSeconds--; + } else { + // Time expired + timer.cancel(); + _handleTimeExpired(); + } + }); + }); + } + + void _handleTimeExpired() { + // Auto-submit the attempt when time expires + if (_attempt != null && _deck != null) { + _completeAttempt(); + } + } + + @override + void dispose() { + _timer?.cancel(); + _pageController.dispose(); + super.dispose(); + } + Deck _createSampleDeck() { const config = DeckConfig(); final questions = List.generate(10, (i) { @@ -44,7 +197,7 @@ class _AttemptScreenState extends State { id: 'q$i', prompt: 'Sample Question $i?', answers: ['A', 'B', 'C', 'D'], - correctAnswerIndex: i % 4, + correctAnswerIndices: [i % 4], ); }); return Deck( @@ -58,84 +211,180 @@ class _AttemptScreenState extends State { Question get _currentQuestion => _attempt!.questions[_currentQuestionIndex]; bool get _isLastQuestion => _currentQuestionIndex == _attempt!.questions.length - 1; - bool get _hasAnswer => _selectedAnswerIndex != null; + bool get _hasMultipleCorrect => _currentQuestion.hasMultipleCorrectAnswers; + bool get _hasAnswer { + if (_hasMultipleCorrect) { + return _selectedAnswerIndices.isNotEmpty; + } else { + return _selectedAnswerIndex != null; + } + } + + /// Returns true if at least one question has been answered (progress has been made) + bool get _hasAnyProgress => _answers.isNotEmpty || _manualOverrides.isNotEmpty; + + void _goToPreviousQuestion() { + if (_currentQuestionIndex > 0) { + // Save current answer if any + if (_hasAnswer) { + if (_hasMultipleCorrect) { + _answers[_currentQuestion.id] = _selectedAnswerIndices.toList()..sort(); + } else { + _answers[_currentQuestion.id] = _selectedAnswerIndex!; + } + } + + _pageController.previousPage( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + } - void _selectAnswer(int index) { + void _goToNextQuestion() { + if (_currentQuestionIndex < _attempt!.questions.length - 1) { + // Save current answer if any + if (_hasAnswer) { + if (_hasMultipleCorrect) { + _answers[_currentQuestion.id] = _selectedAnswerIndices.toList()..sort(); + } else { + _answers[_currentQuestion.id] = _selectedAnswerIndex!; + } + } + + _pageController.nextPage( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + } + + String _formatTime(int seconds) { + final hours = seconds ~/ 3600; + final minutes = (seconds % 3600) ~/ 60; + final secs = seconds % 60; + + if (hours > 0) { + return '${hours}h ${minutes}m ${secs}s'; + } else if (minutes > 0) { + return '${minutes}m ${secs}s'; + } else { + return '${secs}s'; + } + } + + void _onPageChanged(int index) { setState(() { - _selectedAnswerIndex = index; + _currentQuestionIndex = index; + _loadQuestionState(); }); + } - if (_deck != null && _deck!.config.immediateFeedbackEnabled) { - // Show feedback immediately + void _loadQuestionState() { + // Load saved answer for current question + final currentQuestionId = _currentQuestion.id; + final savedAnswer = _answers[currentQuestionId]; + + if (savedAnswer != null) { + if (savedAnswer is int) { + _selectedAnswerIndex = savedAnswer; + _selectedAnswerIndices.clear(); + } else if (savedAnswer is List) { + _selectedAnswerIndices = savedAnswer.toSet(); + _selectedAnswerIndex = null; + } + } else { + _selectedAnswerIndex = null; + _selectedAnswerIndices.clear(); } } void _submitAnswer() { - if (_selectedAnswerIndex == null) return; + if (!_hasAnswer) return; - _answers[_currentQuestion.id] = _selectedAnswerIndex!; + // Store answer(s) based on question type + if (_hasMultipleCorrect) { + _answers[_currentQuestion.id] = _selectedAnswerIndices.toList()..sort(); + } else { + _answers[_currentQuestion.id] = _selectedAnswerIndex!; + } if (_isLastQuestion) { _completeAttempt(); } else { - setState(() { - _currentQuestionIndex++; - _selectedAnswerIndex = null; - }); + _goToNextQuestion(); } } - void _markAsKnown() { - if (_deck == null) return; - setState(() { - _manualOverrides[_currentQuestion.id] = false; // Not needs practice - _deck = DeckService.markQuestionAsKnown( - deck: _deck!, - questionId: _currentQuestion.id, - ); - }); + void _saveForLater() { + if (_deck == null || _attempt == null) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Question marked as Known'), - backgroundColor: Colors.green, - ), + // Save current answer if selected + if (_hasAnswer) { + if (_hasMultipleCorrect) { + _answers[_currentQuestion.id] = _selectedAnswerIndices.toList()..sort(); + } else { + _answers[_currentQuestion.id] = _selectedAnswerIndex!; + } + } + + // Create incomplete attempt + final incompleteAttempt = IncompleteAttempt( + attemptId: _attempt!.id, + questionIds: _attempt!.questions.map((q) => q.id).toList(), + startTime: _attempt!.startTime, + currentQuestionIndex: _currentQuestionIndex, + answers: _answers, + manualOverrides: _manualOverrides, + pausedAt: DateTime.now().millisecondsSinceEpoch, + remainingSeconds: _deck!.config.timeLimitSeconds != null ? _remainingSeconds : null, ); - } - void _markAsNeedsPractice() { - if (_deck == null) return; - setState(() { - _manualOverrides[_currentQuestion.id] = true; - _deck = DeckService.markQuestionAsNeedsPractice( - deck: _deck!, - questionId: _currentQuestion.id, - ); - }); + // Update deck with incomplete attempt + final updatedDeck = _deck!.copyWith(incompleteAttempt: incompleteAttempt); + _deckStorage.saveDeck(updatedDeck); ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text('Question marked as Needs Practice'), + content: Text('Attempt saved. You can continue later.'), + backgroundColor: Colors.blue, + duration: Duration(seconds: 2), ), ); + + Navigator.pop(context); } void _completeAttempt() { if (_deck == null || _attempt == null || _attemptService == null) return; + final endTime = DateTime.now().millisecondsSinceEpoch; final result = _attemptService!.processAttempt( deck: _deck!, attempt: _attempt!, answers: _answers, manualOverrides: _manualOverrides, - endTime: DateTime.now().millisecondsSinceEpoch, + endTime: endTime, ); + // Add attempt to history + final historyEntry = AttemptHistoryEntry.fromAttemptResult( + result: result.result, + timestamp: endTime, + ); + final updatedDeckWithHistory = result.updatedDeck.copyWith( + attemptHistory: [...result.updatedDeck.attemptHistory, historyEntry], + clearIncompleteAttempt: true, // Clear incomplete attempt when completed + ); + + // Save the updated deck to storage + _deckStorage.saveDeck(updatedDeckWithHistory); + Navigator.pushReplacementNamed( context, Routes.attemptResult, arguments: { - 'deck': result.updatedDeck, + 'deck': updatedDeckWithHistory, 'result': result.result, 'attempt': _attempt, }, @@ -150,18 +399,91 @@ class _AttemptScreenState extends State { ); } - return Scaffold( - appBar: AppBar( - title: Text('Attempt - ${_deck!.title}'), - ), - body: Column( - children: [ - // Progress Indicator - LinearProgressIndicator( - value: (_currentQuestionIndex + 1) / _attempt!.questions.length, - minHeight: 4, + return PopScope( + canPop: false, + onPopInvoked: (bool didPop) async { + if (didPop) return; + // Only prompt to save if there's actual progress (at least one question answered) + if (!_hasAnyProgress) { + // No progress made, just allow exit without prompt + if (mounted) { + Navigator.of(context).pop(); + } + return; + } + + // Ask if user wants to save for later + final shouldSave = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Exit Attempt?'), + content: const Text( + 'Your progress will be lost. Would you like to save this attempt to continue later?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Discard'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Save for Later'), + ), + ], ), - const SizedBox(height: 8), + ); + + if (!mounted) return; + + if (shouldSave == true) { + _saveForLater(); + // _saveForLater will handle navigation + } else if (shouldSave == false) { + // User chose to discard, allow pop + Navigator.of(context).pop(); + } + // If cancelled (shouldSave == null), don't pop + }, + child: Scaffold( + appBar: AppBar( + title: Text('Attempt - ${_deck!.title}'), + actions: [ + // Only show "Continue Later" button if there's progress + if (_hasAnyProgress) + TextButton.icon( + onPressed: _saveForLater, + icon: const Icon(Icons.pause), + label: const Text('Continue Later'), + ), + ], + ), + body: Column( + children: [ + // Time Limit Countdown Bar (if time limit is set) + if (_deck!.config.timeLimitSeconds != null) + Container( + height: 6, + color: Theme.of(context).colorScheme.surfaceContainerHighest, + child: FractionallySizedBox( + alignment: Alignment.centerLeft, + widthFactor: _remainingSeconds / _deck!.config.timeLimitSeconds!, + child: Container( + decoration: BoxDecoration( + color: _remainingSeconds <= 60 + ? Colors.red + : _remainingSeconds <= 300 + ? Colors.orange + : Colors.green, + ), + ), + ), + ), + // Progress Indicator + LinearProgressIndicator( + value: (_currentQuestionIndex + 1) / _attempt!.questions.length, + minHeight: 4, + ), + const SizedBox(height: 8), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( @@ -171,97 +493,299 @@ class _AttemptScreenState extends State { '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), - ), + Row( + children: [ + if (_deck!.config.timeLimitSeconds != null) + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: _remainingSeconds <= 60 + ? Colors.red.withValues(alpha: 0.2) + : _remainingSeconds <= 300 + ? Colors.orange.withValues(alpha: 0.2) + : Colors.green.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + _formatTime(_remainingSeconds), + style: TextStyle( + fontWeight: FontWeight.bold, + color: _remainingSeconds <= 60 + ? Colors.red + : _remainingSeconds <= 300 + ? Colors.orange + : Colors.green, + ), + ), + ), + if (_deck!.config.timeLimitSeconds != null && _currentQuestion.isKnown) + const SizedBox(width: 8), + if (_currentQuestion.isKnown) + const Chip( + label: Text('Known'), + avatar: Icon(Icons.check_circle, size: 18), + ), + ], + ), ], ), ), - // Question Card + // Question Card with PageView for 3D transitions 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, + child: PageView.builder( + controller: _pageController, + onPageChanged: _onPageChanged, + itemCount: _attempt!.questions.length, + physics: const BouncingScrollPhysics(), // Always allow swiping, validation happens in onPageChanged + itemBuilder: (context, index) { + final question = _attempt!.questions[index]; + final isMultipleCorrect = question.hasMultipleCorrectAnswers; + final savedAnswer = _answers[question.id]; + + // Determine selected answers for this question + int? selectedIndex; + Set selectedIndices = {}; + if (savedAnswer != null) { + if (savedAnswer is int) { + selectedIndex = savedAnswer; + } else if (savedAnswer is List) { + selectedIndices = savedAnswer.toSet(); + } + } + + return AnimatedBuilder( + animation: _pageController, + builder: (context, child) { + double value = 1.0; + double angle = 0.0; + if (_pageController.position.haveDimensions) { + value = _pageController.page! - index; + value = (1 - (value.abs() * 0.5)).clamp(0.0, 1.0); + + // Calculate rotation angle for 3D cube effect + final pageOffset = _pageController.page! - index; + if (pageOffset.abs() < 1.0) { + angle = pageOffset * math.pi / 2; + } else if (pageOffset < 0) { + angle = -math.pi / 2; + } else { + angle = math.pi / 2; + } + } + + return Transform( + alignment: Alignment.centerLeft, + transform: Matrix4.identity() + ..setEntry(3, 2, 0.001) + ..rotateY(angle), + child: Opacity( + opacity: value.clamp(0.0, 1.0), + child: child, ), - ), - ), + ); + }, + child: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + QuestionCard(question: question), + const SizedBox(height: 24), - 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, + // Answer Options + ...List.generate( + question.answers.length, + (answerIndex) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: AnswerOption( + text: question.answers[answerIndex], + isSelected: isMultipleCorrect + ? selectedIndices.contains(answerIndex) + : selectedIndex == answerIndex, + onTap: () { + // Update selection for this question + setState(() { + if (isMultipleCorrect) { + if (selectedIndices.contains(answerIndex)) { + selectedIndices.remove(answerIndex); + } else { + selectedIndices.add(answerIndex); + } + _answers[question.id] = selectedIndices.toList()..sort(); + } else { + selectedIndex = answerIndex; + _answers[question.id] = selectedIndex; + } + // Update current question state if viewing it + if (index == _currentQuestionIndex) { + if (isMultipleCorrect) { + _selectedAnswerIndices = selectedIndices; + _selectedAnswerIndex = null; + } else { + _selectedAnswerIndex = selectedIndex; + _selectedAnswerIndices.clear(); + } + } + }); + }, + isCorrect: _deck != null && _deck!.config.immediateFeedbackEnabled && + (isMultipleCorrect + ? selectedIndices.contains(answerIndex) + : selectedIndex == answerIndex) + ? question.isCorrectAnswer(answerIndex) + : null, + isMultipleChoice: isMultipleCorrect, ), - const SizedBox(height: 12), - Row( + ), + ), + + const SizedBox(height: 24), + + // Manual Override Buttons + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Expanded( - child: OutlinedButton.icon( - onPressed: _markAsKnown, - icon: const Icon(Icons.check_circle), - label: const Text('Mark as Known'), - ), + Text( + 'Manual Override', + style: Theme.of(context).textTheme.titleSmall, ), - const SizedBox(width: 8), - Expanded( - child: OutlinedButton.icon( - onPressed: _markAsNeedsPractice, - icon: const Icon(Icons.school), - label: const Text('Needs Practice'), - ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: () { + setState(() { + _manualOverrides[question.id] = false; + _deck = DeckService.markQuestionAsKnown( + deck: _deck!, + questionId: question.id, + ); + }); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Question marked as Known'), + backgroundColor: Colors.green, + ), + ); + }, + icon: const Icon(Icons.check_circle), + label: const Text('Mark as Known'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: OutlinedButton.icon( + onPressed: () { + setState(() { + _manualOverrides[question.id] = true; + _deck = DeckService.markQuestionAsNeedsPractice( + deck: _deck!, + questionId: question.id, + ); + }); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Question marked as Needs Practice'), + ), + ); + }, + icon: const Icon(Icons.school), + label: const Text('Needs Practice'), + ), + ), + ], ), ], ), - ], + ), ), - ), + ], ), - ], - ), - ), - ), + ), + }, + pageSnapping: true, + ), + ), - // Submit/Next Button - Padding( + // Navigation Buttons + Container( padding: const EdgeInsets.all(16), - child: FilledButton( - onPressed: _hasAnswer ? _submitAnswer : null, - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 16), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 4, + offset: const Offset(0, -2), + ), + ], + ), + child: SafeArea( + child: Row( + children: [ + // Previous Button + Expanded( + child: OutlinedButton.icon( + onPressed: _currentQuestionIndex > 0 ? _goToPreviousQuestion : null, + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 18, horizontal: 24), + minimumSize: const Size(0, 56), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + icon: const Icon(Icons.arrow_back, size: 20), + label: const Text( + 'Previous', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + ), + ), + ), + ), + const SizedBox(width: 12), + // Next/Complete Button + Expanded( + child: FilledButton.icon( + onPressed: _isLastQuestion + ? (_hasAnswer ? _submitAnswer : null) + : _goToNextQuestion, + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 18, horizontal: 24), + minimumSize: const Size(0, 56), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + icon: Icon( + _isLastQuestion ? Icons.check_circle : Icons.arrow_forward, + size: 24, + color: _isLastQuestion ? Colors.green : null, + ), + label: Text( + _isLastQuestion ? 'Complete' : 'Next', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + ), + ), + ), + ), + ], ), - child: Text(_isLastQuestion ? 'Complete Attempt' : 'Next Question'), ), ), ], ), + ), ); } } diff --git a/lib/screens/deck_config_screen.dart b/lib/screens/deck_config_screen.dart index 013181b..9b33b61 100644 --- a/lib/screens/deck_config_screen.dart +++ b/lib/screens/deck_config_screen.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; import 'package:practice_engine/practice_engine.dart'; +import '../services/deck_storage.dart'; +import '../routes.dart'; class DeckConfigScreen extends StatefulWidget { const DeckConfigScreen({super.key}); @@ -15,23 +17,46 @@ class _DeckConfigScreenState extends State { late TextEditingController _attemptSizeController; late TextEditingController _priorityIncreaseController; late TextEditingController _priorityDecreaseController; + late TextEditingController _timeLimitHoursController; + late TextEditingController _timeLimitMinutesController; + late TextEditingController _timeLimitSecondsController; late bool _immediateFeedback; + late bool _includeKnownInAttempts; + bool _timeLimitEnabled = false; + String? _lastDeckId; + int _configHashCode = 0; @override void initState() { super.initState(); _deck = null; + _lastDeckId = null; + _configHashCode = 0; + // Initialize controllers with default values + _timeLimitHoursController = TextEditingController(); + _timeLimitMinutesController = TextEditingController(); + _timeLimitSecondsController = TextEditingController(); } - @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; + void _initializeFromDeck(Deck deck) { + // Dispose old controllers if they exist + if (_deck != null) { + _consecutiveController.dispose(); + _attemptSizeController.dispose(); + _priorityIncreaseController.dispose(); + _priorityDecreaseController.dispose(); + _timeLimitHoursController.dispose(); + _timeLimitMinutesController.dispose(); + _timeLimitSecondsController.dispose(); + } + + setState(() { + _deck = deck; + _config = deck.config; + _lastDeckId = deck.id; + _configHashCode = deck.config.hashCode; + // Create new controllers with current config values _consecutiveController = TextEditingController( text: _config!.requiredConsecutiveCorrect.toString(), ); @@ -45,6 +70,41 @@ class _DeckConfigScreenState extends State { text: _config!.priorityDecreaseOnCorrect.toString(), ); _immediateFeedback = _config!.immediateFeedbackEnabled; + _includeKnownInAttempts = _config!.includeKnownInAttempts; + + // Initialize time limit controllers + _timeLimitEnabled = _config!.timeLimitSeconds != null; + final totalSeconds = _config!.timeLimitSeconds ?? 0; + final hours = totalSeconds ~/ 3600; + final minutes = (totalSeconds % 3600) ~/ 60; + final seconds = totalSeconds % 60; + + _timeLimitHoursController = TextEditingController( + text: hours > 0 ? hours.toString() : '', + ); + _timeLimitMinutesController = TextEditingController( + text: minutes > 0 ? minutes.toString() : '', + ); + _timeLimitSecondsController = TextEditingController( + text: seconds > 0 ? seconds.toString() : '', + ); + }); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // Get deck from route arguments + final args = ModalRoute.of(context)?.settings.arguments; + final newDeck = args is Deck ? args : _createSampleDeck(); + + // Check if we need to update: first load, different deck, or config changed + final needsUpdate = _deck == null || + _lastDeckId != newDeck.id || + _configHashCode != newDeck.config.hashCode; + + if (needsUpdate) { + _initializeFromDeck(newDeck); } } @@ -64,6 +124,9 @@ class _DeckConfigScreenState extends State { _attemptSizeController.dispose(); _priorityIncreaseController.dispose(); _priorityDecreaseController.dispose(); + _timeLimitHoursController.dispose(); + _timeLimitMinutesController.dispose(); + _timeLimitSecondsController.dispose(); super.dispose(); } @@ -101,16 +164,49 @@ class _DeckConfigScreenState extends State { return; } - final updatedConfig = _config!.copyWith( + // Calculate time limit in seconds + int? timeLimitSeconds; + if (_timeLimitEnabled) { + final hours = int.tryParse(_timeLimitHoursController.text) ?? 0; + final minutes = int.tryParse(_timeLimitMinutesController.text) ?? 0; + final seconds = int.tryParse(_timeLimitSecondsController.text) ?? 0; + final totalSeconds = hours * 3600 + minutes * 60 + seconds; + + if (totalSeconds > 0) { + timeLimitSeconds = totalSeconds; + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Time limit must be greater than 0'), + backgroundColor: Colors.red, + ), + ); + return; + } + } + + final updatedConfig = DeckConfig( requiredConsecutiveCorrect: consecutive, defaultAttemptSize: attemptSize, priorityIncreaseOnIncorrect: priorityIncrease, priorityDecreaseOnCorrect: priorityDecrease, immediateFeedbackEnabled: _immediateFeedback, + includeKnownInAttempts: _includeKnownInAttempts, + timeLimitSeconds: timeLimitSeconds, ); final updatedDeck = _deck!.copyWith(config: updatedConfig); + // Show success message + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Deck configuration saved successfully'), + backgroundColor: Colors.green, + duration: Duration(seconds: 1), + ), + ); + + // Pop with the updated deck Navigator.pop(context, updatedDeck); } @@ -118,6 +214,84 @@ class _DeckConfigScreenState extends State { Navigator.pop(context); } + void _editDeck() { + if (_deck == null) return; + + Navigator.pushNamed( + context, + Routes.deckEdit, + arguments: _deck, + ).then((updatedDeck) { + if (updatedDeck != null && updatedDeck is Deck && mounted) { + // Reload the deck from storage to get the latest state + final deckStorage = DeckStorage(); + final refreshedDeck = deckStorage.getDeck(updatedDeck.id); + if (refreshedDeck != null) { + setState(() { + _deck = refreshedDeck; + _initializeFromDeck(refreshedDeck); + }); + } + } + }); + } + + Future _resetDeck() async { + if (_deck == null) return; + + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Reset Deck'), + content: const Text( + 'Are you sure you want to reset this deck? This will erase all attempt data, progress, and statistics. This action cannot be undone.', + ), + 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) { + // Reset the deck: clear all progress, attempt history, and reset attempt counts + final resetDeck = DeckService.resetDeck( + deck: _deck!, + resetAttemptCounts: true, + clearAttemptHistory: true, + ); + + // Save the reset deck to storage + final deckStorage = DeckStorage(); + deckStorage.saveDeck(resetDeck); + + // Show success message + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Deck has been reset successfully'), + backgroundColor: Colors.green, + duration: Duration(seconds: 2), + ), + ); + } + + // Return the reset deck to update the parent screen + if (mounted) { + Navigator.pop(context, resetDeck); + } + } + } + @override Widget build(BuildContext context) { if (_deck == null || _config == null) { @@ -208,6 +382,164 @@ class _DeckConfigScreenState extends State { }); }, ), + const SizedBox(height: 16), + + // Include Known in Attempts Toggle + SwitchListTile( + title: const Text('Include Known in Attempts'), + subtitle: const Text( + 'Include questions marked as known in practice attempts', + ), + value: _includeKnownInAttempts, + onChanged: (value) { + setState(() { + _includeKnownInAttempts = value; + }); + }, + ), + const SizedBox(height: 16), + + // Time Limit Section + SwitchListTile( + title: const Text('Enable Time Limit'), + subtitle: const Text( + 'Set a time limit for attempts', + ), + value: _timeLimitEnabled, + onChanged: (value) { + setState(() { + _timeLimitEnabled = value; + if (!value) { + _timeLimitHoursController.clear(); + _timeLimitMinutesController.clear(); + _timeLimitSecondsController.clear(); + } + }); + }, + ), + if (_timeLimitEnabled) ...[ + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: TextField( + controller: _timeLimitHoursController, + decoration: const InputDecoration( + labelText: 'Hours', + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.number, + ), + ), + const SizedBox(width: 8), + Expanded( + child: TextField( + controller: _timeLimitMinutesController, + decoration: const InputDecoration( + labelText: 'Minutes', + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.number, + ), + ), + const SizedBox(width: 8), + Expanded( + child: TextField( + controller: _timeLimitSecondsController, + decoration: const InputDecoration( + labelText: 'Seconds', + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.number, + ), + ), + ], + ), + ], + ], + ), + ), + ), + const SizedBox(height: 24), + + // Edit Deck Section + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Deck Management', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + Text( + 'Edit the deck name, description, and questions.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7), + ), + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: _editDeck, + icon: const Icon(Icons.edit), + label: const Text('Edit Deck'), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 24), + + // Reset Deck Section + Card( + color: Theme.of(context).colorScheme.errorContainer.withValues(alpha: 0.1), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.warning_amber_rounded, + color: Theme.of(context).colorScheme.error, + size: 20, + ), + const SizedBox(width: 8), + Text( + 'Danger Zone', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Theme.of(context).colorScheme.error, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + 'Reset this deck to erase all progress, attempt history, and statistics. This action cannot be undone.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7), + ), + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: _resetDeck, + icon: const Icon(Icons.refresh), + label: const Text('Reset Deck'), + style: OutlinedButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, + side: BorderSide(color: Theme.of(context).colorScheme.error), + ), + ), + ), ], ), ), diff --git a/lib/screens/deck_create_screen.dart b/lib/screens/deck_create_screen.dart new file mode 100644 index 0000000..c94b4c4 --- /dev/null +++ b/lib/screens/deck_create_screen.dart @@ -0,0 +1,215 @@ +import 'package:flutter/material.dart'; +import 'package:practice_engine/practice_engine.dart'; +import '../services/deck_storage.dart'; +import 'deck_edit_screen.dart'; + +class DeckCreateScreen extends StatefulWidget { + const DeckCreateScreen({super.key}); + + @override + State createState() => _DeckCreateScreenState(); +} + +class _DeckCreateScreenState extends State { + late TextEditingController _titleController; + late TextEditingController _descriptionController; + final List _questionEditors = []; + final DeckStorage _deckStorage = DeckStorage(); + + @override + void initState() { + super.initState(); + _titleController = TextEditingController(); + _descriptionController = TextEditingController(); + // Start with one empty question + _questionEditors.add(QuestionEditor.empty()); + } + + @override + void dispose() { + _titleController.dispose(); + _descriptionController.dispose(); + for (final editor in _questionEditors) { + editor.dispose(); + } + super.dispose(); + } + + void _addQuestion() { + setState(() { + _questionEditors.add(QuestionEditor.empty()); + }); + } + + void _removeQuestion(int index) { + setState(() { + _questionEditors.removeAt(index); + }); + } + + void _save() { + final title = _titleController.text.trim(); + if (title.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Deck title cannot be empty'), + backgroundColor: Colors.red, + ), + ); + return; + } + + // Validate all questions + final questions = []; + for (int i = 0; i < _questionEditors.length; i++) { + final editor = _questionEditors[i]; + if (!editor.isValid) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Question ${i + 1} is invalid. Please fill in all fields.'), + backgroundColor: Colors.red, + ), + ); + return; + } + questions.add(editor.toQuestion()); + } + + if (questions.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Deck must have at least one question'), + backgroundColor: Colors.red, + ), + ); + return; + } + + // Create new deck + final newDeck = Deck( + id: DateTime.now().millisecondsSinceEpoch.toString(), + title: title, + description: _descriptionController.text.trim(), + questions: questions, + config: const DeckConfig(), + currentAttemptIndex: 0, + attemptHistory: [], + ); + + _deckStorage.saveDeck(newDeck); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Deck created successfully'), + backgroundColor: Colors.green, + duration: Duration(seconds: 1), + ), + ); + + Navigator.pop(context, newDeck); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Create New Deck'), + actions: [ + IconButton( + icon: const Icon(Icons.save), + onPressed: _save, + tooltip: 'Save Deck', + ), + ], + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Deck Title + TextField( + controller: _titleController, + decoration: const InputDecoration( + labelText: 'Deck Title', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 16), + + // Deck Description + TextField( + controller: _descriptionController, + decoration: const InputDecoration( + labelText: 'Description (optional)', + border: OutlineInputBorder(), + ), + maxLines: 3, + ), + const SizedBox(height: 24), + + // Questions Section + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Questions', + style: Theme.of(context).textTheme.titleLarge, + ), + FilledButton.icon( + onPressed: _addQuestion, + icon: const Icon(Icons.add), + label: const Text('Add Question'), + ), + ], + ), + const SizedBox(height: 16), + + // Questions List + ...List.generate(_questionEditors.length, (index) { + return QuestionEditorCard( + key: ValueKey('question_$index'), + editor: _questionEditors[index], + questionNumber: index + 1, + onDelete: () => _removeQuestion(index), + onChanged: () => setState(() {}), + ); + }), + + if (_questionEditors.isEmpty) + Card( + child: Padding( + padding: const EdgeInsets.all(32), + child: Center( + child: Column( + children: [ + Icon( + Icons.quiz_outlined, + size: 48, + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.3), + ), + const SizedBox(height: 16), + Text( + 'No questions yet', + style: Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox(height: 8), + Text( + 'Tap "Add Question" to get started', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + ], + ), + ), + ), + ), + + ], + ), + ), + ); + } +} + diff --git a/lib/screens/deck_edit_screen.dart b/lib/screens/deck_edit_screen.dart new file mode 100644 index 0000000..da1809f --- /dev/null +++ b/lib/screens/deck_edit_screen.dart @@ -0,0 +1,455 @@ +import 'package:flutter/material.dart'; +import 'package:practice_engine/practice_engine.dart'; +import '../services/deck_storage.dart'; + +class DeckEditScreen extends StatefulWidget { + const DeckEditScreen({super.key}); + + @override + State createState() => _DeckEditScreenState(); +} + +class _DeckEditScreenState extends State { + Deck? _deck; + late TextEditingController _titleController; + late TextEditingController _descriptionController; + final List _questionEditors = []; + final DeckStorage _deckStorage = DeckStorage(); + + @override + void initState() { + super.initState(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_deck == null) { + final args = ModalRoute.of(context)?.settings.arguments; + if (args is Deck) { + _initializeFromDeck(args); + } + } + } + + void _initializeFromDeck(Deck deck) { + setState(() { + _deck = deck; + _titleController = TextEditingController(text: deck.title); + _descriptionController = TextEditingController(text: deck.description); + + _questionEditors.clear(); + for (final question in deck.questions) { + _questionEditors.add(QuestionEditor.fromQuestion(question)); + } + }); + } + + @override + void dispose() { + _titleController.dispose(); + _descriptionController.dispose(); + for (final editor in _questionEditors) { + editor.dispose(); + } + super.dispose(); + } + + void _addQuestion() { + setState(() { + _questionEditors.add(QuestionEditor.empty()); + }); + } + + void _removeQuestion(int index) { + setState(() { + _questionEditors.removeAt(index); + }); + } + + void _save() { + if (_deck == null) return; + + final title = _titleController.text.trim(); + if (title.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Deck title cannot be empty'), + backgroundColor: Colors.red, + ), + ); + return; + } + + // Validate all questions + final questions = []; + for (int i = 0; i < _questionEditors.length; i++) { + final editor = _questionEditors[i]; + if (!editor.isValid) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Question ${i + 1} is invalid. Please fill in all fields.'), + backgroundColor: Colors.red, + ), + ); + return; + } + questions.add(editor.toQuestion()); + } + + if (questions.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Deck must have at least one question'), + backgroundColor: Colors.red, + ), + ); + return; + } + + // Preserve attempt history and other deck data when editing + final updatedDeck = _deck!.copyWith( + title: title, + description: _descriptionController.text.trim(), + questions: questions, + // Preserve attempt history, config, and current attempt index + attemptHistory: _deck!.attemptHistory, + config: _deck!.config, + currentAttemptIndex: _deck!.currentAttemptIndex, + ); + + _deckStorage.saveDeck(updatedDeck); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Deck saved successfully'), + backgroundColor: Colors.green, + duration: Duration(seconds: 1), + ), + ); + + Navigator.pop(context, updatedDeck); + } + + @override + Widget build(BuildContext context) { + if (_deck == null) { + return const Scaffold( + body: Center(child: CircularProgressIndicator()), + ); + } + + return Scaffold( + appBar: AppBar( + title: const Text('Edit Deck'), + actions: [ + IconButton( + icon: const Icon(Icons.save), + onPressed: _save, + tooltip: 'Save Deck', + ), + ], + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Deck Title + TextField( + controller: _titleController, + decoration: const InputDecoration( + labelText: 'Deck Title', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 16), + + // Deck Description + TextField( + controller: _descriptionController, + decoration: const InputDecoration( + labelText: 'Description (optional)', + border: OutlineInputBorder(), + ), + maxLines: 3, + ), + const SizedBox(height: 24), + + // Questions Section + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Questions', + style: Theme.of(context).textTheme.titleLarge, + ), + FilledButton.icon( + onPressed: _addQuestion, + icon: const Icon(Icons.add), + label: const Text('Add Question'), + ), + ], + ), + const SizedBox(height: 16), + + // Questions List + ...List.generate(_questionEditors.length, (index) { + return QuestionEditorCard( + key: ValueKey('question_$index'), + editor: _questionEditors[index], + questionNumber: index + 1, + onDelete: () => _removeQuestion(index), + onChanged: () => setState(() {}), + ); + }), + + if (_questionEditors.isEmpty) + Card( + child: Padding( + padding: const EdgeInsets.all(32), + child: Center( + child: Column( + children: [ + Icon( + Icons.quiz_outlined, + size: 48, + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.3), + ), + const SizedBox(height: 16), + Text( + 'No questions yet', + style: Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox(height: 8), + Text( + 'Tap "Add Question" to get started', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + ], + ), + ), + ), + ), + + ], + ), + ), + ); + } +} + +class QuestionEditorCard extends StatefulWidget { + final QuestionEditor editor; + final int questionNumber; + final VoidCallback onDelete; + final VoidCallback onChanged; + + const QuestionEditorCard({ + super.key, + required this.editor, + required this.questionNumber, + required this.onDelete, + required this.onChanged, + }); + + @override + State createState() => _QuestionEditorCardState(); +} + +class _QuestionEditorCardState extends State { + @override + Widget build(BuildContext context) { + return Card( + margin: const EdgeInsets.only(bottom: 16), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Question ${widget.questionNumber}', + style: Theme.of(context).textTheme.titleMedium, + ), + IconButton( + icon: const Icon(Icons.delete, color: Colors.red), + onPressed: widget.onDelete, + tooltip: 'Delete Question', + ), + ], + ), + const SizedBox(height: 12), + // Question Prompt + TextField( + controller: widget.editor.promptController, + decoration: const InputDecoration( + labelText: 'Question', + border: OutlineInputBorder(), + ), + maxLines: 2, + ), + const SizedBox(height: 16), + + // Answers + Text( + 'Answers', + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 8), + ...List.generate(widget.editor.answerControllers.length, (index) { + final isCorrect = widget.editor.correctAnswerIndices.contains(index); + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + Checkbox( + value: isCorrect, + onChanged: (value) { + setState(() { + if (value == true) { + widget.editor.correctAnswerIndices.add(index); + } else { + widget.editor.correctAnswerIndices.remove(index); + // Ensure at least one answer is marked as correct + if (widget.editor.correctAnswerIndices.isEmpty && + widget.editor.answerControllers.length > 0) { + widget.editor.correctAnswerIndices.add(0); + } + } + }); + widget.onChanged(); + }, + ), + Expanded( + child: TextField( + controller: widget.editor.answerControllers[index], + decoration: InputDecoration( + labelText: 'Answer ${index + 1}', + border: const OutlineInputBorder(), + suffixIcon: isCorrect + ? const Icon(Icons.check_circle, color: Colors.green) + : null, + ), + ), + ), + ], + ), + ); + }), + + // Add/Remove Answer buttons + Row( + children: [ + TextButton.icon( + onPressed: () { + setState(() { + widget.editor.answerControllers.add(TextEditingController()); + }); + widget.onChanged(); + }, + icon: const Icon(Icons.add), + label: const Text('Add Answer'), + ), + if (widget.editor.answerControllers.length > 2) + TextButton.icon( + onPressed: () { + setState(() { + final lastIndex = widget.editor.answerControllers.length - 1; + // Remove the index from correct answers if it was marked + widget.editor.correctAnswerIndices.remove(lastIndex); + // Adjust indices if needed + widget.editor.correctAnswerIndices = widget.editor.correctAnswerIndices + .where((idx) => idx < lastIndex) + .toSet(); + // Ensure at least one answer is marked as correct + if (widget.editor.correctAnswerIndices.isEmpty && + widget.editor.answerControllers.length > 1) { + widget.editor.correctAnswerIndices.add(0); + } + widget.editor.answerControllers.removeLast().dispose(); + }); + widget.onChanged(); + }, + icon: const Icon(Icons.remove), + label: const Text('Remove Answer'), + ), + ], + ), + ], + ), + ), + ); + } +} + +class QuestionEditor { + final TextEditingController promptController; + final List answerControllers; + Set correctAnswerIndices; + final String? originalId; + + QuestionEditor({ + required this.promptController, + required this.answerControllers, + Set? correctAnswerIndices, + this.originalId, + }) : correctAnswerIndices = correctAnswerIndices ?? {0}; + + factory QuestionEditor.fromQuestion(Question question) { + return QuestionEditor( + promptController: TextEditingController(text: question.prompt), + answerControllers: question.answers + .map((answer) => TextEditingController(text: answer)) + .toList(), + correctAnswerIndices: question.correctIndices.toSet(), + originalId: question.id, + ); + } + + factory QuestionEditor.empty() { + return QuestionEditor( + promptController: TextEditingController(), + answerControllers: [ + TextEditingController(), + TextEditingController(), + ], + correctAnswerIndices: {0}, + ); + } + + bool get isValid { + if (promptController.text.trim().isEmpty) return false; + if (answerControllers.length < 2) return false; + for (final controller in answerControllers) { + if (controller.text.trim().isEmpty) return false; + } + if (correctAnswerIndices.isEmpty) return false; + if (correctAnswerIndices.any((idx) => idx < 0 || idx >= answerControllers.length)) { + return false; + } + return true; + } + + Question toQuestion() { + return Question( + id: originalId ?? DateTime.now().millisecondsSinceEpoch.toString(), + prompt: promptController.text.trim(), + answers: answerControllers.map((c) => c.text.trim()).toList(), + correctAnswerIndices: correctAnswerIndices.toList()..sort(), + ); + } + + void dispose() { + promptController.dispose(); + for (final controller in answerControllers) { + controller.dispose(); + } + } + +} + diff --git a/lib/screens/deck_import_screen.dart b/lib/screens/deck_import_screen.dart index 2f813fb..f0e3cfb 100644 --- a/lib/screens/deck_import_screen.dart +++ b/lib/screens/deck_import_screen.dart @@ -1,7 +1,8 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:practice_engine/practice_engine.dart'; -import '../routes.dart'; +import '../data/default_deck.dart'; +import '../services/deck_storage.dart'; class DeckImportScreen extends StatefulWidget { const DeckImportScreen({super.key}); @@ -33,12 +34,27 @@ class _DeckImportScreenState extends State { priorityIncreaseOnIncorrect: configJson['priorityIncreaseOnIncorrect'] as int? ?? 5, priorityDecreaseOnCorrect: configJson['priorityDecreaseOnCorrect'] as int? ?? 2, immediateFeedbackEnabled: configJson['immediateFeedbackEnabled'] as bool? ?? true, + includeKnownInAttempts: configJson['includeKnownInAttempts'] as bool? ?? false, ); // Parse questions final questionsJson = json['questions'] as List? ?? []; final questions = questionsJson.map((qJson) { final questionMap = qJson as Map; + + // Support both old format (correctAnswerIndex) and new format (correctAnswerIndices) + List? correctIndices; + if (questionMap['correctAnswerIndices'] != null) { + final indicesJson = questionMap['correctAnswerIndices'] as List?; + correctIndices = indicesJson?.map((e) => e as int).toList(); + } else if (questionMap['correctAnswerIndex'] != null) { + // Backward compatibility: convert single index to list + final singleIndex = questionMap['correctAnswerIndex'] as int?; + if (singleIndex != null) { + correctIndices = [singleIndex]; + } + } + return Question( id: questionMap['id'] as String? ?? '', prompt: questionMap['prompt'] as String? ?? '', @@ -46,7 +62,7 @@ class _DeckImportScreenState extends State { ?.map((e) => e.toString()) .toList() ?? [], - correctAnswerIndex: questionMap['correctAnswerIndex'] as int? ?? 0, + correctAnswerIndices: correctIndices ?? [0], consecutiveCorrect: questionMap['consecutiveCorrect'] as int? ?? 0, isKnown: questionMap['isKnown'] as bool? ?? false, priorityPoints: questionMap['priorityPoints'] as int? ?? 0, @@ -91,12 +107,12 @@ class _DeckImportScreenState extends State { throw FormatException('Deck must contain at least one question'); } - // Navigate to deck overview with the imported deck - Navigator.pushReplacementNamed( - context, - Routes.deckOverview, - arguments: deck, - ); + // Save deck to storage + final deckStorage = DeckStorage(); + deckStorage.saveDeck(deck); + + // Navigate back to deck list + Navigator.pop(context); } catch (e) { setState(() { _errorMessage = e.toString(); @@ -105,6 +121,35 @@ class _DeckImportScreenState extends State { } } + void _useDefaultDeck() { + final defaultDeck = DefaultDeck.deck; + final deckStorage = DeckStorage(); + + // Check if default deck already exists + if (deckStorage.hasDeck(defaultDeck.id)) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Default deck already exists'), + backgroundColor: Colors.orange, + ), + ); + return; + } + + // Save default deck to storage + deckStorage.saveDeck(defaultDeck); + + // Navigate back to deck list + Navigator.pop(context); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Default deck added successfully'), + backgroundColor: Colors.green, + ), + ); + } + void _loadSampleDeck() { final sampleJson = { 'id': 'sample-deck', @@ -116,13 +161,14 @@ class _DeckImportScreenState extends State { 'priorityIncreaseOnIncorrect': 5, 'priorityDecreaseOnCorrect': 2, 'immediateFeedbackEnabled': true, + 'includeKnownInAttempts': false, }, '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, + 'correctAnswerIndices': [i % 4], 'isKnown': i < 5, }; }), @@ -142,6 +188,79 @@ class _DeckImportScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + // Default Deck Button + Card( + color: Theme.of(context).colorScheme.primaryContainer, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Icon( + Icons.quiz, + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Start with Default Quiz', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: Theme.of(context).colorScheme.onPrimaryContainer, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + '20 general knowledge questions ready to practice', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onPrimaryContainer.withValues(alpha: 0.8), + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: _useDefaultDeck, + icon: const Icon(Icons.play_arrow), + label: const Text('Use Default Quiz'), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + backgroundColor: Theme.of(context).colorScheme.primary, + foregroundColor: Theme.of(context).colorScheme.onPrimary, + ), + ), + ], + ), + ), + ), + const SizedBox(height: 24), + + // Divider + Row( + children: [ + Expanded(child: Divider(color: Theme.of(context).colorScheme.outline)), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + 'OR', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + ), + Expanded(child: Divider(color: Theme.of(context).colorScheme.outline)), + ], + ), + const SizedBox(height: 24), + Card( child: Padding( padding: const EdgeInsets.all(16), @@ -272,7 +391,8 @@ class _DeckImportScreenState extends State { const Text('• id: string (required)'), const Text('• prompt: string (required)'), const Text('• answers: array of strings (required)'), - const Text('• correctAnswerIndex: number (required)'), + const Text('• correctAnswerIndex: number (deprecated, use correctAnswerIndices)'), + const Text('• correctAnswerIndices: array of numbers (for multiple correct answers)'), const Text('• isKnown: boolean (optional)'), const Text('• consecutiveCorrect: number (optional)'), const Text('• priorityPoints: number (optional)'), @@ -287,6 +407,7 @@ class _DeckImportScreenState extends State { const Text('• priorityIncreaseOnIncorrect: number'), const Text('• priorityDecreaseOnCorrect: number'), const Text('• immediateFeedbackEnabled: boolean'), + const Text('• includeKnownInAttempts: boolean'), ], ), ), diff --git a/lib/screens/deck_list_screen.dart b/lib/screens/deck_list_screen.dart new file mode 100644 index 0000000..f16e45c --- /dev/null +++ b/lib/screens/deck_list_screen.dart @@ -0,0 +1,423 @@ +import 'package:flutter/material.dart'; +import 'package:practice_engine/practice_engine.dart'; +import '../routes.dart'; +import '../services/deck_storage.dart'; +import '../data/default_deck.dart'; + +class DeckListScreen extends StatefulWidget { + const DeckListScreen({super.key}); + + @override + State createState() => _DeckListScreenState(); +} + +class _DeckListScreenState extends State { + final DeckStorage _deckStorage = DeckStorage(); + List _decks = []; + + @override + void initState() { + super.initState(); + _loadDecks(); + } + + void _loadDecks() { + setState(() { + _decks = _deckStorage.getAllDecks(); + }); + } + + void _openDeck(Deck deck) { + Navigator.pushNamed( + context, + Routes.deckOverview, + arguments: deck, + ).then((_) { + // Reload decks when returning from overview (in case deck was updated) + _loadDecks(); + }); + } + + void _deleteDeck(Deck deck) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Delete Deck'), + content: Text('Are you sure you want to delete "${deck.title}"? This action cannot be undone.'), + 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('Delete'), + ), + ], + ), + ); + + if (confirmed == true) { + _deckStorage.deleteDeck(deck.id); + _loadDecks(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('${deck.title} deleted'), + backgroundColor: Colors.green, + ), + ); + } + } + } + + void _navigateToImport() { + Navigator.pushNamed(context, Routes.deckImport).then((_) { + // Reload decks when returning from import + _loadDecks(); + }); + } + + void _editDeck(Deck deck) { + Navigator.pushNamed( + context, + Routes.deckEdit, + arguments: deck, + ).then((updatedDeck) { + if (updatedDeck != null) { + _loadDecks(); + } + }); + } + + void _cloneDeck(Deck deck) { + // Create a copy of the deck with a new ID and reset progress + final clonedDeck = deck.copyWith( + id: '${deck.id}_clone_${DateTime.now().millisecondsSinceEpoch}', + title: '${deck.title} (Copy)', + currentAttemptIndex: 0, + attemptHistory: [], + questions: deck.questions.map((q) { + return q.copyWith( + consecutiveCorrect: 0, + isKnown: false, + priorityPoints: 0, + lastAttemptIndex: -1, + totalCorrectAttempts: 0, + totalAttempts: 0, + ); + }).toList(), + ); + + _deckStorage.saveDeck(clonedDeck); + _loadDecks(); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('${deck.title} cloned successfully'), + backgroundColor: Colors.green, + ), + ); + } + } + + void _showAddDeckOptions() { + showModalBottomSheet( + context: context, + builder: (context) => SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Add New Deck', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 16), + // Import JSON + ListTile( + leading: const Icon(Icons.upload_file), + title: const Text('Import JSON'), + subtitle: const Text('Import a deck from JSON format'), + onTap: () { + Navigator.pop(context); + _navigateToImport(); + }, + ), + const Divider(), + // Create Manually + ListTile( + leading: const Icon(Icons.edit), + title: const Text('Create Manually'), + subtitle: const Text('Create a new deck from scratch'), + onTap: () { + Navigator.pop(context); + Navigator.pushNamed(context, Routes.deckCreate).then((_) { + _loadDecks(); + }); + }, + ), + const Divider(), + // Use Default Quiz + ListTile( + leading: const Icon(Icons.quiz), + title: const Text('Use Default Quiz'), + subtitle: const Text('Add the default general knowledge quiz'), + onTap: () { + Navigator.pop(context); + _useDefaultDeck(); + }, + ), + const SizedBox(height: 8), + ], + ), + ), + ), + ); + } + + void _useDefaultDeck() { + final defaultDeck = DefaultDeck.deck; + + // Check if default deck already exists + if (_deckStorage.hasDeck(defaultDeck.id)) { + // If it exists, create a copy with a new ID + final clonedDeck = defaultDeck.copyWith( + id: '${defaultDeck.id}_${DateTime.now().millisecondsSinceEpoch}', + ); + _deckStorage.saveDeck(clonedDeck); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Default deck added successfully'), + backgroundColor: Colors.green, + ), + ); + } + } else { + // Save default deck to storage + _deckStorage.saveDeck(defaultDeck); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Default deck added successfully'), + backgroundColor: Colors.green, + ), + ); + } + } + + _loadDecks(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Decky'), + actions: [ + IconButton( + icon: const Icon(Icons.add), + tooltip: 'Add Deck', + onPressed: _showAddDeckOptions, + ), + ], + ), + body: _decks.isEmpty + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.inbox, + size: 64, + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.3), + ), + const SizedBox(height: 16), + Text( + 'No decks yet', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 8), + Text( + 'Tap the + button to add a deck', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + ], + ), + ) + : RefreshIndicator( + onRefresh: () async { + _loadDecks(); + }, + child: ListView.builder( + padding: const EdgeInsets.all(12), + itemCount: _decks.length, + itemBuilder: (context, index) { + final deck = _decks[index]; + return Card( + margin: const EdgeInsets.only(bottom: 12), + child: InkWell( + onTap: () => _openDeck(deck), + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + deck.title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + if (deck.description.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + deck.description, + style: Theme.of(context).textTheme.bodySmall, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + PopupMenuButton( + itemBuilder: (context) => [ + PopupMenuItem( + child: const Row( + children: [ + Icon(Icons.edit, color: Colors.blue), + SizedBox(width: 8), + Text('Edit'), + ], + ), + onTap: () { + Future.delayed( + const Duration(milliseconds: 100), + () => _editDeck(deck), + ); + }, + ), + PopupMenuItem( + child: const Row( + children: [ + Icon(Icons.copy, color: Colors.orange), + SizedBox(width: 8), + Text('Clone'), + ], + ), + onTap: () { + Future.delayed( + const Duration(milliseconds: 100), + () => _cloneDeck(deck), + ); + }, + ), + PopupMenuItem( + child: const Row( + children: [ + Icon(Icons.delete, color: Colors.red), + SizedBox(width: 8), + Text('Delete'), + ], + ), + onTap: () { + Future.delayed( + const Duration(milliseconds: 100), + () => _deleteDeck(deck), + ); + }, + ), + ], + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _StatChip( + icon: Icons.quiz, + label: '${deck.numberOfQuestions} questions', + ), + ), + const SizedBox(width: 8), + Expanded( + child: _StatChip( + icon: Icons.check_circle, + label: '${deck.knownCount} known', + ), + ), + const SizedBox(width: 8), + Expanded( + child: _StatChip( + icon: Icons.trending_up, + label: '${deck.practicePercentage.toStringAsFixed(0)}%', + ), + ), + ], + ), + ], + ), + ), + ), + ); + }, + ), + ), + ); + } +} + +class _StatChip extends StatelessWidget { + final IconData icon; + final String label; + + const _StatChip({ + required this.icon, + required this.label, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 16), + const SizedBox(width: 4), + Flexible( + child: Text( + label, + style: Theme.of(context).textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } +} + diff --git a/lib/screens/deck_overview_screen.dart b/lib/screens/deck_overview_screen.dart index 5c2baf8..73fff40 100644 --- a/lib/screens/deck_overview_screen.dart +++ b/lib/screens/deck_overview_screen.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:practice_engine/practice_engine.dart'; import '../routes.dart'; +import '../services/deck_storage.dart'; +import '../widgets/attempts_chart.dart'; class DeckOverviewScreen extends StatefulWidget { const DeckOverviewScreen({super.key}); @@ -11,52 +13,53 @@ class DeckOverviewScreen extends StatefulWidget { class _DeckOverviewScreenState extends State { Deck? _deck; + final DeckStorage _deckStorage = DeckStorage(); @override void didChangeDependencies() { super.didChangeDependencies(); - // Get deck from route arguments or create sample + // Get deck from route arguments final args = ModalRoute.of(context)?.settings.arguments; if (args is Deck) { + // ALWAYS load the latest version from storage to ensure we have the most up-to-date deck + // This is critical for getting the latest incomplete attempt state + // Don't rely on route arguments - they might be stale + final storedDeck = _deckStorage.getDeck(args.id); + final deckToUse = storedDeck ?? args; + + // Always update to get the latest state from storage + // This ensures we always have the most up-to-date incomplete attempt state setState(() { - _deck = args; - }); - } else if (_deck == null) { - // Only create sample if we don't have a deck yet - setState(() { - _deck = _createSampleDeck(); + _deck = deckToUse; }); } } + void _saveDeck() { + if (_deck != null) { + _deckStorage.saveDeck(_deck!); + } + } + @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) { + void _startAttempt() async { + // Force reload from storage before checking for incomplete attempts to ensure we have latest state + Deck? deckToCheck = _deck; + if (_deck != null) { + final freshDeck = _deckStorage.getDeck(_deck!.id); + if (freshDeck != null) { + setState(() { + _deck = freshDeck; + }); + deckToCheck = freshDeck; // Use the fresh deck for the check + } + } + + if (deckToCheck == null || deckToCheck.questions.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Cannot start attempt: No questions in deck'), @@ -66,11 +69,170 @@ class _DeckOverviewScreenState extends State { return; } - Navigator.pushNamed( - context, - Routes.attempt, - arguments: _deck!, - ); + // Check for incomplete attempt first - use the fresh deck we just loaded + if (deckToCheck.incompleteAttempt != null) { + final incomplete = deckToCheck.incompleteAttempt!; + final progress = incomplete.currentQuestionIndex + 1; + final total = incomplete.questionIds.length; + + final action = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Incomplete Attempt Found'), + content: Text( + 'You have an incomplete attempt (Question $progress of $total).\n\n' + 'Would you like to continue where you left off, or start a new attempt?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, 'ignore'), + child: const Text('Start Fresh'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, 'continue'), + child: const Text('Continue'), + ), + ], + ), + ); + + if (action == 'continue') { + // Continue the incomplete attempt + if (!mounted) return; + Navigator.pushNamed( + context, + Routes.attempt, + arguments: { + 'deck': deckToCheck, + 'resumeAttempt': true, + }, + ).then((_) { + // Refresh deck state when returning from attempt + _refreshDeck(); + }); + return; + } else if (action == 'ignore') { + // Clear incomplete attempt and start fresh + // Clear incomplete attempt - create a fresh copy without it + final updatedDeck = deckToCheck.copyWith(clearIncompleteAttempt: true); + + // Save to storage multiple times to ensure it's persisted + _deckStorage.saveDeck(updatedDeck); + _deckStorage.saveDeck(updatedDeck); // Save twice to be sure + + // Update local state immediately with cleared deck + setState(() { + _deck = updatedDeck; + }); + + // Force reload from storage multiple times to verify it's cleared + Deck? finalClearedDeck = updatedDeck; + for (int i = 0; i < 3; i++) { + final verifiedDeck = _deckStorage.getDeck(updatedDeck.id); + + if (verifiedDeck != null) { + // Ensure incomplete attempt is null even if storage had it + if (verifiedDeck.incompleteAttempt != null) { + final clearedDeck = verifiedDeck.copyWith(clearIncompleteAttempt: true); + _deckStorage.saveDeck(clearedDeck); + setState(() { + _deck = clearedDeck; + }); + finalClearedDeck = clearedDeck; + } else { + // Already cleared, update state + setState(() { + _deck = verifiedDeck; + }); + finalClearedDeck = verifiedDeck; + break; // Exit loop if already cleared + } + } + } + // CRITICAL: Update deckToCheck to use the cleared deck for the rest of the flow + deckToCheck = finalClearedDeck ?? updatedDeck; + // Continue to normal flow below - the deck should now have no incomplete attempt + } else { + // User cancelled + return; + } + } + + // Check if all questions are known and includeKnownInAttempts is disabled + final allKnown = deckToCheck.knownCount == deckToCheck.numberOfQuestions && deckToCheck.numberOfQuestions > 0; + final includeKnownDisabled = !deckToCheck.config.includeKnownInAttempts; + + if (allKnown && includeKnownDisabled) { + // Show confirmation dialog + final shouldIncludeKnown = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('All Questions Known'), + content: const Text( + 'All questions are known, attempt with known questions?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('No'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Yes'), + ), + ], + ), + ); + + if (shouldIncludeKnown == null || !shouldIncludeKnown) { + // User cancelled or said No + return; + } + + // Pass the deck with a flag to include known questions + // Ensure we're using a deck without incomplete attempt + if (!mounted) return; + final deckToUse = deckToCheck.copyWith(clearIncompleteAttempt: true); + Navigator.pushNamed( + context, + Routes.attempt, + arguments: { + 'deck': deckToUse, + 'includeKnown': true, + }, + ).then((_) { + // Refresh deck state when returning from attempt + _refreshDeck(); + }); + } else { + // Normal flow - ensure we're using a deck without incomplete attempt + if (!mounted) return; + // Always use a fresh deck copy without incomplete attempt to prevent stale state + // Also ensure storage has the cleared state + final deckToUse = deckToCheck.copyWith(clearIncompleteAttempt: true); + // Ensure storage also has the cleared state + _deckStorage.saveDeck(deckToUse); + Navigator.pushNamed( + context, + Routes.attempt, + arguments: deckToUse, + ).then((_) { + // Refresh deck state when returning from attempt + _refreshDeck(); + }); + } + } + + void _refreshDeck() { + if (_deck != null) { + // Reload the deck from storage to get the latest state (including incomplete attempts) + final refreshedDeck = _deckStorage.getDeck(_deck!.id); + if (refreshedDeck != null && mounted) { + setState(() { + _deck = refreshedDeck; + }); + } + } } void _openConfig() { @@ -79,53 +241,49 @@ class _DeckOverviewScreenState extends State { Navigator.pushNamed(context, Routes.deckConfig, arguments: _deck) .then((updatedDeck) { if (updatedDeck != null && updatedDeck is Deck) { + final wasReset = updatedDeck.attemptHistory.isEmpty && + updatedDeck.currentAttemptIndex == 0 && + _deck != null && + _deck!.attemptHistory.isNotEmpty; + setState(() { _deck = updatedDeck; }); + _saveDeck(); // Save to storage + + // Show confirmation message + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(wasReset + ? 'Deck has been reset successfully' + : 'Deck settings have been updated'), + backgroundColor: Colors.green, + duration: const Duration(seconds: 2), + ), + ); + } } }); } - 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, - ), - ); - } + String _formatTime(int milliseconds) { + final seconds = milliseconds ~/ 1000; + final minutes = seconds ~/ 60; + final hours = minutes ~/ 60; + final remainingMinutes = minutes % 60; + final remainingSeconds = seconds % 60; + + if (hours > 0) { + return '${hours}h ${remainingMinutes}m'; + } else if (minutes > 0) { + return '${minutes}m ${remainingSeconds}s'; + } else { + return '${remainingSeconds}s'; } } + @override Widget build(BuildContext context) { if (_deck == null) { @@ -137,111 +295,188 @@ class _DeckOverviewScreenState extends State { 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), + padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // Deck Description Card( child: Padding( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Description', - style: Theme.of(context).textTheme.titleMedium, + style: Theme.of(context).textTheme.titleSmall, ), - const SizedBox(height: 8), + const SizedBox(height: 4), Text( _deck!.description, - style: Theme.of(context).textTheme.bodyMedium, + style: Theme.of(context).textTheme.bodySmall, ), ], ), ), ), - const SizedBox(height: 24), + const SizedBox(height: 12), // Practice Progress Card( child: Padding( - padding: const EdgeInsets.all(24), - child: Column( + padding: const EdgeInsets.all(16), + child: Row( children: [ - Text( - 'Practice Progress', - style: Theme.of(context).textTheme.titleLarge, + SizedBox( + width: 80, + height: 80, + child: CircularProgressIndicator( + value: _deck!.practicePercentage / 100, + strokeWidth: 6, + ), ), - 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(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${_deck!.practicePercentage.toStringAsFixed(1)}%', + style: Theme.of(context).textTheme.headlineSmall?.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: 4), + Text( + '${_deck!.knownCount} of ${_deck!.numberOfQuestions} questions known', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), ), ], ), ), ), - const SizedBox(height: 24), + const SizedBox(height: 12), // Statistics + Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _StatItem( + label: 'Total', + value: '${_deck!.numberOfQuestions}', + icon: Icons.quiz, + ), + _StatItem( + label: 'Known', + value: '${_deck!.knownCount}', + icon: Icons.check_circle, + ), + _StatItem( + label: 'Practice', + value: '${_deck!.numberOfQuestions - _deck!.knownCount}', + icon: Icons.school, + ), + ], + ), + ), + ), + const SizedBox(height: 12), + + // Attempts History 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, + Icon( + Icons.history, + size: 20, + color: Theme.of(context).colorScheme.primary, ), - _StatItem( - label: 'Known', - value: '${_deck!.knownCount}', - icon: Icons.check_circle, - ), - _StatItem( - label: 'Needs Practice', - value: '${_deck!.numberOfQuestions - _deck!.knownCount}', - icon: Icons.school, + const SizedBox(width: 8), + Text( + 'Attempts History', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), ), ], ), + const SizedBox(height: 16), + if (_deck!.attemptHistory.isEmpty) + Padding( + padding: const EdgeInsets.all(16), + child: Center( + child: Column( + children: [ + Icon( + Icons.quiz_outlined, + size: 48, + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.3), + ), + const SizedBox(height: 8), + Text( + 'No attempts yet', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + ], + ), + ), + ) + else ...[ + // Summary Statistics + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _HistoryStatItem( + label: 'Attempts', + value: '${_deck!.attemptCount}', + icon: Icons.quiz, + ), + _HistoryStatItem( + label: 'Avg. Score', + value: '${_deck!.averagePercentageCorrect.toStringAsFixed(1)}%', + icon: Icons.trending_up, + ), + _HistoryStatItem( + label: 'Total Time', + value: _formatTime(_deck!.totalTimeSpent), + icon: Icons.timer, + ), + ], + ), + const SizedBox(height: 16), + const Divider(), + const SizedBox(height: 8), + Text( + 'Progress Chart', + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 8), + // Progress chart + AttemptsChart( + attempts: _deck!.attemptHistory, + maxDisplayItems: 15, + ), + ], ], ), ), ), - const SizedBox(height: 24), + const SizedBox(height: 12), // Action Buttons FilledButton.icon( @@ -249,14 +484,17 @@ class _DeckOverviewScreenState extends State { icon: const Icon(Icons.play_arrow), label: const Text('Start Attempt'), style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 16), + padding: const EdgeInsets.symmetric(vertical: 12), ), ), - const SizedBox(height: 12), + const SizedBox(height: 8), OutlinedButton.icon( onPressed: _openConfig, icon: const Icon(Icons.settings), label: const Text('Configure Deck'), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + ), ), ], ), @@ -279,12 +517,46 @@ class _StatItem extends StatelessWidget { @override Widget build(BuildContext context) { return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 24), + const SizedBox(height: 4), + Text( + value, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + Text( + label, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ); + } +} + +class _HistoryStatItem extends StatelessWidget { + final String label; + final String value; + final IconData icon; + + const _HistoryStatItem({ + required this.label, + required this.value, + required this.icon, + }); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, children: [ - Icon(icon, size: 32), - const SizedBox(height: 8), + Icon(icon, size: 20, color: Theme.of(context).colorScheme.primary), + const SizedBox(height: 4), Text( value, - style: Theme.of(context).textTheme.titleLarge?.copyWith( + style: Theme.of(context).textTheme.titleSmall?.copyWith( fontWeight: FontWeight.bold, ), ), @@ -297,3 +569,4 @@ class _StatItem extends StatelessWidget { } } + diff --git a/lib/services/deck_storage.dart b/lib/services/deck_storage.dart new file mode 100644 index 0000000..1a208c2 --- /dev/null +++ b/lib/services/deck_storage.dart @@ -0,0 +1,61 @@ +import 'package:practice_engine/practice_engine.dart'; +import '../data/default_deck.dart'; + +/// Service for managing deck storage and retrieval. +/// Currently uses in-memory storage. Can be extended to use persistent storage. +class DeckStorage { + static final DeckStorage _instance = DeckStorage._internal(); + factory DeckStorage() => _instance; + DeckStorage._internal(); + + final Map _decks = {}; + bool _initialized = false; + + /// Initialize storage with default deck if empty + void initialize() { + if (_initialized) return; + _initialized = true; + + // Add default deck if no decks exist + if (_decks.isEmpty) { + final defaultDeck = DefaultDeck.deck; + _decks[defaultDeck.id] = defaultDeck; + } + } + + /// Get all decks + List getAllDecks() { + initialize(); + return _decks.values.toList(); + } + + /// Get a deck by ID + Deck? getDeck(String id) { + initialize(); + return _decks[id]; + } + + /// Add or update a deck + void saveDeck(Deck deck) { + initialize(); + _decks[deck.id] = deck; + } + + /// Delete a deck + void deleteDeck(String id) { + _decks.remove(id); + } + + /// Check if a deck exists + bool hasDeck(String id) { + initialize(); + return _decks.containsKey(id); + } + + /// Get the number of decks + int get deckCount { + initialize(); + return _decks.length; + } +} + diff --git a/lib/widgets/answer_option.dart b/lib/widgets/answer_option.dart index e84106f..960de09 100644 --- a/lib/widgets/answer_option.dart +++ b/lib/widgets/answer_option.dart @@ -5,6 +5,7 @@ class AnswerOption extends StatelessWidget { final bool isSelected; final VoidCallback onTap; final bool? isCorrect; + final bool isMultipleChoice; const AnswerOption({ super.key, @@ -12,6 +13,7 @@ class AnswerOption extends StatelessWidget { required this.isSelected, required this.onTap, this.isCorrect, + this.isMultipleChoice = false, }); @override @@ -63,12 +65,12 @@ class AnswerOption extends StatelessWidget { ), if (isSelected && icon == null) Icon( - Icons.radio_button_checked, + isMultipleChoice ? Icons.check_box : Icons.radio_button_checked, color: Theme.of(context).colorScheme.primary, ) else if (!isSelected) Icon( - Icons.radio_button_unchecked, + isMultipleChoice ? Icons.check_box_outline_blank : Icons.radio_button_unchecked, color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5), ), ], diff --git a/lib/widgets/attempts_chart.dart b/lib/widgets/attempts_chart.dart new file mode 100644 index 0000000..54bb823 --- /dev/null +++ b/lib/widgets/attempts_chart.dart @@ -0,0 +1,255 @@ +import 'package:flutter/material.dart'; +import 'package:practice_engine/practice_engine.dart'; + +/// A chart widget that visualizes attempt progress over time. +class AttemptsChart extends StatelessWidget { + final List attempts; + final int maxDisplayItems; + + const AttemptsChart({ + super.key, + required this.attempts, + this.maxDisplayItems = 15, + }); + + @override + Widget build(BuildContext context) { + if (attempts.isEmpty) { + return const SizedBox.shrink(); + } + + // Get recent attempts (most recent first, then reverse for display) + final displayAttempts = attempts.reversed.take(maxDisplayItems).toList(); + if (displayAttempts.isEmpty) { + return const SizedBox.shrink(); + } + + // Find min and max values for scaling + final percentages = displayAttempts.map((e) => e.percentageCorrect).toList(); + final minValue = percentages.reduce((a, b) => a < b ? a : b); + final maxValue = percentages.reduce((a, b) => a > b ? a : b); + final range = (maxValue - minValue).clamp(10.0, 100.0); // Ensure minimum range + final chartMin = (minValue - range * 0.1).clamp(0.0, 100.0); + final chartMax = (maxValue + range * 0.1).clamp(0.0, 100.0); + final chartRange = chartMax - chartMin; + + return Container( + height: 220, + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Y-axis labels + SizedBox( + width: 40, + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '${chartMax.toInt()}%', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontSize: 10, + ), + ), + Text( + '${((chartMin + chartMax) / 2).toInt()}%', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontSize: 10, + ), + ), + Text( + '${chartMin.toInt()}%', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontSize: 10, + ), + ), + ], + ), + ), + // Chart area + Expanded( + child: Column( + children: [ + Expanded( + child: CustomPaint( + painter: _AttemptsChartPainter( + attempts: displayAttempts, + chartMin: chartMin, + chartMax: chartMax, + chartRange: chartRange, + colorScheme: Theme.of(context).colorScheme, + ), + child: Container(), + ), + ), + const SizedBox(height: 8), + // X-axis labels (attempt numbers) + SizedBox( + height: 20, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '1', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontSize: 10, + ), + ), + if (displayAttempts.length > 1) + Text( + '${displayAttempts.length}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontSize: 10, + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _AttemptsChartPainter extends CustomPainter { + final List attempts; + final double chartMin; + final double chartMax; + final double chartRange; + final ColorScheme colorScheme; + + _AttemptsChartPainter({ + required this.attempts, + required this.chartMin, + required this.chartMax, + required this.chartRange, + required this.colorScheme, + }); + + @override + void paint(Canvas canvas, Size size) { + if (attempts.isEmpty) return; + + final padding = 8.0; + final chartWidth = size.width - padding * 2; + final chartHeight = size.height - padding * 2; + final pointSpacing = attempts.length > 1 + ? chartWidth / (attempts.length - 1) + : chartWidth; // If only one point, center it + + // Draw grid lines + _drawGridLines(canvas, size, padding, chartHeight); + + // Draw line and points + final path = Path(); + final points = []; + + for (int i = 0; i < attempts.length; i++) { + final percentage = attempts[i].percentageCorrect; + final normalizedValue = chartRange > 0 + ? ((percentage - chartMin) / chartRange).clamp(0.0, 1.0) + : 0.5; // If all values are the same, center vertically + final x = padding + (i * pointSpacing); + final y = padding + chartHeight - (normalizedValue * chartHeight); + final point = Offset(x, y); + points.add(point); + + if (i == 0) { + path.moveTo(point.dx, point.dy); + } else { + path.lineTo(point.dx, point.dy); + } + } + + // Draw the line + final linePaint = Paint() + ..color = colorScheme.primary + ..style = PaintingStyle.stroke + ..strokeWidth = 2.5; + + canvas.drawPath(path, linePaint); + + // Draw points + final pointPaint = Paint() + ..color = colorScheme.primary + ..style = PaintingStyle.fill; + + final selectedPointPaint = Paint() + ..color = colorScheme.primaryContainer + ..style = PaintingStyle.fill; + + for (int i = 0; i < points.length; i++) { + final point = points[i]; + + // Draw point circle + final isRecent = i >= points.length - 3; // Highlight last 3 attempts + canvas.drawCircle( + point, + isRecent ? 5 : 4, + isRecent ? selectedPointPaint : pointPaint, + ); + + // Draw outer ring for better visibility + final ringPaint = Paint() + ..color = colorScheme.primary.withValues(alpha: 0.3) + ..style = PaintingStyle.stroke + ..strokeWidth = 1; + canvas.drawCircle(point, isRecent ? 6 : 5, ringPaint); + } + + // Draw average line (dashed) + final avgPercentage = attempts.map((e) => e.percentageCorrect).reduce((a, b) => a + b) / attempts.length; + final avgNormalized = ((avgPercentage - chartMin) / chartRange).clamp(0.0, 1.0); + final avgY = padding + chartHeight - (avgNormalized * chartHeight); + + final avgLinePaint = Paint() + ..color = colorScheme.secondary.withValues(alpha: 0.5) + ..style = PaintingStyle.stroke + ..strokeWidth = 1; + + // Draw dashed line manually + final dashLength = 5.0; + final gapLength = 5.0; + double currentX = padding; + while (currentX < size.width - padding) { + canvas.drawLine( + Offset(currentX, avgY), + Offset((currentX + dashLength).clamp(padding, size.width - padding), avgY), + avgLinePaint, + ); + currentX += dashLength + gapLength; + } + } + + void _drawGridLines(Canvas canvas, Size size, double padding, double chartHeight) { + final gridPaint = Paint() + ..color = colorScheme.onSurface.withValues(alpha: 0.1) + ..style = PaintingStyle.stroke + ..strokeWidth = 1; + + // Draw horizontal grid lines (0%, 25%, 50%, 75%, 100%) + for (int i = 0; i <= 4; i++) { + final value = chartMin + (chartRange * i / 4); + final normalized = ((value - chartMin) / chartRange).clamp(0.0, 1.0); + final y = padding + chartHeight - (normalized * chartHeight); + + canvas.drawLine( + Offset(padding, y), + Offset(size.width - padding, y), + gridPaint, + ); + } + } + + @override + bool shouldRepaint(_AttemptsChartPainter oldDelegate) { + return oldDelegate.attempts != attempts || + oldDelegate.chartMin != chartMin || + oldDelegate.chartMax != chartMax; + } +} + +