diff --git a/mnemo_cards_backend/lib/tests/generators/question_generators/input_buttons_question_generator.dart b/mnemo_cards_backend/lib/tests/generators/question_generators/input_buttons_question_generator.dart index 697a3fe..be6da48 100644 --- a/mnemo_cards_backend/lib/tests/generators/question_generators/input_buttons_question_generator.dart +++ b/mnemo_cards_backend/lib/tests/generators/question_generators/input_buttons_question_generator.dart @@ -25,6 +25,12 @@ class InputButtonsQuestionGenerator implements QuestionGenerator { InputButtonsQuestionType.translation_and_image_original, }; + /// Convert image ID to proper URL path + String? _imageIdToUrl(String? imageId) { + if (imageId == null || data.packId == null) return null; + return '/api/v2/packs/${data.packId}/cards/$imageId/image'; + } + InputButtonsQuestionType _getRandomQuestionType() { final types = (possibleTypes?.toList() ?? InputButtonsQuestionType.values); return types[random.nextInt(types.length)]; @@ -47,7 +53,9 @@ class InputButtonsQuestionGenerator implements QuestionGenerator { final questionType = type ?? _getRandomQuestionType(); final questionText = questionType.translationQuestion ? answerItem.translation : null; - final questionImage = questionType.imageQuestion ? answerItem.image : null; + final questionImage = questionType.imageQuestion + ? _imageIdToUrl(answerItem.image) + : null; String? questionAudio = null; if (questionType.audioQuestion) { @@ -108,6 +116,7 @@ class InputButtonsQuestionGenerator implements QuestionGenerator { ); } + Future verbTestQuestion( TestDataItem answerCard) async { final ends = ['o', 'as', 'a', 'a', 'a', 'amos', 'ais', 'an', 'an', 'an']; diff --git a/mnemo_cards_backend/lib/tests/generators/question_generators/simple_question_generator.dart b/mnemo_cards_backend/lib/tests/generators/question_generators/simple_question_generator.dart index 7652797..91fbf31 100644 --- a/mnemo_cards_backend/lib/tests/generators/question_generators/simple_question_generator.dart +++ b/mnemo_cards_backend/lib/tests/generators/question_generators/simple_question_generator.dart @@ -25,6 +25,12 @@ class SimpleQuestionGenerator implements QuestionGenerator { SimpleQuestionType.audio_images, }; + /// Convert image ID to proper URL path + String? _imageIdToUrl(String? imageId) { + if (imageId == null || data.packId == null) return null; + return '/api/v2/packs/${data.packId}/cards/$imageId/image'; + } + SimpleQuestionType _getRandomQuestionType() { final types = (possibleTypes?.toList() ?? SimpleQuestionType.values); return types[random.nextInt(types.length)]; @@ -46,7 +52,9 @@ class SimpleQuestionGenerator implements QuestionGenerator { : questionType.translationQuestion ? answerCard.translation : null; - final questionImage = questionType.imageQuestion ? answerCard.image : null; + final questionImage = questionType.imageQuestion + ? _imageIdToUrl(answerCard.image) + : null; String? questionAudio = null; if (questionType.audioQuestion) { if (questionType.translationAnswers || questionType.imagesAnswers) { @@ -79,12 +87,13 @@ class SimpleQuestionGenerator implements QuestionGenerator { ) else if (questionType.imagesAnswers) ...answers.map( - (e) => TestButtonDto.image(e.id, e.image), + (e) => TestButtonDto.image(e.id, _imageIdToUrl(e.image)), ) ], ); } + Future verbTestQuestion( TestDataItem answerCard, { bool withAudio = true, diff --git a/mnemo_cards_web_v2/build/unit_test_assets/AssetManifest.bin b/mnemo_cards_web_v2/build/unit_test_assets/AssetManifest.bin index 180178d..952b75c 100644 --- a/mnemo_cards_web_v2/build/unit_test_assets/AssetManifest.bin +++ b/mnemo_cards_web_v2/build/unit_test_assets/AssetManifest.bin @@ -1 +1 @@ - 1../mnemo_cards/fonts/Nunito-VariableFont_wght.ttf  asset1../mnemo_cards/fonts/Nunito-VariableFont_wght.ttf icons/ad.webp  asset icons/ad.webpicons/back.webp  asseticons/back.webpicons/cards.webp  asseticons/cards.webpicons/chat.webp  asseticons/chat.webpicons/clock.webp  asseticons/clock.webpicons/crown.webp  asseticons/crown.webpicons/down.webp  asseticons/down.webpicons/download.webp  asseticons/download.webpicons/exit.webp  asseticons/exit.webpicons/exit2.webp  asseticons/exit2.webpicons/exit3.webp  asseticons/exit3.webpicons/gift.webp  asseticons/gift.webpicons/google.webp  asseticons/google.webpicons/heart.webp  asseticons/heart.webpicons/heart_fill.webp  asseticons/heart_fill.webpicons/lock.webp  asseticons/lock.webpicons/mic_off.webp  asseticons/mic_off.webpicons/mic_on.webp  asseticons/mic_on.webpicons/next.webp  asseticons/next.webpicons/profile.webp  asseticons/profile.webpicons/settings.webp  asseticons/settings.webpicons/shuffle.webp  asseticons/shuffle.webpicons/skull.webp  asseticons/skull.webpicons/small_circle.webp  asseticons/small_circle.webpicons/sound_off.webp  asseticons/sound_off.webpicons/sound_on.webp  asseticons/sound_on.webpicons/telegram.webp  asseticons/telegram.webp icons/tg.webp  asset icons/tg.webpicons/view.webp  asseticons/view.webp2packages/cupertino_icons/assets/CupertinoIcons.ttf  asset2packages/cupertino_icons/assets/CupertinoIcons.ttf \ No newline at end of file + "fonts/Nunito-VariableFont_wght.ttf  asset"fonts/Nunito-VariableFont_wght.ttf icons/ad.webp  asset icons/ad.webpicons/back.webp  asseticons/back.webpicons/cards.webp  asseticons/cards.webpicons/chat.webp  asseticons/chat.webpicons/clock.webp  asseticons/clock.webpicons/crown.webp  asseticons/crown.webpicons/down.webp  asseticons/down.webpicons/download.webp  asseticons/download.webpicons/exit.webp  asseticons/exit.webpicons/exit2.webp  asseticons/exit2.webpicons/exit3.webp  asseticons/exit3.webpicons/gift.webp  asseticons/gift.webpicons/google.webp  asseticons/google.webpicons/heart.webp  asseticons/heart.webpicons/heart_fill.webp  asseticons/heart_fill.webpicons/lock.webp  asseticons/lock.webpicons/mic_off.webp  asseticons/mic_off.webpicons/mic_on.webp  asseticons/mic_on.webpicons/next.webp  asseticons/next.webpicons/profile.webp  asseticons/profile.webpicons/settings.webp  asseticons/settings.webpicons/shuffle.webp  asseticons/shuffle.webpicons/skull.webp  asseticons/skull.webpicons/small_circle.webp  asseticons/small_circle.webpicons/sound_off.webp  asseticons/sound_off.webpicons/sound_on.webp  asseticons/sound_on.webpicons/telegram.webp  asseticons/telegram.webp icons/tg.webp  asset icons/tg.webpicons/view.webp  asseticons/view.webp2packages/cupertino_icons/assets/CupertinoIcons.ttf  asset2packages/cupertino_icons/assets/CupertinoIcons.ttf \ No newline at end of file diff --git a/mnemo_cards_web_v2/build/unit_test_assets/AssetManifest.json b/mnemo_cards_web_v2/build/unit_test_assets/AssetManifest.json index b7d7719..da5bc45 100644 --- a/mnemo_cards_web_v2/build/unit_test_assets/AssetManifest.json +++ b/mnemo_cards_web_v2/build/unit_test_assets/AssetManifest.json @@ -1 +1 @@ -{"../mnemo_cards/fonts/Nunito-VariableFont_wght.ttf":["../mnemo_cards/fonts/Nunito-VariableFont_wght.ttf"],"icons/ad.webp":["icons/ad.webp"],"icons/back.webp":["icons/back.webp"],"icons/cards.webp":["icons/cards.webp"],"icons/chat.webp":["icons/chat.webp"],"icons/clock.webp":["icons/clock.webp"],"icons/crown.webp":["icons/crown.webp"],"icons/down.webp":["icons/down.webp"],"icons/download.webp":["icons/download.webp"],"icons/exit.webp":["icons/exit.webp"],"icons/exit2.webp":["icons/exit2.webp"],"icons/exit3.webp":["icons/exit3.webp"],"icons/gift.webp":["icons/gift.webp"],"icons/google.webp":["icons/google.webp"],"icons/heart.webp":["icons/heart.webp"],"icons/heart_fill.webp":["icons/heart_fill.webp"],"icons/lock.webp":["icons/lock.webp"],"icons/mic_off.webp":["icons/mic_off.webp"],"icons/mic_on.webp":["icons/mic_on.webp"],"icons/next.webp":["icons/next.webp"],"icons/profile.webp":["icons/profile.webp"],"icons/settings.webp":["icons/settings.webp"],"icons/shuffle.webp":["icons/shuffle.webp"],"icons/skull.webp":["icons/skull.webp"],"icons/small_circle.webp":["icons/small_circle.webp"],"icons/sound_off.webp":["icons/sound_off.webp"],"icons/sound_on.webp":["icons/sound_on.webp"],"icons/telegram.webp":["icons/telegram.webp"],"icons/tg.webp":["icons/tg.webp"],"icons/view.webp":["icons/view.webp"],"packages/cupertino_icons/assets/CupertinoIcons.ttf":["packages/cupertino_icons/assets/CupertinoIcons.ttf"]} \ No newline at end of file +{"fonts/Nunito-VariableFont_wght.ttf":["fonts/Nunito-VariableFont_wght.ttf"],"icons/ad.webp":["icons/ad.webp"],"icons/back.webp":["icons/back.webp"],"icons/cards.webp":["icons/cards.webp"],"icons/chat.webp":["icons/chat.webp"],"icons/clock.webp":["icons/clock.webp"],"icons/crown.webp":["icons/crown.webp"],"icons/down.webp":["icons/down.webp"],"icons/download.webp":["icons/download.webp"],"icons/exit.webp":["icons/exit.webp"],"icons/exit2.webp":["icons/exit2.webp"],"icons/exit3.webp":["icons/exit3.webp"],"icons/gift.webp":["icons/gift.webp"],"icons/google.webp":["icons/google.webp"],"icons/heart.webp":["icons/heart.webp"],"icons/heart_fill.webp":["icons/heart_fill.webp"],"icons/lock.webp":["icons/lock.webp"],"icons/mic_off.webp":["icons/mic_off.webp"],"icons/mic_on.webp":["icons/mic_on.webp"],"icons/next.webp":["icons/next.webp"],"icons/profile.webp":["icons/profile.webp"],"icons/settings.webp":["icons/settings.webp"],"icons/shuffle.webp":["icons/shuffle.webp"],"icons/skull.webp":["icons/skull.webp"],"icons/small_circle.webp":["icons/small_circle.webp"],"icons/sound_off.webp":["icons/sound_off.webp"],"icons/sound_on.webp":["icons/sound_on.webp"],"icons/telegram.webp":["icons/telegram.webp"],"icons/tg.webp":["icons/tg.webp"],"icons/view.webp":["icons/view.webp"],"packages/cupertino_icons/assets/CupertinoIcons.ttf":["packages/cupertino_icons/assets/CupertinoIcons.ttf"]} \ No newline at end of file diff --git a/mnemo_cards_web_v2/build/unit_test_assets/FontManifest.json b/mnemo_cards_web_v2/build/unit_test_assets/FontManifest.json index 4423f34..9b4b2e2 100644 --- a/mnemo_cards_web_v2/build/unit_test_assets/FontManifest.json +++ b/mnemo_cards_web_v2/build/unit_test_assets/FontManifest.json @@ -1 +1 @@ -[{"family":"MaterialIcons","fonts":[{"asset":"fonts/MaterialIcons-Regular.otf"}]},{"family":"Nunito","fonts":[{"asset":"../mnemo_cards/fonts/Nunito-VariableFont_wght.ttf"}]},{"family":"packages/cupertino_icons/CupertinoIcons","fonts":[{"asset":"packages/cupertino_icons/assets/CupertinoIcons.ttf"}]}] \ No newline at end of file +[{"family":"MaterialIcons","fonts":[{"asset":"fonts/MaterialIcons-Regular.otf"}]},{"family":"Nunito","fonts":[{"asset":"fonts/Nunito-VariableFont_wght.ttf"}]},{"family":"packages/cupertino_icons/CupertinoIcons","fonts":[{"asset":"packages/cupertino_icons/assets/CupertinoIcons.ttf"}]}] \ No newline at end of file diff --git a/mnemo_cards_web_v2/lib/app.dart b/mnemo_cards_web_v2/lib/app.dart index 5e2815e..9b0942b 100644 --- a/mnemo_cards_web_v2/lib/app.dart +++ b/mnemo_cards_web_v2/lib/app.dart @@ -160,7 +160,7 @@ class _AppInitializerState extends State<_AppInitializer> { if (!_isInitialized) { log('Showing loading screen', name: 'App'); - return const _LoadingScreen(message: 'Загрузка...'); + return const _LoadingScreen(message: 'Loading...'); } log('Building main app', name: 'App'); @@ -207,7 +207,7 @@ class _LoadingScreen extends StatelessWidget { Widget build(BuildContext context) { return MaterialApp( home: Scaffold( - backgroundColor: Theme.of(context).colorScheme.inversePrimary, + backgroundColor: Theme.of(context).isDark ? AppTheme.dark.backgroundColor : AppTheme.light.backgroundColor, body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, diff --git a/mnemo_cards_web_v2/lib/presentation/pages/test/test_page.dart b/mnemo_cards_web_v2/lib/presentation/pages/test/test_page.dart index cbb6b26..53d3e5b 100644 --- a/mnemo_cards_web_v2/lib/presentation/pages/test/test_page.dart +++ b/mnemo_cards_web_v2/lib/presentation/pages/test/test_page.dart @@ -9,26 +9,7 @@ import '../../../di/app_scope/app_scope_container.dart'; import '../../../presentation/widgets/loading_view.dart'; import '../../../presentation/widgets/error_view.dart'; -/// Simple test result model -class TestResult { - final String testId; - final int correctAnswers; - final int incorrectAnswers; - final int totalQuestions; - final int timeTaken; - final DateTime completedAt; - - const TestResult({ - required this.testId, - required this.correctAnswers, - required this.incorrectAnswers, - required this.totalQuestions, - required this.timeTaken, - required this.completedAt, - }); -} - -/// Test page for displaying and taking a test +/// Test page for displaying test info and launching the game class TestPage extends StatefulWidget { const TestPage({ required this.testId, @@ -45,13 +26,185 @@ class _TestPageState extends State { TestDto? _test; bool _isLoading = true; String? _errorMessage; - - // Test taking state - bool _isTestStarted = false; - int _currentQuestionIndex = 0; - final Map _userAnswers = {}; - bool _isTestCompleted = false; - TestResult? _testResult; + + @override + void initState() { + super.initState(); + _loadTest(); + } + + Future _loadTest() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final appScope = ScopeProvider.of( + context, + listen: false, + ); + final userScope = appScope?.userScopeHolder.scope; + if (userScope == null) { + throw Exception('Scope not available'); + } + + log('Loading test: ${widget.testId}', name: 'TestPage'); + final test = await userScope.testsModule.testManager.loadTest(widget.testId); + + if (mounted) { + setState(() { + _test = test; + _isLoading = false; + }); + } + } catch (e, s) { + log( + 'Error loading test', + error: e, + stackTrace: s, + name: 'TestPage', + ); + if (mounted) { + setState(() { + _isLoading = false; + _errorMessage = 'Failed to load test: ${e.toString()}'; + }); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(_test?.name ?? 'Test'), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => context.pop(), + ), + ), + body: _buildBody(), + ); + } + + Widget _buildBody() { + if (_isLoading) { + return const LoadingView(message: 'Loading test...'); + } + + if (_errorMessage != null) { + return ErrorView( + title: 'Failed to load test', + message: _errorMessage!, + onRetry: _loadTest, + ); + } + + if (_test == null) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, size: 64, color: Colors.grey), + const SizedBox(height: 16), + const Text('Test not found'), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadTest, + child: const Text('Retry'), + ), + ], + ), + ); + } + + return _buildTestIntro(); + } + + Widget _buildTestIntro() { + final test = _test!; + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + test.name, + style: Theme.of(context).textTheme.headlineMedium, + ), + const SizedBox(height: 24), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Test Information', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 16), + _buildInfoRow(Icons.quiz, 'Questions', '${test.questions.length}'), + const SizedBox(height: 8), + _buildInfoRow(Icons.timer, 'Estimated Time', '${test.questions.length * 2} minutes'), + const SizedBox(height: 8), + _buildInfoRow(Icons.help_outline, 'Type', 'Interactive Game'), + ], + ), + ), + ), + const SizedBox(height: 24), + Text( + 'Instructions', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + const Text( + '• Answer each question as it appears\n' + '• Get instant feedback on your answers\n' + '• Complete all questions to see your results\n' + '• Your progress and statistics will be tracked', + style: TextStyle(fontSize: 16), + ), + const SizedBox(height: 32), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () => _playGame(context), + icon: const Icon(Icons.games), + label: const Text('Start Test'), + style: ElevatedButton.styleFrom( + minimumSize: const Size.fromHeight(48), + textStyle: const TextStyle(fontSize: 18), + ), + ), + ), + ], + ), + ); + } + + Widget _buildInfoRow(IconData icon, String label, String value) { + return Row( + children: [ + Icon(icon, size: 20, color: Theme.of(context).colorScheme.primary), + const SizedBox(width: 12), + Text( + '$label: ', + style: const TextStyle(fontWeight: FontWeight.w500), + ), + Text(value), + ], + ); + } + + void _playGame(BuildContext context) { + // Navigate to the game page + context.go('/game/${widget.testId}'); + } +} + @override void initState() { diff --git a/mnemo_cards_web_v2/lib/presentation/widgets/pack_card.dart b/mnemo_cards_web_v2/lib/presentation/widgets/pack_card.dart index bbd051a..2a8a87c 100644 --- a/mnemo_cards_web_v2/lib/presentation/widgets/pack_card.dart +++ b/mnemo_cards_web_v2/lib/presentation/widgets/pack_card.dart @@ -27,103 +27,109 @@ class PackCard extends StatelessWidget { final theme = Theme.of(context); final packColor = pack.color?.asColor ?? theme.colorScheme.primary; - return Semantics( - label: 'Pack: ${pack.title}. Tap to view details.', - button: true, - child: Stack( - children: [ - GestureDetector( - onTap: () { - if (pack.isAvailable) { - context.push('/pack/${pack.id}'); - } else { - context.push('/purchase/${pack.id}'); - } - }, - child: Container( - height: cardHeight, - decoration: BoxDecoration( - color: Colors.transparent, - border: Border.all(color: packColor.withOpacity(0.9), width: 1), - borderRadius: BorderRadius.circular(12.0), - ), - clipBehavior: Clip.antiAliasWithSaveLayer, - child: Row( - mainAxisSize: MainAxisSize.max, - children: [ - // Квадратное изображение слева - _buildPackImage(context, packColor), + return Hero( + tag: 'pack-hero-${pack.id}', + child: Material( + color: Colors.transparent, + child: Semantics( + label: 'Pack: ${pack.title}. Tap to view details.', + button: true, + child: Stack( + children: [ + GestureDetector( + onTap: () { + if (pack.isAvailable) { + context.push('/pack/${pack.id}'); + } else { + context.push('/purchase/${pack.id}'); + } + }, + child: Container( + height: cardHeight, + decoration: BoxDecoration( + color: Colors.transparent, + border: Border.all(color: packColor.withOpacity(0.9), width: 1), + borderRadius: BorderRadius.circular(12.0), + ), + clipBehavior: Clip.antiAliasWithSaveLayer, + child: Row( + mainAxisSize: MainAxisSize.max, + children: [ + // Квадратное изображение слева + _buildPackImage(context, packColor), - const SizedBox(width: 4), + const SizedBox(width: 4), - // Информация о паке справа - Expanded( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Заголовок - Flexible( - child: Text( - pack.title, - style: theme.textTheme.titleLarge?.copyWith( - fontSize: 24, - height: 0.9, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - - // Подзаголовок (если есть) - if (pack.subtitle != null && - pack.subtitle!.isNotEmpty) ...[ - const SizedBox(height: 2), - Flexible( - child: Text( - pack.subtitle!, - style: theme.textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w300, - fontSize: 16, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], - - const Spacer(), - - // Количество карточек - Row( + // Информация о паке справа + Expanded( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - pack.cards.toString(), - style: theme.textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w200, - fontSize: 15, + // Заголовок + Flexible( + child: Text( + pack.title, + style: theme.textTheme.titleLarge?.copyWith( + fontSize: 24, + height: 0.9, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, ), ), - const SizedBox(width: 4), - Icon( - Icons.style, - size: 18, - color: theme.colorScheme.primary, + + // Подзаголовок (если есть) + if (pack.subtitle != null && + pack.subtitle!.isNotEmpty) ...[ + const SizedBox(height: 2), + Flexible( + child: Text( + pack.subtitle!, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w300, + fontSize: 16, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + + const Spacer(), + + // Количество карточек + Row( + children: [ + Text( + pack.cards.toString(), + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w200, + fontSize: 15, + ), + ), + const SizedBox(width: 4), + Icon( + Icons.style, + size: 18, + color: theme.colorScheme.primary, + ), + ], ), ], ), - ], + ), ), - ), + ], ), - ], + ), ), - ), + // Pack tip overlay + if (pack.tip != null) _buildPackTip(context, packColor), + ], ), - // Pack tip overlay - if (pack.tip != null) _buildPackTip(context, packColor), - ], + ), ), ); } @@ -156,29 +162,26 @@ class PackCard extends StatelessWidget { ? MemoryImage(base64Decode(pack.imageBase64!)) : null); - return Hero( - tag: 'pack-${pack.id}', - child: Container( - width: imageSize, - height: imageSize, - decoration: BoxDecoration( - color: packColor.withOpacity(0.1), - borderRadius: BorderRadius.circular(11), - image: imageProvider != null - ? DecorationImage(image: imageProvider, fit: BoxFit.cover) - : null, - ), - // Показываем иконку только если нет изображения - child: imageProvider == null - ? Center( - child: Icon( - Icons.collections_bookmark, - size: 40, - color: packColor, - ), - ) + return Container( + width: imageSize, + height: imageSize, + decoration: BoxDecoration( + color: packColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(11), + image: imageProvider != null + ? DecorationImage(image: imageProvider, fit: BoxFit.cover) : null, ), + // Показываем иконку только если нет изображения + child: imageProvider == null + ? Center( + child: Icon( + Icons.collections_bookmark, + size: 40, + color: packColor, + ), + ) + : null, ); } diff --git a/mnemo_cards_web_v2/lib/presentation/widgets/pack_card_vertical.dart b/mnemo_cards_web_v2/lib/presentation/widgets/pack_card_vertical.dart index c2cd3ed..3824637 100644 --- a/mnemo_cards_web_v2/lib/presentation/widgets/pack_card_vertical.dart +++ b/mnemo_cards_web_v2/lib/presentation/widgets/pack_card_vertical.dart @@ -23,93 +23,99 @@ class PackCardVertical extends StatelessWidget { final theme = Theme.of(context); final packColor = pack.color?.asColor ?? theme.colorScheme.primary; - return Semantics( - label: 'Pack: ${pack.title}. Tap to view details.', - button: true, - child: GestureDetector( - onTap: () { - if (pack.isAvailable) { - context.push('/pack/${pack.id}'); - } else { - context.push('/purchase/${pack.id}'); - } - }, - child: Container( - decoration: BoxDecoration( - color: Colors.transparent, - border: Border.all(color: packColor.withOpacity(0.9), width: 1), - borderRadius: BorderRadius.circular(12.0), - ), - clipBehavior: Clip.antiAliasWithSaveLayer, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Изображение сверху - Flexible(flex: 3, child: _buildPackImage(context, packColor)), - Flexible( - flex: 2, - child: Padding( - padding: const EdgeInsets.all(12.0), - child: Stack( - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, + return Hero( + tag: 'pack-hero-${pack.id}', + child: Material( + color: Colors.transparent, + child: Semantics( + label: 'Pack: ${pack.title}. Tap to view details.', + button: true, + child: GestureDetector( + onTap: () { + if (pack.isAvailable) { + context.push('/pack/${pack.id}'); + } else { + context.push('/purchase/${pack.id}'); + } + }, + child: Container( + decoration: BoxDecoration( + color: Colors.transparent, + border: Border.all(color: packColor.withOpacity(0.9), width: 1), + borderRadius: BorderRadius.circular(12.0), + ), + clipBehavior: Clip.antiAliasWithSaveLayer, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Изображение сверху + Flexible(flex: 3, child: _buildPackImage(context, packColor)), + Flexible( + flex: 2, + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Stack( children: [ - // Заголовок - Text( - pack.title, - style: theme.textTheme.titleLarge?.copyWith( - fontSize: 20, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - - // Подзаголовок (если есть) - if (pack.subtitle != null && - pack.subtitle!.isNotEmpty) ...[ - const SizedBox(height: 4), - Text( - pack.subtitle!, - style: theme.textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w300, - fontSize: 14, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ], - - const Spacer(), - - // Количество карточек - Row( + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, children: [ + // Заголовок Text( - pack.cards.toString(), - style: theme.textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w200, - fontSize: 14, + pack.title, + style: theme.textTheme.titleLarge?.copyWith( + fontSize: 20, ), + maxLines: 2, + overflow: TextOverflow.ellipsis, ), - const SizedBox(width: 4), - Icon( - Icons.style, - size: 16, - color: theme.colorScheme.primary, + + // Подзаголовок (если есть) + if (pack.subtitle != null && + pack.subtitle!.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + pack.subtitle!, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w300, + fontSize: 14, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + + const Spacer(), + + // Количество карточек + Row( + children: [ + Text( + pack.cards.toString(), + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w200, + fontSize: 14, + ), + ), + const SizedBox(width: 4), + Icon( + Icons.style, + size: 16, + color: theme.colorScheme.primary, + ), + ], ), ], ), + // Pack tip overlay + if (pack.tip != null) _buildPackTip(context, packColor), ], ), - // Pack tip overlay - if (pack.tip != null) _buildPackTip(context, packColor), - ], + ), ), - ), + ], ), - ], + ), ), ), ), @@ -117,30 +123,27 @@ class PackCardVertical extends StatelessWidget { } Widget _buildPackImage(BuildContext context, Color packColor) { - return Hero( - tag: 'pack-${pack.id}', - child: Container( - alignment: Alignment.center, - decoration: BoxDecoration( - color: packColor.withOpacity(0.1), - ), - // Показываем изображение или иконку - child: pack.imageBase64 != null - ? Padding( - padding: const EdgeInsets.all(0.0), - child: Image.memory( - base64Decode(pack.imageBase64!), - fit: BoxFit.contain - ), - ) - : Center( - child: Icon( - Icons.collections_bookmark, - size: 64, - color: packColor, - ), - ), + return Container( + alignment: Alignment.center, + decoration: BoxDecoration( + color: packColor.withOpacity(0.1), ), + // Показываем изображение или иконку + child: pack.imageBase64 != null + ? Padding( + padding: const EdgeInsets.all(0.0), + child: Image.memory( + base64Decode(pack.imageBase64!), + fit: BoxFit.contain + ), + ) + : Center( + child: Icon( + Icons.collections_bookmark, + size: 64, + color: packColor, + ), + ), ); } diff --git a/mnemo_cards_web_v2/lib/presentation/widgets/pack_details_header.dart b/mnemo_cards_web_v2/lib/presentation/widgets/pack_details_header.dart index 89e9027..06f14c2 100644 --- a/mnemo_cards_web_v2/lib/presentation/widgets/pack_details_header.dart +++ b/mnemo_cards_web_v2/lib/presentation/widgets/pack_details_header.dart @@ -75,82 +75,87 @@ class PackDetailsHeader extends StatelessWidget { ); } - Widget _buildTitleSection(BuildContext context, Color packColor) { - // Try to get cached pack image - final userScope = ScopeProvider.of( - context, - listen: false, - ); - - final cachedImage = userScope?.imageCacheService.getPackImage(pack.id); - - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Кнопка назад - _buildBackButton(context, packColor), - - const SizedBox(width: 16), - - // Pack icon or image - Hero( - tag: 'pack-${pack.id}', - child: Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: packColor.withOpacity(0.1), - borderRadius: BorderRadius.circular(8), - image: cachedImage != null - ? DecorationImage( - image: cachedImage, - fit: BoxFit.cover, - ) - : null, + return Hero( + tag: 'pack-hero-${pack.id}', + child: Material( + color: Colors.transparent, + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).scaffoldBackgroundColor, + border: Border( + bottom: BorderSide( + color: packColor.withOpacity(0.3), + width: 1, ), - child: cachedImage == null - ? Icon( - Icons.collections_bookmark, - color: packColor, - size: 28, - ) - : null, ), ), - - const SizedBox(width: 16), - - // Заголовок и подзаголовок - Expanded( - child: Column( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 16.0), + child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - pack.title, - style: Theme.of(context).textTheme.headlineMedium?.copyWith( - fontSize: 32, - fontWeight: FontWeight.w700, - height: 1.1, - ), - ), - if (pack.subtitle.isNotEmpty) ...[ - const SizedBox(height: 4), - Text( - pack.subtitle, - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - fontSize: 18, - fontWeight: FontWeight.w400, - color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7), - height: 1.2, - ), + // Кнопка назад + _buildBackButton(context, packColor), + + const SizedBox(width: 16), + + // Pack icon or image + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: packColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + image: cachedImage != null + ? DecorationImage( + image: cachedImage, + fit: BoxFit.cover, + ) + : null, ), - ], + child: cachedImage == null + ? Icon( + Icons.collections_bookmark, + color: packColor, + size: 28, + ) + : null, + ), + + const SizedBox(width: 16), + + // Заголовок и подзаголовок + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + pack.title, + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + fontSize: 32, + fontWeight: FontWeight.w700, + height: 1.1, + ), + ), + if (pack.subtitle.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + pack.subtitle, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + fontSize: 18, + fontWeight: FontWeight.w400, + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7), + height: 1.2, + ), + ), + ], + ], + ), + ), ], ), ), - ], + ), ), ); } diff --git a/mnemo_cards_web_v2/test/domain/state/statistics_state_manager_test.dart b/mnemo_cards_web_v2/test/domain/state/statistics_state_manager_test.dart index 69640e7..8cf15d8 100644 --- a/mnemo_cards_web_v2/test/domain/state/statistics_state_manager_test.dart +++ b/mnemo_cards_web_v2/test/domain/state/statistics_state_manager_test.dart @@ -61,6 +61,18 @@ class StudySessionResponse { }); } +void main() { + group('StatisticsStateManager', () { + late StatisticsStateManager stateManager; + late MockStatisticsService mockStatisticsService; + + setUp(() { + mockStatisticsService = MockStatisticsService(); + stateManager = StatisticsStateManager( + statisticsService: mockStatisticsService, + ); + }); + // Note: These are smoke tests that verify method signatures and basic structure // Full integration tests would require HTTP mocking setup diff --git a/mnemo_cards_web_v2/test/integration/auth_and_progress_integration_test.dart b/mnemo_cards_web_v2/test/integration/auth_and_progress_integration_test.dart new file mode 100644 index 0000000..d2840db --- /dev/null +++ b/mnemo_cards_web_v2/test/integration/auth_and_progress_integration_test.dart @@ -0,0 +1,207 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:google_sign_in/google_sign_in.dart'; +import 'package:mnemo_cards_common/mnemo_cards_common.dart'; +import 'package:mnemo_cards_web_v2/domain/services/auth_service.dart'; +import 'package:mnemo_cards_web_v2/domain/services/http_repository_v2.dart'; +import 'package:mnemo_cards_web_v2/domain/services/pack_progress_service.dart'; +import 'package:mnemo_cards_web_v2/domain/state/user_state_manager.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:mocktail/mocktail.dart'; + +/// Mock classes +class MockGoogleSignIn extends Mock implements GoogleSignIn {} + +class MockHttpRepositoryV2 extends Mock implements HttpRepositoryV2 {} + +/// Integration test for authentication and pack progress flow +/// +/// This test verifies the full user journey: +/// 1. User authenticates (auto-login or manual login) +/// 2. User state is properly managed +/// 3. User learns cards and progress is tracked +/// 4. Progress is persisted locally +/// 5. User logs out and data is cleared appropriately + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('Auth and Progress Integration Tests', () { + late MockGoogleSignIn mockGoogleSignIn; + late MockHttpRepositoryV2 mockHttpRepository; + late AuthService authService; + late PackProgressService packProgressService; + late UserStateManager userStateManager; + late SharedPreferences sharedPreferences; + + const testUser = UserDto( + id: 'test-user-123', + name: 'Test User', + email: 'test@example.com', + ); + + const testPackId = 'pack-abc-123'; + const testCardId1 = 1; + const testCardId2 = 2; + + setUp(() async { + // Initialize SharedPreferences with mock + SharedPreferences.setMockInitialValues({}); + sharedPreferences = await SharedPreferences.getInstance(); + + // Create mocks + mockGoogleSignIn = MockGoogleSignIn(); + mockHttpRepository = MockHttpRepositoryV2(); + + // Create services + authService = AuthService( + googleSignIn: mockGoogleSignIn, + httpRepository: mockHttpRepository, + ); + + packProgressService = PackProgressService( + sharedPreferences: sharedPreferences, + httpRepository: mockHttpRepository, + ); + + userStateManager = UserStateManager(); + }); + + tearDown(() async { + await sharedPreferences.clear(); + }); + + test('Complete user flow: login -> learn cards -> logout', () async { + // === STEP 1: User is initially guest === + expect(userStateManager.state, equals(const UserState.guest())); + + // === STEP 2: Auto-login attempt (no saved token) === + when(mockHttpRepository.isAuthenticated()) + .thenAnswer((_) async => false); + + final autoLoginUser = await authService.autoLogin(); + expect(autoLoginUser, isNull); + + // === STEP 3: Manual login with Telegram === + const telegramCode = 'telegram-auth-code-123'; + final authResponse = AuthResponse( + user: testUser, + accessToken: 'test-access-token', + refreshToken: 'test-refresh-token', + ); + + when(mockHttpRepository.authenticateWithTelegram(telegramCode)) + .thenAnswer((_) async => authResponse); + + final loggedInUser = await authService.loginWithTelegram(telegramCode); + expect(loggedInUser, isNotNull); + expect(loggedInUser?.id, equals(testUser.id)); + expect(loggedInUser?.name, equals(testUser.name)); + + // Update user state + userStateManager.setUser(loggedInUser!); + expect(userStateManager.state, equals(UserState.authenticated(testUser))); + + // === STEP 4: User starts learning cards === + // Check initial progress + expect(packProgressService.getPackProgress(testPackId), equals(0)); + expect(packProgressService.isCardLearned(testPackId, testCardId1), isFalse); + + // Mark first card as learned + await packProgressService.markCardLearned(testPackId, testCardId1); + + expect(packProgressService.getPackProgress(testPackId), equals(1)); + expect(packProgressService.isCardLearned(testPackId, testCardId1), isTrue); + expect(packProgressService.isCardLearned(testPackId, testCardId2), isFalse); + + // Mark second card as learned + await packProgressService.markCardLearned(testPackId, testCardId2); + + expect(packProgressService.getPackProgress(testPackId), equals(2)); + expect(packProgressService.isCardLearned(testPackId, testCardId2), isTrue); + + // Get all learned cards + final learnedCards = packProgressService.getLearnedCards(testPackId); + expect(learnedCards, contains(testCardId1)); + expect(learnedCards, contains(testCardId2)); + expect(learnedCards.length, equals(2)); + + // === STEP 5: Progress persists across service recreations === + // Simulate app restart by creating new service instance + final newPackProgressService = PackProgressService( + sharedPreferences: sharedPreferences, + httpRepository: mockHttpRepository, + ); + + expect(newPackProgressService.getPackProgress(testPackId), equals(2)); + expect(newPackProgressService.isCardLearned(testPackId, testCardId1), isTrue); + expect(newPackProgressService.isCardLearned(testPackId, testCardId2), isTrue); + + // === STEP 6: User logs out === + when(mockHttpRepository.logout()).thenAnswer((_) async => {}); + when(mockHttpRepository.clearTokens()).thenAnswer((_) async => {}); + + await authService.logout(); + userStateManager.logout(); + + expect(userStateManager.state, equals(const UserState.guest())); + + // Progress data should still be preserved locally + // (in real app, you might want to clear it on logout) + expect(newPackProgressService.getPackProgress(testPackId), equals(2)); + }); + + test('Duplicate card learning does not increase progress', () async { + // Mark card as learned + await packProgressService.markCardLearned(testPackId, testCardId1); + expect(packProgressService.getPackProgress(testPackId), equals(1)); + + // Mark the same card again + await packProgressService.markCardLearned(testPackId, testCardId1); + expect(packProgressService.getPackProgress(testPackId), equals(1)); // Still 1 + + // Learned cards set should contain only one card + final learnedCards = packProgressService.getLearnedCards(testPackId); + expect(learnedCards.length, equals(1)); + }); + + test('Multiple packs have independent progress tracking', () async { + const pack1 = 'pack-1'; + const pack2 = 'pack-2'; + + // Learn cards in pack 1 + await packProgressService.markCardLearned(pack1, testCardId1); + await packProgressService.markCardLearned(pack1, testCardId2); + + // Learn cards in pack 2 + await packProgressService.markCardLearned(pack2, testCardId1); + + // Verify independent progress + expect(packProgressService.getPackProgress(pack1), equals(2)); + expect(packProgressService.getPackProgress(pack2), equals(1)); + + // Verify card status is pack-specific + expect(packProgressService.isCardLearned(pack1, testCardId1), isTrue); + expect(packProgressService.isCardLearned(pack2, testCardId1), isTrue); + expect(packProgressService.isCardLearned(pack1, testCardId2), isTrue); + expect(packProgressService.isCardLearned(pack2, testCardId2), isFalse); + }); + + test('User state transitions are correct', () async { + // Start as guest + expect(userStateManager.state.isGuest, isTrue); + expect(userStateManager.state.isAuthenticated, isFalse); + + // Login + userStateManager.setUser(testUser); + expect(userStateManager.state.isGuest, isFalse); + expect(userStateManager.state.isAuthenticated, isTrue); + expect(userStateManager.state.user, equals(testUser)); + + // Logout + userStateManager.logout(); + expect(userStateManager.state.isGuest, isTrue); + expect(userStateManager.state.isAuthenticated, isFalse); + expect(userStateManager.state.user, isNull); + }); + }); +} diff --git a/mnemo_cards_web_v2/test/integration/pack_progress_and_user_state_integration_test.dart b/mnemo_cards_web_v2/test/integration/pack_progress_and_user_state_integration_test.dart new file mode 100644 index 0000000..e9cd210 --- /dev/null +++ b/mnemo_cards_web_v2/test/integration/pack_progress_and_user_state_integration_test.dart @@ -0,0 +1,274 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mnemo_cards_common/mnemo_cards_common.dart'; +import 'package:mnemo_cards_web_v2/domain/services/http_repository_v2.dart'; +import 'package:mnemo_cards_web_v2/domain/services/pack_progress_service.dart'; +import 'package:mnemo_cards_web_v2/domain/state/user_state_manager.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:mocktail/mocktail.dart'; + +/// Mock classes +class MockHttpRepositoryV2 extends Mock implements HttpRepositoryV2 {} + +/// Integration test for pack progress tracking and user state management +/// +/// This test verifies: +/// 1. Pack progress can be tracked and persisted locally +/// 2. User state transitions work correctly +/// 3. Multiple packs have independent progress +/// 4. Data persists across service recreations (simulating app restarts) +/// +/// This is a good example of an integration test because it tests +/// how multiple components work together (PackProgressService, UserStateManager, +/// SharedPreferences) rather than testing them in isolation. + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('Pack Progress and User State Integration Tests', () { + late MockHttpRepositoryV2 mockHttpRepository; + late PackProgressService packProgressService; + late UserStateManager userStateManager; + late SharedPreferences sharedPreferences; + + final testUser = UserDto( + id: 123, + name: 'Test User', + email: 'test@example.com', + ); + + const testPackId = 'pack-abc-123'; + const testCardId1 = 1; + const testCardId2 = 2; + const testCardId3 = 3; + + setUp(() async { + // Initialize SharedPreferences with mock + SharedPreferences.setMockInitialValues({}); + sharedPreferences = await SharedPreferences.getInstance(); + + // Create mocks + mockHttpRepository = MockHttpRepositoryV2(); + + // Create services + packProgressService = PackProgressService( + sharedPreferences: sharedPreferences, + httpRepository: mockHttpRepository, + ); + + userStateManager = UserStateManager(); + }); + + tearDown(() async { + await sharedPreferences.clear(); + }); + + test('Complete user flow: user state + learning progress', () async { + // === STEP 1: User starts as guest === + expect(userStateManager.state, equals(const UserState.guest())); + expect(userStateManager.isGuest, isTrue); + expect(userStateManager.isAuthenticated, isFalse); + + // === STEP 2: Guest user can still track progress locally === + expect(packProgressService.getPackProgress(testPackId), equals(0)); + + await packProgressService.markCardLearned(testPackId, testCardId1); + expect(packProgressService.getPackProgress(testPackId), equals(1)); + expect(packProgressService.isCardLearned(testPackId, testCardId1), isTrue); + + // === STEP 3: User authenticates === + userStateManager.setUser(testUser); + await Future.delayed(const Duration(milliseconds: 50)); + + expect(userStateManager.isAuthenticated, isTrue); + expect(userStateManager.user?.id, equals(testUser.id)); + + // Progress should still be there after authentication + expect(packProgressService.getPackProgress(testPackId), equals(1)); + + // === STEP 4: Authenticated user continues learning === + await packProgressService.markCardLearned(testPackId, testCardId2); + await packProgressService.markCardLearned(testPackId, testCardId3); + + expect(packProgressService.getPackProgress(testPackId), equals(3)); + + final learnedCards = packProgressService.getLearnedCards(testPackId); + expect(learnedCards, containsAll([testCardId1, testCardId2, testCardId3])); + expect(learnedCards.length, equals(3)); + + // === STEP 5: Simulate app restart (recreate services) === + final newPackProgressService = PackProgressService( + sharedPreferences: sharedPreferences, + httpRepository: mockHttpRepository, + ); + + final newUserStateManager = UserStateManager(); + + // Progress should persist + expect(newPackProgressService.getPackProgress(testPackId), equals(3)); + expect(newPackProgressService.isCardLearned(testPackId, testCardId1), isTrue); + expect(newPackProgressService.isCardLearned(testPackId, testCardId2), isTrue); + expect(newPackProgressService.isCardLearned(testPackId, testCardId3), isTrue); + + // User state resets to guest (in real app, would be restored via auto-login) + expect(newUserStateManager.isGuest, isTrue); + + // ===STEP 6: User logs out === + userStateManager.logout(); + await Future.delayed(const Duration(milliseconds: 50)); + + expect(userStateManager.isGuest, isTrue); + expect(userStateManager.user, isNull); + + // Progress data is still preserved locally + expect(packProgressService.getPackProgress(testPackId), equals(3)); + }); + + test('Duplicate card learning does not increase progress', () async { + // Mark card as learned + await packProgressService.markCardLearned(testPackId, testCardId1); + expect(packProgressService.getPackProgress(testPackId), equals(1)); + + // Mark the same card again (should be idempotent) + await packProgressService.markCardLearned(testPackId, testCardId1); + expect(packProgressService.getPackProgress(testPackId), equals(1)); + + // Verify only one card is in learned set + final learnedCards = packProgressService.getLearnedCards(testPackId); + expect(learnedCards.length, equals(1)); + expect(learnedCards, contains(testCardId1)); + }); + + test('Multiple packs have independent progress tracking', () async { + const pack1 = 'pack-1'; + const pack2 = 'pack-2'; + const pack3 = 'pack-3'; + + // Learn different cards in different packs + await packProgressService.markCardLearned(pack1, testCardId1); + await packProgressService.markCardLearned(pack1, testCardId2); + + await packProgressService.markCardLearned(pack2, testCardId1); + + await packProgressService.markCardLearned(pack3, testCardId1); + await packProgressService.markCardLearned(pack3, testCardId2); + await packProgressService.markCardLearned(pack3, testCardId3); + + // Verify independent progress + expect(packProgressService.getPackProgress(pack1), equals(2)); + expect(packProgressService.getPackProgress(pack2), equals(1)); + expect(packProgressService.getPackProgress(pack3), equals(3)); + + // Verify card status is pack-specific + expect(packProgressService.isCardLearned(pack1, testCardId1), isTrue); + expect(packProgressService.isCardLearned(pack1, testCardId3), isFalse); + + expect(packProgressService.isCardLearned(pack2, testCardId1), isTrue); + expect(packProgressService.isCardLearned(pack2, testCardId2), isFalse); + + expect(packProgressService.isCardLearned(pack3, testCardId1), isTrue); + expect(packProgressService.isCardLearned(pack3, testCardId2), isTrue); + expect(packProgressService.isCardLearned(pack3, testCardId3), isTrue); + }); + + test('User state transitions work correctly', () async { + // Initial state: guest + expect(userStateManager.state, equals(const UserState.guest())); + expect(userStateManager.isGuest, isTrue); + expect(userStateManager.isAuthenticated, isFalse); + expect(userStateManager.user, isNull); + + // Transition to authenticated + userStateManager.setUser(testUser); + await Future.delayed(const Duration(milliseconds: 50)); + + expect(userStateManager.isGuest, isFalse); + expect(userStateManager.isAuthenticated, isTrue); + expect(userStateManager.user, equals(testUser)); + expect(userStateManager.user?.name, equals('Test User')); + + // Logout back to guest + userStateManager.logout(); + await Future.delayed(const Duration(milliseconds: 50)); + + expect(userStateManager.isGuest, isTrue); + expect(userStateManager.isAuthenticated, isFalse); + expect(userStateManager.user, isNull); + }); + + test('Pack progress increments correctly', () async { + const packId = 'test-pack'; + + // Initial progress is 0 + expect(packProgressService.getPackProgress(packId), equals(0)); + + // Learn cards one by one + await packProgressService.markCardLearned(packId, 1); + expect(packProgressService.getPackProgress(packId), equals(1)); + + await packProgressService.markCardLearned(packId, 2); + expect(packProgressService.getPackProgress(packId), equals(2)); + + await packProgressService.markCardLearned(packId, 3); + expect(packProgressService.getPackProgress(packId), equals(3)); + + await packProgressService.markCardLearned(packId, 4); + expect(packProgressService.getPackProgress(packId), equals(4)); + + await packProgressService.markCardLearned(packId, 5); + expect(packProgressService.getPackProgress(packId), equals(5)); + + // Verify all cards are learned + final learnedCards = packProgressService.getLearnedCards(packId); + expect(learnedCards.length, equals(5)); + for (int i = 1; i <= 5; i++) { + expect(packProgressService.isCardLearned(packId, i), isTrue); + } + }); + + test('Progress persists after SharedPreferences reload', () async { + // Learn some cards + await packProgressService.markCardLearned(testPackId, testCardId1); + await packProgressService.markCardLearned(testPackId, testCardId2); + + expect(packProgressService.getPackProgress(testPackId), equals(2)); + + // Get the same SharedPreferences instance (simulating persistence) + final samePrefs = await SharedPreferences.getInstance(); + + // Create new service with same SharedPreferences + final newService = PackProgressService( + sharedPreferences: samePrefs, + httpRepository: mockHttpRepository, + ); + + // Progress should be restored + expect(newService.getPackProgress(testPackId), equals(2)); + expect(newService.isCardLearned(testPackId, testCardId1), isTrue); + expect(newService.isCardLearned(testPackId, testCardId2), isTrue); + }); + + test('User can learn and unlearn workflow', () async { + // Set authenticated user + userStateManager.setUser(testUser); + + // Learn cards + await packProgressService.markCardLearned(testPackId, testCardId1); + await packProgressService.markCardLearned(testPackId, testCardId2); + + expect(packProgressService.getPackProgress(testPackId), equals(2)); + + // In a real app, you might have an "unlearn" or "reset progress" feature + // For now, we can verify the current state is correct + expect(packProgressService.getLearnedCards(testPackId).length, equals(2)); + + // User logs out + userStateManager.logout(); + await Future.delayed(const Duration(milliseconds: 50)); + + expect(userStateManager.isGuest, isTrue); + + // Progress remains (local data not cleared on logout) + expect(packProgressService.getPackProgress(testPackId), equals(2)); + }); + }); +}