From 71fb31900ebc1ea63873e8886183c401cf9928a7 Mon Sep 17 00:00:00 2001 From: maximsan Date: Tue, 9 Jun 2026 12:53:34 +0200 Subject: [PATCH 1/8] chore: linter auto fixes --- coffee_quest/integration_test/smoke_test.dart | 2 +- coffee_quest/lib/core/widgets/error_view.dart | 2 +- coffee_quest/lib/core/widgets/pick_card.dart | 5 +- .../presentation/card_detail_screen.dart | 2 +- .../presentation/card_grid_item_widget.dart | 2 +- .../game_type_practice_screen.dart | 2 +- .../presentation/module_card_widget.dart | 2 +- .../presentation/module_detail_screen.dart | 9 +- .../lesson_completion_screen.dart | 4 +- .../lessons/presentation/lesson_screen.dart | 3 +- .../presentation/drag_drop_game.dart | 2 +- .../presentation/lesson_step_runner.dart | 4 +- .../presentation/multiple_choice_game.dart | 4 +- .../mini_games/presentation/slider_game.dart | 2 +- .../presentation/tap_order_game.dart | 2 +- .../presentation/loading/loading_screen.dart | 2 +- .../presentation/path_module_node_widget.dart | 5 +- .../profile/presentation/profile_screen.dart | 7 +- .../profile/presentation/settings_screen.dart | 7 +- .../presentation/widgets/preference_tile.dart | 13 +-- .../presentation/widgets/stat_tile.dart | 5 +- .../noop_remote_config_service.dart | 2 +- .../lib/shared/models/lesson_model.dart | 3 +- .../repositories/settings_repository.dart | 2 +- .../lib/shared/storage/app_database.dart | 1 + .../lib/shared/storage/card_record.dart | 4 +- .../storage/module_progress_record.dart | 4 +- .../lib/shared/storage/progress_record.dart | 6 +- .../lib/shared/storage/settings_record.dart | 7 +- coffee_quest/pubspec.lock | 96 +++++++++++++++---- coffee_quest/pubspec.yaml | 6 +- .../widget/learn_screen_sections_test.dart | 2 +- 32 files changed, 123 insertions(+), 96 deletions(-) diff --git a/coffee_quest/integration_test/smoke_test.dart b/coffee_quest/integration_test/smoke_test.dart index 2061b0d..fb4ee1b 100644 --- a/coffee_quest/integration_test/smoke_test.dart +++ b/coffee_quest/integration_test/smoke_test.dart @@ -70,7 +70,7 @@ void main() { expect(find.text("Today's lesson"), findsOneWidget); }); - testWidgets('play today\'s lesson, complete, see XP on Profile', ( + testWidgets("play today's lesson, complete, see XP on Profile", ( tester, ) async { // Open today's lesson from the card (its subtitle is the lesson title). diff --git a/coffee_quest/lib/core/widgets/error_view.dart b/coffee_quest/lib/core/widgets/error_view.dart index 09259c4..37a325c 100644 --- a/coffee_quest/lib/core/widgets/error_view.dart +++ b/coffee_quest/lib/core/widgets/error_view.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; class ErrorView extends StatelessWidget { - const ErrorView({super.key, required this.message, this.onRetry}); + const ErrorView({required this.message, super.key, this.onRetry}); final String message; final VoidCallback? onRetry; diff --git a/coffee_quest/lib/core/widgets/pick_card.dart b/coffee_quest/lib/core/widgets/pick_card.dart index 79f2665..a7a3138 100644 --- a/coffee_quest/lib/core/widgets/pick_card.dart +++ b/coffee_quest/lib/core/widgets/pick_card.dart @@ -1,8 +1,7 @@ -import 'package:flutter/material.dart'; - import 'package:coffee_quest/shared/theme/app_colors.dart'; -import 'package:coffee_quest/shared/theme/app_typography.dart'; import 'package:coffee_quest/shared/theme/app_spacing.dart'; +import 'package:coffee_quest/shared/theme/app_typography.dart'; +import 'package:flutter/material.dart'; /// Bordered selectable tile used on the onboarding goal + brewer screens. /// Mirrors the `.pick-card` pattern from the design bundle: title + desc on diff --git a/coffee_quest/lib/features/cards/presentation/card_detail_screen.dart b/coffee_quest/lib/features/cards/presentation/card_detail_screen.dart index 30f9e2f..764bf2c 100644 --- a/coffee_quest/lib/features/cards/presentation/card_detail_screen.dart +++ b/coffee_quest/lib/features/cards/presentation/card_detail_screen.dart @@ -7,7 +7,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; class CardDetailScreen extends ConsumerWidget { - const CardDetailScreen({super.key, required this.cardId}); + const CardDetailScreen({required this.cardId, super.key}); final String cardId; diff --git a/coffee_quest/lib/features/cards/presentation/card_grid_item_widget.dart b/coffee_quest/lib/features/cards/presentation/card_grid_item_widget.dart index f10c424..1ef305d 100644 --- a/coffee_quest/lib/features/cards/presentation/card_grid_item_widget.dart +++ b/coffee_quest/lib/features/cards/presentation/card_grid_item_widget.dart @@ -6,7 +6,7 @@ import 'package:go_router/go_router.dart'; /// One tile in the Cards grid. Collected → category icon badge, title, and /// tag, tappable; locked → muted silhouette with "???" and inert. class CardGridItemWidget extends StatelessWidget { - const CardGridItemWidget({super.key, required this.item}); + const CardGridItemWidget({required this.item, super.key}); final CardWithCollection item; diff --git a/coffee_quest/lib/features/learn/presentation/game_type_practice_screen.dart b/coffee_quest/lib/features/learn/presentation/game_type_practice_screen.dart index 9bb1d59..b346755 100644 --- a/coffee_quest/lib/features/learn/presentation/game_type_practice_screen.dart +++ b/coffee_quest/lib/features/learn/presentation/game_type_practice_screen.dart @@ -14,7 +14,7 @@ import 'package:go_router/go_router.dart'; /// the chosen type out of the user's completed lessons and runs them through /// the standard [LessonStepRunner]. No DB writes, no XP, no card unlocks. class GameTypePracticeScreen extends ConsumerStatefulWidget { - const GameTypePracticeScreen({super.key, required this.gameType}); + const GameTypePracticeScreen({required this.gameType, super.key}); /// JSON discriminator: `multiple_choice` | `drag_drop` | `slider` | /// `tap_order`. Passed straight from the route path parameter. diff --git a/coffee_quest/lib/features/learn/presentation/module_card_widget.dart b/coffee_quest/lib/features/learn/presentation/module_card_widget.dart index 48a7e82..017340f 100644 --- a/coffee_quest/lib/features/learn/presentation/module_card_widget.dart +++ b/coffee_quest/lib/features/learn/presentation/module_card_widget.dart @@ -8,7 +8,7 @@ import 'package:go_router/go_router.dart'; /// progress, and lock state. Locked taps surface the unlock hint instead of /// navigating. class ModuleCardWidget extends StatelessWidget { - const ModuleCardWidget({super.key, required this.item}); + const ModuleCardWidget({required this.item, super.key}); final ModuleWithProgress item; diff --git a/coffee_quest/lib/features/learn/presentation/module_detail_screen.dart b/coffee_quest/lib/features/learn/presentation/module_detail_screen.dart index 74e3ffe..65c0b61 100644 --- a/coffee_quest/lib/features/learn/presentation/module_detail_screen.dart +++ b/coffee_quest/lib/features/learn/presentation/module_detail_screen.dart @@ -1,7 +1,3 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - import 'package:coffee_quest/core/utils/module_icons.dart'; import 'package:coffee_quest/core/widgets/error_view.dart'; import 'package:coffee_quest/core/widgets/loading_indicator.dart'; @@ -10,9 +6,12 @@ import 'package:coffee_quest/features/progress/domain/progress_providers.dart'; import 'package:coffee_quest/shared/models/lesson_model.dart'; import 'package:coffee_quest/shared/models/module_model.dart'; import 'package:coffee_quest/shared/repositories/content_repository.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; class ModuleDetailScreen extends ConsumerWidget { - const ModuleDetailScreen({super.key, required this.moduleId}); + const ModuleDetailScreen({required this.moduleId, super.key}); final String moduleId; diff --git a/coffee_quest/lib/features/lessons/presentation/lesson_completion_screen.dart b/coffee_quest/lib/features/lessons/presentation/lesson_completion_screen.dart index 76987f8..fcfe45b 100644 --- a/coffee_quest/lib/features/lessons/presentation/lesson_completion_screen.dart +++ b/coffee_quest/lib/features/lessons/presentation/lesson_completion_screen.dart @@ -24,9 +24,7 @@ class _Reward { class LessonCompletionScreen extends ConsumerStatefulWidget { const LessonCompletionScreen({ - super.key, - required this.lessonId, - required this.score, + required this.lessonId, required this.score, super.key, this.review = false, this.practice = false, }); diff --git a/coffee_quest/lib/features/lessons/presentation/lesson_screen.dart b/coffee_quest/lib/features/lessons/presentation/lesson_screen.dart index db5f436..cf04bea 100644 --- a/coffee_quest/lib/features/lessons/presentation/lesson_screen.dart +++ b/coffee_quest/lib/features/lessons/presentation/lesson_screen.dart @@ -11,8 +11,7 @@ import 'package:go_router/go_router.dart'; class LessonScreen extends ConsumerStatefulWidget { const LessonScreen({ - super.key, - required this.lessonId, + required this.lessonId, super.key, this.review = false, this.practice = false, }); diff --git a/coffee_quest/lib/features/mini_games/presentation/drag_drop_game.dart b/coffee_quest/lib/features/mini_games/presentation/drag_drop_game.dart index bf185fe..2f2432c 100644 --- a/coffee_quest/lib/features/mini_games/presentation/drag_drop_game.dart +++ b/coffee_quest/lib/features/mini_games/presentation/drag_drop_game.dart @@ -6,7 +6,7 @@ import 'package:flutter/material.dart'; /// the term index equals the definition index; wrong drops bounce back with no /// penalty. Auto-completes when every term is correctly placed. class DragDropGame extends StatefulWidget { - const DragDropGame({super.key, required this.step, required this.onResult}); + const DragDropGame({required this.step, required this.onResult, super.key}); final DragDropStep step; final void Function(MiniGameResult) onResult; diff --git a/coffee_quest/lib/features/mini_games/presentation/lesson_step_runner.dart b/coffee_quest/lib/features/mini_games/presentation/lesson_step_runner.dart index dca6965..647edb5 100644 --- a/coffee_quest/lib/features/mini_games/presentation/lesson_step_runner.dart +++ b/coffee_quest/lib/features/mini_games/presentation/lesson_step_runner.dart @@ -11,9 +11,7 @@ import 'package:flutter/material.dart'; /// until a case is added here. class LessonStepRunner extends StatelessWidget { const LessonStepRunner({ - super.key, - required this.step, - required this.onResult, + required this.step, required this.onResult, super.key, }); final LessonStepModel step; diff --git a/coffee_quest/lib/features/mini_games/presentation/multiple_choice_game.dart b/coffee_quest/lib/features/mini_games/presentation/multiple_choice_game.dart index 6132c00..3e4d321 100644 --- a/coffee_quest/lib/features/mini_games/presentation/multiple_choice_game.dart +++ b/coffee_quest/lib/features/mini_games/presentation/multiple_choice_game.dart @@ -5,9 +5,7 @@ import 'package:flutter/material.dart'; class MultipleChoiceGame extends StatefulWidget { const MultipleChoiceGame({ - super.key, - required this.step, - required this.onResult, + required this.step, required this.onResult, super.key, }); final MultipleChoiceStep step; diff --git a/coffee_quest/lib/features/mini_games/presentation/slider_game.dart b/coffee_quest/lib/features/mini_games/presentation/slider_game.dart index 55567c8..5149baa 100644 --- a/coffee_quest/lib/features/mini_games/presentation/slider_game.dart +++ b/coffee_quest/lib/features/mini_games/presentation/slider_game.dart @@ -4,7 +4,7 @@ import 'package:coffee_quest/shared/models/lesson_step_model.dart'; import 'package:flutter/material.dart'; class SliderGame extends StatefulWidget { - const SliderGame({super.key, required this.step, required this.onResult}); + const SliderGame({required this.step, required this.onResult, super.key}); final SliderStep step; final void Function(MiniGameResult) onResult; diff --git a/coffee_quest/lib/features/mini_games/presentation/tap_order_game.dart b/coffee_quest/lib/features/mini_games/presentation/tap_order_game.dart index 6c4d5f0..9bba7d6 100644 --- a/coffee_quest/lib/features/mini_games/presentation/tap_order_game.dart +++ b/coffee_quest/lib/features/mini_games/presentation/tap_order_game.dart @@ -5,7 +5,7 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; class TapOrderGame extends StatefulWidget { - const TapOrderGame({super.key, required this.step, required this.onResult}); + const TapOrderGame({required this.step, required this.onResult, super.key}); final TapOrderStep step; final void Function(MiniGameResult) onResult; diff --git a/coffee_quest/lib/features/onboarding/presentation/loading/loading_screen.dart b/coffee_quest/lib/features/onboarding/presentation/loading/loading_screen.dart index 1d8cc6b..417961b 100644 --- a/coffee_quest/lib/features/onboarding/presentation/loading/loading_screen.dart +++ b/coffee_quest/lib/features/onboarding/presentation/loading/loading_screen.dart @@ -1,8 +1,8 @@ import 'package:coffee_quest/features/onboarding/presentation/loading/loading_animation.dart'; -import 'package:coffee_quest/features/onboarding/presentation/onboarding_providers.dart'; import 'package:coffee_quest/features/onboarding/presentation/loading/wake_sequence_controller.dart'; import 'package:coffee_quest/features/onboarding/presentation/loading/widgets/pulsing_dots.dart'; import 'package:coffee_quest/features/onboarding/presentation/loading/widgets/roasty_stage.dart'; +import 'package:coffee_quest/features/onboarding/presentation/onboarding_providers.dart'; import 'package:coffee_quest/shared/theme/app_colors.dart'; import 'package:coffee_quest/shared/theme/app_spacing.dart'; import 'package:coffee_quest/shared/theme/app_typography.dart'; diff --git a/coffee_quest/lib/features/path/presentation/path_module_node_widget.dart b/coffee_quest/lib/features/path/presentation/path_module_node_widget.dart index f34de10..8837ef4 100644 --- a/coffee_quest/lib/features/path/presentation/path_module_node_widget.dart +++ b/coffee_quest/lib/features/path/presentation/path_module_node_widget.dart @@ -9,10 +9,7 @@ import 'package:go_router/go_router.dart'; /// progress. Locked taps surface the unlock hint instead of navigating. class PathModuleNodeWidget extends StatelessWidget { const PathModuleNodeWidget({ - super.key, - required this.item, - required this.isFirst, - required this.isLast, + required this.item, required this.isFirst, required this.isLast, super.key, }); final ModuleWithProgress item; diff --git a/coffee_quest/lib/features/profile/presentation/profile_screen.dart b/coffee_quest/lib/features/profile/presentation/profile_screen.dart index ad8de3b..be1063d 100644 --- a/coffee_quest/lib/features/profile/presentation/profile_screen.dart +++ b/coffee_quest/lib/features/profile/presentation/profile_screen.dart @@ -1,13 +1,12 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - import 'package:coffee_quest/features/profile/domain/settings_providers.dart'; import 'package:coffee_quest/features/profile/presentation/widgets/preference_tile.dart'; import 'package:coffee_quest/features/profile/presentation/widgets/premium_card.dart'; import 'package:coffee_quest/features/profile/presentation/widgets/profile_header.dart'; import 'package:coffee_quest/features/profile/presentation/widgets/stat_tile.dart'; import 'package:coffee_quest/features/progress/domain/progress_providers.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; class ProfileScreen extends ConsumerWidget { const ProfileScreen({super.key}); diff --git a/coffee_quest/lib/features/profile/presentation/settings_screen.dart b/coffee_quest/lib/features/profile/presentation/settings_screen.dart index d8e7701..30b22b1 100644 --- a/coffee_quest/lib/features/profile/presentation/settings_screen.dart +++ b/coffee_quest/lib/features/profile/presentation/settings_screen.dart @@ -1,12 +1,11 @@ import 'dart:async'; +import 'package:coffee_quest/features/onboarding/presentation/onboarding_providers.dart'; +import 'package:coffee_quest/features/profile/domain/settings_providers.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import 'package:coffee_quest/features/onboarding/presentation/onboarding_providers.dart'; -import 'package:coffee_quest/features/profile/domain/settings_providers.dart'; - /// Dedicated Settings screen reached via the gear icon on Profile. Hosts the /// app-wide preferences (haptics, sound), the destructive reset action, and /// the version footer. @@ -194,7 +193,7 @@ class _ResetOnboardingTile extends ConsumerWidget { builder: (ctx) => AlertDialog( title: const Text('Restart onboarding?'), content: const Text( - 'You\'ll go back through the Welcome screen and pick your goal and ' + "You'll go back through the Welcome screen and pick your goal and " 'brewer again. Your XP, streak, and collected cards stay as they ' 'are.', ), diff --git a/coffee_quest/lib/features/profile/presentation/widgets/preference_tile.dart b/coffee_quest/lib/features/profile/presentation/widgets/preference_tile.dart index 480586f..e996e4b 100644 --- a/coffee_quest/lib/features/profile/presentation/widgets/preference_tile.dart +++ b/coffee_quest/lib/features/profile/presentation/widgets/preference_tile.dart @@ -7,23 +7,14 @@ import 'package:flutter/material.dart'; /// hints at the action (e.g. "Coming soon"). class PreferenceTile extends StatelessWidget { const PreferenceTile.toggle({ - super.key, - required this.icon, - required this.title, - required this.subtitle, - required bool value, - required ValueChanged onChanged, + required this.icon, required this.title, required this.subtitle, required bool value, required ValueChanged onChanged, super.key, }) : _value = value, _onChanged = onChanged, onTap = null, trailingText = null; const PreferenceTile.action({ - super.key, - required this.icon, - required this.title, - required this.subtitle, - required this.onTap, + required this.icon, required this.title, required this.subtitle, required this.onTap, super.key, this.trailingText, }) : _value = null, _onChanged = null; diff --git a/coffee_quest/lib/features/profile/presentation/widgets/stat_tile.dart b/coffee_quest/lib/features/profile/presentation/widgets/stat_tile.dart index 35b6cac..d9d3eb0 100644 --- a/coffee_quest/lib/features/profile/presentation/widgets/stat_tile.dart +++ b/coffee_quest/lib/features/profile/presentation/widgets/stat_tile.dart @@ -4,10 +4,7 @@ import 'package:flutter/material.dart'; /// floats above a large value and a small caption. class StatTile extends StatelessWidget { const StatTile({ - super.key, - required this.icon, - required this.label, - required this.value, + required this.icon, required this.label, required this.value, super.key, }); final IconData icon; diff --git a/coffee_quest/lib/services/remote_config/noop_remote_config_service.dart b/coffee_quest/lib/services/remote_config/noop_remote_config_service.dart index c660b82..3bd642e 100644 --- a/coffee_quest/lib/services/remote_config/noop_remote_config_service.dart +++ b/coffee_quest/lib/services/remote_config/noop_remote_config_service.dart @@ -24,7 +24,7 @@ class NoOpRemoteConfigService implements RemoteConfigService { @override bool getBool(String key) { final v = _defaults[key]; - return v is bool ? v : false; + return v is bool && v; } @override diff --git a/coffee_quest/lib/shared/models/lesson_model.dart b/coffee_quest/lib/shared/models/lesson_model.dart index e8fb24a..d38b070 100644 --- a/coffee_quest/lib/shared/models/lesson_model.dart +++ b/coffee_quest/lib/shared/models/lesson_model.dart @@ -12,8 +12,7 @@ abstract class LessonModel with _$LessonModel { required String title, required String summary, required int xpReward, - String? cardId, - required List steps, + required List steps, String? cardId, }) = _LessonModel; factory LessonModel.fromJson(Map json) => diff --git a/coffee_quest/lib/shared/repositories/settings_repository.dart b/coffee_quest/lib/shared/repositories/settings_repository.dart index 1be7412..c2f2ff3 100644 --- a/coffee_quest/lib/shared/repositories/settings_repository.dart +++ b/coffee_quest/lib/shared/repositories/settings_repository.dart @@ -1,6 +1,6 @@ -import 'package:drift/drift.dart'; import 'package:coffee_quest/shared/storage/app_database.dart'; import 'package:coffee_quest/shared/storage/settings_record.dart'; +import 'package:drift/drift.dart'; class SettingsRepository { AppDatabase get _db => AppDatabaseService.instance; diff --git a/coffee_quest/lib/shared/storage/app_database.dart b/coffee_quest/lib/shared/storage/app_database.dart index 6839bd2..51e6c14 100644 --- a/coffee_quest/lib/shared/storage/app_database.dart +++ b/coffee_quest/lib/shared/storage/app_database.dart @@ -1,3 +1,4 @@ +import 'package:coffee_quest/shared/repositories/settings_repository.dart' show SettingsRepository; import 'package:drift/drift.dart'; import 'package:drift_flutter/drift_flutter.dart'; diff --git a/coffee_quest/lib/shared/storage/card_record.dart b/coffee_quest/lib/shared/storage/card_record.dart index 7d53c57..e51ebb8 100644 --- a/coffee_quest/lib/shared/storage/card_record.dart +++ b/coffee_quest/lib/shared/storage/card_record.dart @@ -1,6 +1,8 @@ +import 'package:coffee_quest/shared/storage/progress_record.dart' show ProgressRecord; + /// Mutable DTO for a collected-card row. See [ProgressRecord] for rationale. class CardRecord { - CardRecord({this.id = 0, required this.cardId, required this.unlockedAt}); + CardRecord({required this.cardId, required this.unlockedAt, this.id = 0}); int id; String cardId; diff --git a/coffee_quest/lib/shared/storage/module_progress_record.dart b/coffee_quest/lib/shared/storage/module_progress_record.dart index cd0c7b8..4816864 100644 --- a/coffee_quest/lib/shared/storage/module_progress_record.dart +++ b/coffee_quest/lib/shared/storage/module_progress_record.dart @@ -2,9 +2,7 @@ /// completion XP has been granted, so its presence is the "awarded" ledger. class ModuleProgressRecord { ModuleProgressRecord({ - this.id = 0, - required this.moduleId, - required this.moduleXpAwarded, + required this.moduleId, required this.moduleXpAwarded, this.id = 0, }); int id; diff --git a/coffee_quest/lib/shared/storage/progress_record.dart b/coffee_quest/lib/shared/storage/progress_record.dart index 4a2a450..71594d7 100644 --- a/coffee_quest/lib/shared/storage/progress_record.dart +++ b/coffee_quest/lib/shared/storage/progress_record.dart @@ -3,11 +3,7 @@ /// Drift companions. class ProgressRecord { ProgressRecord({ - this.id = 0, - required this.lessonId, - required this.isCompleted, - required this.xpEarned, - required this.completedAt, + required this.lessonId, required this.isCompleted, required this.xpEarned, required this.completedAt, this.id = 0, this.fullXpAwarded = true, this.bestScore = 0, this.lastPracticeXpDate, diff --git a/coffee_quest/lib/shared/storage/settings_record.dart b/coffee_quest/lib/shared/storage/settings_record.dart index 905017c..b4c7181 100644 --- a/coffee_quest/lib/shared/storage/settings_record.dart +++ b/coffee_quest/lib/shared/storage/settings_record.dart @@ -3,12 +3,7 @@ /// to/from the Drift companion. class UserSettingsRecord { UserSettingsRecord({ - this.id = 1, - required this.hapticsEnabled, - required this.soundEnabled, - required this.totalXp, - required this.streakDays, - required this.lastActivityDate, + required this.hapticsEnabled, required this.soundEnabled, required this.totalXp, required this.streakDays, required this.lastActivityDate, this.id = 1, this.onboardingCompleted = false, this.onboardingGoal, this.onboardingBrewer, diff --git a/coffee_quest/pubspec.lock b/coffee_quest/pubspec.lock index 297ec84..24f4060 100644 --- a/coffee_quest/pubspec.lock +++ b/coffee_quest/pubspec.lock @@ -17,6 +17,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.72" + analysis_server_plugin: + dependency: transitive + description: + name: analysis_server_plugin + sha256: "3960b28ee740004df39f85d5ebfc91785f7a90e51fd7c9a185e86a36b2f581b4" + url: "https://pub.dev" + source: hosted + version: "0.3.14" analyzer: dependency: transitive description: @@ -33,6 +41,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.2" + analyzer_plugin: + dependency: transitive + description: + name: analyzer_plugin + sha256: "0057a98d64d7bb872b0c87dff6e73d2c2d80c77156e7a03f127a26f8aa240649" + url: "https://pub.dev" + source: hosted + version: "0.14.8" + ansicolor: + dependency: transitive + description: + name: ansicolor + sha256: "50e982d500bc863e1d703448afdbf9e5a72eb48840a4f766fa361ffd6877055f" + url: "https://pub.dev" + source: hosted + version: "2.0.3" args: dependency: transitive description: @@ -209,6 +233,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.2" + dart_code_linter: + dependency: "direct dev" + description: + name: dart_code_linter + sha256: "204603d62841d1ca63be9a1b6e4f2da4089d4070d4ca3ef98dd627e79eb49331" + url: "https://pub.dev" + source: hosted + version: "4.1.2" dart_style: dependency: transitive description: @@ -379,14 +411,6 @@ packages: description: flutter source: sdk version: "0.0.0" - flutter_lints: - dependency: "direct dev" - description: - name: flutter_lints - sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" - url: "https://pub.dev" - source: hosted - version: "6.0.0" flutter_riverpod: dependency: "direct main" description: @@ -607,14 +631,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.2" - lints: - dependency: transitive - description: - name: lints - sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" - url: "https://pub.dev" - source: hosted - version: "6.1.0" logging: dependency: transitive description: @@ -767,6 +783,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" platform: dependency: transitive description: @@ -807,6 +831,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + pub_updater: + dependency: transitive + description: + name: pub_updater + sha256: "739a0161d73a6974c0675b864fb0cf5147305f7b077b7f03a58fa7a9ab3e7e7d" + url: "https://pub.dev" + source: hosted + version: "0.5.0" pubspec_parse: dependency: transitive description: @@ -1060,6 +1092,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + uuid: + dependency: transitive + description: + name: uuid + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + url: "https://pub.dev" + source: hosted + version: "4.5.3" vector_math: dependency: transitive description: @@ -1068,6 +1108,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + very_good_analysis: + dependency: "direct dev" + description: + name: very_good_analysis + sha256: d1cb1d66a5aae2c702d68caca6c8347306d35e728fd94555fa21fa0448a972e0 + url: "https://pub.dev" + source: hosted + version: "10.2.0" video_player: dependency: "direct main" description: @@ -1180,6 +1228,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4" + url: "https://pub.dev" + source: hosted + version: "7.0.1" yaml: dependency: transitive description: @@ -1188,6 +1244,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.3" + yaml_edit: + dependency: transitive + description: + name: yaml_edit + sha256: "07c9e63ba42519745182b88ca12264a7ba2484d8239958778dfe4d44fe760488" + url: "https://pub.dev" + source: hosted + version: "2.2.4" sdks: dart: ">=3.12.0 <4.0.0" flutter: ">=3.44.0" diff --git a/coffee_quest/pubspec.yaml b/coffee_quest/pubspec.yaml index ebe7a13..46c90fc 100644 --- a/coffee_quest/pubspec.yaml +++ b/coffee_quest/pubspec.yaml @@ -70,12 +70,10 @@ dev_dependencies: freezed: ^3.2.6-dev.1 json_serializable: ^6.14.0 drift_dev: ^2.33.0 + very_good_analysis: ^10.2.0 + dart_code_linter: ^4.1.2 # Linting - flutter_lints: ^6.0.0 - # riverpod_lint enabled via the `plugins:` block in analysis_options.yaml - # (not a dependency). - flutter: uses-material-design: true assets: diff --git a/coffee_quest/test/widget/learn_screen_sections_test.dart b/coffee_quest/test/widget/learn_screen_sections_test.dart index bebedf4..ff14250 100644 --- a/coffee_quest/test/widget/learn_screen_sections_test.dart +++ b/coffee_quest/test/widget/learn_screen_sections_test.dart @@ -38,7 +38,7 @@ void main() { // ActionChip with onPressed: null reports `isEnabled == false`. final chips = tester.widgetList(find.byType(ActionChip)); expect(chips, isNotEmpty); - expect(chips.every((c) => c.isEnabled == false), isTrue); + expect(chips.every((c) => !c.isEnabled), isTrue); }, ); From 5a6328b062b30c95ef3cc8605fe48d75fd309b4a Mon Sep 17 00:00:00 2001 From: maximsan Date: Tue, 9 Jun 2026 17:18:36 +0200 Subject: [PATCH 2/8] chore(lint): add very_good_analysis + dart_code_linter; begin remediation Replace flutter_lints with very_good_analysis and add dart_code_linter (analyzer-12 compatible, loaded via the analysis_options plugins block alongside riverpod_lint). Enable no-magic-number + per-function/class size/complexity metrics, with CustomPainter and theme-token exclusions. Keep directives_ordering, drop cascade_invocations; scope public_member_api_docs to real APIs (token/DTO/infra files excluded via ignore_for_file). Begin remediation: core widgets and onboarding welcome documented, magic numbers extracted to named constants. --- coffee_quest/analysis_options.yaml | 59 +++++++++++-------- .../lib/core/constants/ad_unit_ids.dart | 3 + .../lib/core/constants/app_strings.dart | 4 ++ .../lib/core/constants/route_names.dart | 14 +++++ .../lib/core/constants/xp_values.dart | 3 + coffee_quest/lib/core/widgets/error_view.dart | 5 ++ .../lib/core/widgets/link_button.dart | 4 ++ .../lib/core/widgets/loading_indicator.dart | 2 + coffee_quest/lib/core/widgets/pick_card.dart | 19 ++++-- .../lib/core/widgets/primary_button.dart | 8 ++- .../lib/core/widgets/section_header.dart | 2 + .../lib/core/widgets/smallcaps_label.dart | 4 ++ .../presentation/welcome/welcome_screen.dart | 41 +++++++------ .../presentation/widgets/roasty.dart | 19 +----- .../presentation/widgets/roasty_state.dart | 3 + .../lib/shared/storage/app_database.dart | 3 + .../lib/shared/storage/card_record.dart | 3 + .../storage/module_progress_record.dart | 3 + .../lib/shared/storage/progress_record.dart | 3 + .../lib/shared/storage/settings_record.dart | 3 + coffee_quest/lib/shared/theme/app_colors.dart | 42 ++++++++++++- .../lib/shared/theme/app_spacing.dart | 15 +++++ .../lib/shared/theme/app_typography.dart | 3 + 23 files changed, 201 insertions(+), 64 deletions(-) diff --git a/coffee_quest/analysis_options.yaml b/coffee_quest/analysis_options.yaml index 1f2e5f7..ea96e36 100644 --- a/coffee_quest/analysis_options.yaml +++ b/coffee_quest/analysis_options.yaml @@ -1,7 +1,9 @@ -include: package:flutter_lints/flutter.yaml +include: package:very_good_analysis/analysis_options.yaml +# Native analysis_server_plugins (no custom_lint), resolved in isolated envs. plugins: riverpod_lint: ^3.1.3 + dart_code_linter: ^4.1.2 analyzer: exclude: @@ -9,8 +11,6 @@ analyzer: - "**/*.freezed.dart" # Build artifacts — SwiftPM/CocoaPods copy plugin sources into build dirs # at the package root (build/) and under platform dirs (e.g. ios/build/). - # Both globs are needed: "build/**" only matches the root, "**/build/**" - # only matches nested copies. - "build/**" - "**/build/**" errors: @@ -18,24 +18,35 @@ analyzer: linter: rules: - # Widget construction - - prefer_const_constructors - - prefer_const_declarations - - prefer_const_literals_to_create_immutables - - avoid_unnecessary_containers - - sized_box_for_whitespace - - use_key_in_widget_constructors - # Code quality - - avoid_print - - avoid_void_async - - cancel_subscriptions - - prefer_final_locals - - prefer_single_quotes - - prefer_is_not_empty - - unnecessary_lambdas - - avoid_relative_lib_imports - - require_trailing_commas - - always_declare_return_types - - prefer_const_constructors_in_immutables - - avoid_redundant_argument_values - - unawaited_futures + # very_good_analysis re-enables cascade_invocations; keep it off — it fights + # the CustomPainter draw sequences (roasty.dart) and isn't auto-fixable. + cascade_invocations: false + +# dart_code_linter — magic-number + per-function/class size & complexity that the +# stock linter can't enforce. CustomPainter / animation-geometry and theme-token +# files are excluded from no-magic-number (their literals are coordinates/tokens). +dart_code_linter: + metrics: + cyclomatic-complexity: 20 + maximum-nesting-level: 5 + number-of-parameters: 5 + # 75 (not DCM's default 50): Flutter widget `build` methods legitimately run + # 50–60 lines of declarative tree. cyclomatic-complexity guards real logic. + source-lines-of-code: 75 + number-of-methods: 12 + metrics-exclude: + - test/** + - integration_test/** + # CustomPainter with many small _paintX helpers — method count is by design. + - lib/features/onboarding/presentation/widgets/roasty.dart + rules: + - no-magic-number: + severity: warning + allowed: [0, 1, 2] + exclude: + - lib/features/onboarding/presentation/widgets/roasty.dart + - lib/features/onboarding/presentation/loading/widgets/roasty_stage.dart + - lib/features/onboarding/presentation/loading/loading_animation.dart + - lib/shared/theme/app_spacing.dart + - lib/shared/theme/app_colors.dart + - lib/shared/theme/app_typography.dart diff --git a/coffee_quest/lib/core/constants/ad_unit_ids.dart b/coffee_quest/lib/core/constants/ad_unit_ids.dart index ff1f32d..09e73ba 100644 --- a/coffee_quest/lib/core/constants/ad_unit_ids.dart +++ b/coffee_quest/lib/core/constants/ad_unit_ids.dart @@ -1,3 +1,6 @@ +// Self-describing tokens / DTOs / storage infra; no per-member docs. +// ignore_for_file: public_member_api_docs + /// AdMob ad unit IDs. Use the test IDs during development; fill in the /// production IDs from the AdMob console before release. /// diff --git a/coffee_quest/lib/core/constants/app_strings.dart b/coffee_quest/lib/core/constants/app_strings.dart index 94f2aeb..658fdb5 100644 --- a/coffee_quest/lib/core/constants/app_strings.dart +++ b/coffee_quest/lib/core/constants/app_strings.dart @@ -1,3 +1,7 @@ +// Self-descriptive UI string constants — no per-member docs needed. +// ignore_for_file: public_member_api_docs + +/// User-facing string constants. abstract class AppStrings { static const appName = 'Coffee Quest'; static const tabLearn = 'Learn'; diff --git a/coffee_quest/lib/core/constants/route_names.dart b/coffee_quest/lib/core/constants/route_names.dart index 1ca9fc1..665cbb2 100644 --- a/coffee_quest/lib/core/constants/route_names.dart +++ b/coffee_quest/lib/core/constants/route_names.dart @@ -1,9 +1,23 @@ +/// Named-route path constants for the app's go_router configuration. abstract class RouteNames { + /// Learn tab root — the module list. static const learn = '/learn'; + + /// A single learning module's detail screen. static const moduleDetail = '/learn/module/:moduleId'; + + /// An immersive single-lesson flow. static const lesson = '/learn/lesson/:lessonId'; + + /// Path tab — the progress journey. static const path = '/path'; + + /// Cards tab — the collected-cards grid. static const cards = '/cards'; + + /// A single collected card's detail screen. static const cardDetail = '/cards/:cardId'; + + /// Profile tab. static const profile = '/profile'; } diff --git a/coffee_quest/lib/core/constants/xp_values.dart b/coffee_quest/lib/core/constants/xp_values.dart index 2e21c38..1b6b43e 100644 --- a/coffee_quest/lib/core/constants/xp_values.dart +++ b/coffee_quest/lib/core/constants/xp_values.dart @@ -1,3 +1,6 @@ +// Self-describing tokens / DTOs / storage infra; no per-member docs. +// ignore_for_file: public_member_api_docs + abstract class XpValues { static const int perStep = 10; static const int moduleCompletionBonus = 25; diff --git a/coffee_quest/lib/core/widgets/error_view.dart b/coffee_quest/lib/core/widgets/error_view.dart index 37a325c..02b2792 100644 --- a/coffee_quest/lib/core/widgets/error_view.dart +++ b/coffee_quest/lib/core/widgets/error_view.dart @@ -1,9 +1,14 @@ import 'package:flutter/material.dart'; +/// Centered error message with an optional retry button. class ErrorView extends StatelessWidget { + /// Creates an [ErrorView]. const ErrorView({required this.message, super.key, this.onRetry}); + /// The error message to display. final String message; + + /// Optional retry handler; when non-null a "Retry" button is shown. final VoidCallback? onRetry; @override diff --git a/coffee_quest/lib/core/widgets/link_button.dart b/coffee_quest/lib/core/widgets/link_button.dart index 433a8b1..7cae2f7 100644 --- a/coffee_quest/lib/core/widgets/link_button.dart +++ b/coffee_quest/lib/core/widgets/link_button.dart @@ -5,9 +5,13 @@ import 'package:flutter/material.dart'; /// Text-only accent button (e.g. "Already have progress? Restore"). Mirrors /// the `.btn-link` style from the design bundle. class LinkButton extends StatelessWidget { + /// Creates a [LinkButton]. const LinkButton({required this.label, required this.onPressed, super.key}); + /// Text shown on the button. final String label; + + /// Tap handler; `null` disables the button. final VoidCallback? onPressed; @override diff --git a/coffee_quest/lib/core/widgets/loading_indicator.dart b/coffee_quest/lib/core/widgets/loading_indicator.dart index bd6d671..972d612 100644 --- a/coffee_quest/lib/core/widgets/loading_indicator.dart +++ b/coffee_quest/lib/core/widgets/loading_indicator.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; +/// Centered circular progress indicator for loading states. class LoadingIndicator extends StatelessWidget { + /// Creates a [LoadingIndicator]. const LoadingIndicator({super.key}); @override diff --git a/coffee_quest/lib/core/widgets/pick_card.dart b/coffee_quest/lib/core/widgets/pick_card.dart index a7a3138..8e66731 100644 --- a/coffee_quest/lib/core/widgets/pick_card.dart +++ b/coffee_quest/lib/core/widgets/pick_card.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; /// Mirrors the `.pick-card` pattern from the design bundle: title + desc on /// the left, a circular indicator on the right that fills when selected. class PickCard extends StatelessWidget { + /// Creates a [PickCard]. const PickCard({ required this.title, required this.description, @@ -15,9 +16,16 @@ class PickCard extends StatelessWidget { super.key, }); + /// Card title (the option name). final String title; + + /// Supporting description shown under the title. final String description; + + /// Whether this card is the current selection. final bool selected; + + /// Called when the card is tapped. final VoidCallback onTap; @override @@ -63,13 +71,16 @@ class PickCard extends StatelessWidget { class _PickIndicator extends StatelessWidget { const _PickIndicator({required this.selected}); + static const double _size = 28; + static const double _innerDotSize = 14; + final bool selected; @override Widget build(BuildContext context) { return Container( - width: 28, - height: 28, + width: _size, + height: _size, decoration: BoxDecoration( shape: BoxShape.circle, border: Border.all( @@ -79,8 +90,8 @@ class _PickIndicator extends StatelessWidget { child: selected ? Center( child: Container( - width: 14, - height: 14, + width: _innerDotSize, + height: _innerDotSize, decoration: const BoxDecoration( shape: BoxShape.circle, color: AppColors.darkRoastAccent, diff --git a/coffee_quest/lib/core/widgets/primary_button.dart b/coffee_quest/lib/core/widgets/primary_button.dart index 4d1c213..2061120 100644 --- a/coffee_quest/lib/core/widgets/primary_button.dart +++ b/coffee_quest/lib/core/widgets/primary_button.dart @@ -8,13 +8,19 @@ import 'package:flutter/material.dart'; /// the affordance is still clearly visible against the dark-roast /// background — the prototype's 35% opacity fade is invisible on screen. class PrimaryButton extends StatelessWidget { + /// Creates a [PrimaryButton]. const PrimaryButton({ required this.label, required this.onPressed, super.key, }); + static const double _height = 52; + + /// Text shown on the button. final String label; + + /// Tap handler; `null` disables the button. final VoidCallback? onPressed; @override @@ -28,7 +34,7 @@ class PrimaryButton extends StatelessWidget { : AppColors.darkRoastInkMute; return SizedBox( width: double.infinity, - height: 52, + height: _height, child: FilledButton( onPressed: onPressed, style: FilledButton.styleFrom( diff --git a/coffee_quest/lib/core/widgets/section_header.dart b/coffee_quest/lib/core/widgets/section_header.dart index 1bebf8c..58df8b1 100644 --- a/coffee_quest/lib/core/widgets/section_header.dart +++ b/coffee_quest/lib/core/widgets/section_header.dart @@ -4,8 +4,10 @@ import 'package:flutter/material.dart'; /// /// Shared across screens to keep section labels visually consistent. class SectionHeader extends StatelessWidget { + /// Creates a [SectionHeader]. const SectionHeader(this.title, {super.key}); + /// The section heading text. final String title; @override diff --git a/coffee_quest/lib/core/widgets/smallcaps_label.dart b/coffee_quest/lib/core/widgets/smallcaps_label.dart index 5925668..f22b36f 100644 --- a/coffee_quest/lib/core/widgets/smallcaps_label.dart +++ b/coffee_quest/lib/core/widgets/smallcaps_label.dart @@ -6,9 +6,13 @@ import 'package:flutter/material.dart'; /// dividers throughout onboarding. Matches the `.smallcaps` style in the /// design bundle. class SmallcapsLabel extends StatelessWidget { + /// Creates a [SmallcapsLabel]. const SmallcapsLabel(this.text, {this.color, super.key}); + /// The label text (rendered uppercase). final String text; + + /// Optional text color; defaults to muted dark-roast ink. final Color? color; @override diff --git a/coffee_quest/lib/features/onboarding/presentation/welcome/welcome_screen.dart b/coffee_quest/lib/features/onboarding/presentation/welcome/welcome_screen.dart index ced1013..367210b 100644 --- a/coffee_quest/lib/features/onboarding/presentation/welcome/welcome_screen.dart +++ b/coffee_quest/lib/features/onboarding/presentation/welcome/welcome_screen.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:coffee_quest/core/widgets/link_button.dart'; import 'package:coffee_quest/core/widgets/primary_button.dart'; import 'package:coffee_quest/core/widgets/smallcaps_label.dart'; @@ -14,6 +16,8 @@ import 'package:video_player/video_player.dart'; class WelcomeScreen extends ConsumerWidget { const WelcomeScreen({super.key}); + static const double _heroFrameRadius = 4; + @override Widget build(BuildContext context, WidgetRef ref) { return Scaffold( @@ -37,7 +41,7 @@ class WelcomeScreen extends ConsumerWidget { decoration: BoxDecoration( color: AppColors.darkRoastSurface, border: Border.all(color: AppColors.darkRoastRule), - borderRadius: BorderRadius.circular(4), + borderRadius: BorderRadius.circular(_heroFrameRadius), ), clipBehavior: Clip.hardEdge, child: const _VideoHero(), @@ -95,26 +99,29 @@ class _VideoHeroState extends State<_VideoHero> { @override void initState() { super.initState(); - _controller = - VideoPlayerController.asset('assets/video/Flowerpot_seed_to.mp4') - ..setLooping(true) - ..setVolume(0) - ..initialize().then( - (_) { - if (mounted) { - setState(() {}); - _controller.play(); - } - }, - onError: (_) { - if (mounted) setState(() => _initFailed = true); - }, - ); + _controller = VideoPlayerController.asset( + 'assets/video/Flowerpot_seed_to.mp4', + ); + unawaited(_controller.setLooping(true)); + unawaited(_controller.setVolume(0)); + unawaited( + _controller.initialize().then( + (_) { + if (mounted) { + setState(() {}); + unawaited(_controller.play()); + } + }, + onError: (_) { + if (mounted) setState(() => _initFailed = true); + }, + ), + ); } @override void dispose() { - _controller.dispose(); + unawaited(_controller.dispose()); super.dispose(); } diff --git a/coffee_quest/lib/features/onboarding/presentation/widgets/roasty.dart b/coffee_quest/lib/features/onboarding/presentation/widgets/roasty.dart index 3212215..a712461 100644 --- a/coffee_quest/lib/features/onboarding/presentation/widgets/roasty.dart +++ b/coffee_quest/lib/features/onboarding/presentation/widgets/roasty.dart @@ -209,7 +209,7 @@ class _RoastyPainter extends CustomPainter { switch (state) { case RoastyState.module: // grow: 1 → 1.12 → 1.05 - if (t < 0.4) return 1 + (0.12) * (t / 0.4); + if (t < 0.4) return 1 + 0.12 * (t / 0.4); return 1.12 - 0.07 * ((t - 0.4) / 0.6); case RoastyState.awake: // blink-pop: 0.94 → 1.04 → 1 @@ -220,7 +220,7 @@ class _RoastyPainter extends CustomPainter { final v = math.sin(t * math.pi * 2); return 1.0 + 0.005 * v; default: - return 1.0; + return 1; } } @@ -271,7 +271,7 @@ class _RoastyPainter extends CustomPainter { } void _paintCardGlow(Canvas canvas) { - final pulse = (math.sin(t * math.pi * 2) * 0.5 + 0.5); + final pulse = math.sin(t * math.pi * 2) * 0.5 + 0.5; final gradient = RadialGradient( colors: [ const Color(0xFFE6C68A).withValues(alpha: 0.6 * pulse), @@ -426,29 +426,21 @@ class _RoastyPainter extends CustomPainter { switch (state) { case RoastyState.idle: _paintIdleFace(canvas); - break; case RoastyState.correct: case RoastyState.lesson: _paintHappyFace(canvas); - break; case RoastyState.wrong: _paintWrongFace(canvas); - break; case RoastyState.module: _paintModuleFace(canvas); - break; case RoastyState.xp: _paintXpFace(canvas); - break; case RoastyState.card: _paintCardFace(canvas); - break; case RoastyState.sleep: _paintSleepFace(canvas); - break; case RoastyState.awake: _paintAwakeFace(canvas); - break; } canvas.restore(); @@ -459,20 +451,15 @@ class _RoastyPainter extends CustomPainter { switch (state) { case RoastyState.correct: _paintSparkles(canvas); - break; case RoastyState.lesson: case RoastyState.module: _paintConfetti(canvas); - break; case RoastyState.xp: _paintXpBurst(canvas); - break; case RoastyState.wrong: _paintWrongBadge(canvas); - break; case RoastyState.sleep: _paintSleepZzz(canvas); - break; default: break; } diff --git a/coffee_quest/lib/features/onboarding/presentation/widgets/roasty_state.dart b/coffee_quest/lib/features/onboarding/presentation/widgets/roasty_state.dart index ccc2cd5..4aab236 100644 --- a/coffee_quest/lib/features/onboarding/presentation/widgets/roasty_state.dart +++ b/coffee_quest/lib/features/onboarding/presentation/widgets/roasty_state.dart @@ -1,3 +1,6 @@ +// Self-descriptive mascot-state enum. +// ignore_for_file: public_member_api_docs + /// All visual states the Roasty mascot can render. Mirrors the /// `data-state="…"` enum used by the design-bundle prototype /// (coffee_quest/brew-path-app/project/roasty.jsx). diff --git a/coffee_quest/lib/shared/storage/app_database.dart b/coffee_quest/lib/shared/storage/app_database.dart index 51e6c14..cdfed29 100644 --- a/coffee_quest/lib/shared/storage/app_database.dart +++ b/coffee_quest/lib/shared/storage/app_database.dart @@ -1,3 +1,6 @@ +// Self-describing tokens / DTOs / storage infra; no per-member docs. +// ignore_for_file: public_member_api_docs + import 'package:coffee_quest/shared/repositories/settings_repository.dart' show SettingsRepository; import 'package:drift/drift.dart'; import 'package:drift_flutter/drift_flutter.dart'; diff --git a/coffee_quest/lib/shared/storage/card_record.dart b/coffee_quest/lib/shared/storage/card_record.dart index e51ebb8..961da65 100644 --- a/coffee_quest/lib/shared/storage/card_record.dart +++ b/coffee_quest/lib/shared/storage/card_record.dart @@ -1,3 +1,6 @@ +// Self-describing tokens / DTOs / storage infra; no per-member docs. +// ignore_for_file: public_member_api_docs + import 'package:coffee_quest/shared/storage/progress_record.dart' show ProgressRecord; /// Mutable DTO for a collected-card row. See [ProgressRecord] for rationale. diff --git a/coffee_quest/lib/shared/storage/module_progress_record.dart b/coffee_quest/lib/shared/storage/module_progress_record.dart index 4816864..29ec379 100644 --- a/coffee_quest/lib/shared/storage/module_progress_record.dart +++ b/coffee_quest/lib/shared/storage/module_progress_record.dart @@ -1,3 +1,6 @@ +// Self-describing tokens / DTOs / storage infra; no per-member docs. +// ignore_for_file: public_member_api_docs + /// Mutable DTO for a module-progress row. A row exists only once a module's /// completion XP has been granted, so its presence is the "awarded" ledger. class ModuleProgressRecord { diff --git a/coffee_quest/lib/shared/storage/progress_record.dart b/coffee_quest/lib/shared/storage/progress_record.dart index 71594d7..553a159 100644 --- a/coffee_quest/lib/shared/storage/progress_record.dart +++ b/coffee_quest/lib/shared/storage/progress_record.dart @@ -1,3 +1,6 @@ +// Mutable DTO — fields are self-describing. +// ignore_for_file: public_member_api_docs + /// Mutable data-transfer object for a lesson-completion row. Decoupled from /// the Drift table so callers can mutate freely; the repository maps to/from /// Drift companions. diff --git a/coffee_quest/lib/shared/storage/settings_record.dart b/coffee_quest/lib/shared/storage/settings_record.dart index b4c7181..bf791ae 100644 --- a/coffee_quest/lib/shared/storage/settings_record.dart +++ b/coffee_quest/lib/shared/storage/settings_record.dart @@ -1,3 +1,6 @@ +// Mutable DTO — fields are self-describing. +// ignore_for_file: public_member_api_docs + /// Mutable DTO for the singleton settings row. Callers (e.g. /// LessonCompletionService) mutate fields in place; the repository maps /// to/from the Drift companion. diff --git a/coffee_quest/lib/shared/theme/app_colors.dart b/coffee_quest/lib/shared/theme/app_colors.dart index 2fcc05f..7f218a4 100644 --- a/coffee_quest/lib/shared/theme/app_colors.dart +++ b/coffee_quest/lib/shared/theme/app_colors.dart @@ -1,31 +1,69 @@ import 'package:flutter/material.dart'; +/// Centralized color tokens for the app. Feature code should reference these +/// rather than inlining `Color(0x…)` literals. abstract class AppColors { - // Legacy seed tokens retained for screens that haven't been re-tokenized. + /// Legacy primary brown — retained for screens not yet re-tokenized. static const coffeeBrown = Color(0xFF6B3A2A); + + /// Legacy darkest brown accent. static const espresso = Color(0xFF3B1F0F); + + /// Legacy light tan. static const latte = Color(0xFFD4A574); + + /// Legacy cream fill. static const cream = Color(0xFFF5E6D3); + + /// Legacy off-white page surface. static const surface = Color(0xFFFAF7F4); + + /// Disabled / locked element gray. static const locked = Color(0xFFBDBDBD); // ── Dark-roast palette (onboarding + mascot screens) ─────────── // Sourced 1:1 from the design bundle CSS at // coffee_quest/brew-path-app/project/index.html [data-mood="dark-roast"]. + + /// Page background for dark-roast screens. static const darkRoastBg = Color(0xFF1A130E); + + /// Card/panel surface on the dark-roast background. static const darkRoastSurface = Color(0xFF251B14); + + /// Raised secondary surface (one step lighter than [darkRoastSurface]). static const darkRoastSurface2 = Color(0xFF30231A); + + /// Primary text/ink on dark-roast surfaces. static const darkRoastInk = Color(0xFFF3E7D2); + + /// Muted/secondary text on dark-roast surfaces. static const darkRoastInkMute = Color(0xFFB59E84); + + /// Hairline / divider rule on dark-roast surfaces. static const darkRoastRule = Color(0xFF44321E); + + /// Primary accent (CTAs, highlights) on dark-roast screens. static const darkRoastAccent = Color(0xFFE07A4F); + + /// Ink color for content placed on the accent fill. static const darkRoastAccentInk = Color(0xFF1A130E); + + /// Sage-green accent (sprout / growth motifs). static const darkRoastSage = Color(0xFF97A285); + + /// Warning / caution accent. static const darkRoastWarn = Color(0xFFE6A35C); + + /// Berry accent used for error/negative states. static const darkRoastBerry = Color(0xFFC75450); + + /// Cream tint used for subtle highlights. static const darkRoastCreamTint = Color(0xFFF0DCB8); - // Water-drop accent used by the loading-screen brew animation. + /// Water-drop fill for the loading-screen brew animation. static const darkRoastWaterDrop = Color(0xFF6FA3C8); + + /// Water-drop highlight for the loading-screen brew animation. static const darkRoastWaterDropHi = Color(0xFFA9CFE3); } diff --git a/coffee_quest/lib/shared/theme/app_spacing.dart b/coffee_quest/lib/shared/theme/app_spacing.dart index 96d0599..3db561d 100644 --- a/coffee_quest/lib/shared/theme/app_spacing.dart +++ b/coffee_quest/lib/shared/theme/app_spacing.dart @@ -1,13 +1,28 @@ /// Vertical/horizontal spacing stops shared across onboarding screens. /// Values match the design-bundle CSS (24-px gutter, 12/14/16/24/32 stops). abstract class AppSpacing { + /// 4 px — hairline gaps. static const double xxs = 4; + + /// 8 px — tight gaps between related elements. static const double xs = 8; + + /// 12 px. static const double sm = 12; + + /// 14 px — default text/element rhythm. static const double base = 14; + + /// 16 px — standard content padding. static const double md = 16; + + /// 24 px — section spacing. static const double lg = 24; + + /// 32 px — large block separation. static const double xl = 32; + + /// 48 px — hero / major section spacing. static const double xxl = 48; /// Horizontal screen gutter used by `.px-24` in the prototype. diff --git a/coffee_quest/lib/shared/theme/app_typography.dart b/coffee_quest/lib/shared/theme/app_typography.dart index 9f3880e..d72cc64 100644 --- a/coffee_quest/lib/shared/theme/app_typography.dart +++ b/coffee_quest/lib/shared/theme/app_typography.dart @@ -1,3 +1,6 @@ +// Self-describing tokens / DTOs / storage infra; no per-member docs. +// ignore_for_file: public_member_api_docs + import 'package:coffee_quest/shared/theme/app_colors.dart'; import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; From 927c0927501bf0a23d23da630a9aec3682b39933 Mon Sep 17 00:00:00 2001 From: maximsan Date: Wed, 10 Jun 2026 08:59:44 +0200 Subject: [PATCH 3/8] chore(lint): core widgets documented, magic numbers extracted to named constants part 2 --- .../lib/app/analytics_navigator_observer.dart | 7 +++++- coffee_quest/lib/app/app.dart | 2 ++ coffee_quest/lib/app/app_bootstrap.dart | 2 ++ coffee_quest/lib/app/app_shell.dart | 2 ++ coffee_quest/lib/app/app_theme.dart | 2 ++ .../lib/core/errors/app_exception.dart | 8 +++++++ coffee_quest/lib/core/utils/date_utils.dart | 1 + .../cards/domain/cards_providers.dart | 4 ++++ .../presentation/card_grid_item_widget.dart | 17 +++++++++---- .../learn/domain/learn_providers.dart | 16 +++++++++++++ .../presentation/module_card_widget.dart | 22 ++++++++++++----- .../domain/lesson_completion_service.dart | 19 +++++++++++++++ .../lesson_completion_screen.dart | 4 +++- .../lessons/presentation/lesson_screen.dart | 3 ++- .../mini_games/domain/mini_game_result.dart | 6 +++++ .../presentation/drag_drop_game.dart | 10 +++++--- .../presentation/lesson_step_runner.dart | 8 ++++++- .../presentation/multiple_choice_game.dart | 4 +++- .../mini_games/presentation/slider_game.dart | 5 ++++ .../data/onboarding_repository.dart | 9 +++++++ .../brewer/brewer_controller.dart | 12 +++++++--- .../presentation/goal/goal_controller.dart | 9 +++++-- .../loading/loading_animation.dart | 15 ++++++++++-- .../loading/wake_sequence_controller.dart | 6 ++++- .../loading/widgets/pulsing_dots.dart | 6 ++++- .../loading/widgets/roasty_stage.dart | 16 ++++++++----- .../presentation/onboarding_providers.dart | 4 ++++ .../presentation/welcome/welcome_screen.dart | 1 + .../presentation/widgets/roasty.dart | 24 +++++++++++++++---- .../presentation/path_module_node_widget.dart | 5 +++- .../profile/domain/settings_providers.dart | 3 +++ .../presentation/widgets/preference_tile.dart | 13 ++++++++-- .../presentation/widgets/premium_card.dart | 8 ++++++- coffee_quest/lib/main.dart | 5 +++- .../remote_config/remote_config_keys.dart | 5 ++++ .../remote_config/remote_config_provider.dart | 1 + .../remote_config/remote_config_service.dart | 9 +++++++ .../lib/shared/models/coffee_card_model.dart | 3 +++ .../lib/shared/models/lesson_model.dart | 6 ++++- .../lib/shared/models/module_model.dart | 3 +++ .../shared/repositories/card_repository.dart | 3 +++ .../repositories/content_repository.dart | 6 +++++ .../repositories/progress_repository.dart | 5 ++++ .../repositories/repository_providers.dart | 4 ++++ .../repositories/settings_repository.dart | 4 ++++ .../lib/shared/storage/app_database.dart | 3 ++- .../lib/shared/storage/card_record.dart | 3 ++- .../storage/module_progress_record.dart | 4 +++- .../lib/shared/storage/settings_record.dart | 7 +++++- 49 files changed, 296 insertions(+), 48 deletions(-) diff --git a/coffee_quest/lib/app/analytics_navigator_observer.dart b/coffee_quest/lib/app/analytics_navigator_observer.dart index 2cc2e6e..6badc71 100644 --- a/coffee_quest/lib/app/analytics_navigator_observer.dart +++ b/coffee_quest/lib/app/analytics_navigator_observer.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:coffee_quest/services/analytics/analytics_service.dart'; import 'package:flutter/widgets.dart'; @@ -5,13 +7,16 @@ import 'package:flutter/widgets.dart'; /// via `observers`. The injected service is the NoOp impl until Firebase lands /// in Phase 8, so this is currently inert but exercises the call path. class AnalyticsNavigatorObserver extends NavigatorObserver { + /// Creates an [AnalyticsNavigatorObserver] over an [AnalyticsService]. AnalyticsNavigatorObserver(this._analytics); final AnalyticsService _analytics; void _log(Route? route) { final name = route?.settings.name; - if (name != null) _analytics.logScreen(name); + if (name != null) { + unawaited(_analytics.logScreen(name)); + } } @override diff --git a/coffee_quest/lib/app/app.dart b/coffee_quest/lib/app/app.dart index 1ada036..a61b94e 100644 --- a/coffee_quest/lib/app/app.dart +++ b/coffee_quest/lib/app/app.dart @@ -3,7 +3,9 @@ import 'package:coffee_quest/app/app_theme.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +/// Root application widget — wires the router and theme into MaterialApp. class CoffeeQuestApp extends ConsumerWidget { + /// Creates a [CoffeeQuestApp]. const CoffeeQuestApp({super.key}); @override diff --git a/coffee_quest/lib/app/app_bootstrap.dart b/coffee_quest/lib/app/app_bootstrap.dart index f21d546..9881495 100644 --- a/coffee_quest/lib/app/app_bootstrap.dart +++ b/coffee_quest/lib/app/app_bootstrap.dart @@ -3,7 +3,9 @@ import 'package:coffee_quest/services/remote_config/firebase_remote_config_servi import 'package:coffee_quest/shared/storage/app_database.dart'; import 'package:firebase_core/firebase_core.dart'; +/// One-time app initialization (database, and Firebase when enabled). class AppBootstrap { + /// Initializes the database and, when [kUseFirebase] is set, Firebase. static Future initialize() async { AppDatabaseService.instance = AppDatabase(); diff --git a/coffee_quest/lib/app/app_shell.dart b/coffee_quest/lib/app/app_shell.dart index 97c9937..2176b7d 100644 --- a/coffee_quest/lib/app/app_shell.dart +++ b/coffee_quest/lib/app/app_shell.dart @@ -6,8 +6,10 @@ import 'package:go_router/go_router.dart'; /// branch keeps its own navigator stack, so tab state and scroll position /// survive switching tabs. class AppShell extends StatelessWidget { + /// Creates an [AppShell] around [navigationShell]. const AppShell(this.navigationShell, {super.key}); + /// The shell that manages the four bottom-nav branches. final StatefulNavigationShell navigationShell; void _onDestinationSelected(int index) { diff --git a/coffee_quest/lib/app/app_theme.dart b/coffee_quest/lib/app/app_theme.dart index d9e1639..a1b60e7 100644 --- a/coffee_quest/lib/app/app_theme.dart +++ b/coffee_quest/lib/app/app_theme.dart @@ -2,7 +2,9 @@ import 'package:coffee_quest/shared/theme/app_colors.dart'; import 'package:coffee_quest/shared/theme/app_typography.dart'; import 'package:flutter/material.dart'; +/// App-wide Material 3 theme (dark-roast palette). abstract class AppTheme { + /// The app's theme data. static ThemeData get lightTheme => ThemeData( useMaterial3: true, brightness: Brightness.dark, diff --git a/coffee_quest/lib/core/errors/app_exception.dart b/coffee_quest/lib/core/errors/app_exception.dart index daa2638..e316837 100644 --- a/coffee_quest/lib/core/errors/app_exception.dart +++ b/coffee_quest/lib/core/errors/app_exception.dart @@ -1,12 +1,20 @@ +/// Base type for app-domain exceptions. sealed class AppException implements Exception { + /// Creates an [AppException] with a human-readable [message]. const AppException(this.message); + + /// Human-readable error message. final String message; } +/// Thrown when bundled content fails to load or parse. final class ContentLoadException extends AppException { + /// Creates a [ContentLoadException]. const ContentLoadException(super.message); } +/// Thrown when a persistence (Drift) operation fails. final class PersistenceException extends AppException { + /// Creates a [PersistenceException]. const PersistenceException(super.message); } diff --git a/coffee_quest/lib/core/utils/date_utils.dart b/coffee_quest/lib/core/utils/date_utils.dart index 6c769be..d040526 100644 --- a/coffee_quest/lib/core/utils/date_utils.dart +++ b/coffee_quest/lib/core/utils/date_utils.dart @@ -2,6 +2,7 @@ /// local `DateTime` — the streak rule only cares about whole local days. DateTime dateOnly(DateTime d) => DateTime(d.year, d.month, d.day); +/// Whether [a] and [b] fall on the same local calendar day. bool isSameDay(DateTime a, DateTime b) => a.year == b.year && a.month == b.month && a.day == b.day; diff --git a/coffee_quest/lib/features/cards/domain/cards_providers.dart b/coffee_quest/lib/features/cards/domain/cards_providers.dart index 2e30731..3d804dc 100644 --- a/coffee_quest/lib/features/cards/domain/cards_providers.dart +++ b/coffee_quest/lib/features/cards/domain/cards_providers.dart @@ -8,9 +8,13 @@ part 'cards_providers.g.dart'; /// A content card paired with whether the user has collected it. Derived /// read-side value — not persisted or serialized. class CardWithCollection { + /// Creates a [CardWithCollection]. const CardWithCollection({required this.card, required this.isCollected}); + /// The content card. final CoffeeCardModel card; + + /// Whether the user has collected [card]. final bool isCollected; } diff --git a/coffee_quest/lib/features/cards/presentation/card_grid_item_widget.dart b/coffee_quest/lib/features/cards/presentation/card_grid_item_widget.dart index 1ef305d..f1971c1 100644 --- a/coffee_quest/lib/features/cards/presentation/card_grid_item_widget.dart +++ b/coffee_quest/lib/features/cards/presentation/card_grid_item_widget.dart @@ -6,10 +6,17 @@ import 'package:go_router/go_router.dart'; /// One tile in the Cards grid. Collected → category icon badge, title, and /// tag, tappable; locked → muted silhouette with "???" and inert. class CardGridItemWidget extends StatelessWidget { + /// Creates a [CardGridItemWidget]. const CardGridItemWidget({required this.item, super.key}); + /// The card paired with its collected state. final CardWithCollection item; + static const double _cornerRadius = 12; + static const double _badgeSize = 56; + static const double _badgeRadius = 14; + static const double _iconSize = 28; + @override Widget build(BuildContext context) { final collected = item.isCollected; @@ -20,27 +27,27 @@ class CardGridItemWidget extends StatelessWidget { margin: EdgeInsets.zero, child: InkWell( onTap: collected ? () => context.go('/cards/${item.card.id}') : null, - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(_cornerRadius), child: Padding( padding: const EdgeInsets.all(12), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Container( - width: 56, - height: 56, + width: _badgeSize, + height: _badgeSize, alignment: Alignment.center, decoration: BoxDecoration( color: collected ? colors.primaryContainer : colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(14), + borderRadius: BorderRadius.circular(_badgeRadius), ), child: Icon( collected ? moduleIcon(item.card.iconName) : Icons.help_outline, - size: 28, + size: _iconSize, color: collected ? colors.onPrimaryContainer : colors.onSurfaceVariant, diff --git a/coffee_quest/lib/features/learn/domain/learn_providers.dart b/coffee_quest/lib/features/learn/domain/learn_providers.dart index 4b8b10c..a0f38f1 100644 --- a/coffee_quest/lib/features/learn/domain/learn_providers.dart +++ b/coffee_quest/lib/features/learn/domain/learn_providers.dart @@ -11,6 +11,7 @@ part 'learn_providers.g.dart'; /// A module paired with its derived completion state. Not persisted or /// serialized — purely a read-side view value for the Learn screen. class ModuleWithProgress { + /// Creates a [ModuleWithProgress]. const ModuleWithProgress({ required this.module, required this.completedCount, @@ -18,16 +19,23 @@ class ModuleWithProgress { required this.isLocked, }); + /// The underlying module. final ModuleModel module; + + /// Number of completed lessons in the module. final int completedCount; + + /// Total number of lessons in the module. final int totalCount; /// Locked until the module named by `unlockRequirement` is fully complete. /// The first module (no `unlockRequirement`) is always unlocked. final bool isLocked; + /// Whether every lesson in the module is complete. bool get isComplete => totalCount > 0 && completedCount >= totalCount; + /// Completion fraction in the range 0..1. double get progress => totalCount == 0 ? 0 : completedCount / totalCount; } @@ -55,6 +63,7 @@ Future> modulesWithProgress(Ref ref) async { }).toList(); } +/// The next uncompleted lesson in order, or null if all are complete. @riverpod Future todayLesson(Ref ref) async { final content = ref.watch(contentRepositoryProvider); @@ -71,15 +80,20 @@ Future todayLesson(Ref ref) async { } } } + return null; } /// One row per lesson plus its owning module — used by the Learn screen's /// "Practice Any Lesson" section to render a grouped, all-lessons list. class LessonWithModule { + /// Creates a [LessonWithModule]. const LessonWithModule({required this.lesson, required this.module}); + /// The lesson. final LessonModel lesson; + + /// The lesson's owning module. final ModuleModel module; } @@ -101,6 +115,7 @@ Future> allLessonsWithModule(Ref ref) async { } } } + return out; } @@ -146,5 +161,6 @@ const gameTypeLabels = <(String, String)>[ ('slider', 'Slider'), ]; +/// Returns the user-facing label for a game-type [key]. String gameTypeDisplayName(String key) => gameTypeLabels.firstWhere((e) => e.$1 == key, orElse: () => (key, key)).$2; diff --git a/coffee_quest/lib/features/learn/presentation/module_card_widget.dart b/coffee_quest/lib/features/learn/presentation/module_card_widget.dart index 017340f..7f134d7 100644 --- a/coffee_quest/lib/features/learn/presentation/module_card_widget.dart +++ b/coffee_quest/lib/features/learn/presentation/module_card_widget.dart @@ -8,10 +8,14 @@ import 'package:go_router/go_router.dart'; /// progress, and lock state. Locked taps surface the unlock hint instead of /// navigating. class ModuleCardWidget extends StatelessWidget { + /// Creates a [ModuleCardWidget]. const ModuleCardWidget({required this.item, super.key}); + /// The module paired with its derived progress state. final ModuleWithProgress item; + static const double _cornerRadius = 12; + void _onTap(BuildContext context) { if (item.isLocked) { ScaffoldMessenger.of(context) @@ -36,7 +40,7 @@ class ModuleCardWidget extends StatelessWidget { margin: EdgeInsets.zero, child: InkWell( onTap: () => _onTap(context), - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(_cornerRadius), child: Padding( padding: const EdgeInsets.all(16), child: Row( @@ -93,6 +97,9 @@ class _ModuleBadge extends StatelessWidget { final bool locked; final bool complete; + static const double _size = 48; + static const double _radius = 12; + @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; @@ -110,12 +117,12 @@ class _ModuleBadge extends StatelessWidget { } return Container( - width: 48, - height: 48, + width: _size, + height: _size, alignment: Alignment.center, decoration: BoxDecoration( color: background, - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(_radius), ), child: Icon(icon, color: foreground), ); @@ -129,6 +136,9 @@ class _ModuleStatus extends StatelessWidget { final ModuleWithProgress item; + static const double _barHeight = 6; + static const double _barRadius = 3; + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -153,8 +163,8 @@ class _ModuleStatus extends StatelessWidget { const SizedBox(height: 6), LinearProgressIndicator( value: item.progress, - minHeight: 6, - borderRadius: BorderRadius.circular(3), + minHeight: _barHeight, + borderRadius: BorderRadius.circular(_barRadius), backgroundColor: colors.surfaceContainerHighest, color: colors.primary, ), diff --git a/coffee_quest/lib/features/lessons/domain/lesson_completion_service.dart b/coffee_quest/lib/features/lessons/domain/lesson_completion_service.dart index 4fff5c2..0630e5b 100644 --- a/coffee_quest/lib/features/lessons/domain/lesson_completion_service.dart +++ b/coffee_quest/lib/features/lessons/domain/lesson_completion_service.dart @@ -17,6 +17,7 @@ part 'lesson_completion_service.g.dart'; /// banked, split so the completion screen can show the module-completion bonus /// separately from the lesson's own reward. class LessonCompletionResult { + /// Creates a [LessonCompletionResult]. const LessonCompletionResult({ required this.lessonXp, required this.moduleBonusXp, @@ -39,6 +40,7 @@ class LessonCompletionResult { /// Outcome of [LessonCompletionService.reviewLesson] — a review never re-awards /// full lesson or module XP; it only updates mastery and may grant practice XP. class LessonReviewResult { + /// Creates a [LessonReviewResult]. const LessonReviewResult({ required this.bestScore, required this.practiceXpAwarded, @@ -56,6 +58,7 @@ class LessonReviewResult { /// bonus once every lesson in the module is done. Idempotent — replaying a /// completed lesson is a no-op so XP and cards are never double-counted. class LessonCompletionService { + /// Creates a [LessonCompletionService]. const LessonCompletionService({ required this.progressRepository, required this.settingsRepository, @@ -67,13 +70,28 @@ class LessonCompletionService { required this.streakService, }); + /// Lesson-completion records. final ProgressRepository progressRepository; + + /// User settings (XP, streak, preferences). final SettingsRepository settingsRepository; + + /// Collected coffee cards. final CardRepository cardRepository; + + /// Content (modules and lessons). final ContentRepository contentRepository; + + /// Per-module "bonus awarded" ledger. final ModuleProgressRepository moduleProgressRepository; + + /// Analytics sink. final AnalyticsService analyticsService; + + /// XP calculations. final XpService xpService; + + /// Streak calculations. final StreakService streakService; /// First completion of [lesson]. [score] is the run's first-try accuracy @@ -226,6 +244,7 @@ class LessonCompletionService { DateTime _dateOnly(DateTime d) => DateTime(d.year, d.month, d.day); } +/// Provides the [LessonCompletionService] with its dependencies wired in. @riverpod LessonCompletionService lessonCompletionService(Ref ref) => LessonCompletionService( diff --git a/coffee_quest/lib/features/lessons/presentation/lesson_completion_screen.dart b/coffee_quest/lib/features/lessons/presentation/lesson_completion_screen.dart index fcfe45b..50651d3 100644 --- a/coffee_quest/lib/features/lessons/presentation/lesson_completion_screen.dart +++ b/coffee_quest/lib/features/lessons/presentation/lesson_completion_screen.dart @@ -24,7 +24,9 @@ class _Reward { class LessonCompletionScreen extends ConsumerStatefulWidget { const LessonCompletionScreen({ - required this.lessonId, required this.score, super.key, + required this.lessonId, + required this.score, + super.key, this.review = false, this.practice = false, }); diff --git a/coffee_quest/lib/features/lessons/presentation/lesson_screen.dart b/coffee_quest/lib/features/lessons/presentation/lesson_screen.dart index cf04bea..5af4f6f 100644 --- a/coffee_quest/lib/features/lessons/presentation/lesson_screen.dart +++ b/coffee_quest/lib/features/lessons/presentation/lesson_screen.dart @@ -11,7 +11,8 @@ import 'package:go_router/go_router.dart'; class LessonScreen extends ConsumerStatefulWidget { const LessonScreen({ - required this.lessonId, super.key, + required this.lessonId, + super.key, this.review = false, this.practice = false, }); diff --git a/coffee_quest/lib/features/mini_games/domain/mini_game_result.dart b/coffee_quest/lib/features/mini_games/domain/mini_game_result.dart index 3dfacdf..1edf402 100644 --- a/coffee_quest/lib/features/mini_games/domain/mini_game_result.dart +++ b/coffee_quest/lib/features/mini_games/domain/mini_game_result.dart @@ -1,15 +1,21 @@ /// Outcome a mini-game emits back to the lesson runner. Sealed so the runner /// can switch exhaustively. sealed class MiniGameResult { + /// Creates a [MiniGameResult]. const MiniGameResult(); } +/// The user answered correctly. class MiniGameCorrect extends MiniGameResult { + /// Creates a [MiniGameCorrect]. const MiniGameCorrect(); } +/// The user answered incorrectly, optionally with a [hint]. class MiniGameIncorrect extends MiniGameResult { + /// Creates a [MiniGameIncorrect]. const MiniGameIncorrect({this.hint}); + /// Optional hint to show the user. final String? hint; } diff --git a/coffee_quest/lib/features/mini_games/presentation/drag_drop_game.dart b/coffee_quest/lib/features/mini_games/presentation/drag_drop_game.dart index 2f2432c..5c4825f 100644 --- a/coffee_quest/lib/features/mini_games/presentation/drag_drop_game.dart +++ b/coffee_quest/lib/features/mini_games/presentation/drag_drop_game.dart @@ -2,13 +2,17 @@ import 'package:coffee_quest/features/mini_games/domain/mini_game_result.dart'; import 'package:coffee_quest/shared/models/lesson_step_model.dart'; import 'package:flutter/material.dart'; -/// Match terms[i] ↔ definitions[i] by dragging. A drop is only accepted when -/// the term index equals the definition index; wrong drops bounce back with no -/// penalty. Auto-completes when every term is correctly placed. +/// Match `terms[i]` ↔ `definitions[i]` by dragging. A drop is only accepted +/// when the term index equals the definition index; wrong drops bounce back +/// with no penalty. Auto-completes when every term is correctly placed. class DragDropGame extends StatefulWidget { + /// Creates a [DragDropGame]. const DragDropGame({required this.step, required this.onResult, super.key}); + /// The drag-drop step's content/config. final DragDropStep step; + + /// Called with the [MiniGameResult] when the user answers. final void Function(MiniGameResult) onResult; @override diff --git a/coffee_quest/lib/features/mini_games/presentation/lesson_step_runner.dart b/coffee_quest/lib/features/mini_games/presentation/lesson_step_runner.dart index 647edb5..6a9022d 100644 --- a/coffee_quest/lib/features/mini_games/presentation/lesson_step_runner.dart +++ b/coffee_quest/lib/features/mini_games/presentation/lesson_step_runner.dart @@ -10,11 +10,17 @@ import 'package:flutter/material.dart'; /// union makes the switch exhaustive — adding a variant is a compile error /// until a case is added here. class LessonStepRunner extends StatelessWidget { + /// Creates a [LessonStepRunner]. const LessonStepRunner({ - required this.step, required this.onResult, super.key, + required this.step, + required this.onResult, + super.key, }); + /// The lesson step to render. final LessonStepModel step; + + /// Called with the [MiniGameResult] when the step is answered. final void Function(MiniGameResult result) onResult; @override diff --git a/coffee_quest/lib/features/mini_games/presentation/multiple_choice_game.dart b/coffee_quest/lib/features/mini_games/presentation/multiple_choice_game.dart index 3e4d321..8c9d326 100644 --- a/coffee_quest/lib/features/mini_games/presentation/multiple_choice_game.dart +++ b/coffee_quest/lib/features/mini_games/presentation/multiple_choice_game.dart @@ -5,7 +5,9 @@ import 'package:flutter/material.dart'; class MultipleChoiceGame extends StatefulWidget { const MultipleChoiceGame({ - required this.step, required this.onResult, super.key, + required this.step, + required this.onResult, + super.key, }); final MultipleChoiceStep step; diff --git a/coffee_quest/lib/features/mini_games/presentation/slider_game.dart b/coffee_quest/lib/features/mini_games/presentation/slider_game.dart index 5149baa..5de5bdd 100644 --- a/coffee_quest/lib/features/mini_games/presentation/slider_game.dart +++ b/coffee_quest/lib/features/mini_games/presentation/slider_game.dart @@ -3,10 +3,15 @@ import 'package:coffee_quest/features/mini_games/domain/mini_game_result.dart'; import 'package:coffee_quest/shared/models/lesson_step_model.dart'; import 'package:flutter/material.dart'; +/// Slider mini-game: the user drags a value into the target range. class SliderGame extends StatefulWidget { + /// Creates a [SliderGame]. const SliderGame({required this.step, required this.onResult, super.key}); + /// The slider step's content/config. final SliderStep step; + + /// Called with the [MiniGameResult] when the user answers. final void Function(MiniGameResult) onResult; @override diff --git a/coffee_quest/lib/features/onboarding/data/onboarding_repository.dart b/coffee_quest/lib/features/onboarding/data/onboarding_repository.dart index 6bf1ef8..08d45e6 100644 --- a/coffee_quest/lib/features/onboarding/data/onboarding_repository.dart +++ b/coffee_quest/lib/features/onboarding/data/onboarding_repository.dart @@ -3,24 +3,32 @@ import 'package:coffee_quest/shared/repositories/settings_repository.dart'; /// Snapshot of the onboarding gate. `completed=false` means the user has not /// yet finished the post-install flow and must be sent through it on launch. class OnboardingState { + /// Creates an [OnboardingState]. const OnboardingState({ required this.completed, required this.goal, required this.brewer, }); + /// Whether the user has finished onboarding. final bool completed; + + /// The chosen goal key, if any. final String? goal; + + /// The chosen brewer key, if any. final String? brewer; } /// Thin wrapper around [SettingsRepository] that exposes only the onboarding /// gate + selections. Keeps onboarding logic out of the broader settings API. class OnboardingRepository { + /// Creates an [OnboardingRepository] backed by a [SettingsRepository]. OnboardingRepository(this._settings); final SettingsRepository _settings; + /// Returns the current [OnboardingState]. Future getState() async { final s = await _settings.getSettings(); return OnboardingState( @@ -30,6 +38,7 @@ class OnboardingRepository { ); } + /// Marks onboarding complete and saves the [goal]/[brewer] selections. Future markOnboardingComplete({ required String goal, required String brewer, diff --git a/coffee_quest/lib/features/onboarding/presentation/brewer/brewer_controller.dart b/coffee_quest/lib/features/onboarding/presentation/brewer/brewer_controller.dart index 35964b6..6d4a904 100644 --- a/coffee_quest/lib/features/onboarding/presentation/brewer/brewer_controller.dart +++ b/coffee_quest/lib/features/onboarding/presentation/brewer/brewer_controller.dart @@ -3,10 +3,11 @@ import 'package:flutter/foundation.dart'; /// Holds the brewer pick + submission state for `BrewerScreen`, keeping the /// orchestration out of the widget so it can be unit-tested without pumping. /// -/// Persistence and navigation are injected: [onSubmit] receives the chosen +/// Persistence and navigation are injected: `onSubmit` receives the chosen /// option index (the screen maps it to a key and writes through -/// `OnboardingDraft`), and [onFinished] runs once submission completes. +/// `OnboardingDraft`), and `onFinished` runs once submission completes. class BrewerController extends ChangeNotifier { + /// Creates a [BrewerController]. BrewerController({ required Future Function(int selectedIndex) onSubmit, required VoidCallback onFinished, @@ -19,8 +20,13 @@ class BrewerController extends ChangeNotifier { int? _selectedIndex; bool _submitting = false; + /// The currently selected option index, or null if none. int? get selectedIndex => _selectedIndex; + + /// Whether a submission is currently in flight. bool get submitting => _submitting; + + /// Whether a selection exists and no submission is in flight. bool get canSubmit => _selectedIndex != null && !_submitting; /// Selects an option. Ignored once submission is in flight. @@ -30,7 +36,7 @@ class BrewerController extends ChangeNotifier { notifyListeners(); } - /// Persists the selection via [onSubmit], then invokes [onFinished]. Guards + /// Persists the selection via `onSubmit`, then invokes `onFinished`. Guards /// against re-entry and submitting with no selection. Future submit() async { if (!canSubmit) return; diff --git a/coffee_quest/lib/features/onboarding/presentation/goal/goal_controller.dart b/coffee_quest/lib/features/onboarding/presentation/goal/goal_controller.dart index 46b227d..61a1e51 100644 --- a/coffee_quest/lib/features/onboarding/presentation/goal/goal_controller.dart +++ b/coffee_quest/lib/features/onboarding/presentation/goal/goal_controller.dart @@ -4,9 +4,10 @@ import 'package:flutter/foundation.dart'; /// but synchronous — selecting a goal and advancing has no async/persistence /// gap, so there is no `submitting` state. /// -/// [onSubmit] receives the chosen option index; the screen maps it to a key, +/// `onSubmit` receives the chosen option index; the screen maps it to a key, /// records it on `OnboardingDraft`, and navigates to the brewer step. class GoalController extends ChangeNotifier { + /// Creates a [GoalController]. GoalController({required void Function(int selectedIndex) onSubmit}) : _onSubmit = onSubmit; @@ -14,15 +15,19 @@ class GoalController extends ChangeNotifier { int? _selectedIndex; + /// The currently selected option index, or null if none. int? get selectedIndex => _selectedIndex; + + /// Whether a selection has been made. bool get canSubmit => _selectedIndex != null; + /// Selects the option at [index]. void pick(int index) { _selectedIndex = index; notifyListeners(); } - /// Forwards the selection via [onSubmit]. No-op with no selection. + /// Forwards the selection via `onSubmit`. No-op with no selection. void submit() { if (!canSubmit) return; _onSubmit(_selectedIndex!); diff --git a/coffee_quest/lib/features/onboarding/presentation/loading/loading_animation.dart b/coffee_quest/lib/features/onboarding/presentation/loading/loading_animation.dart index 2c00c70..ca51ce8 100644 --- a/coffee_quest/lib/features/onboarding/presentation/loading/loading_animation.dart +++ b/coffee_quest/lib/features/onboarding/presentation/loading/loading_animation.dart @@ -5,11 +5,22 @@ import 'package:coffee_quest/features/onboarding/presentation/widgets/roasty_sta /// derives every piece of UI state (caption, drop /// overlay, mascot pose) so adding or reordering a step can't desync them. enum WakePhase { + /// Roasty is asleep (before the drop falls). sleeping(Duration(milliseconds: 1200)), + + /// A water drop falls toward Roasty. dropFalling(Duration(milliseconds: 800)), + + /// Roasty wakes — eyes open. awake(Duration(milliseconds: 600)), + + /// The sprout grows out of Roasty's head. sproutGrows(Duration(milliseconds: 700)), + + /// Idle "brewing" bob with the caption visible. brewing(Duration(milliseconds: 1800)), + + /// Final hold before the sequence loops. hold(Duration(milliseconds: 1400)); const WakePhase(this.duration); @@ -40,8 +51,8 @@ enum WakePhase { } /// One frame of the falling-drop animation, expressed in normalized units: -/// [top] is a 0–1 fraction of the stage height; [opacity] is 0–1; [scaleX]/ -/// [scaleY] apply the impact squash. +/// `top` is a 0–1 fraction of the stage height; `opacity` is 0–1; `scaleX`/ +/// `scaleY` apply the impact squash. typedef DropFrame = ({ double top, double opacity, diff --git a/coffee_quest/lib/features/onboarding/presentation/loading/wake_sequence_controller.dart b/coffee_quest/lib/features/onboarding/presentation/loading/wake_sequence_controller.dart index d55b563..d44f320 100644 --- a/coffee_quest/lib/features/onboarding/presentation/loading/wake_sequence_controller.dart +++ b/coffee_quest/lib/features/onboarding/presentation/loading/wake_sequence_controller.dart @@ -9,7 +9,7 @@ import 'package:flutter/foundation.dart'; /// Two modes, selected at construction: /// - **Animated:** loops the [WakePhase] state machine on a per-phase timer, /// notifying listeners on each step. Once the first full cycle has played -/// *and* the bootstrap gate has resolved, it fires [onAdvance] once and +/// *and* the bootstrap gate has resolved, it fires `onAdvance` once and /// stops — guaranteeing the user sees a full wake-up even on a fast boot. /// - **Reduced motion:** runs no timer; [phase] is a static [WakePhase.brewing] /// frame and advancement is driven solely by [notifyGateResolved]. @@ -17,6 +17,7 @@ import 'package:flutter/foundation.dart'; /// In both modes [loopForever] (a debug toggle) suppresses advancement so the /// animation can be inspected indefinitely. class WakeSequenceController extends ChangeNotifier { + /// Creates a [WakeSequenceController]. WakeSequenceController({ required this.reduceMotion, required this.loopForever, @@ -25,7 +26,10 @@ class WakeSequenceController extends ChangeNotifier { }) : _isGateResolved = isGateResolved, _onAdvance = onAdvance; + /// Whether reduced-motion mode is active (static frame, no looping timer). final bool reduceMotion; + + /// Debug toggle: when set, advancement is suppressed indefinitely. final bool loopForever; final bool Function() _isGateResolved; final VoidCallback _onAdvance; diff --git a/coffee_quest/lib/features/onboarding/presentation/loading/widgets/pulsing_dots.dart b/coffee_quest/lib/features/onboarding/presentation/loading/widgets/pulsing_dots.dart index a6cb61b..dc74ac0 100644 --- a/coffee_quest/lib/features/onboarding/presentation/loading/widgets/pulsing_dots.dart +++ b/coffee_quest/lib/features/onboarding/presentation/loading/widgets/pulsing_dots.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:coffee_quest/features/onboarding/presentation/loading/loading_animation.dart'; import 'package:coffee_quest/shared/theme/app_colors.dart'; import 'package:coffee_quest/shared/theme/app_spacing.dart'; @@ -6,6 +8,7 @@ import 'package:flutter/material.dart'; /// Three accent dots that pulse in a staggered loop beneath the loading /// caption. Opacity is derived by the pure [pulsingDotOpacity] helper. class PulsingDots extends StatefulWidget { + /// Creates a [PulsingDots]. const PulsingDots({super.key}); @override @@ -27,7 +30,8 @@ class _PulsingDotsState extends State @override void initState() { super.initState(); - _controller = AnimationController(vsync: this, duration: _period)..repeat(); + _controller = AnimationController(vsync: this, duration: _period); + unawaited(_controller.repeat()); } @override diff --git a/coffee_quest/lib/features/onboarding/presentation/loading/widgets/roasty_stage.dart b/coffee_quest/lib/features/onboarding/presentation/loading/widgets/roasty_stage.dart index 64fd2a0..7346d3a 100644 --- a/coffee_quest/lib/features/onboarding/presentation/loading/widgets/roasty_stage.dart +++ b/coffee_quest/lib/features/onboarding/presentation/loading/widgets/roasty_stage.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:coffee_quest/features/onboarding/presentation/loading/loading_animation.dart'; import 'package:coffee_quest/features/onboarding/presentation/widgets/roasty.dart'; import 'package:coffee_quest/shared/theme/app_colors.dart'; @@ -6,9 +8,13 @@ import 'package:flutter/material.dart'; /// Hosts Roasty plus the falling water-drop overlay (visible only during /// [WakePhase.dropFalling]). The drop animates top → 41% via a 700ms tween. class RoastyStage extends StatefulWidget { + /// Creates a [RoastyStage]. const RoastyStage({required this.phase, required this.mascotSize, super.key}); + /// The current wake-up phase to render. final WakePhase phase; + + /// Rendered size of the Roasty mascot. final double mascotSize; @override @@ -48,17 +54,15 @@ class _RoastyStageState extends State void _maybeRunDrop() { if (widget.phase.showsDrop) { - _dropController - ..reset() - ..forward(); + _dropController.reset(); + unawaited(_dropController.forward()); } } void _maybeGrowSprout() { if (widget.phase.growsSprout) { - _growController - ..reset() - ..forward(); + _growController.reset(); + unawaited(_growController.forward()); } } diff --git a/coffee_quest/lib/features/onboarding/presentation/onboarding_providers.dart b/coffee_quest/lib/features/onboarding/presentation/onboarding_providers.dart index 8fc7ec5..39aa36a 100644 --- a/coffee_quest/lib/features/onboarding/presentation/onboarding_providers.dart +++ b/coffee_quest/lib/features/onboarding/presentation/onboarding_providers.dart @@ -4,6 +4,7 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'onboarding_providers.g.dart'; +/// Provides the [OnboardingRepository]. @riverpod OnboardingRepository onboardingRepository(Ref ref) => OnboardingRepository(ref.watch(settingsRepositoryProvider)); @@ -26,14 +27,17 @@ class OnboardingDraft extends _$OnboardingDraft { @override ({String? goal, String? brewer}) build() => (goal: null, brewer: null); + /// Sets the chosen goal key. void setGoal(String value) { state = (goal: value, brewer: state.brewer); } + /// Sets the chosen brewer key. void setBrewer(String value) { state = (goal: state.goal, brewer: value); } + /// Persists the goal + brewer selections, then resets the draft. Future complete() async { final goal = state.goal; final brewer = state.brewer; diff --git a/coffee_quest/lib/features/onboarding/presentation/welcome/welcome_screen.dart b/coffee_quest/lib/features/onboarding/presentation/welcome/welcome_screen.dart index 367210b..725e154 100644 --- a/coffee_quest/lib/features/onboarding/presentation/welcome/welcome_screen.dart +++ b/coffee_quest/lib/features/onboarding/presentation/welcome/welcome_screen.dart @@ -14,6 +14,7 @@ import 'package:go_router/go_router.dart'; import 'package:video_player/video_player.dart'; class WelcomeScreen extends ConsumerWidget { + /// Creates a [WelcomeScreen]. const WelcomeScreen({super.key}); static const double _heroFrameRadius = 4; diff --git a/coffee_quest/lib/features/onboarding/presentation/widgets/roasty.dart b/coffee_quest/lib/features/onboarding/presentation/widgets/roasty.dart index a712461..849fb8c 100644 --- a/coffee_quest/lib/features/onboarding/presentation/widgets/roasty.dart +++ b/coffee_quest/lib/features/onboarding/presentation/widgets/roasty.dart @@ -1,8 +1,13 @@ +import 'dart:async'; import 'dart:math' as math; import 'package:coffee_quest/features/onboarding/presentation/widgets/roasty_state.dart'; import 'package:flutter/material.dart'; +// Animation switches handle the states with special motion and default the +// rest; enumerating every no-op state would bloat these mascot switches. +// ignore_for_file: no_default_cases + /// Animated Roasty mascot. Reproduces the geometry + per-state animations /// from the design bundle (`coffee_quest/brew-path-app/project/roasty.jsx`) /// using Flutter's Canvas + a single [AnimationController]. Public API: @@ -10,6 +15,7 @@ import 'package:flutter/material.dart'; /// prototype's `key={state + ':' + replayKey}` so one-shot animations /// restart on demand. class Roasty extends StatefulWidget { + /// Creates a [Roasty]. const Roasty({ required this.state, this.size = 160, @@ -18,8 +24,13 @@ class Roasty extends StatefulWidget { super.key, }); + /// The mascot's current visual state. final RoastyState state; + + /// Rendered width/height in logical pixels. final double size; + + /// Changing this restarts one-shot animations (mirrors the prototype key). final Object? replayKey; /// Overrides the sprout's scale when non-null, letting a host (e.g. the @@ -41,7 +52,7 @@ class _RoastyState extends State with SingleTickerProviderStateMixin { AnimationController(vsync: this, duration: _durationFor(widget.state)) ..addStatusListener((status) { if (status == AnimationStatus.completed && _loops(widget.state)) { - _controller.repeat(); + unawaited(_controller.repeat()); } }); _startForState(widget.state); @@ -62,9 +73,9 @@ class _RoastyState extends State with SingleTickerProviderStateMixin { void _startForState(RoastyState state) { _controller.reset(); if (_loops(state)) { - _controller.repeat(); + unawaited(_controller.repeat()); } else { - _controller.forward(); + unawaited(_controller.forward()); } } @@ -97,7 +108,12 @@ class _RoastyState extends State with SingleTickerProviderStateMixin { case RoastyState.card: case RoastyState.sleep: return true; - default: + case RoastyState.correct: + case RoastyState.wrong: + case RoastyState.lesson: + case RoastyState.module: + case RoastyState.xp: + case RoastyState.awake: return false; } } diff --git a/coffee_quest/lib/features/path/presentation/path_module_node_widget.dart b/coffee_quest/lib/features/path/presentation/path_module_node_widget.dart index 8837ef4..5c546bc 100644 --- a/coffee_quest/lib/features/path/presentation/path_module_node_widget.dart +++ b/coffee_quest/lib/features/path/presentation/path_module_node_widget.dart @@ -9,7 +9,10 @@ import 'package:go_router/go_router.dart'; /// progress. Locked taps surface the unlock hint instead of navigating. class PathModuleNodeWidget extends StatelessWidget { const PathModuleNodeWidget({ - required this.item, required this.isFirst, required this.isLast, super.key, + required this.item, + required this.isFirst, + required this.isLast, + super.key, }); final ModuleWithProgress item; diff --git a/coffee_quest/lib/features/profile/domain/settings_providers.dart b/coffee_quest/lib/features/profile/domain/settings_providers.dart index cb642be..efd8348 100644 --- a/coffee_quest/lib/features/profile/domain/settings_providers.dart +++ b/coffee_quest/lib/features/profile/domain/settings_providers.dart @@ -18,9 +18,11 @@ class SettingsController extends _$SettingsController { Future build() => ref.watch(settingsRepositoryProvider).getSettings(); + /// Toggles the haptics preference and persists it. Future toggleHaptics() => _update((s) => s.hapticsEnabled = !s.hapticsEnabled); + /// Toggles the sound preference and persists it. Future toggleSound() => _update((s) => s.soundEnabled = !s.soundEnabled); @@ -33,6 +35,7 @@ class SettingsController extends _$SettingsController { } } +/// The current app version string, formatted as `x.y.z+build`. @riverpod Future appVersion(Ref ref) async { final info = await PackageInfo.fromPlatform(); diff --git a/coffee_quest/lib/features/profile/presentation/widgets/preference_tile.dart b/coffee_quest/lib/features/profile/presentation/widgets/preference_tile.dart index e996e4b..6d11b3f 100644 --- a/coffee_quest/lib/features/profile/presentation/widgets/preference_tile.dart +++ b/coffee_quest/lib/features/profile/presentation/widgets/preference_tile.dart @@ -7,14 +7,23 @@ import 'package:flutter/material.dart'; /// hints at the action (e.g. "Coming soon"). class PreferenceTile extends StatelessWidget { const PreferenceTile.toggle({ - required this.icon, required this.title, required this.subtitle, required bool value, required ValueChanged onChanged, super.key, + required this.icon, + required this.title, + required this.subtitle, + required bool value, + required ValueChanged onChanged, + super.key, }) : _value = value, _onChanged = onChanged, onTap = null, trailingText = null; const PreferenceTile.action({ - required this.icon, required this.title, required this.subtitle, required this.onTap, super.key, + required this.icon, + required this.title, + required this.subtitle, + required this.onTap, + super.key, this.trailingText, }) : _value = null, _onChanged = null; diff --git a/coffee_quest/lib/features/profile/presentation/widgets/premium_card.dart b/coffee_quest/lib/features/profile/presentation/widgets/premium_card.dart index 4b92c6c..a782146 100644 --- a/coffee_quest/lib/features/profile/presentation/widgets/premium_card.dart +++ b/coffee_quest/lib/features/profile/presentation/widgets/premium_card.dart @@ -1,9 +1,12 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; /// Hero CTA at the top of the Profile screen. The real in-app purchase flow /// lands with the AdMob/IAP integration; for now this opens a coming-soon /// dialog so the visual hierarchy is in place ahead of monetization. class PremiumCard extends StatelessWidget { + /// Creates a [PremiumCard]. const PremiumCard({super.key}); @override @@ -69,7 +72,8 @@ class PremiumCard extends StatelessWidget { } void _showComingSoon(BuildContext context) { - showDialog( + unawaited( + showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('Premium is brewing'), @@ -84,6 +88,8 @@ class PremiumCard extends StatelessWidget { ), ], ), + ), ); } } + diff --git a/coffee_quest/lib/main.dart b/coffee_quest/lib/main.dart index 05b7d33..77deb33 100644 --- a/coffee_quest/lib/main.dart +++ b/coffee_quest/lib/main.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:ui'; import 'package:coffee_quest/app/app.dart'; @@ -16,7 +17,9 @@ void main() async { if (kUseFirebase) { FlutterError.onError = FirebaseCrashlytics.instance.recordFlutterFatalError; PlatformDispatcher.instance.onError = (error, stack) { - FirebaseCrashlytics.instance.recordError(error, stack, fatal: true); + unawaited( + FirebaseCrashlytics.instance.recordError(error, stack, fatal: true), + ); return true; }; } diff --git a/coffee_quest/lib/services/remote_config/remote_config_keys.dart b/coffee_quest/lib/services/remote_config/remote_config_keys.dart index 9dc043b..4646bed 100644 --- a/coffee_quest/lib/services/remote_config/remote_config_keys.dart +++ b/coffee_quest/lib/services/remote_config/remote_config_keys.dart @@ -1,6 +1,11 @@ /// Remote Config key names. Reads go through `RemoteConfigService` only. abstract class RemoteConfigKeys { + /// Minimum supported app version; below it forces an update. static const String forceUpdateMinVersion = 'force_update_min_version'; + + /// Target number of lessons per day. static const String dailyLessonGoal = 'daily_lesson_goal'; + + /// Whether collectible-card animations are enabled. static const String enableCardAnimations = 'enable_card_animations'; } diff --git a/coffee_quest/lib/services/remote_config/remote_config_provider.dart b/coffee_quest/lib/services/remote_config/remote_config_provider.dart index 6729931..9053c7b 100644 --- a/coffee_quest/lib/services/remote_config/remote_config_provider.dart +++ b/coffee_quest/lib/services/remote_config/remote_config_provider.dart @@ -8,6 +8,7 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'remote_config_provider.g.dart'; +/// Provides the active [RemoteConfigService] — No-Op until Firebase is on. @riverpod RemoteConfigService remoteConfigService(Ref ref) => const NoOpRemoteConfigService(); diff --git a/coffee_quest/lib/services/remote_config/remote_config_service.dart b/coffee_quest/lib/services/remote_config/remote_config_service.dart index e3b64e8..73bb245 100644 --- a/coffee_quest/lib/services/remote_config/remote_config_service.dart +++ b/coffee_quest/lib/services/remote_config/remote_config_service.dart @@ -1,9 +1,18 @@ /// Abstract Remote Config accessor. Feature code reads flags through this — /// never via `FirebaseRemoteConfig.instance` directly. abstract class RemoteConfigService { + /// Fetches the latest config and activates it. Future fetchAndActivate(); + + /// Returns the string value for [key] (empty if unset). String getString(String key); + + /// Returns the bool value for [key] (false if unset). bool getBool(String key); + + /// Returns the int value for [key] (0 if unset). int getInt(String key); + + /// Returns the double value for [key] (0 if unset). double getDouble(String key); } diff --git a/coffee_quest/lib/shared/models/coffee_card_model.dart b/coffee_quest/lib/shared/models/coffee_card_model.dart index ff9d7fe..0d7f2de 100644 --- a/coffee_quest/lib/shared/models/coffee_card_model.dart +++ b/coffee_quest/lib/shared/models/coffee_card_model.dart @@ -3,8 +3,10 @@ import 'package:freezed_annotation/freezed_annotation.dart'; part 'coffee_card_model.freezed.dart'; part 'coffee_card_model.g.dart'; +/// Content model for a collectible coffee card. @freezed abstract class CoffeeCardModel with _$CoffeeCardModel { + /// Creates a [CoffeeCardModel]. const factory CoffeeCardModel({ required String id, required String title, @@ -14,6 +16,7 @@ abstract class CoffeeCardModel with _$CoffeeCardModel { required String lessonId, }) = _CoffeeCardModel; + /// Creates a [CoffeeCardModel] from decoded JSON. factory CoffeeCardModel.fromJson(Map json) => _$CoffeeCardModelFromJson(json); } diff --git a/coffee_quest/lib/shared/models/lesson_model.dart b/coffee_quest/lib/shared/models/lesson_model.dart index d38b070..5148652 100644 --- a/coffee_quest/lib/shared/models/lesson_model.dart +++ b/coffee_quest/lib/shared/models/lesson_model.dart @@ -4,17 +4,21 @@ import 'package:freezed_annotation/freezed_annotation.dart'; part 'lesson_model.freezed.dart'; part 'lesson_model.g.dart'; +/// Content model for a single lesson (an ordered list of steps). @freezed abstract class LessonModel with _$LessonModel { + /// Creates a [LessonModel]. const factory LessonModel({ required String id, required String moduleId, required String title, required String summary, required int xpReward, - required List steps, String? cardId, + required List steps, + String? cardId, }) = _LessonModel; + /// Creates a [LessonModel] from decoded JSON. factory LessonModel.fromJson(Map json) => _$LessonModelFromJson(json); } diff --git a/coffee_quest/lib/shared/models/module_model.dart b/coffee_quest/lib/shared/models/module_model.dart index 3d25bcb..084fbdb 100644 --- a/coffee_quest/lib/shared/models/module_model.dart +++ b/coffee_quest/lib/shared/models/module_model.dart @@ -3,8 +3,10 @@ import 'package:freezed_annotation/freezed_annotation.dart'; part 'module_model.freezed.dart'; part 'module_model.g.dart'; +/// Content model for a learning module (a named group of lessons). @freezed abstract class ModuleModel with _$ModuleModel { + /// Creates a [ModuleModel]. const factory ModuleModel({ required String id, required String title, @@ -14,6 +16,7 @@ abstract class ModuleModel with _$ModuleModel { String? unlockRequirement, }) = _ModuleModel; + /// Creates a [ModuleModel] from decoded JSON. factory ModuleModel.fromJson(Map json) => _$ModuleModelFromJson(json); } diff --git a/coffee_quest/lib/shared/repositories/card_repository.dart b/coffee_quest/lib/shared/repositories/card_repository.dart index a4dfda5..9e3f370 100644 --- a/coffee_quest/lib/shared/repositories/card_repository.dart +++ b/coffee_quest/lib/shared/repositories/card_repository.dart @@ -1,14 +1,17 @@ import 'package:coffee_quest/shared/storage/app_database.dart'; import 'package:drift/drift.dart'; +/// Reads/writes collected coffee cards via Drift. class CardRepository { AppDatabase get _db => AppDatabaseService.instance; + /// Returns the ids of all collected cards. Future> getAllCollectedCardIds() async { final rows = await _db.select(_db.cardRecords).get(); return rows.map((r) => r.cardId).toList(); } + /// Whether the card with [cardId] has been collected. Future isCardCollected(String cardId) async { final row = await (_db.select( _db.cardRecords, diff --git a/coffee_quest/lib/shared/repositories/content_repository.dart b/coffee_quest/lib/shared/repositories/content_repository.dart index 882e07c..2a24cae 100644 --- a/coffee_quest/lib/shared/repositories/content_repository.dart +++ b/coffee_quest/lib/shared/repositories/content_repository.dart @@ -8,11 +8,13 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'content_repository.g.dart'; +/// Loads content models (modules, lessons, cards) from bundled JSON assets. class ContentRepository { List? _modules; List? _lessons; List? _cards; + /// Loads and caches all modules from bundled JSON. Future> getModules() async { _modules ??= await _load( 'assets/content/modules.json', @@ -21,6 +23,7 @@ class ContentRepository { return _modules!; } + /// Loads and caches all lessons from bundled JSON. Future> getLessons() async { _lessons ??= await _load( 'assets/content/lessons.json', @@ -29,6 +32,7 @@ class ContentRepository { return _lessons!; } + /// Loads and caches all coffee cards from bundled JSON. Future> getCards() async { _cards ??= await _load( 'assets/content/cards.json', @@ -37,6 +41,7 @@ class ContentRepository { return _cards!; } + /// Returns the lesson with [id], or null if none exists. Future getLessonById(String id) async { final lessons = await getLessons(); return lessons.where((l) => l.id == id).firstOrNull; @@ -52,5 +57,6 @@ class ContentRepository { } } +/// Provides the singleton [ContentRepository]. @riverpod ContentRepository contentRepository(Ref ref) => ContentRepository(); diff --git a/coffee_quest/lib/shared/repositories/progress_repository.dart b/coffee_quest/lib/shared/repositories/progress_repository.dart index 3322874..c73aa2d 100644 --- a/coffee_quest/lib/shared/repositories/progress_repository.dart +++ b/coffee_quest/lib/shared/repositories/progress_repository.dart @@ -2,20 +2,25 @@ import 'package:coffee_quest/shared/storage/app_database.dart'; import 'package:coffee_quest/shared/storage/progress_record.dart'; import 'package:drift/drift.dart'; +/// Reads/writes lesson-completion records via Drift. class ProgressRepository { AppDatabase get _db => AppDatabaseService.instance; + /// Returns all completed-lesson records. Future> getAllCompleted() async { final rows = await (_db.select( _db.progressRecords, )..where((t) => t.isCompleted.equals(true))).get(); + return rows.map(_toDto).toList(); } + /// Returns the completion record for [lessonId], or null if none. Future getByLessonId(String lessonId) async { final row = await (_db.select( _db.progressRecords, )..where((t) => t.lessonId.equals(lessonId))).getSingleOrNull(); + return row == null ? null : _toDto(row); } diff --git a/coffee_quest/lib/shared/repositories/repository_providers.dart b/coffee_quest/lib/shared/repositories/repository_providers.dart index 7acf972..b8cf2c0 100644 --- a/coffee_quest/lib/shared/repositories/repository_providers.dart +++ b/coffee_quest/lib/shared/repositories/repository_providers.dart @@ -6,15 +6,19 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'repository_providers.g.dart'; +/// Provides the [ProgressRepository]. @riverpod ProgressRepository progressRepository(Ref ref) => ProgressRepository(); +/// Provides the [CardRepository]. @riverpod CardRepository cardRepository(Ref ref) => CardRepository(); +/// Provides the [SettingsRepository]. @riverpod SettingsRepository settingsRepository(Ref ref) => SettingsRepository(); +/// Provides the [ModuleProgressRepository]. @riverpod ModuleProgressRepository moduleProgressRepository(Ref ref) => ModuleProgressRepository(); diff --git a/coffee_quest/lib/shared/repositories/settings_repository.dart b/coffee_quest/lib/shared/repositories/settings_repository.dart index c2f2ff3..62b3a3b 100644 --- a/coffee_quest/lib/shared/repositories/settings_repository.dart +++ b/coffee_quest/lib/shared/repositories/settings_repository.dart @@ -2,9 +2,11 @@ import 'package:coffee_quest/shared/storage/app_database.dart'; import 'package:coffee_quest/shared/storage/settings_record.dart'; import 'package:drift/drift.dart'; +/// Reads/writes the singleton user-settings row via Drift. class SettingsRepository { AppDatabase get _db => AppDatabaseService.instance; + /// Primary-key id of the singleton settings row. static const int settingsId = 1; /// Returns the singleton settings row, or transient defaults on first @@ -36,6 +38,7 @@ class SettingsRepository { ); } + /// Upserts the singleton settings row. Future saveSettings(UserSettingsRecord settings) async { await _db .into(_db.userSettings) @@ -54,6 +57,7 @@ class SettingsRepository { ); } + /// Adds [xp] to the user's running total and persists it. Future addXp(int xp) async { final settings = await getSettings(); settings.totalXp += xp; diff --git a/coffee_quest/lib/shared/storage/app_database.dart b/coffee_quest/lib/shared/storage/app_database.dart index cdfed29..10a8f59 100644 --- a/coffee_quest/lib/shared/storage/app_database.dart +++ b/coffee_quest/lib/shared/storage/app_database.dart @@ -1,7 +1,8 @@ // Self-describing tokens / DTOs / storage infra; no per-member docs. // ignore_for_file: public_member_api_docs -import 'package:coffee_quest/shared/repositories/settings_repository.dart' show SettingsRepository; +import 'package:coffee_quest/shared/repositories/settings_repository.dart' + show SettingsRepository; import 'package:drift/drift.dart'; import 'package:drift_flutter/drift_flutter.dart'; diff --git a/coffee_quest/lib/shared/storage/card_record.dart b/coffee_quest/lib/shared/storage/card_record.dart index 961da65..fadd804 100644 --- a/coffee_quest/lib/shared/storage/card_record.dart +++ b/coffee_quest/lib/shared/storage/card_record.dart @@ -1,7 +1,8 @@ // Self-describing tokens / DTOs / storage infra; no per-member docs. // ignore_for_file: public_member_api_docs -import 'package:coffee_quest/shared/storage/progress_record.dart' show ProgressRecord; +import 'package:coffee_quest/shared/storage/progress_record.dart' + show ProgressRecord; /// Mutable DTO for a collected-card row. See [ProgressRecord] for rationale. class CardRecord { diff --git a/coffee_quest/lib/shared/storage/module_progress_record.dart b/coffee_quest/lib/shared/storage/module_progress_record.dart index 29ec379..8e82ca9 100644 --- a/coffee_quest/lib/shared/storage/module_progress_record.dart +++ b/coffee_quest/lib/shared/storage/module_progress_record.dart @@ -5,7 +5,9 @@ /// completion XP has been granted, so its presence is the "awarded" ledger. class ModuleProgressRecord { ModuleProgressRecord({ - required this.moduleId, required this.moduleXpAwarded, this.id = 0, + required this.moduleId, + required this.moduleXpAwarded, + this.id = 0, }); int id; diff --git a/coffee_quest/lib/shared/storage/settings_record.dart b/coffee_quest/lib/shared/storage/settings_record.dart index bf791ae..e425c6a 100644 --- a/coffee_quest/lib/shared/storage/settings_record.dart +++ b/coffee_quest/lib/shared/storage/settings_record.dart @@ -6,7 +6,12 @@ /// to/from the Drift companion. class UserSettingsRecord { UserSettingsRecord({ - required this.hapticsEnabled, required this.soundEnabled, required this.totalXp, required this.streakDays, required this.lastActivityDate, this.id = 1, + required this.hapticsEnabled, + required this.soundEnabled, + required this.totalXp, + required this.streakDays, + required this.lastActivityDate, + this.id = 1, this.onboardingCompleted = false, this.onboardingGoal, this.onboardingBrewer, From 5e157c1ced3a3b94b82f2fc5307f2032c0b555a4 Mon Sep 17 00:00:00 2001 From: maximsan Date: Wed, 10 Jun 2026 09:01:18 +0200 Subject: [PATCH 4/8] chore(lint): features, services documented, magic numbers extracted to named constants part 3 --- .../presentation/widgets/profile_header.dart | 41 +++++++++++++------ .../presentation/widgets/stat_tile.dart | 30 +++++++++++--- .../progress/domain/progress_providers.dart | 4 ++ .../progress/domain/streak_service.dart | 7 ++++ .../features/progress/domain/xp_service.dart | 4 ++ .../lib/services/ads/admob_ads_service.dart | 1 + .../lib/services/ads/ads_service.dart | 12 +++++- .../lib/services/ads/noop_ads_service.dart | 1 + .../analytics/analytics_provider.dart | 1 + .../services/analytics/analytics_service.dart | 3 ++ .../analytics/firebase_analytics_service.dart | 1 + .../analytics/noop_analytics_service.dart | 1 + .../crash_reporting_provider.dart | 1 + .../crash_reporting_service.dart | 5 +++ .../firebase_crashlytics_service.dart | 1 + .../noop_crash_reporting_service.dart | 1 + .../payments/in_app_purchase_service.dart | 1 + .../payments/noop_payments_service.dart | 1 + .../services/payments/payments_provider.dart | 1 + .../services/payments/payments_service.dart | 18 +++++++- .../lib/services/payments/store_product.dart | 7 ++++ .../firebase_remote_config_service.dart | 1 + .../noop_remote_config_service.dart | 1 + .../lib/shared/storage/progress_record.dart | 6 ++- 24 files changed, 129 insertions(+), 21 deletions(-) diff --git a/coffee_quest/lib/features/profile/presentation/widgets/profile_header.dart b/coffee_quest/lib/features/profile/presentation/widgets/profile_header.dart index 7b9c692..79e62ce 100644 --- a/coffee_quest/lib/features/profile/presentation/widgets/profile_header.dart +++ b/coffee_quest/lib/features/profile/presentation/widgets/profile_header.dart @@ -8,18 +8,30 @@ import 'package:flutter/material.dart'; /// near-opaque tinted surface plus a soft drop shadow build up behind the /// bar — so it reads as a distinct floating header over the content. class ProfileHeaderDelegate extends SliverPersistentHeaderDelegate { + /// Creates a [ProfileHeaderDelegate]. ProfileHeaderDelegate({ required this.title, required this.onClose, required this.onSettings, }); + /// The header title text. final String title; + + /// Called when the close (X) button is tapped. final VoidCallback onClose; + + /// Called when the settings (gear) button is tapped. final VoidCallback onSettings; static const double _expanded = 136; static const double _collapsed = 64; + static const double _hInset = 16; + static const double _topInset = 8; + static const double _bottomInset = 8; + static const double _shadowMaxAlpha = 0.08; + static const double _shadowBlur = 12; + static const double _shadowOffsetY = 4; @override double get maxExtent => _expanded; @@ -63,9 +75,11 @@ class ProfileHeaderDelegate extends SliverPersistentHeaderDelegate { color: colors.surfaceContainerHigh.withValues(alpha: tintAlpha), boxShadow: [ BoxShadow( - color: Colors.black.withValues(alpha: 0.08 * shadowOpacity), - blurRadius: 12, - offset: const Offset(0, 4), + color: Colors.black.withValues( + alpha: _shadowMaxAlpha * shadowOpacity, + ), + blurRadius: _shadowBlur, + offset: const Offset(0, _shadowOffsetY), ), ], ), @@ -73,9 +87,9 @@ class ProfileHeaderDelegate extends SliverPersistentHeaderDelegate { ), ), Positioned( - left: 16, - right: 16, - top: 8, + left: _hInset, + right: _hInset, + top: _topInset, child: Row( children: [ _CircleIconButton( @@ -106,9 +120,9 @@ class ProfileHeaderDelegate extends SliverPersistentHeaderDelegate { ), ), Positioned( - left: 16, - right: 16, - bottom: 8, + left: _hInset, + right: _hInset, + bottom: _bottomInset, child: Opacity( opacity: largeTitleOpacity, child: Align( @@ -150,6 +164,9 @@ class _CircleIconButton extends StatelessWidget { final VoidCallback onPressed; final String tooltip; + static const double _size = 48; + static const double _iconSize = 26; + @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; @@ -163,9 +180,9 @@ class _CircleIconButton extends StatelessWidget { onTap: onPressed, customBorder: const CircleBorder(), child: SizedBox( - width: 48, - height: 48, - child: Icon(icon, size: 26, color: colors.onSurface), + width: _size, + height: _size, + child: Icon(icon, size: _iconSize, color: colors.onSurface), ), ), ), diff --git a/coffee_quest/lib/features/profile/presentation/widgets/stat_tile.dart b/coffee_quest/lib/features/profile/presentation/widgets/stat_tile.dart index d9d3eb0..fb559ec 100644 --- a/coffee_quest/lib/features/profile/presentation/widgets/stat_tile.dart +++ b/coffee_quest/lib/features/profile/presentation/widgets/stat_tile.dart @@ -3,14 +3,28 @@ import 'package:flutter/material.dart'; /// Square stat tile used in the "Your progress" grid: a tinted icon badge /// floats above a large value and a small caption. class StatTile extends StatelessWidget { + /// Creates a [StatTile]. const StatTile({ - required this.icon, required this.label, required this.value, super.key, + required this.icon, + required this.label, + required this.value, + super.key, }); + /// Icon shown in the badge. final IconData icon; + + /// Caption shown under the value. final String label; + + /// The stat value text. final String value; + static const double _cornerRadius = 20; + static const double _badgeSize = 48; + static const double _badgeRadius = 14; + static const double _iconSize = 24; + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -19,7 +33,7 @@ class StatTile extends StatelessWidget { return Container( decoration: BoxDecoration( color: colors.surface, - borderRadius: BorderRadius.circular(20), + borderRadius: BorderRadius.circular(_cornerRadius), border: Border.all(color: colors.outlineVariant), ), padding: const EdgeInsets.all(16), @@ -27,14 +41,18 @@ class StatTile extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( - width: 48, - height: 48, + width: _badgeSize, + height: _badgeSize, alignment: Alignment.center, decoration: BoxDecoration( color: colors.primaryContainer, - borderRadius: BorderRadius.circular(14), + borderRadius: BorderRadius.circular(_badgeRadius), + ), + child: Icon( + icon, + size: _iconSize, + color: colors.onPrimaryContainer, ), - child: Icon(icon, size: 24, color: colors.onPrimaryContainer), ), const Spacer(), Text( diff --git a/coffee_quest/lib/features/progress/domain/progress_providers.dart b/coffee_quest/lib/features/progress/domain/progress_providers.dart index fa4ff33..26542c0 100644 --- a/coffee_quest/lib/features/progress/domain/progress_providers.dart +++ b/coffee_quest/lib/features/progress/domain/progress_providers.dart @@ -4,22 +4,26 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'progress_providers.g.dart'; +/// The user's total XP. @riverpod Future totalXp(Ref ref) async { final settings = await ref.watch(settingsRepositoryProvider).getSettings(); return settings.totalXp; } +/// The user's current streak in days. @riverpod Future streak(Ref ref) async { final settings = await ref.watch(settingsRepositoryProvider).getSettings(); return settings.streakDays; } +/// All of the user's completed-lesson records. @riverpod Future> completedLessons(Ref ref) => ref.watch(progressRepositoryProvider).getAllCompleted(); +/// The ids of all cards the user has collected. @riverpod Future> collectedCards(Ref ref) => ref.watch(cardRepositoryProvider).getAllCollectedCardIds(); diff --git a/coffee_quest/lib/features/progress/domain/streak_service.dart b/coffee_quest/lib/features/progress/domain/streak_service.dart index 30226c4..e42185c 100644 --- a/coffee_quest/lib/features/progress/domain/streak_service.dart +++ b/coffee_quest/lib/features/progress/domain/streak_service.dart @@ -1,12 +1,17 @@ import 'package:coffee_quest/core/utils/date_utils.dart'; +/// Result of a streak update. class StreakResult { + /// Creates a [StreakResult]. const StreakResult({ required this.streakDays, required this.lastActivityDate, }); + /// Consecutive-day streak count. final int streakDays; + + /// Calendar day of the most recent activity. final DateTime lastActivityDate; } @@ -14,8 +19,10 @@ class StreakResult { /// calendar days with at least one completed lesson and resets after any missed /// day. No multipliers or bonuses in the MVP. class StreakService { + /// Creates a [StreakService]. const StreakService(); + /// Computes the new streak after a lesson completes at [now]. StreakResult onLessonCompleted({ required int currentStreak, required DateTime? lastActivityDate, diff --git a/coffee_quest/lib/features/progress/domain/xp_service.dart b/coffee_quest/lib/features/progress/domain/xp_service.dart index 1b94295..f711351 100644 --- a/coffee_quest/lib/features/progress/domain/xp_service.dart +++ b/coffee_quest/lib/features/progress/domain/xp_service.dart @@ -3,11 +3,15 @@ import 'package:coffee_quest/core/constants/xp_values.dart'; /// Domain entry point for XP math. Thin wrapper over the [XpValues] constants — /// kept as a class so it can be injected and stubbed in tests. class XpService { + /// Creates an [XpService]. const XpService(); + /// XP awarded for completing a lesson with [stepCount] steps. int calculateLessonXp(int stepCount) => XpValues.forLesson(stepCount); + /// Bonus XP awarded for completing a module. int get moduleCompletionBonus => XpValues.moduleCompletionBonus; + /// XP awarded for a single practice run. int get practiceXp => XpValues.practiceXp; } diff --git a/coffee_quest/lib/services/ads/admob_ads_service.dart b/coffee_quest/lib/services/ads/admob_ads_service.dart index 1fb2b7f..1a31d67 100644 --- a/coffee_quest/lib/services/ads/admob_ads_service.dart +++ b/coffee_quest/lib/services/ads/admob_ads_service.dart @@ -12,6 +12,7 @@ import 'package:coffee_quest/services/ads/ads_service.dart'; +/// Real [AdsService] backed by AdMob (stubbed for now; see docs/11-ads.md). class AdMobAdsService implements AdsService { @override Future initialize() => diff --git a/coffee_quest/lib/services/ads/ads_service.dart b/coffee_quest/lib/services/ads/ads_service.dart index 7a92cf8..4c08d29 100644 --- a/coffee_quest/lib/services/ads/ads_service.dart +++ b/coffee_quest/lib/services/ads/ads_service.dart @@ -1,4 +1,14 @@ -enum AdLoadStatus { loaded, failed, notAvailable } +/// Result of an ad load attempt. +enum AdLoadStatus { + /// The ad loaded successfully. + loaded, + + /// The ad failed to load. + failed, + + /// Ads are unavailable (e.g. the No-Op implementation). + notAvailable, +} /// Abstract ads layer. No feature code touches `google_mobile_ads` directly — /// only this interface. NoOp is active in the MVP; ads never appear in diff --git a/coffee_quest/lib/services/ads/noop_ads_service.dart b/coffee_quest/lib/services/ads/noop_ads_service.dart index 95e012d..bf69ff9 100644 --- a/coffee_quest/lib/services/ads/noop_ads_service.dart +++ b/coffee_quest/lib/services/ads/noop_ads_service.dart @@ -2,6 +2,7 @@ import 'package:coffee_quest/services/ads/ads_service.dart'; /// Active ads implementation for the MVP — never loads or shows anything. class NoOpAdsService implements AdsService { + /// Creates a [NoOpAdsService]. const NoOpAdsService(); @override diff --git a/coffee_quest/lib/services/analytics/analytics_provider.dart b/coffee_quest/lib/services/analytics/analytics_provider.dart index 50daa36..4a06355 100644 --- a/coffee_quest/lib/services/analytics/analytics_provider.dart +++ b/coffee_quest/lib/services/analytics/analytics_provider.dart @@ -8,6 +8,7 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'analytics_provider.g.dart'; +/// Provides the active [AnalyticsService] — No-Op until Firebase is enabled. @riverpod AnalyticsService analyticsService(Ref ref) => const NoOpAnalyticsService(); // To activate Firebase: => FirebaseAnalyticsService(); diff --git a/coffee_quest/lib/services/analytics/analytics_service.dart b/coffee_quest/lib/services/analytics/analytics_service.dart index 7436a45..72d8d8d 100644 --- a/coffee_quest/lib/services/analytics/analytics_service.dart +++ b/coffee_quest/lib/services/analytics/analytics_service.dart @@ -2,7 +2,10 @@ /// sibling files; the provider selects one. Feature code depends only on this /// interface — never on `FirebaseAnalytics.instance` directly. abstract class AnalyticsService { + /// Logs a named analytics event with optional [parameters]. Future logEvent(String name, {Map? parameters}); + + /// Logs a screen view for [screenName]. Future logScreen(String screenName); /// Passing `null` clears the user ID. diff --git a/coffee_quest/lib/services/analytics/firebase_analytics_service.dart b/coffee_quest/lib/services/analytics/firebase_analytics_service.dart index e10aee7..ba15825 100644 --- a/coffee_quest/lib/services/analytics/firebase_analytics_service.dart +++ b/coffee_quest/lib/services/analytics/firebase_analytics_service.dart @@ -4,6 +4,7 @@ import 'package:firebase_analytics/firebase_analytics.dart'; /// Firebase-backed analytics. Wired in `analytics_provider.dart` once /// `kUseFirebase` is enabled. class FirebaseAnalyticsService implements AnalyticsService { + /// Creates a [FirebaseAnalyticsService] (optional custom [analytics]). FirebaseAnalyticsService([FirebaseAnalytics? analytics]) : _analytics = analytics ?? FirebaseAnalytics.instance; diff --git a/coffee_quest/lib/services/analytics/noop_analytics_service.dart b/coffee_quest/lib/services/analytics/noop_analytics_service.dart index 3cf2c3c..aba5bb2 100644 --- a/coffee_quest/lib/services/analytics/noop_analytics_service.dart +++ b/coffee_quest/lib/services/analytics/noop_analytics_service.dart @@ -3,6 +3,7 @@ import 'package:coffee_quest/services/analytics/analytics_service.dart'; /// Inert analytics — the default until Firebase is activated, and the impl /// used by all tests. class NoOpAnalyticsService implements AnalyticsService { + /// Creates a [NoOpAnalyticsService]. const NoOpAnalyticsService(); @override diff --git a/coffee_quest/lib/services/crash_reporting/crash_reporting_provider.dart b/coffee_quest/lib/services/crash_reporting/crash_reporting_provider.dart index 7e07e8e..d8f0629 100644 --- a/coffee_quest/lib/services/crash_reporting/crash_reporting_provider.dart +++ b/coffee_quest/lib/services/crash_reporting/crash_reporting_provider.dart @@ -8,6 +8,7 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'crash_reporting_provider.g.dart'; +/// Provides the active [CrashReportingService] — No-Op until Firebase is on. @riverpod CrashReportingService crashReportingService(Ref ref) => const NoOpCrashReportingService(); diff --git a/coffee_quest/lib/services/crash_reporting/crash_reporting_service.dart b/coffee_quest/lib/services/crash_reporting/crash_reporting_service.dart index 94abb77..0942219 100644 --- a/coffee_quest/lib/services/crash_reporting/crash_reporting_service.dart +++ b/coffee_quest/lib/services/crash_reporting/crash_reporting_service.dart @@ -1,11 +1,16 @@ /// Abstract crash/error sink. Feature and bootstrap code depend only on this /// interface — never on `FirebaseCrashlytics.instance` directly. abstract class CrashReportingService { + /// Records an [error] (with optional [stack]); set [fatal] for crashes. Future recordError( Object error, StackTrace? stack, { bool fatal = false, }); + + /// Logs a breadcrumb [message] attached to subsequent reports. Future log(String message); + + /// Attaches a custom [key]/[value] pair to subsequent reports. Future setCustomKey(String key, Object value); } diff --git a/coffee_quest/lib/services/crash_reporting/firebase_crashlytics_service.dart b/coffee_quest/lib/services/crash_reporting/firebase_crashlytics_service.dart index 1f6558d..951ce7b 100644 --- a/coffee_quest/lib/services/crash_reporting/firebase_crashlytics_service.dart +++ b/coffee_quest/lib/services/crash_reporting/firebase_crashlytics_service.dart @@ -4,6 +4,7 @@ import 'package:firebase_crashlytics/firebase_crashlytics.dart'; /// Firebase Crashlytics-backed implementation. Wired in /// `crash_reporting_provider.dart` once `kUseFirebase` is enabled. class FirebaseCrashlyticsService implements CrashReportingService { + /// Creates a [FirebaseCrashlyticsService] (optional custom [crashlytics]). FirebaseCrashlyticsService([FirebaseCrashlytics? crashlytics]) : _crashlytics = crashlytics ?? FirebaseCrashlytics.instance; diff --git a/coffee_quest/lib/services/crash_reporting/noop_crash_reporting_service.dart b/coffee_quest/lib/services/crash_reporting/noop_crash_reporting_service.dart index 091aad3..9a2ebbc 100644 --- a/coffee_quest/lib/services/crash_reporting/noop_crash_reporting_service.dart +++ b/coffee_quest/lib/services/crash_reporting/noop_crash_reporting_service.dart @@ -2,6 +2,7 @@ import 'package:coffee_quest/services/crash_reporting/crash_reporting_service.da /// Inert crash reporting — the default until Firebase is activated. class NoOpCrashReportingService implements CrashReportingService { + /// Creates a [NoOpCrashReportingService]. const NoOpCrashReportingService(); @override diff --git a/coffee_quest/lib/services/payments/in_app_purchase_service.dart b/coffee_quest/lib/services/payments/in_app_purchase_service.dart index f7b14c2..40597fd 100644 --- a/coffee_quest/lib/services/payments/in_app_purchase_service.dart +++ b/coffee_quest/lib/services/payments/in_app_purchase_service.dart @@ -12,6 +12,7 @@ import 'package:coffee_quest/services/payments/payments_service.dart'; import 'package:coffee_quest/services/payments/store_product.dart'; +/// Real [PaymentsService] backed by `in_app_purchase` (stubbed for now). class InAppPurchaseService implements PaymentsService { @override Future hasActiveEntitlement() => diff --git a/coffee_quest/lib/services/payments/noop_payments_service.dart b/coffee_quest/lib/services/payments/noop_payments_service.dart index 607da97..23dbc8f 100644 --- a/coffee_quest/lib/services/payments/noop_payments_service.dart +++ b/coffee_quest/lib/services/payments/noop_payments_service.dart @@ -3,6 +3,7 @@ import 'package:coffee_quest/services/payments/store_product.dart'; /// Active payments implementation for the MVP — no store, no entitlements. class NoOpPaymentsService implements PaymentsService { + /// Creates a [NoOpPaymentsService]. const NoOpPaymentsService(); @override diff --git a/coffee_quest/lib/services/payments/payments_provider.dart b/coffee_quest/lib/services/payments/payments_provider.dart index 27e28d3..da5e720 100644 --- a/coffee_quest/lib/services/payments/payments_provider.dart +++ b/coffee_quest/lib/services/payments/payments_provider.dart @@ -8,6 +8,7 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'payments_provider.g.dart'; +/// Provides the active [PaymentsService] — No-Op until payments go live. @riverpod PaymentsService paymentsService(Ref ref) => const NoOpPaymentsService(); // To go live: => InAppPurchaseService(); diff --git a/coffee_quest/lib/services/payments/payments_service.dart b/coffee_quest/lib/services/payments/payments_service.dart index 6d535ec..96c1bbb 100644 --- a/coffee_quest/lib/services/payments/payments_service.dart +++ b/coffee_quest/lib/services/payments/payments_service.dart @@ -1,6 +1,22 @@ import 'package:coffee_quest/services/payments/store_product.dart'; -enum PurchaseStatus { purchased, pending, restored, cancelled, error } +/// Outcome of a purchase or restore flow. +enum PurchaseStatus { + /// The purchase completed and the entitlement is active. + purchased, + + /// The purchase is awaiting external action (e.g. parental approval). + pending, + + /// A prior purchase was restored. + restored, + + /// The user cancelled the flow. + cancelled, + + /// The purchase failed. + error, +} /// Abstract store layer. No feature code calls StoreKit / `in_app_purchase` /// directly — only this interface. NoOp is active in the MVP. diff --git a/coffee_quest/lib/services/payments/store_product.dart b/coffee_quest/lib/services/payments/store_product.dart index 9c5b0e2..aba7fde 100644 --- a/coffee_quest/lib/services/payments/store_product.dart +++ b/coffee_quest/lib/services/payments/store_product.dart @@ -1,4 +1,6 @@ +/// Immutable store product surfaced to the paywall. class StoreProduct { + /// Creates a [StoreProduct]. const StoreProduct({ required this.id, required this.title, @@ -7,8 +9,13 @@ class StoreProduct { required this.currencyCode, }); + /// Store product identifier. final String id; + + /// Localized product title. final String title; + + /// Localized product description. final String description; /// Formatted, localized price string, e.g. `"$2.99"`. diff --git a/coffee_quest/lib/services/remote_config/firebase_remote_config_service.dart b/coffee_quest/lib/services/remote_config/firebase_remote_config_service.dart index 91f7676..7415e91 100644 --- a/coffee_quest/lib/services/remote_config/firebase_remote_config_service.dart +++ b/coffee_quest/lib/services/remote_config/firebase_remote_config_service.dart @@ -5,6 +5,7 @@ import 'package:firebase_remote_config/firebase_remote_config.dart'; /// Firebase Remote Config-backed implementation. Wired in /// `remote_config_provider.dart` once `kUseFirebase` is enabled. class FirebaseRemoteConfigService implements RemoteConfigService { + /// Creates a [FirebaseRemoteConfigService] (optional custom [config]). FirebaseRemoteConfigService([FirebaseRemoteConfig? config]) : _config = config ?? FirebaseRemoteConfig.instance; diff --git a/coffee_quest/lib/services/remote_config/noop_remote_config_service.dart b/coffee_quest/lib/services/remote_config/noop_remote_config_service.dart index 3bd642e..a5cb7cb 100644 --- a/coffee_quest/lib/services/remote_config/noop_remote_config_service.dart +++ b/coffee_quest/lib/services/remote_config/noop_remote_config_service.dart @@ -4,6 +4,7 @@ import 'package:coffee_quest/services/remote_config/remote_config_service.dart'; /// Returns the MVP defaults — the default until Firebase is activated, and the /// impl used by tests. Mirrors the defaults the Firebase impl seeds. class NoOpRemoteConfigService implements RemoteConfigService { + /// Creates a [NoOpRemoteConfigService]. const NoOpRemoteConfigService(); static const Map _defaults = { diff --git a/coffee_quest/lib/shared/storage/progress_record.dart b/coffee_quest/lib/shared/storage/progress_record.dart index 553a159..a2c7715 100644 --- a/coffee_quest/lib/shared/storage/progress_record.dart +++ b/coffee_quest/lib/shared/storage/progress_record.dart @@ -6,7 +6,11 @@ /// Drift companions. class ProgressRecord { ProgressRecord({ - required this.lessonId, required this.isCompleted, required this.xpEarned, required this.completedAt, this.id = 0, + required this.lessonId, + required this.isCompleted, + required this.xpEarned, + required this.completedAt, + this.id = 0, this.fullXpAwarded = true, this.bestScore = 0, this.lastPracticeXpDate, From 25af65eddb98607af4ac6baadddfc575c89d6252 Mon Sep 17 00:00:00 2001 From: maximsan Date: Wed, 10 Jun 2026 09:23:16 +0200 Subject: [PATCH 5/8] chore(lint): features, services documented, perf optimizations, magic numbers extracted to named constants part 3 --- coffee_quest/analysis_options.yaml | 2 + coffee_quest/lib/app/app_router.dart | 1 + .../presentation/card_detail_screen.dart | 15 ++++-- .../cards/presentation/cards_screen.dart | 7 ++- .../learn/domain/learn_providers.dart | 1 + .../game_type_practice_screen.dart | 1 + .../learn/presentation/learn_screen.dart | 48 ++++++++++++------- .../presentation/module_detail_screen.dart | 29 +++++++---- .../domain/lesson_completion_service.dart | 4 +- .../lesson_completion_screen.dart | 19 +++++--- .../lessons/presentation/lesson_screen.dart | 35 ++++++++++---- .../presentation/drag_drop_game.dart | 7 ++- .../presentation/multiple_choice_game.dart | 5 ++ .../presentation/tap_order_game.dart | 14 ++++-- .../monetization/monetization_config.dart | 3 +- .../presentation/brewer/brewer_screen.dart | 2 + .../presentation/goal/goal_controller.dart | 6 +-- .../presentation/goal/goal_screen.dart | 2 + .../loading/loading_animation.dart | 1 + .../presentation/loading/loading_screen.dart | 3 +- .../loading/wake_sequence_controller.dart | 5 +- .../presentation/welcome/welcome_screen.dart | 2 + .../presentation/widgets/roasty.dart | 3 +- .../presentation/path_module_node_widget.dart | 39 ++++++++++----- .../path/presentation/path_screen.dart | 7 ++- .../profile/presentation/profile_screen.dart | 11 +++-- .../profile/presentation/settings_screen.dart | 1 + .../presentation/widgets/preference_tile.dart | 11 +++++ .../presentation/widgets/premium_card.dart | 47 ++++++++++-------- .../lib/shared/storage/app_database.dart | 6 ++- 30 files changed, 233 insertions(+), 104 deletions(-) diff --git a/coffee_quest/analysis_options.yaml b/coffee_quest/analysis_options.yaml index ea96e36..f16ca80 100644 --- a/coffee_quest/analysis_options.yaml +++ b/coffee_quest/analysis_options.yaml @@ -39,6 +39,8 @@ dart_code_linter: - integration_test/** # CustomPainter with many small _paintX helpers — method count is by design. - lib/features/onboarding/presentation/widgets/roasty.dart + # Declarative GoRouter route table — long by nature, low logic complexity. + - lib/app/app_router.dart rules: - no-magic-number: severity: warning diff --git a/coffee_quest/lib/app/app_router.dart b/coffee_quest/lib/app/app_router.dart index 0996d29..c8ac021 100644 --- a/coffee_quest/lib/app/app_router.dart +++ b/coffee_quest/lib/app/app_router.dart @@ -24,6 +24,7 @@ part 'app_router.g.dart'; final _rootKey = GlobalKey(); +/// Provides the app's [GoRouter] (rebuilds on onboarding-gate changes). @riverpod GoRouter appRouter(Ref ref) { // Ticks whenever the async onboarding gate resolves; passed to the router diff --git a/coffee_quest/lib/features/cards/presentation/card_detail_screen.dart b/coffee_quest/lib/features/cards/presentation/card_detail_screen.dart index 764bf2c..d031eb3 100644 --- a/coffee_quest/lib/features/cards/presentation/card_detail_screen.dart +++ b/coffee_quest/lib/features/cards/presentation/card_detail_screen.dart @@ -6,11 +6,18 @@ import 'package:coffee_quest/shared/repositories/content_repository.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +/// Detail screen for a single collected coffee card. class CardDetailScreen extends ConsumerWidget { + /// Creates a [CardDetailScreen]. const CardDetailScreen({required this.cardId, super.key}); + /// Id of the card to display. final String cardId; + static const double _badgeSize = 80; + static const double _badgeRadius = 20; + static const double _iconSize = 40; + @override Widget build(BuildContext context, WidgetRef ref) { final repo = ref.watch(contentRepositoryProvider); @@ -38,16 +45,16 @@ class CardDetailScreen extends ConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( - width: 80, - height: 80, + width: _badgeSize, + height: _badgeSize, alignment: Alignment.center, decoration: BoxDecoration( color: colors.primaryContainer, - borderRadius: BorderRadius.circular(20), + borderRadius: BorderRadius.circular(_badgeRadius), ), child: Icon( moduleIcon(card.iconName), - size: 40, + size: _iconSize, color: colors.onPrimaryContainer, ), ), diff --git a/coffee_quest/lib/features/cards/presentation/cards_screen.dart b/coffee_quest/lib/features/cards/presentation/cards_screen.dart index a508e50..77db50d 100644 --- a/coffee_quest/lib/features/cards/presentation/cards_screen.dart +++ b/coffee_quest/lib/features/cards/presentation/cards_screen.dart @@ -4,10 +4,13 @@ import 'package:coffee_quest/core/widgets/loading_indicator.dart'; import 'package:coffee_quest/core/widgets/section_header.dart'; import 'package:coffee_quest/features/cards/domain/cards_providers.dart'; import 'package:coffee_quest/features/cards/presentation/card_grid_item_widget.dart'; +import 'package:coffee_quest/shared/theme/app_spacing.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +/// Cards tab: a grid of collectible coffee cards (locked until earned). class CardsScreen extends ConsumerWidget { + /// Creates a [CardsScreen]. const CardsScreen({super.key}); @override @@ -111,8 +114,8 @@ class _CollectionHeader extends StatelessWidget { const SizedBox(height: 10), LinearProgressIndicator( value: progress, - minHeight: 8, - borderRadius: BorderRadius.circular(4), + minHeight: AppSpacing.xs, + borderRadius: BorderRadius.circular(AppSpacing.xxs), backgroundColor: colors.surfaceContainerHighest, color: colors.primary, ), diff --git a/coffee_quest/lib/features/learn/domain/learn_providers.dart b/coffee_quest/lib/features/learn/domain/learn_providers.dart index a0f38f1..71cbe0a 100644 --- a/coffee_quest/lib/features/learn/domain/learn_providers.dart +++ b/coffee_quest/lib/features/learn/domain/learn_providers.dart @@ -39,6 +39,7 @@ class ModuleWithProgress { double get progress => totalCount == 0 ? 0 : completedCount / totalCount; } +/// All modules paired with their derived completion/lock state. @riverpod Future> modulesWithProgress(Ref ref) async { final content = ref.watch(contentRepositoryProvider); diff --git a/coffee_quest/lib/features/learn/presentation/game_type_practice_screen.dart b/coffee_quest/lib/features/learn/presentation/game_type_practice_screen.dart index b346755..b343581 100644 --- a/coffee_quest/lib/features/learn/presentation/game_type_practice_screen.dart +++ b/coffee_quest/lib/features/learn/presentation/game_type_practice_screen.dart @@ -14,6 +14,7 @@ import 'package:go_router/go_router.dart'; /// the chosen type out of the user's completed lessons and runs them through /// the standard [LessonStepRunner]. No DB writes, no XP, no card unlocks. class GameTypePracticeScreen extends ConsumerStatefulWidget { + /// Creates a [GameTypePracticeScreen]. const GameTypePracticeScreen({required this.gameType, super.key}); /// JSON discriminator: `multiple_choice` | `drag_drop` | `slider` | diff --git a/coffee_quest/lib/features/learn/presentation/learn_screen.dart b/coffee_quest/lib/features/learn/presentation/learn_screen.dart index 2f736ef..a7430a0 100644 --- a/coffee_quest/lib/features/learn/presentation/learn_screen.dart +++ b/coffee_quest/lib/features/learn/presentation/learn_screen.dart @@ -11,7 +11,21 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +const double _heroRadius = 12; +const double _pillRadius = 20; +const double _rowBadgeSize = 36; +const double _rowBadgeRadius = 10; +const double _iconSm = 18; +const double _iconMd = 16; +const double _iconLg = 40; +const double _chipGap = 8; +const double _mutedAlpha = 0.8; +const double _pillAlpha = 0.12; +const double _heroLetterSpacing = 0.6; + +/// Learn tab: today's lesson, the module list, and practice sections. class LearnScreen extends ConsumerWidget { + /// Creates a [LearnScreen]. const LearnScreen({super.key}); @override @@ -93,7 +107,7 @@ class _TodayCard extends StatelessWidget { ) { return InkWell( onTap: () => context.go('/learn/lesson/${lesson.id}'), - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(_heroRadius), child: Padding( padding: const EdgeInsets.all(20), child: Column( @@ -104,7 +118,7 @@ class _TodayCard extends StatelessWidget { children: [ Icon( Icons.local_cafe, - size: 18, + size: _iconSm, color: colors.onPrimaryContainer, ), const SizedBox(width: 8), @@ -112,7 +126,7 @@ class _TodayCard extends StatelessWidget { "Today's lesson", style: theme.textTheme.labelMedium?.copyWith( color: colors.onPrimaryContainer, - letterSpacing: 0.6, + letterSpacing: _heroLetterSpacing, ), ), ], @@ -131,7 +145,7 @@ class _TodayCard extends StatelessWidget { maxLines: 2, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodyMedium?.copyWith( - color: colors.onPrimaryContainer.withValues(alpha: 0.8), + color: colors.onPrimaryContainer.withValues(alpha: _mutedAlpha), ), ), const SizedBox(height: 16), @@ -157,7 +171,7 @@ class _TodayCard extends StatelessWidget { padding: const EdgeInsets.all(20), child: Row( children: [ - Icon(Icons.check_circle, size: 40, color: colors.onPrimaryContainer), + Icon(Icons.check_circle, size: _iconLg, color: colors.onPrimaryContainer), const SizedBox(width: 16), Expanded( child: Column( @@ -175,7 +189,7 @@ class _TodayCard extends StatelessWidget { Text( 'No lessons left to study.', style: theme.textTheme.bodyMedium?.copyWith( - color: colors.onPrimaryContainer.withValues(alpha: 0.8), + color: colors.onPrimaryContainer.withValues(alpha: _mutedAlpha), ), ), ], @@ -200,13 +214,13 @@ class _XpPill extends StatelessWidget { return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), decoration: BoxDecoration( - color: colors.onPrimaryContainer.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(20), + color: colors.onPrimaryContainer.withValues(alpha: _pillAlpha), + borderRadius: BorderRadius.circular(_pillRadius), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.bolt, size: 16, color: colors.onPrimaryContainer), + Icon(Icons.bolt, size: _iconMd, color: colors.onPrimaryContainer), const SizedBox(width: 4), Text( '+$xp XP', @@ -276,7 +290,7 @@ class _PracticeAnyLessonSectionState extends State<_PracticeAnyLessonSection> { children: [ Icon( _expanded ? Icons.expand_less : Icons.expand_more, - size: 18, + size: _iconSm, color: colors.primary, ), const SizedBox(width: 4), @@ -311,16 +325,16 @@ class _LessonRow extends StatelessWidget { final colors = theme.colorScheme; return ListTile( leading: Container( - width: 36, - height: 36, + width: _rowBadgeSize, + height: _rowBadgeSize, alignment: Alignment.center, decoration: BoxDecoration( color: colors.primaryContainer, - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(_rowBadgeRadius), ), child: Icon( moduleIcon(entry.module.iconName), - size: 18, + size: _iconSm, color: colors.onPrimaryContainer, ), ), @@ -354,8 +368,8 @@ class _PracticeByGameTypeSection extends StatelessWidget { final theme = Theme.of(context); final colors = theme.colorScheme; return Wrap( - spacing: 8, - runSpacing: 8, + spacing: _chipGap, + runSpacing: _chipGap, children: [ for (final entry in gameTypeLabels) () { @@ -366,7 +380,7 @@ class _PracticeByGameTypeSection extends StatelessWidget { return ActionChip( avatar: Icon( _iconFor(key), - size: 18, + size: _iconSm, color: enabled ? colors.primary : colors.onSurfaceVariant, ), label: Text( diff --git a/coffee_quest/lib/features/learn/presentation/module_detail_screen.dart b/coffee_quest/lib/features/learn/presentation/module_detail_screen.dart index 65c0b61..048d957 100644 --- a/coffee_quest/lib/features/learn/presentation/module_detail_screen.dart +++ b/coffee_quest/lib/features/learn/presentation/module_detail_screen.dart @@ -10,9 +10,20 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +const double _headerBadgeSize = 56; +const double _headerBadgeRadius = 14; +const double _headerIconSize = 28; +const double _cardRadius = 12; +const double _stepBadgeSize = 36; +const double _checkIconSize = 20; +const double _xpIconSize = 14; + +/// Module detail: lists the module's lessons with progress and a start CTA. class ModuleDetailScreen extends ConsumerWidget { + /// Creates a [ModuleDetailScreen]. const ModuleDetailScreen({required this.moduleId, super.key}); + /// Id of the module to display. final String moduleId; @override @@ -90,16 +101,16 @@ class _ModuleHero extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( - width: 56, - height: 56, + width: _headerBadgeSize, + height: _headerBadgeSize, alignment: Alignment.center, decoration: BoxDecoration( color: colors.primaryContainer, - borderRadius: BorderRadius.circular(14), + borderRadius: BorderRadius.circular(_headerBadgeRadius), ), child: Icon( moduleIcon(module.iconName), - size: 28, + size: _headerIconSize, color: colors.onPrimaryContainer, ), ), @@ -155,7 +166,7 @@ class _LessonCard extends StatelessWidget { margin: EdgeInsets.zero, child: InkWell( onTap: () => context.go(destination), - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(_cardRadius), child: Padding( padding: const EdgeInsets.all(14), child: Row( @@ -215,12 +226,12 @@ class _LessonBadge extends StatelessWidget { : colors.onPrimaryContainer; return Container( - width: 36, - height: 36, + width: _stepBadgeSize, + height: _stepBadgeSize, alignment: Alignment.center, decoration: BoxDecoration(color: background, shape: BoxShape.circle), child: isCompleted - ? Icon(Icons.check, size: 20, color: foreground) + ? Icon(Icons.check, size: _checkIconSize, color: foreground) : Text( '$index', style: theme.textTheme.titleSmall?.copyWith( @@ -245,7 +256,7 @@ class _XpInline extends StatelessWidget { return Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.bolt, size: 14, color: colors.onSurfaceVariant), + Icon(Icons.bolt, size: _xpIconSize, color: colors.onSurfaceVariant), const SizedBox(width: 2), Text( '+$xp XP', diff --git a/coffee_quest/lib/features/lessons/domain/lesson_completion_service.dart b/coffee_quest/lib/features/lessons/domain/lesson_completion_service.dart index 0630e5b..f17b267 100644 --- a/coffee_quest/lib/features/lessons/domain/lesson_completion_service.dart +++ b/coffee_quest/lib/features/lessons/domain/lesson_completion_service.dart @@ -53,8 +53,8 @@ class LessonReviewResult { final bool practiceXpAwarded; } -/// Orchestrates everything that happens when a lesson finishes: persist progress, -/// award XP, unlock the lesson's card, advance the streak, and grant the module +/// Orchestrates everything that happens when a lesson finishes: persist +/// progress, award XP, unlock the lesson's card, advance the streak, and grant /// bonus once every lesson in the module is done. Idempotent — replaying a /// completed lesson is a no-op so XP and cards are never double-counted. class LessonCompletionService { diff --git a/coffee_quest/lib/features/lessons/presentation/lesson_completion_screen.dart b/coffee_quest/lib/features/lessons/presentation/lesson_completion_screen.dart index 50651d3..bd0efc5 100644 --- a/coffee_quest/lib/features/lessons/presentation/lesson_completion_screen.dart +++ b/coffee_quest/lib/features/lessons/presentation/lesson_completion_screen.dart @@ -22,7 +22,13 @@ class _Reward { final CoffeeCardModel? card; } +const double _heroBadgeSize = 96; +const double _badgeSize = 48; +const double _cardRadius = 12; + +/// Post-lesson screen: shows earned XP and any unlocked card, then routes back. class LessonCompletionScreen extends ConsumerStatefulWidget { + /// Creates a [LessonCompletionScreen]. const LessonCompletionScreen({ required this.lessonId, required this.score, @@ -31,6 +37,7 @@ class LessonCompletionScreen extends ConsumerStatefulWidget { this.practice = false, }); + /// Id of the completed lesson. final String lessonId; /// First-try accuracy of the run that reached this screen (0–100). @@ -273,14 +280,14 @@ class _HeroBadge extends StatelessWidget { final colors = Theme.of(context).colorScheme; return Center( child: Container( - width: 96, - height: 96, + width: _heroBadgeSize, + height: _heroBadgeSize, alignment: Alignment.center, decoration: BoxDecoration( color: colors.primaryContainer, shape: BoxShape.circle, ), - child: Icon(icon, size: 48, color: colors.onPrimaryContainer), + child: Icon(icon, size: _badgeSize, color: colors.onPrimaryContainer), ), ); } @@ -303,12 +310,12 @@ class _RewardCard extends StatelessWidget { child: Row( children: [ Container( - width: 48, - height: 48, + width: _badgeSize, + height: _badgeSize, alignment: Alignment.center, decoration: BoxDecoration( color: colors.primaryContainer, - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(_cardRadius), ), child: Icon( moduleIcon(card.iconName), diff --git a/coffee_quest/lib/features/lessons/presentation/lesson_screen.dart b/coffee_quest/lib/features/lessons/presentation/lesson_screen.dart index 5af4f6f..eebb1cd 100644 --- a/coffee_quest/lib/features/lessons/presentation/lesson_screen.dart +++ b/coffee_quest/lib/features/lessons/presentation/lesson_screen.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:coffee_quest/core/widgets/error_view.dart'; import 'package:coffee_quest/core/widgets/loading_indicator.dart'; import 'package:coffee_quest/features/mini_games/domain/mini_game_result.dart'; @@ -9,7 +11,14 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +const double _pillRadius = 20; +const int _percentScale = 100; +const double _progressBarHeight = 6; +const double _progressBarRadius = 3; + +/// Immersive single-lesson flow: plays each step, then routes to completion. class LessonScreen extends ConsumerStatefulWidget { + /// Creates a [LessonScreen]. const LessonScreen({ required this.lessonId, super.key, @@ -17,6 +26,7 @@ class LessonScreen extends ConsumerStatefulWidget { this.practice = false, }); + /// Id of the lesson to play. final String lessonId; /// Whether this run is a review of an already-completed lesson. Carried @@ -43,12 +53,17 @@ class _LessonScreenState extends ConsumerState { if (_started) return; _started = true; WidgetsBinding.instance.addPostFrameCallback((_) { - ref - .read(analyticsServiceProvider) - .logEvent( - 'lesson_started', - parameters: {'lesson_id': lesson.id, 'module_id': lesson.moduleId}, - ); + unawaited( + ref + .read(analyticsServiceProvider) + .logEvent( + 'lesson_started', + parameters: { + 'lesson_id': lesson.id, + 'module_id': lesson.moduleId, + }, + ), + ); }); } @@ -160,7 +175,7 @@ class _StepProgress extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration( color: colors.primaryContainer, - borderRadius: BorderRadius.circular(20), + borderRadius: BorderRadius.circular(_pillRadius), ), child: Text( 'Step $current of $total', @@ -171,7 +186,7 @@ class _StepProgress extends StatelessWidget { ), ), Text( - '${(progress * 100).round()}%', + '${(progress * _percentScale).round()}%', style: theme.textTheme.labelMedium?.copyWith( color: colors.onSurfaceVariant, ), @@ -181,8 +196,8 @@ class _StepProgress extends StatelessWidget { const SizedBox(height: 8), LinearProgressIndicator( value: progress, - minHeight: 6, - borderRadius: BorderRadius.circular(3), + minHeight: _progressBarHeight, + borderRadius: BorderRadius.circular(_progressBarRadius), backgroundColor: colors.surfaceContainerHighest, color: colors.primary, ), diff --git a/coffee_quest/lib/features/mini_games/presentation/drag_drop_game.dart b/coffee_quest/lib/features/mini_games/presentation/drag_drop_game.dart index 5c4825f..02503f6 100644 --- a/coffee_quest/lib/features/mini_games/presentation/drag_drop_game.dart +++ b/coffee_quest/lib/features/mini_games/presentation/drag_drop_game.dart @@ -1,5 +1,6 @@ import 'package:coffee_quest/features/mini_games/domain/mini_game_result.dart'; import 'package:coffee_quest/shared/models/lesson_step_model.dart'; +import 'package:coffee_quest/shared/theme/app_spacing.dart'; import 'package:flutter/material.dart'; /// Match `terms[i]` ↔ `definitions[i]` by dragging. A drop is only accepted @@ -20,6 +21,8 @@ class DragDropGame extends StatefulWidget { } class _DragDropGameState extends State { + static const double _draggingOpacity = 0.3; + /// definitionIndex -> matched termIndex (absent until correctly placed). final Map _placed = {}; @@ -73,7 +76,7 @@ class _DragDropGameState extends State { child: Chip(label: Text(terms[i])), ), childWhenDragging: Opacity( - opacity: 0.3, + opacity: _draggingOpacity, child: Chip(label: Text(terms[i])), ), child: Chip(label: Text(terms[i])), @@ -100,7 +103,7 @@ class _DragDropGameState extends State { color: done ? colors.primary : colors.outline, width: done ? 2 : 1, ), - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(AppSpacing.xs), ), child: Text(definitions[j]), ); diff --git a/coffee_quest/lib/features/mini_games/presentation/multiple_choice_game.dart b/coffee_quest/lib/features/mini_games/presentation/multiple_choice_game.dart index 8c9d326..3d19786 100644 --- a/coffee_quest/lib/features/mini_games/presentation/multiple_choice_game.dart +++ b/coffee_quest/lib/features/mini_games/presentation/multiple_choice_game.dart @@ -3,14 +3,19 @@ import 'package:coffee_quest/features/mini_games/domain/mini_game_result.dart'; import 'package:coffee_quest/shared/models/lesson_step_model.dart'; import 'package:flutter/material.dart'; +/// Multiple-choice mini-game: pick the correct option. class MultipleChoiceGame extends StatefulWidget { + /// Creates a [MultipleChoiceGame]. const MultipleChoiceGame({ required this.step, required this.onResult, super.key, }); + /// The multiple-choice step's content/config. final MultipleChoiceStep step; + + /// Called with the [MiniGameResult] when answered. final void Function(MiniGameResult) onResult; @override diff --git a/coffee_quest/lib/features/mini_games/presentation/tap_order_game.dart b/coffee_quest/lib/features/mini_games/presentation/tap_order_game.dart index 9bba7d6..9a7b571 100644 --- a/coffee_quest/lib/features/mini_games/presentation/tap_order_game.dart +++ b/coffee_quest/lib/features/mini_games/presentation/tap_order_game.dart @@ -1,13 +1,19 @@ import 'package:coffee_quest/core/constants/app_strings.dart'; import 'package:coffee_quest/features/mini_games/domain/mini_game_result.dart'; import 'package:coffee_quest/shared/models/lesson_step_model.dart'; +import 'package:coffee_quest/shared/theme/app_spacing.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; +/// Tap-order mini-game: tap items into the correct sequence. class TapOrderGame extends StatefulWidget { + /// Creates a [TapOrderGame]. const TapOrderGame({required this.step, required this.onResult, super.key}); + /// The tap-order step's content/config. final TapOrderStep step; + + /// Called with the [MiniGameResult] when answered. final void Function(MiniGameResult) onResult; @override @@ -75,14 +81,14 @@ class _TapOrderGameState extends State { Text(widget.step.instruction, style: theme.textTheme.titleLarge), const SizedBox(height: 24), Wrap( - spacing: 8, - runSpacing: 8, + spacing: AppSpacing.xs, + runSpacing: AppSpacing.xs, children: [for (final item in _selected) Chip(label: Text(item))], ), const Divider(height: 32), Wrap( - spacing: 8, - runSpacing: 8, + spacing: AppSpacing.xs, + runSpacing: AppSpacing.xs, children: [ for (final item in _pool) ActionChip( diff --git a/coffee_quest/lib/features/monetization/monetization_config.dart b/coffee_quest/lib/features/monetization/monetization_config.dart index 9380e86..1bdb350 100644 --- a/coffee_quest/lib/features/monetization/monetization_config.dart +++ b/coffee_quest/lib/features/monetization/monetization_config.dart @@ -2,7 +2,8 @@ // Phase 8 activates AdMob — see docs/11-ads.md for the full integration plan. // // DO NOT import google_mobile_ads here until Phase 8. -// DO NOT call MobileAds.instance.initialize() outside a dedicated ads bootstrap. +// DO NOT call MobileAds.instance.initialize() outside a dedicated ads +// bootstrap. /// Whether ads are enabled in the current build. /// diff --git a/coffee_quest/lib/features/onboarding/presentation/brewer/brewer_screen.dart b/coffee_quest/lib/features/onboarding/presentation/brewer/brewer_screen.dart index 0c61a3a..e75e062 100644 --- a/coffee_quest/lib/features/onboarding/presentation/brewer/brewer_screen.dart +++ b/coffee_quest/lib/features/onboarding/presentation/brewer/brewer_screen.dart @@ -31,7 +31,9 @@ const _options = <_BrewerOption>[ ), ]; +/// Onboarding step: pick your usual brewing method. class BrewerScreen extends ConsumerStatefulWidget { + /// Creates a [BrewerScreen]. const BrewerScreen({super.key}); @override diff --git a/coffee_quest/lib/features/onboarding/presentation/goal/goal_controller.dart b/coffee_quest/lib/features/onboarding/presentation/goal/goal_controller.dart index 61a1e51..74b7b11 100644 --- a/coffee_quest/lib/features/onboarding/presentation/goal/goal_controller.dart +++ b/coffee_quest/lib/features/onboarding/presentation/goal/goal_controller.dart @@ -1,8 +1,8 @@ import 'package:flutter/foundation.dart'; -/// Holds the goal pick state for `GoalScreen`. The sibling of `BrewerController` -/// but synchronous — selecting a goal and advancing has no async/persistence -/// gap, so there is no `submitting` state. +/// Holds the goal pick state for `GoalScreen`. The sibling of +/// `BrewerController` but synchronous — selecting a goal and advancing has no +/// async/persistence gap, so there is no `submitting` state. /// /// `onSubmit` receives the chosen option index; the screen maps it to a key, /// records it on `OnboardingDraft`, and navigates to the brewer step. diff --git a/coffee_quest/lib/features/onboarding/presentation/goal/goal_screen.dart b/coffee_quest/lib/features/onboarding/presentation/goal/goal_screen.dart index b1be846..b7a664b 100644 --- a/coffee_quest/lib/features/onboarding/presentation/goal/goal_screen.dart +++ b/coffee_quest/lib/features/onboarding/presentation/goal/goal_screen.dart @@ -35,7 +35,9 @@ const _options = <_GoalOption>[ ), ]; +/// Onboarding step: pick your learning goal. class GoalScreen extends ConsumerStatefulWidget { + /// Creates a [GoalScreen]. const GoalScreen({super.key}); @override diff --git a/coffee_quest/lib/features/onboarding/presentation/loading/loading_animation.dart b/coffee_quest/lib/features/onboarding/presentation/loading/loading_animation.dart index ca51ce8..8810f1b 100644 --- a/coffee_quest/lib/features/onboarding/presentation/loading/loading_animation.dart +++ b/coffee_quest/lib/features/onboarding/presentation/loading/loading_animation.dart @@ -25,6 +25,7 @@ enum WakePhase { const WakePhase(this.duration); + /// How long this phase stays on screen. final Duration duration; /// The next phase, looping back to [sleeping] after [hold]. diff --git a/coffee_quest/lib/features/onboarding/presentation/loading/loading_screen.dart b/coffee_quest/lib/features/onboarding/presentation/loading/loading_screen.dart index 417961b..7735e83 100644 --- a/coffee_quest/lib/features/onboarding/presentation/loading/loading_screen.dart +++ b/coffee_quest/lib/features/onboarding/presentation/loading/loading_screen.dart @@ -17,6 +17,7 @@ import 'package:go_router/go_router.dart'; /// returning users on to /learn). When the platform requests reduced motion, /// the looping animation is replaced by a static idle frame. class LoadingScreen extends ConsumerStatefulWidget { + /// Creates a [LoadingScreen]. const LoadingScreen({super.key}); @override @@ -67,7 +68,7 @@ class _LoadingScreenState extends ConsumerState { } /// Leaves the loading screen for `/welcome`. The router redirect - /// ([appRouter]) owns the gate→destination policy and bounces returning + /// (`appRouter`) owns the gate→destination policy and bounces returning /// users on to `/learn`, so this screen does not duplicate that decision. void _advance() { if (!mounted) return; diff --git a/coffee_quest/lib/features/onboarding/presentation/loading/wake_sequence_controller.dart b/coffee_quest/lib/features/onboarding/presentation/loading/wake_sequence_controller.dart index d44f320..acc1949 100644 --- a/coffee_quest/lib/features/onboarding/presentation/loading/wake_sequence_controller.dart +++ b/coffee_quest/lib/features/onboarding/presentation/loading/wake_sequence_controller.dart @@ -11,8 +11,9 @@ import 'package:flutter/foundation.dart'; /// notifying listeners on each step. Once the first full cycle has played /// *and* the bootstrap gate has resolved, it fires `onAdvance` once and /// stops — guaranteeing the user sees a full wake-up even on a fast boot. -/// - **Reduced motion:** runs no timer; [phase] is a static [WakePhase.brewing] -/// frame and advancement is driven solely by [notifyGateResolved]. +/// - **Reduced motion:** runs no timer; [phase] is a static +/// [WakePhase.brewing] frame and advancement is driven solely by +/// [notifyGateResolved]. /// /// In both modes [loopForever] (a debug toggle) suppresses advancement so the /// animation can be inspected indefinitely. diff --git a/coffee_quest/lib/features/onboarding/presentation/welcome/welcome_screen.dart b/coffee_quest/lib/features/onboarding/presentation/welcome/welcome_screen.dart index 725e154..a2c11f2 100644 --- a/coffee_quest/lib/features/onboarding/presentation/welcome/welcome_screen.dart +++ b/coffee_quest/lib/features/onboarding/presentation/welcome/welcome_screen.dart @@ -13,7 +13,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:video_player/video_player.dart'; +/// Welcome / onboarding intro: hero video, Roasty, and the "plant your seed" CTA. class WelcomeScreen extends ConsumerWidget { + /// Creates a [WelcomeScreen]. /// Creates a [WelcomeScreen]. const WelcomeScreen({super.key}); diff --git a/coffee_quest/lib/features/onboarding/presentation/widgets/roasty.dart b/coffee_quest/lib/features/onboarding/presentation/widgets/roasty.dart index 849fb8c..0aa5c03 100644 --- a/coffee_quest/lib/features/onboarding/presentation/widgets/roasty.dart +++ b/coffee_quest/lib/features/onboarding/presentation/widgets/roasty.dart @@ -163,7 +163,8 @@ class _RoastyPainter extends CustomPainter { /// Scale origin while the host drives the grow: the stem base sits at the /// bean's top edge, so the sprout emerges from the head rather than scaling - /// in mid-air. The default sleeping shrink keeps its original (100, 75) pivot. + /// in mid-air. The default sleeping shrink keeps its original (100, 75) + /// pivot. static const Offset _sproutGrowAnchor = Offset(100, 88); static const Offset _sproutDefaultAnchor = Offset(100, 75); diff --git a/coffee_quest/lib/features/path/presentation/path_module_node_widget.dart b/coffee_quest/lib/features/path/presentation/path_module_node_widget.dart index 5c546bc..98e78e8 100644 --- a/coffee_quest/lib/features/path/presentation/path_module_node_widget.dart +++ b/coffee_quest/lib/features/path/presentation/path_module_node_widget.dart @@ -4,10 +4,22 @@ import 'package:coffee_quest/features/learn/domain/learn_providers.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +const double _railWidth = 32; +const double _connectorWidth = 3; +const double _cardBottomGap = 12; +const double _cardRadius = 12; +const double _nodeIconSize = 18; +const double _cardBadgeSize = 40; +const double _cardBadgeRadius = 10; +const double _cardIconSize = 22; +const double _progressBarHeight = 5; +const double _progressBarRadius = 3; + /// A single node in the vertical learning path: a state-colored circle on the /// connecting rail plus a content card with the module icon, title, and /// progress. Locked taps surface the unlock hint instead of navigating. class PathModuleNodeWidget extends StatelessWidget { + /// Creates a [PathModuleNodeWidget]. const PathModuleNodeWidget({ required this.item, required this.isFirst, @@ -15,10 +27,13 @@ class PathModuleNodeWidget extends StatelessWidget { super.key, }); + /// The module paired with its progress. final ModuleWithProgress item; - /// Whether this is the first/last node — the rail trims its connector there. + /// Whether this is the first node (the rail trims its top connector). final bool isFirst; + + /// Whether this is the last node (the rail trims its bottom connector). final bool isLast; void _onTap(BuildContext context) { @@ -43,7 +58,7 @@ class PathModuleNodeWidget extends StatelessWidget { const SizedBox(width: 12), Expanded( child: Padding( - padding: EdgeInsets.only(bottom: isLast ? 0 : 12), + padding: EdgeInsets.only(bottom: isLast ? 0 : _cardBottomGap), child: _NodeCard(item: item, onTap: () => _onTap(context)), ), ), @@ -75,7 +90,7 @@ class _NodeRail extends StatelessWidget { final reached = !item.isLocked; return SizedBox( - width: 32, + width: _railWidth, child: Column( children: [ Expanded( @@ -105,7 +120,7 @@ class _Connector extends StatelessWidget { @override Widget build(BuildContext context) { - return Center(child: Container(width: 3, color: color)); + return Center(child: Container(width: _connectorWidth, color: color)); } } @@ -156,7 +171,7 @@ class _NodeCircle extends StatelessWidget { shape: BoxShape.circle, border: border, ), - child: Icon(icon, size: 18, color: foreground), + child: Icon(icon, size: _nodeIconSize, color: foreground), ); } } @@ -179,24 +194,24 @@ class _NodeCard extends StatelessWidget { margin: EdgeInsets.zero, child: InkWell( onTap: onTap, - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(_cardRadius), child: Padding( padding: const EdgeInsets.all(14), child: Row( children: [ Container( - width: 40, - height: 40, + width: _cardBadgeSize, + height: _cardBadgeSize, alignment: Alignment.center, decoration: BoxDecoration( color: locked ? colors.surfaceContainerHighest : colors.primaryContainer, - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(_cardBadgeRadius), ), child: Icon( locked ? Icons.lock_outline : moduleIcon(module.iconName), - size: 22, + size: _cardIconSize, color: locked ? colors.onSurfaceVariant : colors.onPrimaryContainer, @@ -271,8 +286,8 @@ class _NodeStatus extends StatelessWidget { const SizedBox(height: 6), LinearProgressIndicator( value: item.progress, - minHeight: 5, - borderRadius: BorderRadius.circular(3), + minHeight: _progressBarHeight, + borderRadius: BorderRadius.circular(_progressBarRadius), backgroundColor: colors.surfaceContainerHighest, color: colors.primary, ), diff --git a/coffee_quest/lib/features/path/presentation/path_screen.dart b/coffee_quest/lib/features/path/presentation/path_screen.dart index 0a121f7..25e9c3f 100644 --- a/coffee_quest/lib/features/path/presentation/path_screen.dart +++ b/coffee_quest/lib/features/path/presentation/path_screen.dart @@ -3,10 +3,13 @@ import 'package:coffee_quest/core/widgets/error_view.dart'; import 'package:coffee_quest/core/widgets/loading_indicator.dart'; import 'package:coffee_quest/features/learn/domain/learn_providers.dart'; import 'package:coffee_quest/features/path/presentation/path_module_node_widget.dart'; +import 'package:coffee_quest/shared/theme/app_spacing.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +/// Path tab: the vertical learning journey of module nodes. class PathScreen extends ConsumerWidget { + /// Creates a [PathScreen]. const PathScreen({super.key}); @override @@ -70,8 +73,8 @@ class _PathHeader extends StatelessWidget { const SizedBox(height: 10), LinearProgressIndicator( value: progress, - minHeight: 8, - borderRadius: BorderRadius.circular(4), + minHeight: AppSpacing.xs, + borderRadius: BorderRadius.circular(AppSpacing.xxs), backgroundColor: colors.surfaceContainerHighest, color: colors.primary, ), diff --git a/coffee_quest/lib/features/profile/presentation/profile_screen.dart b/coffee_quest/lib/features/profile/presentation/profile_screen.dart index be1063d..218267b 100644 --- a/coffee_quest/lib/features/profile/presentation/profile_screen.dart +++ b/coffee_quest/lib/features/profile/presentation/profile_screen.dart @@ -4,11 +4,14 @@ import 'package:coffee_quest/features/profile/presentation/widgets/premium_card. import 'package:coffee_quest/features/profile/presentation/widgets/profile_header.dart'; import 'package:coffee_quest/features/profile/presentation/widgets/stat_tile.dart'; import 'package:coffee_quest/features/progress/domain/progress_providers.dart'; +import 'package:coffee_quest/shared/theme/app_spacing.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +/// Profile tab: progress stats, the premium CTA, and preferences. class ProfileScreen extends ConsumerWidget { + /// Creates a [ProfileScreen]. const ProfileScreen({super.key}); @override @@ -119,8 +122,8 @@ class _StatsGrid extends StatelessWidget { shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), crossAxisCount: 2, - mainAxisSpacing: 12, - crossAxisSpacing: 12, + mainAxisSpacing: AppSpacing.sm, + crossAxisSpacing: AppSpacing.sm, children: [ StatTile(icon: Icons.bolt, label: 'Total XP', value: '$xp'), StatTile( @@ -160,8 +163,8 @@ class _CustomizeGrid extends StatelessWidget { shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), crossAxisCount: 2, - mainAxisSpacing: 12, - crossAxisSpacing: 12, + mainAxisSpacing: AppSpacing.sm, + crossAxisSpacing: AppSpacing.sm, children: [ PreferenceTile.toggle( icon: Icons.volume_up_outlined, diff --git a/coffee_quest/lib/features/profile/presentation/settings_screen.dart b/coffee_quest/lib/features/profile/presentation/settings_screen.dart index 30b22b1..8e38f55 100644 --- a/coffee_quest/lib/features/profile/presentation/settings_screen.dart +++ b/coffee_quest/lib/features/profile/presentation/settings_screen.dart @@ -10,6 +10,7 @@ import 'package:go_router/go_router.dart'; /// app-wide preferences (haptics, sound), the destructive reset action, and /// the version footer. class SettingsScreen extends ConsumerWidget { + /// Creates a [SettingsScreen]. const SettingsScreen({super.key}); @override diff --git a/coffee_quest/lib/features/profile/presentation/widgets/preference_tile.dart b/coffee_quest/lib/features/profile/presentation/widgets/preference_tile.dart index 6d11b3f..6a6e4e7 100644 --- a/coffee_quest/lib/features/profile/presentation/widgets/preference_tile.dart +++ b/coffee_quest/lib/features/profile/presentation/widgets/preference_tile.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; /// * [PreferenceTile.action] — taps run [onTap]; an optional [trailingText] /// hints at the action (e.g. "Coming soon"). class PreferenceTile extends StatelessWidget { + /// Creates a toggle [PreferenceTile] backed by a [Switch]. const PreferenceTile.toggle({ required this.icon, required this.title, @@ -18,6 +19,7 @@ class PreferenceTile extends StatelessWidget { onTap = null, trailingText = null; + /// Creates an action [PreferenceTile] that runs [onTap] when tapped. const PreferenceTile.action({ required this.icon, required this.title, @@ -28,10 +30,19 @@ class PreferenceTile extends StatelessWidget { }) : _value = null, _onChanged = null; + /// Leading icon. final IconData icon; + + /// Tile title. final String title; + + /// Supporting subtitle. final String subtitle; + + /// Tap handler (action flavor). final VoidCallback? onTap; + + /// Optional trailing hint text (action flavor). final String? trailingText; final bool? _value; final ValueChanged? _onChanged; diff --git a/coffee_quest/lib/features/profile/presentation/widgets/premium_card.dart b/coffee_quest/lib/features/profile/presentation/widgets/premium_card.dart index a782146..9db23e8 100644 --- a/coffee_quest/lib/features/profile/presentation/widgets/premium_card.dart +++ b/coffee_quest/lib/features/profile/presentation/widgets/premium_card.dart @@ -9,6 +9,12 @@ class PremiumCard extends StatelessWidget { /// Creates a [PremiumCard]. const PremiumCard({super.key}); + static const double _cornerRadius = 20; + static const double _subtitleAlpha = 0.85; + static const double _iconBadgeSize = 72; + static const double _iconBadgeAlpha = 0.12; + static const double _iconSize = 40; + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -42,7 +48,7 @@ class PremiumCard extends StatelessWidget { 'safe with a Coffee Quest subscription.', style: theme.textTheme.bodyMedium?.copyWith( color: colors.onPrimaryContainer.withValues( - alpha: 0.85, + alpha: _subtitleAlpha, ), ), ), @@ -51,16 +57,18 @@ class PremiumCard extends StatelessWidget { ), const SizedBox(width: 12), Container( - width: 72, - height: 72, + width: _iconBadgeSize, + height: _iconBadgeSize, alignment: Alignment.center, decoration: BoxDecoration( - color: colors.onPrimaryContainer.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(20), + color: colors.onPrimaryContainer.withValues( + alpha: _iconBadgeAlpha, + ), + borderRadius: BorderRadius.circular(_cornerRadius), ), child: Icon( Icons.workspace_premium, - size: 40, + size: _iconSize, color: colors.onPrimaryContainer, ), ), @@ -74,22 +82,21 @@ class PremiumCard extends StatelessWidget { void _showComingSoon(BuildContext context) { unawaited( showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('Premium is brewing'), - content: const Text( - 'In-app purchases are wired up in a later release. Hold tight — ' - 'your streak counts either way.', - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text('OK'), + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Premium is brewing'), + content: const Text( + 'In-app purchases are wired up in a later release. Hold tight — ' + 'your streak counts either way.', ), - ], - ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('OK'), + ), + ], + ), ), ); } } - diff --git a/coffee_quest/lib/shared/storage/app_database.dart b/coffee_quest/lib/shared/storage/app_database.dart index 10a8f59..1fdcf30 100644 --- a/coffee_quest/lib/shared/storage/app_database.dart +++ b/coffee_quest/lib/shared/storage/app_database.dart @@ -83,8 +83,10 @@ class AppDatabase extends _$AppDatabase { AppDatabase([QueryExecutor? executor]) : super(executor ?? driftDatabase(name: 'coffee_quest')); + static const int _schemaVersion = 3; + @override - int get schemaVersion => 3; + int get schemaVersion => _schemaVersion; @override MigrationStrategy get migration => MigrationStrategy( @@ -98,7 +100,7 @@ class AppDatabase extends _$AppDatabase { await m.createTable(moduleProgressRecords); } // v2 → v3: onboarding gate + selection columns on user_settings. - if (from < 3) { + if (from < _schemaVersion) { await m.addColumn(userSettings, userSettings.onboardingCompleted); await m.addColumn(userSettings, userSettings.onboardingGoal); await m.addColumn(userSettings, userSettings.onboardingBrewer); From 5cf6a63bcbc41d10837f9cd99ebb7c7106280f13 Mon Sep 17 00:00:00 2001 From: maximsan Date: Wed, 10 Jun 2026 21:38:10 +0200 Subject: [PATCH 6/8] chore(lint): extract remaining magic numbers, calibrate DCL metrics, gate CI Finish no-magic-number remediation (learn/game-type/premium/settings + token mappings to AppSpacing); raise source-lines-of-code metric to 90 for declarative build methods; clear very_good full-tree analyze (disable sort_pub_dependencies, wrap long test lines); add dart_code_linter metrics step to CI; update CLAUDE/AGENTS/README to reflect lint-enforced magic numbers + per-function size. --- .github/workflows/ci.yml | 9 +++++++ AGENTS.md | 27 +++++++++---------- CLAUDE.md | 27 +++++++++---------- coffee_quest/README.md | 9 ++++--- coffee_quest/analysis_options.yaml | 11 +++++--- .../game_type_practice_screen.dart | 24 +++++++++++------ .../learn/presentation/learn_screen.dart | 10 +++++-- .../presentation/drag_drop_game.dart | 4 ++- .../presentation/path_module_node_widget.dart | 4 ++- .../profile/presentation/settings_screen.dart | 4 ++- .../presentation/widgets/premium_card.dart | 2 +- .../support/fake_onboarding_repository.dart | 1 + .../test/unit/content_repository_test.dart | 3 ++- .../test/widget/settings_screen_test.dart | 2 +- 14 files changed, 87 insertions(+), 50 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2ea3569..eeeb9f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,15 @@ jobs: - name: Analyze run: flutter analyze + # dart_code_linter's per-function metrics (source-lines-of-code, + # cyclomatic-complexity, nesting, parameter/method counts) aren't surfaced + # by `flutter analyze` — run the CLI gate so metric breaches fail CI. + # no-magic-number is a plugin rule, already enforced by Analyze above. + - name: Metrics (dart_code_linter) + run: > + dart run dart_code_linter:metrics analyze lib + --set-exit-on-violation-level=warning + # Runs every *_test.dart under test/ recursively, including the # Drift schema smoke/UNIQUE tests in test/database/. Generated # helpers are committed, so no codegen step is required here. diff --git a/AGENTS.md b/AGENTS.md index 96b2251..fbc209a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,26 +62,25 @@ explanations — plus test and iOS/SPM build notes — lives in - **Providers:** function-style `@riverpod` only; class-based `@riverpod` only when state is mutable - **Tests:** unit tests in `test/unit/`; widget tests in `test/widget/`; integration tests in `integration_test/` - **No print statements** — use `debugPrint` only in development guards; never in production paths -- **Lints:** `riverpod_lint` is active via the `plugins:` block in `analysis_options.yaml` (native analysis_server_plugin — not a dependency, not `custom_lint`) +- **Lints:** `very_good_analysis` baseline + `riverpod_lint` and `dart_code_linter` active via the `plugins:` block in `analysis_options.yaml` (native analysis_server_plugins — not `custom_lint`) ## Code Quality Rules -These are not all lint-enforceable. `riverpod_lint` 3.x **is** active (native -`analysis_server_plugin`, enabled via the `plugins:` block in -`analysis_options.yaml` — no `custom_lint`). But the stock linter still has no -rule for magic numbers, identifier length, or file/class size, and -`custom_lint`-based packs remain blocked (analyzer-8 cap). So the rules below -stay conventions — follow them on every new or modified file: - -- **No magic numbers.** Extract meaningful or repeated literals to named - `static const` or theme tokens (`AppSpacing`, `AppColors`, `AppTypography`). - Only `0`/`1` may appear inline. Animation/layout constants get intent names - (`_stageSize`, `_captionGap`), not bare numbers in the widget tree. +Magic numbers and per-function size/complexity are now **lint-enforced** by +`dart_code_linter` (config + exclusions in `analysis_options.yaml`). +**Identifier length** and **per-file size** have no rule, so they stay +conventions — follow every rule below on each new or modified file: + +- **No magic numbers** (lint-enforced). Extract meaningful or repeated literals + to named `static const` or theme tokens (`AppSpacing`, `AppColors`, + `AppTypography`); only `0`/`1`/`2` inline, with intent names (`_stageSize`), + never bare numbers in the widget tree. - **Descriptive names.** No single-letter identifiers except trivial loop indices (`i`). Animation/controller values are `progress`, not `t`; phase fractions get real names (`normalizedPhase`, not `p`). -- **Small files & classes.** Soft cap ≈ 250 lines/file. When a file grows - multiple `State`/widget classes or mixes UI with logic, split it. Keep +- **Small files & classes.** Per-file size stays a convention (soft cap ≈ 250 + lines/file — no rule); per-function size/complexity is lint-enforced. Split a + file that grows multiple `State`/widget classes or mixes UI with logic; keep `build()` declarative — push math and derivations into named helpers. - **Extract pure helpers.** Animation math, mapping, and derivations live in a sibling pure-Dart file (e.g. `*_animation.dart`) as named top-level functions, diff --git a/CLAUDE.md b/CLAUDE.md index e8e7d62..b801bf2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,26 +62,25 @@ explanations — plus test and iOS/SPM build notes — lives in - **Providers:** function-style `@riverpod` only; class-based `@riverpod` only when state is mutable - **Tests:** unit tests in `test/unit/`; widget tests in `test/widget/`; integration tests in `integration_test/` - **No print statements** — use `debugPrint` only in development guards; never in production paths -- **Lints:** `riverpod_lint` is active via the `plugins:` block in `analysis_options.yaml` (native analysis_server_plugin — not a dependency, not `custom_lint`) +- **Lints:** `very_good_analysis` baseline + `riverpod_lint` and `dart_code_linter` active via the `plugins:` block in `analysis_options.yaml` (native analysis_server_plugins — not `custom_lint`) ## Code Quality Rules -These are not all lint-enforceable. `riverpod_lint` 3.x **is** active (native -`analysis_server_plugin`, enabled via the `plugins:` block in -`analysis_options.yaml` — no `custom_lint`). But the stock linter still has no -rule for magic numbers, identifier length, or file/class size, and -`custom_lint`-based packs remain blocked (analyzer-8 cap). So the rules below -stay conventions — follow them on every new or modified file: - -- **No magic numbers.** Extract meaningful or repeated literals to named - `static const` or theme tokens (`AppSpacing`, `AppColors`, `AppTypography`). - Only `0`/`1` may appear inline. Animation/layout constants get intent names - (`_stageSize`, `_captionGap`), not bare numbers in the widget tree. +Magic numbers and per-function size/complexity are now **lint-enforced** by +`dart_code_linter` (config + exclusions in `analysis_options.yaml`). +**Identifier length** and **per-file size** have no rule, so they stay +conventions — follow every rule below on each new or modified file: + +- **No magic numbers** (lint-enforced). Extract meaningful or repeated literals + to named `static const` or theme tokens (`AppSpacing`, `AppColors`, + `AppTypography`); only `0`/`1`/`2` inline, with intent names (`_stageSize`), + never bare numbers in the widget tree. - **Descriptive names.** No single-letter identifiers except trivial loop indices (`i`). Animation/controller values are `progress`, not `t`; phase fractions get real names (`normalizedPhase`, not `p`). -- **Small files & classes.** Soft cap ≈ 250 lines/file. When a file grows - multiple `State`/widget classes or mixes UI with logic, split it. Keep +- **Small files & classes.** Per-file size stays a convention (soft cap ≈ 250 + lines/file — no rule); per-function size/complexity is lint-enforced. Split a + file that grows multiple `State`/widget classes or mixes UI with logic; keep `build()` declarative — push math and derivations into named helpers. - **Extract pure helpers.** Animation math, mapping, and derivations live in a sibling pure-Dart file (e.g. `*_animation.dart`) as named top-level functions, diff --git a/coffee_quest/README.md b/coffee_quest/README.md index 90f65b1..c22c3ce 100644 --- a/coffee_quest/README.md +++ b/coffee_quest/README.md @@ -7,9 +7,12 @@ time. Flutter, offline-first. **Stack:** Flutter · Riverpod 3 (state) · go_router 17 (navigation) · Drift 2.33 / SQLite (offline persistence) · Freezed 3 + json_serializable (content models). -**Toolchain:** analyzer 12 · `riverpod_lint` enabled via the `plugins:` block in -`analysis_options.yaml` (native analysis_server_plugin — not a dependency, no -`custom_lint`). +**Toolchain:** analyzer 12 · `very_good_analysis` (strict lint baseline) · +`riverpod_lint` + `dart_code_linter` enabled via the `plugins:` block in +`analysis_options.yaml` (native analysis_server_plugins — not dependencies, no +`custom_lint`). `dart_code_linter` adds `no-magic-number` plus a CI metrics gate +(`dart run dart_code_linter:metrics analyze lib`) for per-function size & +complexity. Architecture and conventions live in [`../CLAUDE.md`](../CLAUDE.md); deeper design and milestone docs are in [`../docs/`](../docs/). diff --git a/coffee_quest/analysis_options.yaml b/coffee_quest/analysis_options.yaml index f16ca80..73444de 100644 --- a/coffee_quest/analysis_options.yaml +++ b/coffee_quest/analysis_options.yaml @@ -21,6 +21,9 @@ linter: # very_good_analysis re-enables cascade_invocations; keep it off — it fights # the CustomPainter draw sequences (roasty.dart) and isn't auto-fixable. cascade_invocations: false + # pubspec deps are grouped by concern with explanatory comments (State / Nav + # / Persistence / Firebase …); alphabetical sorting would scatter the groups. + sort_pub_dependencies: false # dart_code_linter — magic-number + per-function/class size & complexity that the # stock linter can't enforce. CustomPainter / animation-geometry and theme-token @@ -30,9 +33,11 @@ dart_code_linter: cyclomatic-complexity: 20 maximum-nesting-level: 5 number-of-parameters: 5 - # 75 (not DCM's default 50): Flutter widget `build` methods legitimately run - # 50–60 lines of declarative tree. cyclomatic-complexity guards real logic. - source-lines-of-code: 75 + # 90 (not DCM's default 50): measured declarative `build` methods here — the + # collapsing profile header, preference/stat tiles — legitimately run 75–90 + # lines of widget tree with no branching. cyclomatic-complexity (20) and + # maximum-nesting-level (5) stay strict; they guard real logic, SLOC doesn't. + source-lines-of-code: 90 number-of-methods: 12 metrics-exclude: - test/** diff --git a/coffee_quest/lib/features/learn/presentation/game_type_practice_screen.dart b/coffee_quest/lib/features/learn/presentation/game_type_practice_screen.dart index b343581..ccbeebc 100644 --- a/coffee_quest/lib/features/learn/presentation/game_type_practice_screen.dart +++ b/coffee_quest/lib/features/learn/presentation/game_type_practice_screen.dart @@ -10,6 +10,14 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +const double _emptyIconSize = 56; +const double _summaryBadgeSize = 96; +const double _summaryIconSize = 48; +const double _pillRadius = 20; +const double _progressBarHeight = 6; +const double _progressBarRadius = 3; +const double _percentScale = 100; + /// Cross-lesson practice drill for a single game type. Pulls every step of /// the chosen type out of the user's completed lessons and runs them through /// the standard [LessonStepRunner]. No DB writes, no XP, no card unlocks. @@ -131,7 +139,7 @@ class _EmptyState extends StatelessWidget { children: [ Icon( Icons.school_outlined, - size: 56, + size: _emptyIconSize, color: theme.colorScheme.onSurfaceVariant, ), const SizedBox(height: 16), @@ -177,8 +185,8 @@ class _Summary extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ Container( - width: 96, - height: 96, + width: _summaryBadgeSize, + height: _summaryBadgeSize, alignment: Alignment.center, decoration: BoxDecoration( color: colors.primaryContainer, @@ -186,7 +194,7 @@ class _Summary extends StatelessWidget { ), child: Icon( Icons.fitness_center, - size: 48, + size: _summaryIconSize, color: colors.onPrimaryContainer, ), ), @@ -247,7 +255,7 @@ class _StepProgress extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration( color: colors.primaryContainer, - borderRadius: BorderRadius.circular(20), + borderRadius: BorderRadius.circular(_pillRadius), ), child: Text( 'Step $current of $total', @@ -258,7 +266,7 @@ class _StepProgress extends StatelessWidget { ), ), Text( - '${(progress * 100).round()}%', + '${(progress * _percentScale).round()}%', style: theme.textTheme.labelMedium?.copyWith( color: colors.onSurfaceVariant, ), @@ -268,8 +276,8 @@ class _StepProgress extends StatelessWidget { const SizedBox(height: 8), LinearProgressIndicator( value: progress, - minHeight: 6, - borderRadius: BorderRadius.circular(3), + minHeight: _progressBarHeight, + borderRadius: BorderRadius.circular(_progressBarRadius), backgroundColor: colors.surfaceContainerHighest, color: colors.primary, ), diff --git a/coffee_quest/lib/features/learn/presentation/learn_screen.dart b/coffee_quest/lib/features/learn/presentation/learn_screen.dart index a7430a0..978d79d 100644 --- a/coffee_quest/lib/features/learn/presentation/learn_screen.dart +++ b/coffee_quest/lib/features/learn/presentation/learn_screen.dart @@ -171,7 +171,11 @@ class _TodayCard extends StatelessWidget { padding: const EdgeInsets.all(20), child: Row( children: [ - Icon(Icons.check_circle, size: _iconLg, color: colors.onPrimaryContainer), + Icon( + Icons.check_circle, + size: _iconLg, + color: colors.onPrimaryContainer, + ), const SizedBox(width: 16), Expanded( child: Column( @@ -189,7 +193,9 @@ class _TodayCard extends StatelessWidget { Text( 'No lessons left to study.', style: theme.textTheme.bodyMedium?.copyWith( - color: colors.onPrimaryContainer.withValues(alpha: _mutedAlpha), + color: colors.onPrimaryContainer.withValues( + alpha: _mutedAlpha, + ), ), ), ], diff --git a/coffee_quest/lib/features/mini_games/presentation/drag_drop_game.dart b/coffee_quest/lib/features/mini_games/presentation/drag_drop_game.dart index 02503f6..a9bb309 100644 --- a/coffee_quest/lib/features/mini_games/presentation/drag_drop_game.dart +++ b/coffee_quest/lib/features/mini_games/presentation/drag_drop_game.dart @@ -103,7 +103,9 @@ class _DragDropGameState extends State { color: done ? colors.primary : colors.outline, width: done ? 2 : 1, ), - borderRadius: BorderRadius.circular(AppSpacing.xs), + borderRadius: BorderRadius.circular( + AppSpacing.xs, + ), ), child: Text(definitions[j]), ); diff --git a/coffee_quest/lib/features/path/presentation/path_module_node_widget.dart b/coffee_quest/lib/features/path/presentation/path_module_node_widget.dart index 98e78e8..224f9c8 100644 --- a/coffee_quest/lib/features/path/presentation/path_module_node_widget.dart +++ b/coffee_quest/lib/features/path/presentation/path_module_node_widget.dart @@ -120,7 +120,9 @@ class _Connector extends StatelessWidget { @override Widget build(BuildContext context) { - return Center(child: Container(width: _connectorWidth, color: color)); + return Center( + child: Container(width: _connectorWidth, color: color), + ); } } diff --git a/coffee_quest/lib/features/profile/presentation/settings_screen.dart b/coffee_quest/lib/features/profile/presentation/settings_screen.dart index 8e38f55..08b7fe8 100644 --- a/coffee_quest/lib/features/profile/presentation/settings_screen.dart +++ b/coffee_quest/lib/features/profile/presentation/settings_screen.dart @@ -82,6 +82,8 @@ class SettingsScreen extends ConsumerWidget { class _SectionLabel extends StatelessWidget { const _SectionLabel(this.text); + static const double _letterSpacing = 1.2; + final String text; @override @@ -93,7 +95,7 @@ class _SectionLabel extends StatelessWidget { text.toUpperCase(), style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, - letterSpacing: 1.2, + letterSpacing: _letterSpacing, fontWeight: FontWeight.w700, ), ), diff --git a/coffee_quest/lib/features/profile/presentation/widgets/premium_card.dart b/coffee_quest/lib/features/profile/presentation/widgets/premium_card.dart index 9db23e8..ae35428 100644 --- a/coffee_quest/lib/features/profile/presentation/widgets/premium_card.dart +++ b/coffee_quest/lib/features/profile/presentation/widgets/premium_card.dart @@ -22,7 +22,7 @@ class PremiumCard extends StatelessWidget { return Material( color: colors.primaryContainer, - borderRadius: BorderRadius.circular(20), + borderRadius: BorderRadius.circular(_cornerRadius), clipBehavior: Clip.antiAlias, child: InkWell( onTap: () => _showComingSoon(context), diff --git a/coffee_quest/test/support/fake_onboarding_repository.dart b/coffee_quest/test/support/fake_onboarding_repository.dart index 4513cfb..2dce9b6 100644 --- a/coffee_quest/test/support/fake_onboarding_repository.dart +++ b/coffee_quest/test/support/fake_onboarding_repository.dart @@ -17,6 +17,7 @@ class FakeOnboardingRepository implements OnboardingRepository { /// Overrides the state returned by [getState] (e.g. to simulate a returning, /// already-onboarded user). + // ignore: use_setters_to_change_properties — imperative test-arrange helper void setState(OnboardingState state) => _state = state; @override diff --git a/coffee_quest/test/unit/content_repository_test.dart b/coffee_quest/test/unit/content_repository_test.dart index 615a7f1..77d26db 100644 --- a/coffee_quest/test/unit/content_repository_test.dart +++ b/coffee_quest/test/unit/content_repository_test.dart @@ -21,7 +21,8 @@ void main() { module.lessonIds.length, greaterThanOrEqualTo(5), reason: - '${module.id} has only ${module.lessonIds.length} lessons (need ≥5)', + '${module.id} has only ${module.lessonIds.length} lessons ' + '(need ≥5)', ); } }); diff --git a/coffee_quest/test/widget/settings_screen_test.dart b/coffee_quest/test/widget/settings_screen_test.dart index 953e54a..f98e51d 100644 --- a/coffee_quest/test/widget/settings_screen_test.dart +++ b/coffee_quest/test/widget/settings_screen_test.dart @@ -52,7 +52,7 @@ void main() { testWidgets('Reset Progress is gated behind a confirmation dialog', ( tester, ) async { - // Seed some progress so we can prove the dialog Cancel path is a true no-op. + // Seed progress so we can prove the dialog Cancel path is a true no-op. await ProgressRepository().saveCompletion( lessonId: 'lesson_a', xpEarned: 30, From b8e9ae93889abc7af18a83ad45802e007cbd5883 Mon Sep 17 00:00:00 2001 From: maximsan Date: Wed, 10 Jun 2026 22:59:24 +0200 Subject: [PATCH 7/8] refactor(learn,lessons,path): split oversized screens into collocated widgets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decompose learn / lesson-completion / game-type-practice / module-detail / path-node files (271–437 lines) into one-public-widget-per-file units; extract LessonCompletionBody as a pure (DB-free) view + reward DTO. --- .../game_type_practice_screen.dart | 180 +------- .../game_type_practice_widgets.dart | 194 +++++++++ .../learn/presentation/learn_screen.dart | 383 +----------------- .../module_detail_hero_widget.dart | 64 +++ .../presentation/module_detail_screen.dart | 200 +-------- .../module_lesson_card_widget.dart | 146 +++++++ .../practice_any_lesson_widget.dart | 156 +++++++ .../practice_by_game_type_widget.dart | 60 +++ .../learn/presentation/today_card_widget.dart | 179 ++++++++ .../presentation/lesson_completion_body.dart | 245 +++++++++++ .../lesson_completion_reward.dart | 19 + .../lesson_completion_screen.dart | 245 +---------- .../presentation/path_module_node_widget.dart | 250 +----------- .../path/presentation/path_node_card.dart | 135 ++++++ .../path/presentation/path_node_rail.dart | 125 ++++++ 15 files changed, 1357 insertions(+), 1224 deletions(-) create mode 100644 coffee_quest/lib/features/learn/presentation/game_type_practice_widgets.dart create mode 100644 coffee_quest/lib/features/learn/presentation/module_detail_hero_widget.dart create mode 100644 coffee_quest/lib/features/learn/presentation/module_lesson_card_widget.dart create mode 100644 coffee_quest/lib/features/learn/presentation/practice_any_lesson_widget.dart create mode 100644 coffee_quest/lib/features/learn/presentation/practice_by_game_type_widget.dart create mode 100644 coffee_quest/lib/features/learn/presentation/today_card_widget.dart create mode 100644 coffee_quest/lib/features/lessons/presentation/lesson_completion_body.dart create mode 100644 coffee_quest/lib/features/lessons/presentation/lesson_completion_reward.dart create mode 100644 coffee_quest/lib/features/path/presentation/path_node_card.dart create mode 100644 coffee_quest/lib/features/path/presentation/path_node_rail.dart diff --git a/coffee_quest/lib/features/learn/presentation/game_type_practice_screen.dart b/coffee_quest/lib/features/learn/presentation/game_type_practice_screen.dart index ccbeebc..857a94b 100644 --- a/coffee_quest/lib/features/learn/presentation/game_type_practice_screen.dart +++ b/coffee_quest/lib/features/learn/presentation/game_type_practice_screen.dart @@ -1,6 +1,7 @@ import 'package:coffee_quest/core/widgets/error_view.dart'; import 'package:coffee_quest/core/widgets/loading_indicator.dart'; import 'package:coffee_quest/features/learn/domain/learn_providers.dart'; +import 'package:coffee_quest/features/learn/presentation/game_type_practice_widgets.dart'; import 'package:coffee_quest/features/mini_games/domain/mini_game_result.dart'; import 'package:coffee_quest/features/mini_games/presentation/lesson_step_runner.dart'; import 'package:coffee_quest/features/progress/domain/progress_providers.dart'; @@ -8,15 +9,6 @@ import 'package:coffee_quest/shared/models/lesson_step_model.dart'; import 'package:coffee_quest/shared/repositories/content_repository.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -const double _emptyIconSize = 56; -const double _summaryBadgeSize = 96; -const double _summaryIconSize = 48; -const double _pillRadius = 20; -const double _progressBarHeight = 6; -const double _progressBarRadius = 3; -const double _percentScale = 100; /// Cross-lesson practice drill for a single game type. Pulls every step of /// the chosen type out of the user's completed lessons and runs them through @@ -88,9 +80,9 @@ class _GameTypePracticeScreenState } if (snap.hasError) return ErrorView(message: '${snap.error}'); final steps = snap.data ?? const []; - if (steps.isEmpty) return const _EmptyState(); + if (steps.isEmpty) return const GameTypeEmptyState(); if (_stepIndex >= steps.length) { - return _Summary( + return GameTypeSummary( correct: _firstTryCorrectCount, total: steps.length, ); @@ -109,7 +101,10 @@ class _GameTypePracticeScreenState ), ), const SizedBox(height: 16), - _StepProgress(current: _stepIndex + 1, total: steps.length), + GameTypeStepProgress( + current: _stepIndex + 1, + total: steps.length, + ), const SizedBox(height: 24), LessonStepRunner( key: ValueKey('${_stepIndex}_$_attempt'), @@ -124,164 +119,3 @@ class _GameTypePracticeScreenState ); } } - -class _EmptyState extends StatelessWidget { - const _EmptyState(); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.school_outlined, - size: _emptyIconSize, - color: theme.colorScheme.onSurfaceVariant, - ), - const SizedBox(height: 16), - Text( - 'No practice questions yet', - textAlign: TextAlign.center, - style: theme.textTheme.titleMedium, - ), - const SizedBox(height: 4), - Text( - 'Complete a lesson with this game type to unlock practice.', - textAlign: TextAlign.center, - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 16), - FilledButton( - onPressed: () => context.go('/learn'), - child: const Text('Back to Learn'), - ), - ], - ), - ), - ); - } -} - -class _Summary extends StatelessWidget { - const _Summary({required this.correct, required this.total}); - - final int correct; - final int total; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final colors = theme.colorScheme; - return Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: _summaryBadgeSize, - height: _summaryBadgeSize, - alignment: Alignment.center, - decoration: BoxDecoration( - color: colors.primaryContainer, - shape: BoxShape.circle, - ), - child: Icon( - Icons.fitness_center, - size: _summaryIconSize, - color: colors.onPrimaryContainer, - ), - ), - const SizedBox(height: 20), - Text( - 'Practice complete!', - style: theme.textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 12), - Text( - '$correct / $total first-try correct', - style: theme.textTheme.titleLarge?.copyWith( - color: colors.primary, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 4), - Text( - 'Practice runs do not change your XP, streak, or progress.', - textAlign: TextAlign.center, - style: theme.textTheme.bodyMedium?.copyWith( - color: colors.onSurfaceVariant, - ), - ), - const SizedBox(height: 32), - FilledButton( - onPressed: () => context.go('/learn'), - child: const Text('Continue'), - ), - ], - ), - ), - ); - } -} - -/// Compact step indicator matching the standard lesson runner's style. -class _StepProgress extends StatelessWidget { - const _StepProgress({required this.current, required this.total}); - - final int current; - final int total; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final colors = theme.colorScheme; - final progress = total == 0 ? 0.0 : current / total; - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: colors.primaryContainer, - borderRadius: BorderRadius.circular(_pillRadius), - ), - child: Text( - 'Step $current of $total', - style: theme.textTheme.labelMedium?.copyWith( - color: colors.onPrimaryContainer, - fontWeight: FontWeight.w600, - ), - ), - ), - Text( - '${(progress * _percentScale).round()}%', - style: theme.textTheme.labelMedium?.copyWith( - color: colors.onSurfaceVariant, - ), - ), - ], - ), - const SizedBox(height: 8), - LinearProgressIndicator( - value: progress, - minHeight: _progressBarHeight, - borderRadius: BorderRadius.circular(_progressBarRadius), - backgroundColor: colors.surfaceContainerHighest, - color: colors.primary, - ), - ], - ); - } -} diff --git a/coffee_quest/lib/features/learn/presentation/game_type_practice_widgets.dart b/coffee_quest/lib/features/learn/presentation/game_type_practice_widgets.dart new file mode 100644 index 0000000..79752a3 --- /dev/null +++ b/coffee_quest/lib/features/learn/presentation/game_type_practice_widgets.dart @@ -0,0 +1,194 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +/// Shown when the user has no completed lessons containing the chosen game +/// type, so there is nothing to practice yet. +class GameTypeEmptyState extends StatelessWidget { + /// Creates a [GameTypeEmptyState]. + const GameTypeEmptyState({super.key}); + + static const double _iconSize = 56; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.school_outlined, + size: _iconSize, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 16), + Text( + 'No practice questions yet', + textAlign: TextAlign.center, + style: theme.textTheme.titleMedium, + ), + const SizedBox(height: 4), + Text( + 'Complete a lesson with this game type to unlock practice.', + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 16), + FilledButton( + onPressed: () => context.go('/learn'), + child: const Text('Back to Learn'), + ), + ], + ), + ), + ); + } +} + +/// Terminal screen of a practice drill: a badge, the first-try score, and a +/// reminder that practice runs change nothing, then a Continue button. +class GameTypeSummary extends StatelessWidget { + /// Creates a [GameTypeSummary]. + const GameTypeSummary({ + required this.correct, + required this.total, + super.key, + }); + + /// Number of steps answered correctly on the first attempt. + final int correct; + + /// Total steps in the drill. + final int total; + + static const double _badgeSize = 96; + static const double _iconSize = 48; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: _badgeSize, + height: _badgeSize, + alignment: Alignment.center, + decoration: BoxDecoration( + color: colors.primaryContainer, + shape: BoxShape.circle, + ), + child: Icon( + Icons.fitness_center, + size: _iconSize, + color: colors.onPrimaryContainer, + ), + ), + const SizedBox(height: 20), + Text( + 'Practice complete!', + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 12), + Text( + '$correct / $total first-try correct', + style: theme.textTheme.titleLarge?.copyWith( + color: colors.primary, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + 'Practice runs do not change your XP, streak, or progress.', + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + const SizedBox(height: 32), + FilledButton( + onPressed: () => context.go('/learn'), + child: const Text('Continue'), + ), + ], + ), + ), + ); + } +} + +/// Compact step indicator matching the standard lesson runner's style. +class GameTypeStepProgress extends StatelessWidget { + /// Creates a [GameTypeStepProgress]. + const GameTypeStepProgress({ + required this.current, + required this.total, + super.key, + }); + + /// 1-based index of the current step. + final int current; + + /// Total steps in the drill. + final int total; + + static const double _pillRadius = 20; + static const double _barHeight = 6; + static const double _barRadius = 3; + static const double _percentScale = 100; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + final progress = total == 0 ? 0.0 : current / total; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: colors.primaryContainer, + borderRadius: BorderRadius.circular(_pillRadius), + ), + child: Text( + 'Step $current of $total', + style: theme.textTheme.labelMedium?.copyWith( + color: colors.onPrimaryContainer, + fontWeight: FontWeight.w600, + ), + ), + ), + Text( + '${(progress * _percentScale).round()}%', + style: theme.textTheme.labelMedium?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ], + ), + const SizedBox(height: 8), + LinearProgressIndicator( + value: progress, + minHeight: _barHeight, + borderRadius: BorderRadius.circular(_barRadius), + backgroundColor: colors.surfaceContainerHighest, + color: colors.primary, + ), + ], + ); + } +} diff --git a/coffee_quest/lib/features/learn/presentation/learn_screen.dart b/coffee_quest/lib/features/learn/presentation/learn_screen.dart index 978d79d..e106047 100644 --- a/coffee_quest/lib/features/learn/presentation/learn_screen.dart +++ b/coffee_quest/lib/features/learn/presentation/learn_screen.dart @@ -1,27 +1,15 @@ import 'package:coffee_quest/core/constants/app_strings.dart'; -import 'package:coffee_quest/core/utils/module_icons.dart'; import 'package:coffee_quest/core/widgets/error_view.dart'; import 'package:coffee_quest/core/widgets/loading_indicator.dart'; import 'package:coffee_quest/core/widgets/section_header.dart'; import 'package:coffee_quest/features/learn/domain/learn_providers.dart'; import 'package:coffee_quest/features/learn/presentation/module_card_widget.dart'; +import 'package:coffee_quest/features/learn/presentation/practice_any_lesson_widget.dart'; +import 'package:coffee_quest/features/learn/presentation/practice_by_game_type_widget.dart'; +import 'package:coffee_quest/features/learn/presentation/today_card_widget.dart'; import 'package:coffee_quest/features/progress/domain/progress_providers.dart'; -import 'package:coffee_quest/shared/models/lesson_model.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -const double _heroRadius = 12; -const double _pillRadius = 20; -const double _rowBadgeSize = 36; -const double _rowBadgeRadius = 10; -const double _iconSm = 18; -const double _iconMd = 16; -const double _iconLg = 40; -const double _chipGap = 8; -const double _mutedAlpha = 0.8; -const double _pillAlpha = 0.12; -const double _heroLetterSpacing = 0.6; /// Learn tab: today's lesson, the module list, and practice sections. class LearnScreen extends ConsumerWidget { @@ -45,11 +33,11 @@ class LearnScreen extends ConsumerWidget { padding: const EdgeInsets.all(16), physics: const AlwaysScrollableScrollPhysics(), children: [ - _TodayCard(today: today.asData?.value), + TodayCardWidget(today: today.asData?.value), const SizedBox(height: 24), const SectionHeader('Practice any lesson'), const SizedBox(height: 12), - _PracticeAnyLessonSection( + PracticeAnyLessonWidget( lessons: allLessons.asData?.value ?? const [], completedIds: completedLessons.asData?.value @@ -60,7 +48,7 @@ class LearnScreen extends ConsumerWidget { const SizedBox(height: 24), const SectionHeader('Practice by game type'), const SizedBox(height: 12), - _PracticeByGameTypeSection( + PracticeByGameTypeWidget( counts: gameTypeCounts.asData?.value ?? const {}, ), const SizedBox(height: 24), @@ -76,362 +64,3 @@ class LearnScreen extends ConsumerWidget { ); } } - -/// Hero card for the day's primary action. Renders the next lesson with a -/// prominent `Start` CTA, or a friendly caught-up state when nothing is due. -class _TodayCard extends StatelessWidget { - const _TodayCard({required this.today}); - - final LessonModel? today; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final colors = theme.colorScheme; - final lesson = today; - - return Card( - margin: EdgeInsets.zero, - color: colors.primaryContainer, - child: lesson == null - ? _buildCaughtUp(theme, colors) - : _buildLesson(context, theme, colors, lesson), - ); - } - - Widget _buildLesson( - BuildContext context, - ThemeData theme, - ColorScheme colors, - LessonModel lesson, - ) { - return InkWell( - onTap: () => context.go('/learn/lesson/${lesson.id}'), - borderRadius: BorderRadius.circular(_heroRadius), - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Row( - children: [ - Icon( - Icons.local_cafe, - size: _iconSm, - color: colors.onPrimaryContainer, - ), - const SizedBox(width: 8), - Text( - "Today's lesson", - style: theme.textTheme.labelMedium?.copyWith( - color: colors.onPrimaryContainer, - letterSpacing: _heroLetterSpacing, - ), - ), - ], - ), - const SizedBox(height: 8), - Text( - lesson.title, - style: theme.textTheme.titleLarge?.copyWith( - color: colors.onPrimaryContainer, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 4), - Text( - lesson.summary, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodyMedium?.copyWith( - color: colors.onPrimaryContainer.withValues(alpha: _mutedAlpha), - ), - ), - const SizedBox(height: 16), - Row( - children: [ - _XpPill(xp: lesson.xpReward), - const Spacer(), - FilledButton.icon( - onPressed: () => context.go('/learn/lesson/${lesson.id}'), - icon: const Icon(Icons.play_arrow), - label: const Text('Start'), - ), - ], - ), - ], - ), - ), - ); - } - - Widget _buildCaughtUp(ThemeData theme, ColorScheme colors) { - return Padding( - padding: const EdgeInsets.all(20), - child: Row( - children: [ - Icon( - Icons.check_circle, - size: _iconLg, - color: colors.onPrimaryContainer, - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - "You're all caught up!", - style: theme.textTheme.titleMedium?.copyWith( - color: colors.onPrimaryContainer, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 4), - Text( - 'No lessons left to study.', - style: theme.textTheme.bodyMedium?.copyWith( - color: colors.onPrimaryContainer.withValues( - alpha: _mutedAlpha, - ), - ), - ), - ], - ), - ), - ], - ), - ); - } -} - -/// Compact `+XP` reward chip shown on the hero card. -class _XpPill extends StatelessWidget { - const _XpPill({required this.xp}); - - final int xp; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final colors = theme.colorScheme; - return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - decoration: BoxDecoration( - color: colors.onPrimaryContainer.withValues(alpha: _pillAlpha), - borderRadius: BorderRadius.circular(_pillRadius), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.bolt, size: _iconMd, color: colors.onPrimaryContainer), - const SizedBox(width: 4), - Text( - '+$xp XP', - style: theme.textTheme.labelMedium?.copyWith( - color: colors.onPrimaryContainer, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ); - } -} - -/// Browse-all section that lists every lesson grouped by its module, so the -/// user can pick any lesson to practice without first opening a module. -class _PracticeAnyLessonSection extends StatefulWidget { - const _PracticeAnyLessonSection({ - required this.lessons, - required this.completedIds, - }); - - final List lessons; - final Set completedIds; - - @override - State<_PracticeAnyLessonSection> createState() => - _PracticeAnyLessonSectionState(); -} - -class _PracticeAnyLessonSectionState extends State<_PracticeAnyLessonSection> { - bool _expanded = false; - - @override - Widget build(BuildContext context) { - if (widget.lessons.isEmpty) { - return const _SectionPlaceholder(text: 'No lessons available yet.'); - } - final theme = Theme.of(context); - final colors = theme.colorScheme; - // Collapsed view shows the first few; expanding reveals the rest. Stops - // this section from dominating the screen on first paint while still - // letting the user reach every lesson. - const previewCount = 4; - final list = _expanded - ? widget.lessons - : widget.lessons.take(previewCount).toList(); - - return Card( - margin: EdgeInsets.zero, - child: Column( - children: [ - for (var i = 0; i < list.length; i++) ...[ - if (i > 0) Divider(height: 1, color: colors.outlineVariant), - _LessonRow( - entry: list[i], - completed: widget.completedIds.contains(list[i].lesson.id), - ), - ], - if (widget.lessons.length > previewCount) - InkWell( - onTap: () => setState(() => _expanded = !_expanded), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 12), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - _expanded ? Icons.expand_less : Icons.expand_more, - size: _iconSm, - color: colors.primary, - ), - const SizedBox(width: 4), - Text( - _expanded - ? 'Show fewer' - : 'Show all ${widget.lessons.length} lessons', - style: theme.textTheme.labelLarge?.copyWith( - color: colors.primary, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - ), - ], - ), - ); - } -} - -class _LessonRow extends StatelessWidget { - const _LessonRow({required this.entry, required this.completed}); - - final LessonWithModule entry; - final bool completed; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final colors = theme.colorScheme; - return ListTile( - leading: Container( - width: _rowBadgeSize, - height: _rowBadgeSize, - alignment: Alignment.center, - decoration: BoxDecoration( - color: colors.primaryContainer, - borderRadius: BorderRadius.circular(_rowBadgeRadius), - ), - child: Icon( - moduleIcon(entry.module.iconName), - size: _iconSm, - color: colors.onPrimaryContainer, - ), - ), - title: Text(entry.lesson.title), - subtitle: Text( - entry.module.title, - style: theme.textTheme.bodySmall?.copyWith( - color: colors.onSurfaceVariant, - ), - ), - trailing: Icon( - completed ? Icons.check_circle : Icons.fitness_center, - color: completed ? colors.primary : colors.onSurfaceVariant, - ), - onTap: () => context.go('/learn/practice/lesson/${entry.lesson.id}'), - ); - } -} - -/// Row of chips, one per supported game type. Each chip is enabled only when -/// at least one step of that type lives in a completed lesson. -class _PracticeByGameTypeSection extends StatelessWidget { - const _PracticeByGameTypeSection({required this.counts}); - - /// `gameType` discriminator → number of practiceable steps in completed - /// lessons. - final Map counts; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final colors = theme.colorScheme; - return Wrap( - spacing: _chipGap, - runSpacing: _chipGap, - children: [ - for (final entry in gameTypeLabels) - () { - final key = entry.$1; - final label = entry.$2; - final count = counts[key] ?? 0; - final enabled = count > 0; - return ActionChip( - avatar: Icon( - _iconFor(key), - size: _iconSm, - color: enabled ? colors.primary : colors.onSurfaceVariant, - ), - label: Text( - enabled ? '$label ($count)' : label, - style: theme.textTheme.labelLarge?.copyWith( - color: enabled ? colors.onSurface : colors.onSurfaceVariant, - ), - ), - onPressed: enabled - ? () => context.go('/learn/practice/game-type/$key') - : null, - ); - }(), - ], - ); - } - - IconData _iconFor(String gameType) => switch (gameType) { - 'multiple_choice' => Icons.check_box_outlined, - 'drag_drop' => Icons.compare_arrows, - 'tap_order' => Icons.format_list_numbered, - 'slider' => Icons.tune, - _ => Icons.extension_outlined, - }; -} - -class _SectionPlaceholder extends StatelessWidget { - const _SectionPlaceholder({required this.text}); - - final String text; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return Card( - margin: EdgeInsets.zero, - child: Padding( - padding: const EdgeInsets.all(16), - child: Text( - text, - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ), - ); - } -} diff --git a/coffee_quest/lib/features/learn/presentation/module_detail_hero_widget.dart b/coffee_quest/lib/features/learn/presentation/module_detail_hero_widget.dart new file mode 100644 index 0000000..42f6033 --- /dev/null +++ b/coffee_quest/lib/features/learn/presentation/module_detail_hero_widget.dart @@ -0,0 +1,64 @@ +import 'package:coffee_quest/core/utils/module_icons.dart'; +import 'package:coffee_quest/shared/models/module_model.dart'; +import 'package:flutter/material.dart'; + +/// Tinted hero at the top of the module screen: category icon + title + +/// description. +class ModuleHeroWidget extends StatelessWidget { + /// Creates a [ModuleHeroWidget]. + const ModuleHeroWidget({required this.module, super.key}); + + /// The module to summarise. + final ModuleModel module; + + static const double _badgeSize = 56; + static const double _badgeRadius = 14; + static const double _iconSize = 28; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: _badgeSize, + height: _badgeSize, + alignment: Alignment.center, + decoration: BoxDecoration( + color: colors.primaryContainer, + borderRadius: BorderRadius.circular(_badgeRadius), + ), + child: Icon( + moduleIcon(module.iconName), + size: _iconSize, + color: colors.onPrimaryContainer, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + module.title, + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + module.description, + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/coffee_quest/lib/features/learn/presentation/module_detail_screen.dart b/coffee_quest/lib/features/learn/presentation/module_detail_screen.dart index 048d957..cbec944 100644 --- a/coffee_quest/lib/features/learn/presentation/module_detail_screen.dart +++ b/coffee_quest/lib/features/learn/presentation/module_detail_screen.dart @@ -1,22 +1,14 @@ -import 'package:coffee_quest/core/utils/module_icons.dart'; import 'package:coffee_quest/core/widgets/error_view.dart'; import 'package:coffee_quest/core/widgets/loading_indicator.dart'; import 'package:coffee_quest/core/widgets/section_header.dart'; +import 'package:coffee_quest/features/learn/presentation/module_detail_hero_widget.dart'; +import 'package:coffee_quest/features/learn/presentation/module_lesson_card_widget.dart'; import 'package:coffee_quest/features/progress/domain/progress_providers.dart'; import 'package:coffee_quest/shared/models/lesson_model.dart'; import 'package:coffee_quest/shared/models/module_model.dart'; import 'package:coffee_quest/shared/repositories/content_repository.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -const double _headerBadgeSize = 56; -const double _headerBadgeRadius = 14; -const double _headerIconSize = 28; -const double _cardRadius = 12; -const double _stepBadgeSize = 36; -const double _checkIconSize = 20; -const double _xpIconSize = 14; /// Module detail: lists the module's lessons with progress and a start CTA. class ModuleDetailScreen extends ConsumerWidget { @@ -51,13 +43,13 @@ class ModuleDetailScreen extends ConsumerWidget { padding: const EdgeInsets.all(16), physics: const AlwaysScrollableScrollPhysics(), children: [ - _ModuleHero(module: module), + ModuleHeroWidget(module: module), const SizedBox(height: 24), const SectionHeader('Lessons'), const SizedBox(height: 12), for (var i = 0; i < lessons.length; i++) ...[ if (i > 0) const SizedBox(height: 8), - _LessonCard( + ModuleLessonCardWidget( lesson: lessons[i], index: i + 1, isCompleted: completedIds.contains(lessons[i].id), @@ -85,187 +77,3 @@ class ModuleDetailScreen extends ConsumerWidget { return (module, lessons); } } - -/// Tinted hero at the top of the module screen: category icon + title + -/// description. -class _ModuleHero extends StatelessWidget { - const _ModuleHero({required this.module}); - - final ModuleModel module; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final colors = theme.colorScheme; - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: _headerBadgeSize, - height: _headerBadgeSize, - alignment: Alignment.center, - decoration: BoxDecoration( - color: colors.primaryContainer, - borderRadius: BorderRadius.circular(_headerBadgeRadius), - ), - child: Icon( - moduleIcon(module.iconName), - size: _headerIconSize, - color: colors.onPrimaryContainer, - ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - module.title, - style: theme.textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 4), - Text( - module.description, - style: theme.textTheme.bodyMedium?.copyWith( - color: colors.onSurfaceVariant, - ), - ), - ], - ), - ), - ], - ); - } -} - -/// A lesson row in the module's lesson list. Completed lessons re-open in -/// review mode and expose a `Review` action; new lessons start fresh. -class _LessonCard extends StatelessWidget { - const _LessonCard({ - required this.lesson, - required this.index, - required this.isCompleted, - }); - - final LessonModel lesson; - final int index; - final bool isCompleted; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final colors = theme.colorScheme; - final destination = isCompleted - ? '/learn/lesson/${lesson.id}?review=true' - : '/learn/lesson/${lesson.id}'; - - return Card( - margin: EdgeInsets.zero, - child: InkWell( - onTap: () => context.go(destination), - borderRadius: BorderRadius.circular(_cardRadius), - child: Padding( - padding: const EdgeInsets.all(14), - child: Row( - children: [ - _LessonBadge(index: index, isCompleted: isCompleted), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text(lesson.title, style: theme.textTheme.titleSmall), - const SizedBox(height: 4), - Text( - lesson.summary, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodySmall?.copyWith( - color: colors.onSurfaceVariant, - ), - ), - const SizedBox(height: 6), - _XpInline(xp: lesson.xpReward), - ], - ), - ), - const SizedBox(width: 8), - if (isCompleted) - TextButton( - onPressed: () => context.go(destination), - child: const Text('Review'), - ) - else - Icon(Icons.chevron_right, color: colors.onSurfaceVariant), - ], - ), - ), - ), - ); - } -} - -/// Numbered/check badge on the leading edge of a lesson card. -class _LessonBadge extends StatelessWidget { - const _LessonBadge({required this.index, required this.isCompleted}); - - final int index; - final bool isCompleted; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final colors = theme.colorScheme; - final background = isCompleted ? colors.primary : colors.primaryContainer; - final foreground = isCompleted - ? colors.onPrimary - : colors.onPrimaryContainer; - - return Container( - width: _stepBadgeSize, - height: _stepBadgeSize, - alignment: Alignment.center, - decoration: BoxDecoration(color: background, shape: BoxShape.circle), - child: isCompleted - ? Icon(Icons.check, size: _checkIconSize, color: foreground) - : Text( - '$index', - style: theme.textTheme.titleSmall?.copyWith( - color: foreground, - fontWeight: FontWeight.w700, - ), - ), - ); - } -} - -/// Inline `+N XP` label shown beneath each lesson row. -class _XpInline extends StatelessWidget { - const _XpInline({required this.xp}); - - final int xp; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final colors = theme.colorScheme; - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.bolt, size: _xpIconSize, color: colors.onSurfaceVariant), - const SizedBox(width: 2), - Text( - '+$xp XP', - style: theme.textTheme.labelSmall?.copyWith( - color: colors.onSurfaceVariant, - fontWeight: FontWeight.w600, - ), - ), - ], - ); - } -} diff --git a/coffee_quest/lib/features/learn/presentation/module_lesson_card_widget.dart b/coffee_quest/lib/features/learn/presentation/module_lesson_card_widget.dart new file mode 100644 index 0000000..1abee00 --- /dev/null +++ b/coffee_quest/lib/features/learn/presentation/module_lesson_card_widget.dart @@ -0,0 +1,146 @@ +import 'package:coffee_quest/shared/models/lesson_model.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +/// A lesson row in the module's lesson list. Completed lessons re-open in +/// review mode and expose a `Review` action; new lessons start fresh. +class ModuleLessonCardWidget extends StatelessWidget { + /// Creates a [ModuleLessonCardWidget]. + const ModuleLessonCardWidget({ + required this.lesson, + required this.index, + required this.isCompleted, + super.key, + }); + + /// The lesson to render. + final LessonModel lesson; + + /// 1-based position of the lesson within its module. + final int index; + + /// Whether the user has already completed this lesson. + final bool isCompleted; + + static const double _cardRadius = 12; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + final destination = isCompleted + ? '/learn/lesson/${lesson.id}?review=true' + : '/learn/lesson/${lesson.id}'; + + return Card( + margin: EdgeInsets.zero, + child: InkWell( + onTap: () => context.go(destination), + borderRadius: BorderRadius.circular(_cardRadius), + child: Padding( + padding: const EdgeInsets.all(14), + child: Row( + children: [ + _LessonBadge(index: index, isCompleted: isCompleted), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(lesson.title, style: theme.textTheme.titleSmall), + const SizedBox(height: 4), + Text( + lesson.summary, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + const SizedBox(height: 6), + _XpInline(xp: lesson.xpReward), + ], + ), + ), + const SizedBox(width: 8), + if (isCompleted) + TextButton( + onPressed: () => context.go(destination), + child: const Text('Review'), + ) + else + Icon(Icons.chevron_right, color: colors.onSurfaceVariant), + ], + ), + ), + ), + ); + } +} + +/// Numbered/check badge on the leading edge of a lesson card. +class _LessonBadge extends StatelessWidget { + const _LessonBadge({required this.index, required this.isCompleted}); + + final int index; + final bool isCompleted; + + static const double _size = 36; + static const double _checkIconSize = 20; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + final background = isCompleted ? colors.primary : colors.primaryContainer; + final foreground = isCompleted + ? colors.onPrimary + : colors.onPrimaryContainer; + + return Container( + width: _size, + height: _size, + alignment: Alignment.center, + decoration: BoxDecoration(color: background, shape: BoxShape.circle), + child: isCompleted + ? Icon(Icons.check, size: _checkIconSize, color: foreground) + : Text( + '$index', + style: theme.textTheme.titleSmall?.copyWith( + color: foreground, + fontWeight: FontWeight.w700, + ), + ), + ); + } +} + +/// Inline `+N XP` label shown beneath each lesson row. +class _XpInline extends StatelessWidget { + const _XpInline({required this.xp}); + + final int xp; + + static const double _iconSize = 14; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.bolt, size: _iconSize, color: colors.onSurfaceVariant), + const SizedBox(width: 2), + Text( + '+$xp XP', + style: theme.textTheme.labelSmall?.copyWith( + color: colors.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ], + ); + } +} diff --git a/coffee_quest/lib/features/learn/presentation/practice_any_lesson_widget.dart b/coffee_quest/lib/features/learn/presentation/practice_any_lesson_widget.dart new file mode 100644 index 0000000..22f90e3 --- /dev/null +++ b/coffee_quest/lib/features/learn/presentation/practice_any_lesson_widget.dart @@ -0,0 +1,156 @@ +import 'package:coffee_quest/core/utils/module_icons.dart'; +import 'package:coffee_quest/features/learn/domain/learn_providers.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +/// Browse-all section that lists every lesson grouped by its module, so the +/// user can pick any lesson to practice without first opening a module. +class PracticeAnyLessonWidget extends StatefulWidget { + /// Creates a [PracticeAnyLessonWidget]. + const PracticeAnyLessonWidget({ + required this.lessons, + required this.completedIds, + super.key, + }); + + /// Every lesson (with its module) available to practice. + final List lessons; + + /// Ids of lessons the user has already completed. + final Set completedIds; + + @override + State createState() => + _PracticeAnyLessonWidgetState(); +} + +class _PracticeAnyLessonWidgetState extends State { + /// Collapsed view shows the first few lessons; expanding reveals the rest, so + /// the section never dominates the screen on first paint. + static const int _previewCount = 4; + static const double _iconSm = 18; + + bool _expanded = false; + + @override + Widget build(BuildContext context) { + if (widget.lessons.isEmpty) { + return const _SectionPlaceholder(text: 'No lessons available yet.'); + } + final theme = Theme.of(context); + final colors = theme.colorScheme; + final list = _expanded + ? widget.lessons + : widget.lessons.take(_previewCount).toList(); + + return Card( + margin: EdgeInsets.zero, + child: Column( + children: [ + for (var i = 0; i < list.length; i++) ...[ + if (i > 0) Divider(height: 1, color: colors.outlineVariant), + _LessonRow( + entry: list[i], + completed: widget.completedIds.contains(list[i].lesson.id), + ), + ], + if (widget.lessons.length > _previewCount) + InkWell( + onTap: () => setState(() => _expanded = !_expanded), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + _expanded ? Icons.expand_less : Icons.expand_more, + size: _iconSm, + color: colors.primary, + ), + const SizedBox(width: 4), + Text( + _expanded + ? 'Show fewer' + : 'Show all ${widget.lessons.length} lessons', + style: theme.textTheme.labelLarge?.copyWith( + color: colors.primary, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} + +class _LessonRow extends StatelessWidget { + const _LessonRow({required this.entry, required this.completed}); + + final LessonWithModule entry; + final bool completed; + + static const double _rowBadgeSize = 36; + static const double _rowBadgeRadius = 10; + static const double _iconSm = 18; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + return ListTile( + leading: Container( + width: _rowBadgeSize, + height: _rowBadgeSize, + alignment: Alignment.center, + decoration: BoxDecoration( + color: colors.primaryContainer, + borderRadius: BorderRadius.circular(_rowBadgeRadius), + ), + child: Icon( + moduleIcon(entry.module.iconName), + size: _iconSm, + color: colors.onPrimaryContainer, + ), + ), + title: Text(entry.lesson.title), + subtitle: Text( + entry.module.title, + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + trailing: Icon( + completed ? Icons.check_circle : Icons.fitness_center, + color: completed ? colors.primary : colors.onSurfaceVariant, + ), + onTap: () => context.go('/learn/practice/lesson/${entry.lesson.id}'), + ); + } +} + +class _SectionPlaceholder extends StatelessWidget { + const _SectionPlaceholder({required this.text}); + + final String text; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Card( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.all(16), + child: Text( + text, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ); + } +} diff --git a/coffee_quest/lib/features/learn/presentation/practice_by_game_type_widget.dart b/coffee_quest/lib/features/learn/presentation/practice_by_game_type_widget.dart new file mode 100644 index 0000000..0271054 --- /dev/null +++ b/coffee_quest/lib/features/learn/presentation/practice_by_game_type_widget.dart @@ -0,0 +1,60 @@ +import 'package:coffee_quest/features/learn/domain/learn_providers.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +/// Row of chips, one per supported game type. Each chip is enabled only when +/// at least one step of that type lives in a completed lesson. +class PracticeByGameTypeWidget extends StatelessWidget { + /// Creates a [PracticeByGameTypeWidget]. + const PracticeByGameTypeWidget({required this.counts, super.key}); + + /// `gameType` discriminator → number of practiceable steps in completed + /// lessons. + final Map counts; + + static const double _chipGap = 8; + static const double _iconSm = 18; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + return Wrap( + spacing: _chipGap, + runSpacing: _chipGap, + children: [ + for (final entry in gameTypeLabels) + () { + final key = entry.$1; + final label = entry.$2; + final count = counts[key] ?? 0; + final enabled = count > 0; + return ActionChip( + avatar: Icon( + _iconFor(key), + size: _iconSm, + color: enabled ? colors.primary : colors.onSurfaceVariant, + ), + label: Text( + enabled ? '$label ($count)' : label, + style: theme.textTheme.labelLarge?.copyWith( + color: enabled ? colors.onSurface : colors.onSurfaceVariant, + ), + ), + onPressed: enabled + ? () => context.go('/learn/practice/game-type/$key') + : null, + ); + }(), + ], + ); + } + + IconData _iconFor(String gameType) => switch (gameType) { + 'multiple_choice' => Icons.check_box_outlined, + 'drag_drop' => Icons.compare_arrows, + 'tap_order' => Icons.format_list_numbered, + 'slider' => Icons.tune, + _ => Icons.extension_outlined, + }; +} diff --git a/coffee_quest/lib/features/learn/presentation/today_card_widget.dart b/coffee_quest/lib/features/learn/presentation/today_card_widget.dart new file mode 100644 index 0000000..3e13d0b --- /dev/null +++ b/coffee_quest/lib/features/learn/presentation/today_card_widget.dart @@ -0,0 +1,179 @@ +import 'package:coffee_quest/shared/models/lesson_model.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +/// Hero card for the day's primary action. Renders the next lesson with a +/// prominent `Start` CTA, or a friendly caught-up state when nothing is due. +class TodayCardWidget extends StatelessWidget { + /// Creates a [TodayCardWidget]. + const TodayCardWidget({required this.today, super.key}); + + /// The lesson due today, or `null` when the user is caught up. + final LessonModel? today; + + static const double _heroRadius = 12; + static const double _heroLetterSpacing = 0.6; + static const double _mutedAlpha = 0.8; + static const double _iconSm = 18; + static const double _iconLg = 40; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + final lesson = today; + + return Card( + margin: EdgeInsets.zero, + color: colors.primaryContainer, + child: lesson == null + ? _buildCaughtUp(theme, colors) + : _buildLesson(context, theme, colors, lesson), + ); + } + + Widget _buildLesson( + BuildContext context, + ThemeData theme, + ColorScheme colors, + LessonModel lesson, + ) { + return InkWell( + onTap: () => context.go('/learn/lesson/${lesson.id}'), + borderRadius: BorderRadius.circular(_heroRadius), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Icon( + Icons.local_cafe, + size: _iconSm, + color: colors.onPrimaryContainer, + ), + const SizedBox(width: 8), + Text( + "Today's lesson", + style: theme.textTheme.labelMedium?.copyWith( + color: colors.onPrimaryContainer, + letterSpacing: _heroLetterSpacing, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + lesson.title, + style: theme.textTheme.titleLarge?.copyWith( + color: colors.onPrimaryContainer, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + lesson.summary, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onPrimaryContainer.withValues(alpha: _mutedAlpha), + ), + ), + const SizedBox(height: 16), + Row( + children: [ + _XpPill(xp: lesson.xpReward), + const Spacer(), + FilledButton.icon( + onPressed: () => context.go('/learn/lesson/${lesson.id}'), + icon: const Icon(Icons.play_arrow), + label: const Text('Start'), + ), + ], + ), + ], + ), + ), + ); + } + + Widget _buildCaughtUp(ThemeData theme, ColorScheme colors) { + return Padding( + padding: const EdgeInsets.all(20), + child: Row( + children: [ + Icon( + Icons.check_circle, + size: _iconLg, + color: colors.onPrimaryContainer, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + "You're all caught up!", + style: theme.textTheme.titleMedium?.copyWith( + color: colors.onPrimaryContainer, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + 'No lessons left to study.', + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onPrimaryContainer.withValues( + alpha: _mutedAlpha, + ), + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +/// Compact `+XP` reward chip shown on the hero card. +class _XpPill extends StatelessWidget { + const _XpPill({required this.xp}); + + final int xp; + + static const double _pillRadius = 20; + static const double _pillAlpha = 0.12; + static const double _iconMd = 16; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: colors.onPrimaryContainer.withValues(alpha: _pillAlpha), + borderRadius: BorderRadius.circular(_pillRadius), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.bolt, size: _iconMd, color: colors.onPrimaryContainer), + const SizedBox(width: 4), + Text( + '+$xp XP', + style: theme.textTheme.labelMedium?.copyWith( + color: colors.onPrimaryContainer, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + } +} diff --git a/coffee_quest/lib/features/lessons/presentation/lesson_completion_body.dart b/coffee_quest/lib/features/lessons/presentation/lesson_completion_body.dart new file mode 100644 index 0000000..058f6b0 --- /dev/null +++ b/coffee_quest/lib/features/lessons/presentation/lesson_completion_body.dart @@ -0,0 +1,245 @@ +import 'package:coffee_quest/core/constants/app_strings.dart'; +import 'package:coffee_quest/core/utils/module_icons.dart'; +import 'package:coffee_quest/features/lessons/domain/lesson_completion_service.dart'; +import 'package:coffee_quest/features/lessons/presentation/lesson_completion_reward.dart'; +import 'package:coffee_quest/shared/models/coffee_card_model.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +/// Presentation for the post-lesson screen: a hero badge, outcome text and any +/// reward card, then the Continue button. Pure view — it renders the loaded +/// [LessonCompletionReward] and the run score, and performs no I/O. +class LessonCompletionBody extends StatelessWidget { + /// Creates a [LessonCompletionBody]. + const LessonCompletionBody({ + required this.reward, + required this.score, + super.key, + }); + + /// The loaded reward to render. + final LessonCompletionReward reward; + + /// First-try accuracy of the run (0–100); shown for practice runs. + final int score; + + @override + Widget build(BuildContext context) { + return Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ..._content(context), + const SizedBox(height: 32), + FilledButton( + onPressed: () => context.go('/learn'), + child: const Text(AppStrings.continueLabel), + ), + ], + ), + ), + ); + } + + List _content(BuildContext context) { + final reviewResult = reward.reviewResult; + final completion = reward.completion; + if (reviewResult != null) return _reviewContent(context, reviewResult); + if (completion != null) return _completionContent(context, completion); + return _practiceContent(context); + } + + List _completionContent( + BuildContext context, + LessonCompletionResult completion, + ) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + return [ + const _HeroBadge(icon: Icons.celebration), + const SizedBox(height: 20), + Text( + 'Lesson complete!', + textAlign: TextAlign.center, + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 12), + Text( + '+${completion.lessonXp} XP', + textAlign: TextAlign.center, + style: theme.textTheme.titleLarge?.copyWith( + color: colors.primary, + fontWeight: FontWeight.w700, + ), + ), + if (completion.moduleCompleted) ...[ + const SizedBox(height: 4), + Text( + '+${completion.moduleBonusXp} XP · Module complete!', + textAlign: TextAlign.center, + style: theme.textTheme.titleMedium?.copyWith(color: colors.primary), + ), + ], + if (reward.card != null) ...[ + const SizedBox(height: 24), + _RewardCard(card: reward.card!), + ], + ]; + } + + List _practiceContent(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + return [ + const _HeroBadge(icon: Icons.fitness_center), + const SizedBox(height: 20), + Text( + 'Practice complete!', + textAlign: TextAlign.center, + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 12), + Text( + 'Score: $score%', + textAlign: TextAlign.center, + style: theme.textTheme.titleLarge?.copyWith( + color: colors.primary, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + 'Practice runs do not change your XP, streak, or progress.', + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ]; + } + + List _reviewContent(BuildContext context, LessonReviewResult review) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + return [ + const _HeroBadge(icon: Icons.replay), + const SizedBox(height: 20), + Text( + 'Review complete!', + textAlign: TextAlign.center, + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 12), + Text( + 'Best score: ${review.bestScore}%', + textAlign: TextAlign.center, + style: theme.textTheme.titleLarge?.copyWith( + color: colors.primary, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + review.practiceXpAwarded + ? '+2 XP · Practice' + : 'Practice XP already earned today', + textAlign: TextAlign.center, + style: theme.textTheme.titleMedium?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ]; + } +} + +/// Round tinted celebration/replay badge shown above the headline. +class _HeroBadge extends StatelessWidget { + const _HeroBadge({required this.icon}); + + final IconData icon; + + static const double _size = 96; + static const double _iconSize = 48; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return Center( + child: Container( + width: _size, + height: _size, + alignment: Alignment.center, + decoration: BoxDecoration( + color: colors.primaryContainer, + shape: BoxShape.circle, + ), + child: Icon(icon, size: _iconSize, color: colors.onPrimaryContainer), + ), + ); + } +} + +/// Card-reward row shown when the completed lesson grants a collectible. +class _RewardCard extends StatelessWidget { + const _RewardCard({required this.card}); + + final CoffeeCardModel card; + + static const double _badgeSize = 48; + static const double _cardRadius = 12; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + return Card( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.all(14), + child: Row( + children: [ + Container( + width: _badgeSize, + height: _badgeSize, + alignment: Alignment.center, + decoration: BoxDecoration( + color: colors.primaryContainer, + borderRadius: BorderRadius.circular(_cardRadius), + ), + child: Icon( + moduleIcon(card.iconName), + color: colors.onPrimaryContainer, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(card.title, style: theme.textTheme.titleSmall), + const SizedBox(height: 2), + Text( + card.moduleTag, + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/coffee_quest/lib/features/lessons/presentation/lesson_completion_reward.dart b/coffee_quest/lib/features/lessons/presentation/lesson_completion_reward.dart new file mode 100644 index 0000000..d272b3b --- /dev/null +++ b/coffee_quest/lib/features/lessons/presentation/lesson_completion_reward.dart @@ -0,0 +1,19 @@ +import 'package:coffee_quest/features/lessons/domain/lesson_completion_service.dart'; +import 'package:coffee_quest/shared/models/coffee_card_model.dart'; + +/// Loaded outcome for the post-lesson screen. Exactly one of [completion] / +/// [reviewResult] is set for first-completion / review runs; both are null for +/// pure practice runs (which write nothing). +class LessonCompletionReward { + /// Creates a [LessonCompletionReward]. + const LessonCompletionReward({this.completion, this.reviewResult, this.card}); + + /// First-completion result (lesson XP, any module bonus), or null. + final LessonCompletionResult? completion; + + /// Review result (best score, practice XP), or null. + final LessonReviewResult? reviewResult; + + /// Collectible card unlocked by a first completion, if any. + final CoffeeCardModel? card; +} diff --git a/coffee_quest/lib/features/lessons/presentation/lesson_completion_screen.dart b/coffee_quest/lib/features/lessons/presentation/lesson_completion_screen.dart index bd0efc5..28dd66a 100644 --- a/coffee_quest/lib/features/lessons/presentation/lesson_completion_screen.dart +++ b/coffee_quest/lib/features/lessons/presentation/lesson_completion_screen.dart @@ -1,30 +1,15 @@ -import 'package:coffee_quest/core/constants/app_strings.dart'; -import 'package:coffee_quest/core/utils/module_icons.dart'; import 'package:coffee_quest/core/widgets/error_view.dart'; import 'package:coffee_quest/core/widgets/loading_indicator.dart'; import 'package:coffee_quest/features/cards/domain/cards_providers.dart'; import 'package:coffee_quest/features/learn/domain/learn_providers.dart'; import 'package:coffee_quest/features/lessons/domain/lesson_completion_service.dart'; +import 'package:coffee_quest/features/lessons/presentation/lesson_completion_body.dart'; +import 'package:coffee_quest/features/lessons/presentation/lesson_completion_reward.dart'; import 'package:coffee_quest/features/progress/domain/progress_providers.dart'; import 'package:coffee_quest/shared/models/coffee_card_model.dart'; import 'package:coffee_quest/shared/repositories/content_repository.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -/// Loaded outcome for the screen. Exactly one of [completion] / [reviewResult] -/// is set for first-completion / review runs; both are null for pure -/// [LessonCompletionScreen.practice] runs (which write nothing). -class _Reward { - const _Reward({this.completion, this.reviewResult, this.card}); - final LessonCompletionResult? completion; - final LessonReviewResult? reviewResult; - final CoffeeCardModel? card; -} - -const double _heroBadgeSize = 96; -const double _badgeSize = 48; -const double _cardRadius = 12; /// Post-lesson screen: shows earned XP and any unlocked card, then routes back. class LessonCompletionScreen extends ConsumerStatefulWidget { @@ -57,13 +42,13 @@ class LessonCompletionScreen extends ConsumerStatefulWidget { class _LessonCompletionScreenState extends ConsumerState { - late final Future<_Reward> _future = _completeAndLoad(); + late final Future _future = _completeAndLoad(); /// Persists the run exactly once. First completion awards full XP/cards; /// review only updates mastery and may grant practice XP; practice runs /// from the Learn-tab practice section write nothing at all. Every path is /// idempotent, so a rebuild or revisit will not double-award anything. - Future<_Reward> _completeAndLoad() async { + Future _completeAndLoad() async { final content = ref.read(contentRepositoryProvider); final lesson = await content.getLessonById(widget.lessonId); if (lesson == null) { @@ -73,7 +58,7 @@ class _LessonCompletionScreenState if (widget.practice) { // Pure practice — no service call, no XP, no card, no streak. Just // display a summary using the run's score. - return const _Reward(); + return const LessonCompletionReward(); } if (widget.review) { @@ -84,7 +69,7 @@ class _LessonCompletionScreenState if (reviewResult.practiceXpAwarded) { ref.invalidate(totalXpProvider); } - return _Reward(reviewResult: reviewResult); + return LessonCompletionReward(reviewResult: reviewResult); } final completion = await ref @@ -113,235 +98,27 @@ class _LessonCompletionScreenState final cards = await content.getCards(); card = cards.where((c) => c.id == cardId).firstOrNull; } - return _Reward(completion: completion, card: card); + return LessonCompletionReward(completion: completion, card: card); } @override Widget build(BuildContext context) { return Scaffold( body: SafeArea( - child: FutureBuilder<_Reward>( + child: FutureBuilder( future: _future, builder: (context, snap) { if (snap.connectionState != ConnectionState.done) { return const LoadingIndicator(); } if (snap.hasError) return ErrorView(message: '${snap.error}'); - final reward = snap.data!; - return Center( - child: SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - ...(reward.reviewResult != null - ? _reviewContent(context, reward.reviewResult!) - : reward.completion != null - ? _completionContent( - context, - reward, - reward.completion!, - ) - : _practiceContent(context)), - const SizedBox(height: 32), - FilledButton( - onPressed: () => context.go('/learn'), - child: const Text(AppStrings.continueLabel), - ), - ], - ), - ), + return LessonCompletionBody( + reward: snap.data!, + score: widget.score, ); }, ), ), ); } - - List _completionContent( - BuildContext context, - _Reward reward, - LessonCompletionResult completion, - ) { - final theme = Theme.of(context); - final colors = theme.colorScheme; - return [ - const _HeroBadge(icon: Icons.celebration), - const SizedBox(height: 20), - Text( - 'Lesson complete!', - textAlign: TextAlign.center, - style: theme.textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 12), - Text( - '+${completion.lessonXp} XP', - textAlign: TextAlign.center, - style: theme.textTheme.titleLarge?.copyWith( - color: colors.primary, - fontWeight: FontWeight.w700, - ), - ), - if (completion.moduleCompleted) ...[ - const SizedBox(height: 4), - Text( - '+${completion.moduleBonusXp} XP · Module complete!', - textAlign: TextAlign.center, - style: theme.textTheme.titleMedium?.copyWith(color: colors.primary), - ), - ], - if (reward.card != null) ...[ - const SizedBox(height: 24), - _RewardCard(card: reward.card!), - ], - ]; - } - - List _practiceContent(BuildContext context) { - final theme = Theme.of(context); - final colors = theme.colorScheme; - return [ - const _HeroBadge(icon: Icons.fitness_center), - const SizedBox(height: 20), - Text( - 'Practice complete!', - textAlign: TextAlign.center, - style: theme.textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 12), - Text( - 'Score: ${widget.score}%', - textAlign: TextAlign.center, - style: theme.textTheme.titleLarge?.copyWith( - color: colors.primary, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 4), - Text( - 'Practice runs do not change your XP, streak, or progress.', - textAlign: TextAlign.center, - style: theme.textTheme.bodyMedium?.copyWith( - color: colors.onSurfaceVariant, - ), - ), - ]; - } - - List _reviewContent(BuildContext context, LessonReviewResult review) { - final theme = Theme.of(context); - final colors = theme.colorScheme; - return [ - const _HeroBadge(icon: Icons.replay), - const SizedBox(height: 20), - Text( - 'Review complete!', - textAlign: TextAlign.center, - style: theme.textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 12), - Text( - 'Best score: ${review.bestScore}%', - textAlign: TextAlign.center, - style: theme.textTheme.titleLarge?.copyWith( - color: colors.primary, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 4), - Text( - review.practiceXpAwarded - ? '+2 XP · Practice' - : 'Practice XP already earned today', - textAlign: TextAlign.center, - style: theme.textTheme.titleMedium?.copyWith( - color: colors.onSurfaceVariant, - ), - ), - ]; - } -} - -/// Round tinted celebration/replay badge shown above the headline. -class _HeroBadge extends StatelessWidget { - const _HeroBadge({required this.icon}); - - final IconData icon; - - @override - Widget build(BuildContext context) { - final colors = Theme.of(context).colorScheme; - return Center( - child: Container( - width: _heroBadgeSize, - height: _heroBadgeSize, - alignment: Alignment.center, - decoration: BoxDecoration( - color: colors.primaryContainer, - shape: BoxShape.circle, - ), - child: Icon(icon, size: _badgeSize, color: colors.onPrimaryContainer), - ), - ); - } -} - -/// Card-reward row shown when the completed lesson grants a collectible. -class _RewardCard extends StatelessWidget { - const _RewardCard({required this.card}); - - final CoffeeCardModel card; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final colors = theme.colorScheme; - return Card( - margin: EdgeInsets.zero, - child: Padding( - padding: const EdgeInsets.all(14), - child: Row( - children: [ - Container( - width: _badgeSize, - height: _badgeSize, - alignment: Alignment.center, - decoration: BoxDecoration( - color: colors.primaryContainer, - borderRadius: BorderRadius.circular(_cardRadius), - ), - child: Icon( - moduleIcon(card.iconName), - color: colors.onPrimaryContainer, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text(card.title, style: theme.textTheme.titleSmall), - const SizedBox(height: 2), - Text( - card.moduleTag, - style: theme.textTheme.bodySmall?.copyWith( - color: colors.onSurfaceVariant, - ), - ), - ], - ), - ), - ], - ), - ), - ); - } } diff --git a/coffee_quest/lib/features/path/presentation/path_module_node_widget.dart b/coffee_quest/lib/features/path/presentation/path_module_node_widget.dart index 224f9c8..21ddb98 100644 --- a/coffee_quest/lib/features/path/presentation/path_module_node_widget.dart +++ b/coffee_quest/lib/features/path/presentation/path_module_node_widget.dart @@ -1,20 +1,10 @@ import 'package:coffee_quest/core/constants/app_strings.dart'; -import 'package:coffee_quest/core/utils/module_icons.dart'; import 'package:coffee_quest/features/learn/domain/learn_providers.dart'; +import 'package:coffee_quest/features/path/presentation/path_node_card.dart'; +import 'package:coffee_quest/features/path/presentation/path_node_rail.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; -const double _railWidth = 32; -const double _connectorWidth = 3; -const double _cardBottomGap = 12; -const double _cardRadius = 12; -const double _nodeIconSize = 18; -const double _cardBadgeSize = 40; -const double _cardBadgeRadius = 10; -const double _cardIconSize = 22; -const double _progressBarHeight = 5; -const double _progressBarRadius = 3; - /// A single node in the vertical learning path: a state-colored circle on the /// connecting rail plus a content card with the module icon, title, and /// progress. Locked taps surface the unlock hint instead of navigating. @@ -36,6 +26,8 @@ class PathModuleNodeWidget extends StatelessWidget { /// Whether this is the last node (the rail trims its bottom connector). final bool isLast; + static const double _cardBottomGap = 12; + void _onTap(BuildContext context) { if (item.isLocked) { ScaffoldMessenger.of(context) @@ -54,12 +46,12 @@ class PathModuleNodeWidget extends StatelessWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _NodeRail(item: item, isFirst: isFirst, isLast: isLast), + PathNodeRail(item: item, isFirst: isFirst, isLast: isLast), const SizedBox(width: 12), Expanded( child: Padding( padding: EdgeInsets.only(bottom: isLast ? 0 : _cardBottomGap), - child: _NodeCard(item: item, onTap: () => _onTap(context)), + child: PathNodeCard(item: item, onTap: () => _onTap(context)), ), ), ], @@ -67,233 +59,3 @@ class PathModuleNodeWidget extends StatelessWidget { ); } } - -/// The left rail: connector segments above and below a state-colored node -/// circle. Connectors are hidden at the path's ends and "light up" through -/// modules the user has already reached. -class _NodeRail extends StatelessWidget { - const _NodeRail({ - required this.item, - required this.isFirst, - required this.isLast, - }); - - final ModuleWithProgress item; - final bool isFirst; - final bool isLast; - - @override - Widget build(BuildContext context) { - final colors = Theme.of(context).colorScheme; - final lit = colors.primary; - final dim = colors.surfaceContainerHighest; - final reached = !item.isLocked; - - return SizedBox( - width: _railWidth, - child: Column( - children: [ - Expanded( - child: _Connector( - color: isFirst ? Colors.transparent : (reached ? lit : dim), - ), - ), - _NodeCircle(item: item), - Expanded( - child: _Connector( - color: isLast - ? Colors.transparent - : (item.isComplete ? lit : dim), - ), - ), - ], - ), - ); - } -} - -/// A single vertical trail segment. -class _Connector extends StatelessWidget { - const _Connector({required this.color}); - - final Color color; - - @override - Widget build(BuildContext context) { - return Center( - child: Container(width: _connectorWidth, color: color), - ); - } -} - -/// The node marker: filled when complete, outlined when current/available, -/// muted with a lock when locked. -class _NodeCircle extends StatelessWidget { - const _NodeCircle({required this.item}); - - final ModuleWithProgress item; - - @override - Widget build(BuildContext context) { - final colors = Theme.of(context).colorScheme; - const size = 36.0; - - final ( - Color background, - Color foreground, - IconData icon, - Border? border, - ) = switch (item) { - _ when item.isLocked => ( - colors.surfaceContainerHighest, - colors.onSurfaceVariant, - Icons.lock_outline, - null, - ), - _ when item.isComplete => ( - colors.primary, - colors.onPrimary, - Icons.check, - null, - ), - _ => ( - colors.primaryContainer, - colors.primary, - Icons.play_arrow, - Border.all(color: colors.primary, width: 2), - ), - }; - - return Container( - width: size, - height: size, - alignment: Alignment.center, - decoration: BoxDecoration( - color: background, - shape: BoxShape.circle, - border: border, - ), - child: Icon(icon, size: _nodeIconSize, color: foreground), - ); - } -} - -/// The content panel beside a node: module icon, title, and progress. -class _NodeCard extends StatelessWidget { - const _NodeCard({required this.item, required this.onTap}); - - final ModuleWithProgress item; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final colors = theme.colorScheme; - final module = item.module; - final locked = item.isLocked; - - return Card( - margin: EdgeInsets.zero, - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(_cardRadius), - child: Padding( - padding: const EdgeInsets.all(14), - child: Row( - children: [ - Container( - width: _cardBadgeSize, - height: _cardBadgeSize, - alignment: Alignment.center, - decoration: BoxDecoration( - color: locked - ? colors.surfaceContainerHighest - : colors.primaryContainer, - borderRadius: BorderRadius.circular(_cardBadgeRadius), - ), - child: Icon( - locked ? Icons.lock_outline : moduleIcon(module.iconName), - size: _cardIconSize, - color: locked - ? colors.onSurfaceVariant - : colors.onPrimaryContainer, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - module.title, - style: theme.textTheme.titleSmall?.copyWith( - color: locked - ? colors.onSurfaceVariant - : colors.onSurface, - ), - ), - const SizedBox(height: 6), - _NodeStatus(item: item), - ], - ), - ), - if (!locked) ...[ - const SizedBox(width: 8), - Icon( - item.isComplete ? Icons.check_circle : Icons.chevron_right, - color: item.isComplete - ? colors.primary - : colors.onSurfaceVariant, - ), - ], - ], - ), - ), - ), - ); - } -} - -/// Progress line under the module title: a `Locked` hint, a `Complete` -/// label, or a `done / total` count above a slim progress bar. -class _NodeStatus extends StatelessWidget { - const _NodeStatus({required this.item}); - - final ModuleWithProgress item; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final colors = theme.colorScheme; - final mutedText = theme.textTheme.bodySmall?.copyWith( - color: colors.onSurfaceVariant, - ); - - if (item.isLocked) { - return Text('Locked', style: mutedText); - } - if (item.isComplete) { - return Text('Complete', style: mutedText); - } - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - '${item.completedCount} / ${item.totalCount} lessons', - style: mutedText, - ), - const SizedBox(height: 6), - LinearProgressIndicator( - value: item.progress, - minHeight: _progressBarHeight, - borderRadius: BorderRadius.circular(_progressBarRadius), - backgroundColor: colors.surfaceContainerHighest, - color: colors.primary, - ), - ], - ); - } -} diff --git a/coffee_quest/lib/features/path/presentation/path_node_card.dart b/coffee_quest/lib/features/path/presentation/path_node_card.dart new file mode 100644 index 0000000..c70682a --- /dev/null +++ b/coffee_quest/lib/features/path/presentation/path_node_card.dart @@ -0,0 +1,135 @@ +import 'package:coffee_quest/core/utils/module_icons.dart'; +import 'package:coffee_quest/features/learn/domain/learn_providers.dart'; +import 'package:flutter/material.dart'; + +/// The content panel beside a path node: module icon, title, and progress. +class PathNodeCard extends StatelessWidget { + /// Creates a [PathNodeCard]. + const PathNodeCard({required this.item, required this.onTap, super.key}); + + /// The module paired with its progress. + final ModuleWithProgress item; + + /// Invoked when the card is tapped. + final VoidCallback onTap; + + static const double _cardRadius = 12; + static const double _badgeSize = 40; + static const double _badgeRadius = 10; + static const double _iconSize = 22; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + final module = item.module; + final locked = item.isLocked; + + return Card( + margin: EdgeInsets.zero, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(_cardRadius), + child: Padding( + padding: const EdgeInsets.all(14), + child: Row( + children: [ + Container( + width: _badgeSize, + height: _badgeSize, + alignment: Alignment.center, + decoration: BoxDecoration( + color: locked + ? colors.surfaceContainerHighest + : colors.primaryContainer, + borderRadius: BorderRadius.circular(_badgeRadius), + ), + child: Icon( + locked ? Icons.lock_outline : moduleIcon(module.iconName), + size: _iconSize, + color: locked + ? colors.onSurfaceVariant + : colors.onPrimaryContainer, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + module.title, + style: theme.textTheme.titleSmall?.copyWith( + color: locked + ? colors.onSurfaceVariant + : colors.onSurface, + ), + ), + const SizedBox(height: 6), + _NodeStatus(item: item), + ], + ), + ), + if (!locked) ...[ + const SizedBox(width: 8), + Icon( + item.isComplete ? Icons.check_circle : Icons.chevron_right, + color: item.isComplete + ? colors.primary + : colors.onSurfaceVariant, + ), + ], + ], + ), + ), + ), + ); + } +} + +/// Progress line under the module title: a `Locked` hint, a `Complete` +/// label, or a `done / total` count above a slim progress bar. +class _NodeStatus extends StatelessWidget { + const _NodeStatus({required this.item}); + + final ModuleWithProgress item; + + static const double _barHeight = 5; + static const double _barRadius = 3; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + final mutedText = theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ); + + if (item.isLocked) { + return Text('Locked', style: mutedText); + } + if (item.isComplete) { + return Text('Complete', style: mutedText); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '${item.completedCount} / ${item.totalCount} lessons', + style: mutedText, + ), + const SizedBox(height: 6), + LinearProgressIndicator( + value: item.progress, + minHeight: _barHeight, + borderRadius: BorderRadius.circular(_barRadius), + backgroundColor: colors.surfaceContainerHighest, + color: colors.primary, + ), + ], + ); + } +} diff --git a/coffee_quest/lib/features/path/presentation/path_node_rail.dart b/coffee_quest/lib/features/path/presentation/path_node_rail.dart new file mode 100644 index 0000000..dd5737b --- /dev/null +++ b/coffee_quest/lib/features/path/presentation/path_node_rail.dart @@ -0,0 +1,125 @@ +import 'package:coffee_quest/features/learn/domain/learn_providers.dart'; +import 'package:flutter/material.dart'; + +/// The left rail of a path node: connector segments above and below a +/// state-colored node circle. Connectors are hidden at the path's ends and +/// "light up" through modules the user has already reached. +class PathNodeRail extends StatelessWidget { + /// Creates a [PathNodeRail]. + const PathNodeRail({ + required this.item, + required this.isFirst, + required this.isLast, + super.key, + }); + + /// The module paired with its progress. + final ModuleWithProgress item; + + /// Whether this is the first node (trims the top connector). + final bool isFirst; + + /// Whether this is the last node (trims the bottom connector). + final bool isLast; + + static const double _railWidth = 32; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final lit = colors.primary; + final dim = colors.surfaceContainerHighest; + final reached = !item.isLocked; + + return SizedBox( + width: _railWidth, + child: Column( + children: [ + Expanded( + child: _Connector( + color: isFirst ? Colors.transparent : (reached ? lit : dim), + ), + ), + _NodeCircle(item: item), + Expanded( + child: _Connector( + color: isLast + ? Colors.transparent + : (item.isComplete ? lit : dim), + ), + ), + ], + ), + ); + } +} + +/// A single vertical trail segment. +class _Connector extends StatelessWidget { + const _Connector({required this.color}); + + final Color color; + + static const double _width = 3; + + @override + Widget build(BuildContext context) { + return Center( + child: Container(width: _width, color: color), + ); + } +} + +/// The node marker: filled when complete, outlined when current/available, +/// muted with a lock when locked. +class _NodeCircle extends StatelessWidget { + const _NodeCircle({required this.item}); + + final ModuleWithProgress item; + + static const double _size = 36; + static const double _iconSize = 18; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + + final ( + Color background, + Color foreground, + IconData icon, + Border? border, + ) = switch (item) { + _ when item.isLocked => ( + colors.surfaceContainerHighest, + colors.onSurfaceVariant, + Icons.lock_outline, + null, + ), + _ when item.isComplete => ( + colors.primary, + colors.onPrimary, + Icons.check, + null, + ), + _ => ( + colors.primaryContainer, + colors.primary, + Icons.play_arrow, + Border.all(color: colors.primary, width: 2), + ), + }; + + return Container( + width: _size, + height: _size, + alignment: Alignment.center, + decoration: BoxDecoration( + color: background, + shape: BoxShape.circle, + border: border, + ), + child: Icon(icon, size: _iconSize, color: foreground), + ); + } +} From c850a0dfb121c1d578e2634b57325aed1cf0f2a5 Mon Sep 17 00:00:00 2001 From: maximsan Date: Fri, 12 Jun 2026 15:50:24 +0200 Subject: [PATCH 8/8] feat: refactor roasty mascot animation --- coffee_quest/analysis_options.yaml | 7 + coffee_quest/lib/app/app_router.g.dart | 4 + .../learn/domain/learn_providers.g.dart | 9 + .../domain/lesson_completion_service.g.dart | 4 + .../presentation/onboarding_providers.g.dart | 4 + .../presentation/widgets/roasty.dart | 731 +----------------- .../widgets/roasty_animation.dart | 119 +++ .../presentation/widgets/roasty_body.dart | 144 ++++ .../presentation/widgets/roasty_faces.dart | 245 ++++++ .../widgets/roasty_particles.dart | 225 ++++++ .../profile/domain/settings_providers.g.dart | 5 + .../progress/domain/progress_providers.g.dart | 19 + .../analytics/analytics_provider.g.dart | 4 + .../crash_reporting_provider.g.dart | 4 + .../payments/payments_provider.g.dart | 4 + .../remote_config_provider.g.dart | 4 + .../shared/models/lesson_model.freezed.dart | 50 +- .../lib/shared/models/lesson_model.g.dart | 4 +- .../repositories/content_repository.g.dart | 4 + .../repositories/repository_providers.g.dart | 19 + .../onboarding/roasty_animation_test.dart | 158 ++++ 21 files changed, 1030 insertions(+), 737 deletions(-) create mode 100644 coffee_quest/lib/features/onboarding/presentation/widgets/roasty_animation.dart create mode 100644 coffee_quest/lib/features/onboarding/presentation/widgets/roasty_body.dart create mode 100644 coffee_quest/lib/features/onboarding/presentation/widgets/roasty_faces.dart create mode 100644 coffee_quest/lib/features/onboarding/presentation/widgets/roasty_particles.dart create mode 100644 coffee_quest/test/unit/features/onboarding/roasty_animation_test.dart diff --git a/coffee_quest/analysis_options.yaml b/coffee_quest/analysis_options.yaml index 73444de..c839c75 100644 --- a/coffee_quest/analysis_options.yaml +++ b/coffee_quest/analysis_options.yaml @@ -44,6 +44,9 @@ dart_code_linter: - integration_test/** # CustomPainter with many small _paintX helpers — method count is by design. - lib/features/onboarding/presentation/widgets/roasty.dart + - lib/features/onboarding/presentation/widgets/roasty_body.dart + - lib/features/onboarding/presentation/widgets/roasty_faces.dart + - lib/features/onboarding/presentation/widgets/roasty_particles.dart # Declarative GoRouter route table — long by nature, low logic complexity. - lib/app/app_router.dart rules: @@ -52,6 +55,10 @@ dart_code_linter: allowed: [0, 1, 2] exclude: - lib/features/onboarding/presentation/widgets/roasty.dart + - lib/features/onboarding/presentation/widgets/roasty_animation.dart + - lib/features/onboarding/presentation/widgets/roasty_body.dart + - lib/features/onboarding/presentation/widgets/roasty_faces.dart + - lib/features/onboarding/presentation/widgets/roasty_particles.dart - lib/features/onboarding/presentation/loading/widgets/roasty_stage.dart - lib/features/onboarding/presentation/loading/loading_animation.dart - lib/shared/theme/app_spacing.dart diff --git a/coffee_quest/lib/app/app_router.g.dart b/coffee_quest/lib/app/app_router.g.dart index a8c1ea1..3ac522e 100644 --- a/coffee_quest/lib/app/app_router.g.dart +++ b/coffee_quest/lib/app/app_router.g.dart @@ -8,13 +8,17 @@ part of 'app_router.dart'; // GENERATED CODE - DO NOT MODIFY BY HAND // ignore_for_file: type=lint, type=warning +/// Provides the app's [GoRouter] (rebuilds on onboarding-gate changes). @ProviderFor(appRouter) final appRouterProvider = AppRouterProvider._(); +/// Provides the app's [GoRouter] (rebuilds on onboarding-gate changes). + final class AppRouterProvider extends $FunctionalProvider with $Provider { + /// Provides the app's [GoRouter] (rebuilds on onboarding-gate changes). AppRouterProvider._() : super( from: null, diff --git a/coffee_quest/lib/features/learn/domain/learn_providers.g.dart b/coffee_quest/lib/features/learn/domain/learn_providers.g.dart index ff25850..11bab6f 100644 --- a/coffee_quest/lib/features/learn/domain/learn_providers.g.dart +++ b/coffee_quest/lib/features/learn/domain/learn_providers.g.dart @@ -8,10 +8,13 @@ part of 'learn_providers.dart'; // GENERATED CODE - DO NOT MODIFY BY HAND // ignore_for_file: type=lint, type=warning +/// All modules paired with their derived completion/lock state. @ProviderFor(modulesWithProgress) final modulesWithProgressProvider = ModulesWithProgressProvider._(); +/// All modules paired with their derived completion/lock state. + final class ModulesWithProgressProvider extends $FunctionalProvider< @@ -22,6 +25,7 @@ final class ModulesWithProgressProvider with $FutureModifier>, $FutureProvider> { + /// All modules paired with their derived completion/lock state. ModulesWithProgressProvider._() : super( from: null, @@ -51,9 +55,13 @@ final class ModulesWithProgressProvider String _$modulesWithProgressHash() => r'4edce864510a70fd09058fc8405de592533acde4'; +/// The next uncompleted lesson in order, or null if all are complete. + @ProviderFor(todayLesson) final todayLessonProvider = TodayLessonProvider._(); +/// The next uncompleted lesson in order, or null if all are complete. + final class TodayLessonProvider extends $FunctionalProvider< @@ -62,6 +70,7 @@ final class TodayLessonProvider FutureOr > with $FutureModifier, $FutureProvider { + /// The next uncompleted lesson in order, or null if all are complete. TodayLessonProvider._() : super( from: null, diff --git a/coffee_quest/lib/features/lessons/domain/lesson_completion_service.g.dart b/coffee_quest/lib/features/lessons/domain/lesson_completion_service.g.dart index 51ed5c9..646fa9f 100644 --- a/coffee_quest/lib/features/lessons/domain/lesson_completion_service.g.dart +++ b/coffee_quest/lib/features/lessons/domain/lesson_completion_service.g.dart @@ -8,10 +8,13 @@ part of 'lesson_completion_service.dart'; // GENERATED CODE - DO NOT MODIFY BY HAND // ignore_for_file: type=lint, type=warning +/// Provides the [LessonCompletionService] with its dependencies wired in. @ProviderFor(lessonCompletionService) final lessonCompletionServiceProvider = LessonCompletionServiceProvider._(); +/// Provides the [LessonCompletionService] with its dependencies wired in. + final class LessonCompletionServiceProvider extends $FunctionalProvider< @@ -20,6 +23,7 @@ final class LessonCompletionServiceProvider LessonCompletionService > with $Provider { + /// Provides the [LessonCompletionService] with its dependencies wired in. LessonCompletionServiceProvider._() : super( from: null, diff --git a/coffee_quest/lib/features/onboarding/presentation/onboarding_providers.g.dart b/coffee_quest/lib/features/onboarding/presentation/onboarding_providers.g.dart index c118e2b..7714500 100644 --- a/coffee_quest/lib/features/onboarding/presentation/onboarding_providers.g.dart +++ b/coffee_quest/lib/features/onboarding/presentation/onboarding_providers.g.dart @@ -8,10 +8,13 @@ part of 'onboarding_providers.dart'; // GENERATED CODE - DO NOT MODIFY BY HAND // ignore_for_file: type=lint, type=warning +/// Provides the [OnboardingRepository]. @ProviderFor(onboardingRepository) final onboardingRepositoryProvider = OnboardingRepositoryProvider._(); +/// Provides the [OnboardingRepository]. + final class OnboardingRepositoryProvider extends $FunctionalProvider< @@ -20,6 +23,7 @@ final class OnboardingRepositoryProvider OnboardingRepository > with $Provider { + /// Provides the [OnboardingRepository]. OnboardingRepositoryProvider._() : super( from: null, diff --git a/coffee_quest/lib/features/onboarding/presentation/widgets/roasty.dart b/coffee_quest/lib/features/onboarding/presentation/widgets/roasty.dart index 0aa5c03..27307ed 100644 --- a/coffee_quest/lib/features/onboarding/presentation/widgets/roasty.dart +++ b/coffee_quest/lib/features/onboarding/presentation/widgets/roasty.dart @@ -1,13 +1,13 @@ import 'dart:async'; import 'dart:math' as math; +import 'package:coffee_quest/features/onboarding/presentation/widgets/roasty_animation.dart'; +import 'package:coffee_quest/features/onboarding/presentation/widgets/roasty_body.dart'; +import 'package:coffee_quest/features/onboarding/presentation/widgets/roasty_faces.dart'; +import 'package:coffee_quest/features/onboarding/presentation/widgets/roasty_particles.dart'; import 'package:coffee_quest/features/onboarding/presentation/widgets/roasty_state.dart'; import 'package:flutter/material.dart'; -// Animation switches handle the states with special motion and default the -// rest; enumerating every no-op state would bloat these mascot switches. -// ignore_for_file: no_default_cases - /// Animated Roasty mascot. Reproduces the geometry + per-state animations /// from the design bundle (`coffee_quest/brew-path-app/project/roasty.jsx`) /// using Flutter's Canvas + a single [AnimationController]. Public API: @@ -49,9 +49,10 @@ class _RoastyState extends State with SingleTickerProviderStateMixin { void initState() { super.initState(); _controller = - AnimationController(vsync: this, duration: _durationFor(widget.state)) + AnimationController(vsync: this, duration: roastyDuration(widget.state)) ..addStatusListener((status) { - if (status == AnimationStatus.completed && _loops(widget.state)) { + if (status == AnimationStatus.completed && + roastyLoops(widget.state)) { unawaited(_controller.repeat()); } }); @@ -65,59 +66,20 @@ class _RoastyState extends State with SingleTickerProviderStateMixin { final replayChanged = oldWidget.replayKey != widget.replayKey; if (stateChanged || replayChanged) { _controller.stop(); - _controller.duration = _durationFor(widget.state); + _controller.duration = roastyDuration(widget.state); _startForState(widget.state); } } void _startForState(RoastyState state) { _controller.reset(); - if (_loops(state)) { + if (roastyLoops(state)) { unawaited(_controller.repeat()); } else { unawaited(_controller.forward()); } } - static Duration _durationFor(RoastyState state) { - switch (state) { - case RoastyState.idle: - return const Duration(milliseconds: 3200); // breathe loop - case RoastyState.correct: - return const Duration(milliseconds: 900); // hop one-shot - case RoastyState.wrong: - return const Duration(milliseconds: 500); // shake one-shot - case RoastyState.lesson: - return const Duration(milliseconds: 1100); // jump one-shot - case RoastyState.module: - return const Duration(milliseconds: 900); // grow one-shot - case RoastyState.xp: - return const Duration(milliseconds: 1300); // xp rise one-shot - case RoastyState.card: - return const Duration(milliseconds: 1600); // shimmer loop - case RoastyState.sleep: - return const Duration(milliseconds: 3400); // slow breathe loop - case RoastyState.awake: - return const Duration(milliseconds: 400); // blink-pop one-shot - } - } - - static bool _loops(RoastyState state) { - switch (state) { - case RoastyState.idle: - case RoastyState.card: - case RoastyState.sleep: - return true; - case RoastyState.correct: - case RoastyState.wrong: - case RoastyState.lesson: - case RoastyState.module: - case RoastyState.xp: - case RoastyState.awake: - return false; - } - } - @override void dispose() { _controller.dispose(); @@ -147,7 +109,9 @@ class _RoastyState extends State with SingleTickerProviderStateMixin { /// Paints the bean body, current-state face, sprout, and the state-specific /// particle layer onto a 200x280 logical canvas (matches the prototype's -/// SVG viewBox so geometry copies 1:1 from roasty.jsx). +/// SVG viewBox so geometry copies 1:1 from roasty.jsx). Drawing is delegated +/// to the sibling `roasty_body` / `roasty_faces` / `roasty_particles` modules; +/// the animation math lives in `roasty_animation`. class _RoastyPainter extends CustomPainter { _RoastyPainter({required this.state, required this.t, this.sproutScale}); @@ -161,13 +125,6 @@ class _RoastyPainter extends CustomPainter { static const double _vbW = 200; static const double _vbH = 280; - /// Scale origin while the host drives the grow: the stem base sits at the - /// bean's top edge, so the sprout emerges from the head rather than scaling - /// in mid-air. The default sleeping shrink keeps its original (100, 75) - /// pivot. - static const Offset _sproutGrowAnchor = Offset(100, 88); - static const Offset _sproutDefaultAnchor = Offset(100, 75); - @override void paint(Canvas canvas, Size size) { canvas.save(); @@ -177,673 +134,27 @@ class _RoastyPainter extends CustomPainter { canvas.translate((size.width - _vbW * s) / 2, (size.height - _vbH * s) / 2); canvas.scale(s, s); - _paintParticlesBack(canvas); - _paintSprout(canvas); - _paintBody(canvas); + paintRoastyParticlesBack(canvas, state, t); + paintRoastySprout(canvas, state, t, sproutScale); + paintRoastyBody(canvas, state, t); _paintFace(canvas); - _paintParticlesFront(canvas); - - canvas.restore(); - } - - // ── Animation drivers (mapped 1:1 from roasty.jsx CSS keyframes) ───── - - Offset _bodyOffset() { - switch (state) { - case RoastyState.idle: - // breathe: translateY 0 → -3 → 0 - final v = math.sin(t * math.pi * 2); - return Offset(0, -3 * (v * 0.5 + 0.5) * math.sin(t * math.pi)); - case RoastyState.correct: - // hop: -10 at 25% and 75%, 0 at 0/50/100% - final hop = math.sin(t * math.pi * 2).abs(); - return Offset(0, -10 * hop); - case RoastyState.wrong: - // shake: ±5 → ±3 - final amp = (1 - t) * 5; - final dx = math.sin(t * math.pi * 8) * amp; - return Offset(dx, 0); - case RoastyState.lesson: - // jump: 0 → -18 → -8 → -14 → 0 - if (t < 0.3) { - return Offset(0, -18 * (t / 0.3)); - } else if (t < 0.5) { - final p = (t - 0.3) / 0.2; - return Offset(0, -18 + 10 * p); - } else if (t < 0.7) { - final p = (t - 0.5) / 0.2; - return Offset(0, -8 - 6 * p); - } else { - final p = (t - 0.7) / 0.3; - return Offset(0, -14 + 14 * p); - } - default: - return Offset.zero; - } - } - - double _bodyScale() { - switch (state) { - case RoastyState.module: - // grow: 1 → 1.12 → 1.05 - if (t < 0.4) return 1 + 0.12 * (t / 0.4); - return 1.12 - 0.07 * ((t - 0.4) / 0.6); - case RoastyState.awake: - // blink-pop: 0.94 → 1.04 → 1 - if (t < 0.5) return 0.94 + (1.04 - 0.94) * (t / 0.5); - return 1.04 - (1.04 - 1.0) * ((t - 0.5) / 0.5); - case RoastyState.idle: - // subtle 0.5% squash on the breath beat - final v = math.sin(t * math.pi * 2); - return 1.0 + 0.005 * v; - default: - return 1; - } - } - - double _bodyRotation() { - switch (state) { - case RoastyState.correct: - // ±3° rotate on hop peaks - final s = math.sin(t * math.pi * 2); - return (s * 3) * math.pi / 180; - case RoastyState.sleep: - // permanent 6° tilt - return 6 * math.pi / 180; - default: - return 0; - } - } - - // ── Particles back (behind body) ───────────────────────────────────── - void _paintParticlesBack(Canvas canvas) { - if (state == RoastyState.module) { - _paintModuleRays(canvas); - } - if (state == RoastyState.card) { - _paintCardGlow(canvas); - } - } - - void _paintModuleRays(Canvas canvas) { - canvas.save(); - const cx = 100.0; - const cy = 158.0; - canvas.translate(cx, cy); - canvas.rotate(t * math.pi * 2); - final paint = Paint() - ..color = const Color(0xFFC8843A).withValues(alpha: 0.55) - ..strokeWidth = 2 - ..strokeCap = StrokeCap.round - ..style = PaintingStyle.stroke; - for (var i = 0; i < 8; i++) { - final a = (i / 8) * math.pi * 2; - final x1 = math.cos(a) * 80; - final y1 = math.sin(a) * 80; - final x2 = math.cos(a) * 62; - final y2 = math.sin(a) * 62; - canvas.drawLine(Offset(x1, y1), Offset(x2, y2), paint); - } - canvas.restore(); - } - - void _paintCardGlow(Canvas canvas) { - final pulse = math.sin(t * math.pi * 2) * 0.5 + 0.5; - final gradient = RadialGradient( - colors: [ - const Color(0xFFE6C68A).withValues(alpha: 0.6 * pulse), - const Color(0xFFE6C68A).withValues(alpha: 0), - ], - ); - final rect = Rect.fromCenter( - center: const Offset(100, 160), - width: 240, - height: 220, - ); - final paint = Paint()..shader = gradient.createShader(rect); - canvas.drawOval(rect, paint); - } - - // ── Sprout ─────────────────────────────────────────────────────────── - void _paintSprout(Canvas canvas) { - final sleeping = state == RoastyState.sleep || state == RoastyState.awake; - final usingGrow = sproutScale != null; - final scale = sproutScale ?? (sleeping ? 0.15 : 1.0); - if (scale <= 0) return; // fully hidden; also skips a degenerate matrix - canvas.save(); - final anchor = usingGrow ? _sproutGrowAnchor : _sproutDefaultAnchor; - canvas.translate(anchor.dx, anchor.dy); - - // leaf sway: gentle ±2° rotation on most states - if (!sleeping) { - final sway = math.sin(t * math.pi * 2) * 2 * math.pi / 180; - canvas.rotate(sway); - } - - canvas.scale(scale); - canvas.translate(-anchor.dx, -anchor.dy); - - final stem = Paint() - ..color = const Color(0xFF5E7148) - ..strokeWidth = 3 - ..strokeCap = StrokeCap.round - ..style = PaintingStyle.stroke; - final stemPath = Path() - ..moveTo(100, 88) - ..quadraticBezierTo(100, 80, 100, 70); - canvas.drawPath(stemPath, stem); - - const leafGradient = RadialGradient( - center: Alignment(-0.3, -0.4), - radius: 0.75, - colors: [Color(0xFFB5C497), Color(0xFF5E7148)], - ); - const leafRect = Rect.fromLTWH(60, 55, 80, 30); - final leafPaint = Paint()..shader = leafGradient.createShader(leafRect); - - final leafL = Path() - ..moveTo(100, 72) - ..cubicTo(86, 58, 70, 60, 66, 70) - ..cubicTo(70, 82, 88, 80, 100, 74) - ..close(); - final leafR = Path() - ..moveTo(100, 72) - ..cubicTo(114, 58, 130, 60, 134, 70) - ..cubicTo(130, 82, 112, 80, 100, 74) - ..close(); - canvas.drawPath(leafL, leafPaint); - canvas.drawPath(leafR, leafPaint); - - final vein = Paint() - ..color = const Color(0xFF5E7148).withValues(alpha: 0.6) - ..strokeWidth = 1 - ..style = PaintingStyle.stroke - ..strokeCap = StrokeCap.round; - final veinL = Path() - ..moveTo(100, 73) - ..quadraticBezierTo(86, 70, 72, 72); - final veinR = Path() - ..moveTo(100, 73) - ..quadraticBezierTo(114, 70, 128, 72); - canvas.drawPath(veinL, vein); - canvas.drawPath(veinR, vein); + paintRoastyParticlesFront(canvas, state, t); canvas.restore(); } - // ── Body (bean) ────────────────────────────────────────────────────── - void _paintBody(Canvas canvas) { - canvas.save(); - final offset = _bodyOffset(); - canvas.translate(100 + offset.dx, 158 + offset.dy); - canvas.rotate(_bodyRotation()); - final scale = _bodyScale(); - canvas.scale(scale); - canvas.translate(-100, -158); - - // contact shadow - final shadow = Paint() - ..color = const Color(0xFF2F1A0E).withValues(alpha: 0.18); - canvas.drawOval( - Rect.fromCenter(center: const Offset(100, 232), width: 112, height: 12), - shadow, - ); - - // bean body — radial gradient #8C5634 → #6B3E22 → #4A2B19 - const bodyRect = Rect.fromLTWH(38, 90, 124, 136); - const bodyGradient = RadialGradient( - center: Alignment(-0.36, -0.36), - radius: 0.75, - colors: [Color(0xFF8C5634), Color(0xFF6B3E22), Color(0xFF4A2B19)], - stops: [0.0, 0.55, 1.0], - ); - final bodyPaint = Paint()..shader = bodyGradient.createShader(bodyRect); - final bodyPath = Path() - ..moveTo(100, 90) - ..cubicTo(62, 90, 38, 120, 38, 158) - ..cubicTo(38, 200, 64, 226, 100, 226) - ..cubicTo(136, 226, 162, 200, 162, 158) - ..cubicTo(162, 120, 138, 90, 100, 90) - ..close(); - canvas.drawPath(bodyPath, bodyPaint); - - // top highlight - final highlight = Paint() - ..color = const Color(0xFFA26945).withValues(alpha: 0.45); - canvas.drawOval( - Rect.fromCenter(center: const Offset(78, 115), width: 44, height: 28), - highlight, - ); - - // bean crease - final crease = Paint() - ..color = const Color(0xFF2F1A0E).withValues(alpha: 0.55) - ..style = PaintingStyle.stroke - ..strokeWidth = 2.5 - ..strokeCap = StrokeCap.round; - final creasePath = Path() - ..moveTo(100, 96) - ..quadraticBezierTo(88, 130, 100, 158) - ..quadraticBezierTo(112, 186, 100, 218); - canvas.drawPath(creasePath, crease); - - canvas.restore(); - } - - // ── Face (state-specific) ──────────────────────────────────────────── + /// Faces ride along with the body transform, so apply it before drawing. void _paintFace(Canvas canvas) { - // Faces ride along with the body transform. canvas.save(); - final offset = _bodyOffset(); + final offset = roastyBodyOffset(state, t); canvas.translate(100 + offset.dx, 158 + offset.dy); - canvas.rotate(_bodyRotation()); - canvas.scale(_bodyScale()); + canvas.rotate(roastyBodyRotation(state, t)); + canvas.scale(roastyBodyScale(state, t)); canvas.translate(-100, -158); - - switch (state) { - case RoastyState.idle: - _paintIdleFace(canvas); - case RoastyState.correct: - case RoastyState.lesson: - _paintHappyFace(canvas); - case RoastyState.wrong: - _paintWrongFace(canvas); - case RoastyState.module: - _paintModuleFace(canvas); - case RoastyState.xp: - _paintXpFace(canvas); - case RoastyState.card: - _paintCardFace(canvas); - case RoastyState.sleep: - _paintSleepFace(canvas); - case RoastyState.awake: - _paintAwakeFace(canvas); - } - + paintRoastyFace(canvas, state); canvas.restore(); } - // ── Particles in front (xp burst, wrong x, sleep zzz, sparkles, confetti) - void _paintParticlesFront(Canvas canvas) { - switch (state) { - case RoastyState.correct: - _paintSparkles(canvas); - case RoastyState.lesson: - case RoastyState.module: - _paintConfetti(canvas); - case RoastyState.xp: - _paintXpBurst(canvas); - case RoastyState.wrong: - _paintWrongBadge(canvas); - case RoastyState.sleep: - _paintSleepZzz(canvas); - default: - break; - } - } - - // ── Face primitives ────────────────────────────────────────────────── - Paint get _eyeWhite => Paint()..color = const Color(0xFFFBF7EE); - Paint get _pupil => Paint()..color = const Color(0xFF2A1B12); - Paint get _cheek => - Paint()..color = const Color(0xFFC47654).withValues(alpha: 0.45); - Paint get _mouthStroke => Paint() - ..color = const Color(0xFF2A1B12) - ..style = PaintingStyle.stroke - ..strokeWidth = 2.5 - ..strokeCap = StrokeCap.round; - - void _paintEyeOpen(Canvas c, double cx, double cy) { - c.drawOval( - Rect.fromCenter(center: Offset(cx, cy), width: 20, height: 24), - _eyeWhite, - ); - c.drawOval( - Rect.fromCenter(center: Offset(cx, cy + 3), width: 10, height: 12), - _pupil, - ); - c.drawCircle(Offset(cx + 2, cy), 1.7, _eyeWhite); - } - - void _paintEyeArchUp(Canvas c, double cx, double cy) { - final p = Path() - ..moveTo(cx - 10, cy) - ..quadraticBezierTo(cx, cy - 10, cx + 10, cy); - c.drawPath(p, _mouthStroke..strokeWidth = 3); - } - - void _paintIdleFace(Canvas c) { - _paintEyeOpen(c, 80, 148); - _paintEyeOpen(c, 120, 148); - c.drawOval( - Rect.fromCenter(center: const Offset(68, 172), width: 12, height: 6), - _cheek, - ); - c.drawOval( - Rect.fromCenter(center: const Offset(132, 172), width: 12, height: 6), - _cheek, - ); - final mouth = Path() - ..moveTo(90, 180) - ..quadraticBezierTo(100, 188, 110, 180); - c.drawPath(mouth, _mouthStroke); - } - - void _paintHappyFace(Canvas c) { - _paintEyeArchUp(c, 80, 148); - _paintEyeArchUp(c, 120, 148); - c.drawOval( - Rect.fromCenter(center: const Offset(68, 170), width: 14, height: 7), - _cheek..color = const Color(0xFFC47654).withValues(alpha: 0.55), - ); - c.drawOval( - Rect.fromCenter(center: const Offset(132, 170), width: 14, height: 7), - _cheek, - ); - final mouth = Path() - ..moveTo(86, 178) - ..quadraticBezierTo(100, 192, 114, 178); - c.drawPath(mouth, _mouthStroke..strokeWidth = 3); - } - - void _paintWrongFace(Canvas c) { - // closed-low eyes - c.drawOval( - Rect.fromCenter(center: const Offset(80, 148), width: 20, height: 24), - _eyeWhite, - ); - c.drawOval( - Rect.fromCenter(center: const Offset(80, 155), width: 10, height: 10), - _pupil, - ); - c.drawOval( - Rect.fromCenter(center: const Offset(120, 148), width: 20, height: 24), - _eyeWhite, - ); - c.drawOval( - Rect.fromCenter(center: const Offset(120, 155), width: 10, height: 10), - _pupil, - ); - final brow = Paint() - ..color = const Color(0xFF2A1B12) - ..style = PaintingStyle.stroke - ..strokeWidth = 2 - ..strokeCap = StrokeCap.round; - c.drawLine(const Offset(71, 138), const Offset(84, 135), brow); - c.drawLine(const Offset(129, 138), const Offset(116, 135), brow); - final mouth = Path() - ..moveTo(91, 184) - ..quadraticBezierTo(100, 180, 109, 184); - c.drawPath(mouth, _mouthStroke); - } - - void _paintStar(Canvas c, double cx, double cy, double r, Color color) { - final path = Path(); - for (var i = 0; i < 10; i++) { - final angle = -math.pi / 2 + i * math.pi / 5; - final rad = i.isEven ? r : r * 0.42; - final x = cx + math.cos(angle) * rad; - final y = cy + math.sin(angle) * rad; - if (i == 0) { - path.moveTo(x, y); - } else { - path.lineTo(x, y); - } - } - path.close(); - c.drawPath(path, Paint()..color = color); - } - - void _paintModuleFace(Canvas c) { - _paintStar(c, 80, 148, 11, const Color(0xFFC8843A)); - _paintStar(c, 120, 148, 11, const Color(0xFFC8843A)); - c.drawOval( - Rect.fromCenter(center: const Offset(68, 172), width: 14, height: 7), - _cheek, - ); - c.drawOval( - Rect.fromCenter(center: const Offset(132, 172), width: 14, height: 7), - _cheek, - ); - c.drawOval( - Rect.fromCenter(center: const Offset(100, 185), width: 16, height: 18), - _pupil, - ); - } - - void _paintXpFace(Canvas c) { - // open left eye, winking right eye - _paintEyeOpen(c, 80, 148); - final wink = Path() - ..moveTo(110, 148) - ..quadraticBezierTo(120, 142, 130, 148); - c.drawPath(wink, _mouthStroke..strokeWidth = 3); - c.drawOval( - Rect.fromCenter(center: const Offset(68, 170), width: 12, height: 6), - _cheek, - ); - c.drawOval( - Rect.fromCenter(center: const Offset(132, 170), width: 12, height: 6), - _cheek, - ); - final mouth = Path() - ..moveTo(90, 180) - ..quadraticBezierTo(100, 188, 113, 178); - c.drawPath(mouth, _mouthStroke); - } - - void _paintCardFace(Canvas c) { - // wide-eyed O - c.drawOval( - Rect.fromCenter(center: const Offset(80, 146), width: 22, height: 26), - _eyeWhite, - ); - c.drawOval( - Rect.fromCenter(center: const Offset(80, 148), width: 12, height: 14), - _pupil, - ); - c.drawCircle(const Offset(83, 145), 2, _eyeWhite); - c.drawOval( - Rect.fromCenter(center: const Offset(120, 146), width: 22, height: 26), - _eyeWhite, - ); - c.drawOval( - Rect.fromCenter(center: const Offset(120, 148), width: 12, height: 14), - _pupil, - ); - c.drawCircle(const Offset(123, 145), 2, _eyeWhite); - c.drawOval( - Rect.fromCenter(center: const Offset(100, 184), width: 10, height: 12), - _pupil, - ); - } - - void _paintSleepFace(Canvas c) { - final closed = Paint() - ..color = const Color(0xFF2A1B12) - ..style = PaintingStyle.stroke - ..strokeWidth = 3 - ..strokeCap = StrokeCap.round; - c.drawLine(const Offset(71, 150), const Offset(89, 150), closed); - c.drawLine(const Offset(111, 150), const Offset(129, 150), closed); - final mouth = Path() - ..moveTo(95, 182) - ..quadraticBezierTo(100, 184, 105, 182); - c.drawPath(mouth, _mouthStroke); - } - - void _paintAwakeFace(Canvas c) { - // big O-eyes (like card but smaller) - c.drawOval( - Rect.fromCenter(center: const Offset(80, 146), width: 22, height: 28), - _eyeWhite, - ); - c.drawOval( - Rect.fromCenter(center: const Offset(80, 148), width: 12, height: 14), - _pupil, - ); - c.drawCircle(const Offset(83, 145), 2, _eyeWhite); - c.drawOval( - Rect.fromCenter(center: const Offset(120, 146), width: 22, height: 28), - _eyeWhite, - ); - c.drawOval( - Rect.fromCenter(center: const Offset(120, 148), width: 12, height: 14), - _pupil, - ); - c.drawCircle(const Offset(123, 145), 2, _eyeWhite); - c.drawOval( - Rect.fromCenter(center: const Offset(100, 184), width: 8, height: 10), - _pupil, - ); - } - - // ── Particle implementations ───────────────────────────────────────── - void _paintSparkles(Canvas c) { - const centers = [ - Offset(36, 80), - Offset(168, 100), - Offset(40, 200), - Offset(170, 200), - ]; - const delays = [0.0, 0.25, 0.5, 0.75]; - const colors = [ - Color(0xFFC8843A), - Color(0xFF7A8471), - Color(0xFFB8533A), - Color(0xFFC8843A), - ]; - for (var i = 0; i < centers.length; i++) { - final phase = (t - delays[i]) % 1.0; - final wave = phase < 0 ? 0.0 : math.sin(phase * math.pi).abs(); - final opacity = wave; - final scale = 0.3 + wave * 0.7; - final p = Paint()..color = colors[i].withValues(alpha: opacity); - c.save(); - c.translate(centers[i].dx, centers[i].dy); - c.scale(scale); - _paintStar(c, 0, 0, 5, p.color); - c.restore(); - } - } - - void _paintConfetti(Canvas c) { - const pieces = [ - [40.0, 60.0, 0.0], - [158.0, 50.0, 0.2], - [30.0, 120.0, 0.4], - [170.0, 100.0, 0.6], - [172.0, 180.0, 0.1], - [22.0, 180.0, 0.5], - [60.0, 40.0, 0.3], - [140.0, 40.0, 0.7], - ]; - const colors = [ - Color(0xFFB8533A), - Color(0xFF7A8471), - Color(0xFFC8843A), - Color(0xFFB8533A), - Color(0xFF7A8471), - Color(0xFFC8843A), - Color(0xFF7A8471), - Color(0xFFB8533A), - ]; - for (var i = 0; i < pieces.length; i++) { - final piece = pieces[i]; - final phase = (t + piece[2]) % 1.0; - final dy = -30 + phase * 210; - final rotate = phase * 540 * math.pi / 180; - final opacity = phase < 0.2 - ? phase / 0.2 - : (phase > 0.95 ? (1 - phase) / 0.05 : 1.0); - final paint = Paint() - ..color = colors[i].withValues(alpha: opacity.clamp(0, 1)); - c.save(); - c.translate(piece[0], piece[1] + dy); - c.rotate(rotate); - if (i.isEven) { - c.drawRect(const Rect.fromLTWH(-3, -4, 6, 8), paint); - } else { - c.drawCircle(Offset.zero, 3, paint); - } - c.restore(); - } - } - - void _paintXpBurst(Canvas c) { - final phase = t; - final dy = -50 * phase; - final opacity = phase < 0.2 - ? phase / 0.2 - : (1 - phase).clamp(0, 1).toDouble(); - final paint = Paint() - ..color = const Color(0xFFC8843A).withValues(alpha: opacity); - final rect = Rect.fromLTWH(68, 42 + dy, 64, 24); - c.drawRRect(RRect.fromRectAndRadius(rect, const Radius.circular(2)), paint); - final textStyle = TextStyle( - color: const Color(0xFFFBF7EE).withValues(alpha: opacity), - fontWeight: FontWeight.w600, - fontSize: 13, - fontFamily: 'IBMPlexMono', - letterSpacing: 1, - ); - final tp = TextPainter( - text: TextSpan(text: '+15 XP', style: textStyle), - textDirection: TextDirection.ltr, - textAlign: TextAlign.center, - )..layout(minWidth: 64, maxWidth: 64); - tp.paint(c, Offset(68, 47 + dy)); - } - - void _paintWrongBadge(Canvas c) { - final fill = Paint()..color = const Color(0xFFFBF7EE); - final stroke = Paint() - ..color = const Color(0xFFB8533A) - ..style = PaintingStyle.stroke - ..strokeWidth = 2; - c.drawCircle(const Offset(148, 76), 13, fill); - c.drawCircle(const Offset(148, 76), 13, stroke); - final bar = Paint()..color = const Color(0xFFB8533A); - c.drawRRect( - RRect.fromRectAndRadius( - const Rect.fromLTWH(146, 68, 4, 9), - const Radius.circular(1), - ), - bar, - ); - c.drawCircle(const Offset(148, 82), 1.6, bar); - } - - void _paintSleepZzz(Canvas c) { - const letters = <({double x, double y, double size, double delay})>[ - (x: 148, y: 80, size: 18, delay: 0.0), - (x: 158, y: 68, size: 14, delay: 0.6 / 2.6), - (x: 166, y: 58, size: 11, delay: 1.2 / 2.6), - ]; - for (final l in letters) { - final raw = (t - l.delay) % 1.0; - final p = raw < 0 ? raw + 1 : raw; - final opacity = p < 0.3 ? p / 0.3 : (p > 0.7 ? (1 - p) / 0.3 : 1.0); - final dx = p * 8; - final dy = -p * 12; - final tp = TextPainter( - text: TextSpan( - text: 'z', - style: TextStyle( - color: const Color( - 0xFF6B5F54, - ).withValues(alpha: opacity.clamp(0.0, 1.0)), - fontStyle: FontStyle.italic, - fontSize: l.size, - fontFamily: 'Fraunces', - ), - ), - textDirection: TextDirection.ltr, - )..layout(); - tp.paint(c, Offset(l.x + dx, l.y + dy)); - } - } - @override bool shouldRepaint(covariant _RoastyPainter old) => old.state != state || old.t != t || old.sproutScale != sproutScale; diff --git a/coffee_quest/lib/features/onboarding/presentation/widgets/roasty_animation.dart b/coffee_quest/lib/features/onboarding/presentation/widgets/roasty_animation.dart new file mode 100644 index 0000000..18253fe --- /dev/null +++ b/coffee_quest/lib/features/onboarding/presentation/widgets/roasty_animation.dart @@ -0,0 +1,119 @@ +import 'dart:math' as math; + +import 'package:coffee_quest/features/onboarding/presentation/widgets/roasty_state.dart'; +import 'package:flutter/material.dart'; + +// Animation switches handle the states with special motion and default the +// rest; enumerating every no-op state would bloat these mascot switches. +// ignore_for_file: no_default_cases + +/// Controller duration for the Roasty mascot's [state] animation. +Duration roastyDuration(RoastyState state) { + switch (state) { + case RoastyState.idle: + return const Duration(milliseconds: 3200); // breathe loop + case RoastyState.correct: + return const Duration(milliseconds: 900); // hop one-shot + case RoastyState.wrong: + return const Duration(milliseconds: 500); // shake one-shot + case RoastyState.lesson: + return const Duration(milliseconds: 1100); // jump one-shot + case RoastyState.module: + return const Duration(milliseconds: 900); // grow one-shot + case RoastyState.xp: + return const Duration(milliseconds: 1300); // xp rise one-shot + case RoastyState.card: + return const Duration(milliseconds: 1600); // shimmer loop + case RoastyState.sleep: + return const Duration(milliseconds: 3400); // slow breathe loop + case RoastyState.awake: + return const Duration(milliseconds: 400); // blink-pop one-shot + } +} + +/// Whether [state]'s animation repeats (vs. plays once). +bool roastyLoops(RoastyState state) { + switch (state) { + case RoastyState.idle: + case RoastyState.card: + case RoastyState.sleep: + return true; + case RoastyState.correct: + case RoastyState.wrong: + case RoastyState.lesson: + case RoastyState.module: + case RoastyState.xp: + case RoastyState.awake: + return false; + } +} + +/// Body translation for [state] at controller progress [t] (0..1). +Offset roastyBodyOffset(RoastyState state, double t) { + switch (state) { + case RoastyState.idle: + // breathe: translateY 0 → -3 → 0 + final v = math.sin(t * math.pi * 2); + return Offset(0, -3 * (v * 0.5 + 0.5) * math.sin(t * math.pi)); + case RoastyState.correct: + // hop: -10 at 25% and 75%, 0 at 0/50/100% + final hop = math.sin(t * math.pi * 2).abs(); + return Offset(0, -10 * hop); + case RoastyState.wrong: + // shake: ±5 → ±3 + final amp = (1 - t) * 5; + final dx = math.sin(t * math.pi * 8) * amp; + return Offset(dx, 0); + case RoastyState.lesson: + // jump: 0 → -18 → -8 → -14 → 0 + if (t < 0.3) { + return Offset(0, -18 * (t / 0.3)); + } else if (t < 0.5) { + final p = (t - 0.3) / 0.2; + return Offset(0, -18 + 10 * p); + } else if (t < 0.7) { + final p = (t - 0.5) / 0.2; + return Offset(0, -8 - 6 * p); + } else { + final p = (t - 0.7) / 0.3; + return Offset(0, -14 + 14 * p); + } + default: + return Offset.zero; + } +} + +/// Body scale for [state] at controller progress [t] (0..1). +double roastyBodyScale(RoastyState state, double t) { + switch (state) { + case RoastyState.module: + // grow: 1 → 1.12 → 1.05 + if (t < 0.4) return 1 + 0.12 * (t / 0.4); + return 1.12 - 0.07 * ((t - 0.4) / 0.6); + case RoastyState.awake: + // blink-pop: 0.94 → 1.04 → 1 + if (t < 0.5) return 0.94 + (1.04 - 0.94) * (t / 0.5); + return 1.04 - (1.04 - 1.0) * ((t - 0.5) / 0.5); + case RoastyState.idle: + // subtle 0.5% squash on the breath beat + final v = math.sin(t * math.pi * 2); + return 1.0 + 0.005 * v; + default: + return 1; + } +} + +/// Body rotation (radians) for [state] at controller progress [t] (0..1). +double roastyBodyRotation(RoastyState state, double t) { + switch (state) { + case RoastyState.correct: + // ±3° rotate on hop peaks + final s = math.sin(t * math.pi * 2); + return (s * 3) * math.pi / 180; + case RoastyState.sleep: + // permanent 6° tilt + return 6 * math.pi / 180; + default: + return 0; + } +} diff --git a/coffee_quest/lib/features/onboarding/presentation/widgets/roasty_body.dart b/coffee_quest/lib/features/onboarding/presentation/widgets/roasty_body.dart new file mode 100644 index 0000000..dd105fc --- /dev/null +++ b/coffee_quest/lib/features/onboarding/presentation/widgets/roasty_body.dart @@ -0,0 +1,144 @@ +import 'dart:math' as math; + +import 'package:coffee_quest/features/onboarding/presentation/widgets/roasty_animation.dart'; +import 'package:coffee_quest/features/onboarding/presentation/widgets/roasty_state.dart'; +import 'package:flutter/material.dart'; + +/// Scale origin while the host drives the grow: the stem base sits at the +/// bean's top edge, so the sprout emerges from the head rather than scaling +/// in mid-air. The default sleeping shrink keeps its original (100, 75) pivot. +const Offset _sproutGrowAnchor = Offset(100, 88); +const Offset _sproutDefaultAnchor = Offset(100, 75); + +/// Paints the sprout (stem + leaves) above the bean. [sproutScale] overrides +/// the state-derived scale when non-null (used by the loading wake-up grow). +void paintRoastySprout( + Canvas canvas, + RoastyState state, + double t, + double? sproutScale, +) { + final sleeping = state == RoastyState.sleep || state == RoastyState.awake; + final usingGrow = sproutScale != null; + final scale = sproutScale ?? (sleeping ? 0.15 : 1.0); + if (scale <= 0) return; // fully hidden; also skips a degenerate matrix + canvas.save(); + final anchor = usingGrow ? _sproutGrowAnchor : _sproutDefaultAnchor; + canvas.translate(anchor.dx, anchor.dy); + + // leaf sway: gentle ±2° rotation on most states + if (!sleeping) { + final sway = math.sin(t * math.pi * 2) * 2 * math.pi / 180; + canvas.rotate(sway); + } + + canvas.scale(scale); + canvas.translate(-anchor.dx, -anchor.dy); + + final stem = Paint() + ..color = const Color(0xFF5E7148) + ..strokeWidth = 3 + ..strokeCap = StrokeCap.round + ..style = PaintingStyle.stroke; + final stemPath = Path() + ..moveTo(100, 88) + ..quadraticBezierTo(100, 80, 100, 70); + canvas.drawPath(stemPath, stem); + + const leafGradient = RadialGradient( + center: Alignment(-0.3, -0.4), + radius: 0.75, + colors: [Color(0xFFB5C497), Color(0xFF5E7148)], + ); + const leafRect = Rect.fromLTWH(60, 55, 80, 30); + final leafPaint = Paint()..shader = leafGradient.createShader(leafRect); + + final leafL = Path() + ..moveTo(100, 72) + ..cubicTo(86, 58, 70, 60, 66, 70) + ..cubicTo(70, 82, 88, 80, 100, 74) + ..close(); + final leafR = Path() + ..moveTo(100, 72) + ..cubicTo(114, 58, 130, 60, 134, 70) + ..cubicTo(130, 82, 112, 80, 100, 74) + ..close(); + canvas.drawPath(leafL, leafPaint); + canvas.drawPath(leafR, leafPaint); + + final vein = Paint() + ..color = const Color(0xFF5E7148).withValues(alpha: 0.6) + ..strokeWidth = 1 + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round; + final veinL = Path() + ..moveTo(100, 73) + ..quadraticBezierTo(86, 70, 72, 72); + final veinR = Path() + ..moveTo(100, 73) + ..quadraticBezierTo(114, 70, 128, 72); + canvas.drawPath(veinL, vein); + canvas.drawPath(veinR, vein); + + canvas.restore(); +} + +/// Paints the bean body (shadow, gradient body, highlight, crease) with the +/// per-state body transform for [state] at progress [t]. +void paintRoastyBody(Canvas canvas, RoastyState state, double t) { + canvas.save(); + final offset = roastyBodyOffset(state, t); + canvas.translate(100 + offset.dx, 158 + offset.dy); + canvas.rotate(roastyBodyRotation(state, t)); + final scale = roastyBodyScale(state, t); + canvas.scale(scale); + canvas.translate(-100, -158); + + // contact shadow + final shadow = Paint() + ..color = const Color(0xFF2F1A0E).withValues(alpha: 0.18); + canvas.drawOval( + Rect.fromCenter(center: const Offset(100, 232), width: 112, height: 12), + shadow, + ); + + // bean body — radial gradient #8C5634 → #6B3E22 → #4A2B19 + const bodyRect = Rect.fromLTWH(38, 90, 124, 136); + const bodyGradient = RadialGradient( + center: Alignment(-0.36, -0.36), + radius: 0.75, + colors: [Color(0xFF8C5634), Color(0xFF6B3E22), Color(0xFF4A2B19)], + stops: [0.0, 0.55, 1.0], + ); + final bodyPaint = Paint()..shader = bodyGradient.createShader(bodyRect); + final bodyPath = Path() + ..moveTo(100, 90) + ..cubicTo(62, 90, 38, 120, 38, 158) + ..cubicTo(38, 200, 64, 226, 100, 226) + ..cubicTo(136, 226, 162, 200, 162, 158) + ..cubicTo(162, 120, 138, 90, 100, 90) + ..close(); + canvas.drawPath(bodyPath, bodyPaint); + + // top highlight + final highlight = Paint() + ..color = const Color(0xFFA26945).withValues(alpha: 0.45); + canvas.drawOval( + Rect.fromCenter(center: const Offset(78, 115), width: 44, height: 28), + highlight, + ); + + // bean crease + final crease = Paint() + ..color = const Color(0xFF2F1A0E).withValues(alpha: 0.55) + ..style = PaintingStyle.stroke + ..strokeWidth = 2.5 + ..strokeCap = StrokeCap.round; + final creasePath = Path() + ..moveTo(100, 96) + ..quadraticBezierTo(88, 130, 100, 158) + ..quadraticBezierTo(112, 186, 100, 218); + canvas.drawPath(creasePath, crease); + + canvas.restore(); +} diff --git a/coffee_quest/lib/features/onboarding/presentation/widgets/roasty_faces.dart b/coffee_quest/lib/features/onboarding/presentation/widgets/roasty_faces.dart new file mode 100644 index 0000000..3b9c462 --- /dev/null +++ b/coffee_quest/lib/features/onboarding/presentation/widgets/roasty_faces.dart @@ -0,0 +1,245 @@ +import 'dart:math' as math; + +import 'package:coffee_quest/features/onboarding/presentation/widgets/roasty_state.dart'; +import 'package:flutter/material.dart'; + +/// Paints the state-specific face onto the already-transformed bean canvas. +void paintRoastyFace(Canvas canvas, RoastyState state) { + switch (state) { + case RoastyState.idle: + _paintIdleFace(canvas); + case RoastyState.correct: + case RoastyState.lesson: + _paintHappyFace(canvas); + case RoastyState.wrong: + _paintWrongFace(canvas); + case RoastyState.module: + _paintModuleFace(canvas); + case RoastyState.xp: + _paintXpFace(canvas); + case RoastyState.card: + _paintCardFace(canvas); + case RoastyState.sleep: + _paintSleepFace(canvas); + case RoastyState.awake: + _paintAwakeFace(canvas); + } +} + +// ── Face primitives ──────────────────────────────────────────────────── +Paint get _eyeWhite => Paint()..color = const Color(0xFFFBF7EE); +Paint get _pupil => Paint()..color = const Color(0xFF2A1B12); +Paint get _cheek => + Paint()..color = const Color(0xFFC47654).withValues(alpha: 0.45); +Paint get _mouthStroke => Paint() + ..color = const Color(0xFF2A1B12) + ..style = PaintingStyle.stroke + ..strokeWidth = 2.5 + ..strokeCap = StrokeCap.round; + +void _paintEyeOpen(Canvas c, double cx, double cy) { + c.drawOval( + Rect.fromCenter(center: Offset(cx, cy), width: 20, height: 24), + _eyeWhite, + ); + c.drawOval( + Rect.fromCenter(center: Offset(cx, cy + 3), width: 10, height: 12), + _pupil, + ); + c.drawCircle(Offset(cx + 2, cy), 1.7, _eyeWhite); +} + +void _paintEyeArchUp(Canvas c, double cx, double cy) { + final p = Path() + ..moveTo(cx - 10, cy) + ..quadraticBezierTo(cx, cy - 10, cx + 10, cy); + c.drawPath(p, _mouthStroke..strokeWidth = 3); +} + +void _paintIdleFace(Canvas c) { + _paintEyeOpen(c, 80, 148); + _paintEyeOpen(c, 120, 148); + c.drawOval( + Rect.fromCenter(center: const Offset(68, 172), width: 12, height: 6), + _cheek, + ); + c.drawOval( + Rect.fromCenter(center: const Offset(132, 172), width: 12, height: 6), + _cheek, + ); + final mouth = Path() + ..moveTo(90, 180) + ..quadraticBezierTo(100, 188, 110, 180); + c.drawPath(mouth, _mouthStroke); +} + +void _paintHappyFace(Canvas c) { + _paintEyeArchUp(c, 80, 148); + _paintEyeArchUp(c, 120, 148); + c.drawOval( + Rect.fromCenter(center: const Offset(68, 170), width: 14, height: 7), + _cheek..color = const Color(0xFFC47654).withValues(alpha: 0.55), + ); + c.drawOval( + Rect.fromCenter(center: const Offset(132, 170), width: 14, height: 7), + _cheek, + ); + final mouth = Path() + ..moveTo(86, 178) + ..quadraticBezierTo(100, 192, 114, 178); + c.drawPath(mouth, _mouthStroke..strokeWidth = 3); +} + +void _paintWrongFace(Canvas c) { + // closed-low eyes + c.drawOval( + Rect.fromCenter(center: const Offset(80, 148), width: 20, height: 24), + _eyeWhite, + ); + c.drawOval( + Rect.fromCenter(center: const Offset(80, 155), width: 10, height: 10), + _pupil, + ); + c.drawOval( + Rect.fromCenter(center: const Offset(120, 148), width: 20, height: 24), + _eyeWhite, + ); + c.drawOval( + Rect.fromCenter(center: const Offset(120, 155), width: 10, height: 10), + _pupil, + ); + final brow = Paint() + ..color = const Color(0xFF2A1B12) + ..style = PaintingStyle.stroke + ..strokeWidth = 2 + ..strokeCap = StrokeCap.round; + c.drawLine(const Offset(71, 138), const Offset(84, 135), brow); + c.drawLine(const Offset(129, 138), const Offset(116, 135), brow); + final mouth = Path() + ..moveTo(91, 184) + ..quadraticBezierTo(100, 180, 109, 184); + c.drawPath(mouth, _mouthStroke); +} + +/// Draws a five-pointed star centered at (cx, cy). Shared by the module face +/// and the correct-state sparkle particles. +void paintStar(Canvas c, double cx, double cy, double r, Color color) { + final path = Path(); + for (var i = 0; i < 10; i++) { + final angle = -math.pi / 2 + i * math.pi / 5; + final rad = i.isEven ? r : r * 0.42; + final x = cx + math.cos(angle) * rad; + final y = cy + math.sin(angle) * rad; + if (i == 0) { + path.moveTo(x, y); + } else { + path.lineTo(x, y); + } + } + path.close(); + c.drawPath(path, Paint()..color = color); +} + +void _paintModuleFace(Canvas c) { + paintStar(c, 80, 148, 11, const Color(0xFFC8843A)); + paintStar(c, 120, 148, 11, const Color(0xFFC8843A)); + c.drawOval( + Rect.fromCenter(center: const Offset(68, 172), width: 14, height: 7), + _cheek, + ); + c.drawOval( + Rect.fromCenter(center: const Offset(132, 172), width: 14, height: 7), + _cheek, + ); + c.drawOval( + Rect.fromCenter(center: const Offset(100, 185), width: 16, height: 18), + _pupil, + ); +} + +void _paintXpFace(Canvas c) { + // open left eye, winking right eye + _paintEyeOpen(c, 80, 148); + final wink = Path() + ..moveTo(110, 148) + ..quadraticBezierTo(120, 142, 130, 148); + c.drawPath(wink, _mouthStroke..strokeWidth = 3); + c.drawOval( + Rect.fromCenter(center: const Offset(68, 170), width: 12, height: 6), + _cheek, + ); + c.drawOval( + Rect.fromCenter(center: const Offset(132, 170), width: 12, height: 6), + _cheek, + ); + final mouth = Path() + ..moveTo(90, 180) + ..quadraticBezierTo(100, 188, 113, 178); + c.drawPath(mouth, _mouthStroke); +} + +void _paintCardFace(Canvas c) { + // wide-eyed O + c.drawOval( + Rect.fromCenter(center: const Offset(80, 146), width: 22, height: 26), + _eyeWhite, + ); + c.drawOval( + Rect.fromCenter(center: const Offset(80, 148), width: 12, height: 14), + _pupil, + ); + c.drawCircle(const Offset(83, 145), 2, _eyeWhite); + c.drawOval( + Rect.fromCenter(center: const Offset(120, 146), width: 22, height: 26), + _eyeWhite, + ); + c.drawOval( + Rect.fromCenter(center: const Offset(120, 148), width: 12, height: 14), + _pupil, + ); + c.drawCircle(const Offset(123, 145), 2, _eyeWhite); + c.drawOval( + Rect.fromCenter(center: const Offset(100, 184), width: 10, height: 12), + _pupil, + ); +} + +void _paintSleepFace(Canvas c) { + final closed = Paint() + ..color = const Color(0xFF2A1B12) + ..style = PaintingStyle.stroke + ..strokeWidth = 3 + ..strokeCap = StrokeCap.round; + c.drawLine(const Offset(71, 150), const Offset(89, 150), closed); + c.drawLine(const Offset(111, 150), const Offset(129, 150), closed); + final mouth = Path() + ..moveTo(95, 182) + ..quadraticBezierTo(100, 184, 105, 182); + c.drawPath(mouth, _mouthStroke); +} + +void _paintAwakeFace(Canvas c) { + // big O-eyes (like card but smaller) + c.drawOval( + Rect.fromCenter(center: const Offset(80, 146), width: 22, height: 28), + _eyeWhite, + ); + c.drawOval( + Rect.fromCenter(center: const Offset(80, 148), width: 12, height: 14), + _pupil, + ); + c.drawCircle(const Offset(83, 145), 2, _eyeWhite); + c.drawOval( + Rect.fromCenter(center: const Offset(120, 146), width: 22, height: 28), + _eyeWhite, + ); + c.drawOval( + Rect.fromCenter(center: const Offset(120, 148), width: 12, height: 14), + _pupil, + ); + c.drawCircle(const Offset(123, 145), 2, _eyeWhite); + c.drawOval( + Rect.fromCenter(center: const Offset(100, 184), width: 8, height: 10), + _pupil, + ); +} diff --git a/coffee_quest/lib/features/onboarding/presentation/widgets/roasty_particles.dart b/coffee_quest/lib/features/onboarding/presentation/widgets/roasty_particles.dart new file mode 100644 index 0000000..fb6dc74 --- /dev/null +++ b/coffee_quest/lib/features/onboarding/presentation/widgets/roasty_particles.dart @@ -0,0 +1,225 @@ +import 'dart:math' as math; + +import 'package:coffee_quest/features/onboarding/presentation/widgets/roasty_faces.dart'; +import 'package:coffee_quest/features/onboarding/presentation/widgets/roasty_state.dart'; +import 'package:flutter/material.dart'; + +// The front particle dispatch handles the states with particles and defaults +// the rest; enumerating every no-op state would bloat the switch. +// ignore_for_file: no_default_cases + +/// Particle layer painted behind the bean body (rays / glow). +void paintRoastyParticlesBack(Canvas canvas, RoastyState state, double t) { + if (state == RoastyState.module) { + _paintModuleRays(canvas, t); + } + if (state == RoastyState.card) { + _paintCardGlow(canvas, t); + } +} + +/// Particle layer painted in front of the bean body (sparkles, confetti, xp +/// burst, wrong badge, sleep zzz). +void paintRoastyParticlesFront(Canvas canvas, RoastyState state, double t) { + switch (state) { + case RoastyState.correct: + _paintSparkles(canvas, t); + case RoastyState.lesson: + case RoastyState.module: + _paintConfetti(canvas, t); + case RoastyState.xp: + _paintXpBurst(canvas, t); + case RoastyState.wrong: + _paintWrongBadge(canvas); + case RoastyState.sleep: + _paintSleepZzz(canvas, t); + default: + break; + } +} + +// ── Particles back (behind body) ─────────────────────────────────────── +void _paintModuleRays(Canvas canvas, double t) { + canvas.save(); + const cx = 100.0; + const cy = 158.0; + canvas.translate(cx, cy); + canvas.rotate(t * math.pi * 2); + final paint = Paint() + ..color = const Color(0xFFC8843A).withValues(alpha: 0.55) + ..strokeWidth = 2 + ..strokeCap = StrokeCap.round + ..style = PaintingStyle.stroke; + for (var i = 0; i < 8; i++) { + final a = (i / 8) * math.pi * 2; + final x1 = math.cos(a) * 80; + final y1 = math.sin(a) * 80; + final x2 = math.cos(a) * 62; + final y2 = math.sin(a) * 62; + canvas.drawLine(Offset(x1, y1), Offset(x2, y2), paint); + } + canvas.restore(); +} + +void _paintCardGlow(Canvas canvas, double t) { + final pulse = math.sin(t * math.pi * 2) * 0.5 + 0.5; + final gradient = RadialGradient( + colors: [ + const Color(0xFFE6C68A).withValues(alpha: 0.6 * pulse), + const Color(0xFFE6C68A).withValues(alpha: 0), + ], + ); + final rect = Rect.fromCenter( + center: const Offset(100, 160), + width: 240, + height: 220, + ); + final paint = Paint()..shader = gradient.createShader(rect); + canvas.drawOval(rect, paint); +} + +// ── Particles in front ───────────────────────────────────────────────── +void _paintSparkles(Canvas c, double t) { + const centers = [ + Offset(36, 80), + Offset(168, 100), + Offset(40, 200), + Offset(170, 200), + ]; + const delays = [0.0, 0.25, 0.5, 0.75]; + const colors = [ + Color(0xFFC8843A), + Color(0xFF7A8471), + Color(0xFFB8533A), + Color(0xFFC8843A), + ]; + for (var i = 0; i < centers.length; i++) { + final phase = (t - delays[i]) % 1.0; + final wave = phase < 0 ? 0.0 : math.sin(phase * math.pi).abs(); + final opacity = wave; + final scale = 0.3 + wave * 0.7; + final p = Paint()..color = colors[i].withValues(alpha: opacity); + c.save(); + c.translate(centers[i].dx, centers[i].dy); + c.scale(scale); + paintStar(c, 0, 0, 5, p.color); + c.restore(); + } +} + +void _paintConfetti(Canvas c, double t) { + const pieces = [ + [40.0, 60.0, 0.0], + [158.0, 50.0, 0.2], + [30.0, 120.0, 0.4], + [170.0, 100.0, 0.6], + [172.0, 180.0, 0.1], + [22.0, 180.0, 0.5], + [60.0, 40.0, 0.3], + [140.0, 40.0, 0.7], + ]; + const colors = [ + Color(0xFFB8533A), + Color(0xFF7A8471), + Color(0xFFC8843A), + Color(0xFFB8533A), + Color(0xFF7A8471), + Color(0xFFC8843A), + Color(0xFF7A8471), + Color(0xFFB8533A), + ]; + for (var i = 0; i < pieces.length; i++) { + final piece = pieces[i]; + final phase = (t + piece[2]) % 1.0; + final dy = -30 + phase * 210; + final rotate = phase * 540 * math.pi / 180; + final opacity = phase < 0.2 + ? phase / 0.2 + : (phase > 0.95 ? (1 - phase) / 0.05 : 1.0); + final paint = Paint() + ..color = colors[i].withValues(alpha: opacity.clamp(0, 1)); + c.save(); + c.translate(piece[0], piece[1] + dy); + c.rotate(rotate); + if (i.isEven) { + c.drawRect(const Rect.fromLTWH(-3, -4, 6, 8), paint); + } else { + c.drawCircle(Offset.zero, 3, paint); + } + c.restore(); + } +} + +void _paintXpBurst(Canvas c, double t) { + final phase = t; + final dy = -50 * phase; + final opacity = phase < 0.2 + ? phase / 0.2 + : (1 - phase).clamp(0, 1).toDouble(); + final paint = Paint() + ..color = const Color(0xFFC8843A).withValues(alpha: opacity); + final rect = Rect.fromLTWH(68, 42 + dy, 64, 24); + c.drawRRect(RRect.fromRectAndRadius(rect, const Radius.circular(2)), paint); + final textStyle = TextStyle( + color: const Color(0xFFFBF7EE).withValues(alpha: opacity), + fontWeight: FontWeight.w600, + fontSize: 13, + fontFamily: 'IBMPlexMono', + letterSpacing: 1, + ); + final tp = TextPainter( + text: TextSpan(text: '+15 XP', style: textStyle), + textDirection: TextDirection.ltr, + textAlign: TextAlign.center, + )..layout(minWidth: 64, maxWidth: 64); + tp.paint(c, Offset(68, 47 + dy)); +} + +void _paintWrongBadge(Canvas c) { + final fill = Paint()..color = const Color(0xFFFBF7EE); + final stroke = Paint() + ..color = const Color(0xFFB8533A) + ..style = PaintingStyle.stroke + ..strokeWidth = 2; + c.drawCircle(const Offset(148, 76), 13, fill); + c.drawCircle(const Offset(148, 76), 13, stroke); + final bar = Paint()..color = const Color(0xFFB8533A); + c.drawRRect( + RRect.fromRectAndRadius( + const Rect.fromLTWH(146, 68, 4, 9), + const Radius.circular(1), + ), + bar, + ); + c.drawCircle(const Offset(148, 82), 1.6, bar); +} + +void _paintSleepZzz(Canvas c, double t) { + const letters = <({double x, double y, double size, double delay})>[ + (x: 148, y: 80, size: 18, delay: 0.0), + (x: 158, y: 68, size: 14, delay: 0.6 / 2.6), + (x: 166, y: 58, size: 11, delay: 1.2 / 2.6), + ]; + for (final l in letters) { + final raw = (t - l.delay) % 1.0; + final p = raw < 0 ? raw + 1 : raw; + final opacity = p < 0.3 ? p / 0.3 : (p > 0.7 ? (1 - p) / 0.3 : 1.0); + final dx = p * 8; + final dy = -p * 12; + final tp = TextPainter( + text: TextSpan( + text: 'z', + style: TextStyle( + color: const Color( + 0xFF6B5F54, + ).withValues(alpha: opacity.clamp(0.0, 1.0)), + fontStyle: FontStyle.italic, + fontSize: l.size, + fontFamily: 'Fraunces', + ), + ), + textDirection: TextDirection.ltr, + )..layout(); + tp.paint(c, Offset(l.x + dx, l.y + dy)); + } +} diff --git a/coffee_quest/lib/features/profile/domain/settings_providers.g.dart b/coffee_quest/lib/features/profile/domain/settings_providers.g.dart index 56a1b78..b86713e 100644 --- a/coffee_quest/lib/features/profile/domain/settings_providers.g.dart +++ b/coffee_quest/lib/features/profile/domain/settings_providers.g.dart @@ -68,12 +68,17 @@ abstract class _$SettingsController extends $AsyncNotifier { } } +/// The current app version string, formatted as `x.y.z+build`. + @ProviderFor(appVersion) final appVersionProvider = AppVersionProvider._(); +/// The current app version string, formatted as `x.y.z+build`. + final class AppVersionProvider extends $FunctionalProvider, String, FutureOr> with $FutureModifier, $FutureProvider { + /// The current app version string, formatted as `x.y.z+build`. AppVersionProvider._() : super( from: null, diff --git a/coffee_quest/lib/features/progress/domain/progress_providers.g.dart b/coffee_quest/lib/features/progress/domain/progress_providers.g.dart index efb455d..c7dc01d 100644 --- a/coffee_quest/lib/features/progress/domain/progress_providers.g.dart +++ b/coffee_quest/lib/features/progress/domain/progress_providers.g.dart @@ -8,13 +8,17 @@ part of 'progress_providers.dart'; // GENERATED CODE - DO NOT MODIFY BY HAND // ignore_for_file: type=lint, type=warning +/// The user's total XP. @ProviderFor(totalXp) final totalXpProvider = TotalXpProvider._(); +/// The user's total XP. + final class TotalXpProvider extends $FunctionalProvider, int, FutureOr> with $FutureModifier, $FutureProvider { + /// The user's total XP. TotalXpProvider._() : super( from: null, @@ -42,12 +46,17 @@ final class TotalXpProvider String _$totalXpHash() => r'9d152d22babc01660d17d3b49a7aba3eeabc930f'; +/// The user's current streak in days. + @ProviderFor(streak) final streakProvider = StreakProvider._(); +/// The user's current streak in days. + final class StreakProvider extends $FunctionalProvider, int, FutureOr> with $FutureModifier, $FutureProvider { + /// The user's current streak in days. StreakProvider._() : super( from: null, @@ -75,9 +84,13 @@ final class StreakProvider String _$streakHash() => r'eb7a2c35c7e2f7624444b1e6ad37e22bf96500bb'; +/// All of the user's completed-lesson records. + @ProviderFor(completedLessons) final completedLessonsProvider = CompletedLessonsProvider._(); +/// All of the user's completed-lesson records. + final class CompletedLessonsProvider extends $FunctionalProvider< @@ -88,6 +101,7 @@ final class CompletedLessonsProvider with $FutureModifier>, $FutureProvider> { + /// All of the user's completed-lesson records. CompletedLessonsProvider._() : super( from: null, @@ -116,9 +130,13 @@ final class CompletedLessonsProvider String _$completedLessonsHash() => r'c29c67109f5f475a6482b2f49c6e10eb5a32e746'; +/// The ids of all cards the user has collected. + @ProviderFor(collectedCards) final collectedCardsProvider = CollectedCardsProvider._(); +/// The ids of all cards the user has collected. + final class CollectedCardsProvider extends $FunctionalProvider< @@ -127,6 +145,7 @@ final class CollectedCardsProvider FutureOr> > with $FutureModifier>, $FutureProvider> { + /// The ids of all cards the user has collected. CollectedCardsProvider._() : super( from: null, diff --git a/coffee_quest/lib/services/analytics/analytics_provider.g.dart b/coffee_quest/lib/services/analytics/analytics_provider.g.dart index 2d40e01..b919738 100644 --- a/coffee_quest/lib/services/analytics/analytics_provider.g.dart +++ b/coffee_quest/lib/services/analytics/analytics_provider.g.dart @@ -8,10 +8,13 @@ part of 'analytics_provider.dart'; // GENERATED CODE - DO NOT MODIFY BY HAND // ignore_for_file: type=lint, type=warning +/// Provides the active [AnalyticsService] — No-Op until Firebase is enabled. @ProviderFor(analyticsService) final analyticsServiceProvider = AnalyticsServiceProvider._(); +/// Provides the active [AnalyticsService] — No-Op until Firebase is enabled. + final class AnalyticsServiceProvider extends $FunctionalProvider< @@ -20,6 +23,7 @@ final class AnalyticsServiceProvider AnalyticsService > with $Provider { + /// Provides the active [AnalyticsService] — No-Op until Firebase is enabled. AnalyticsServiceProvider._() : super( from: null, diff --git a/coffee_quest/lib/services/crash_reporting/crash_reporting_provider.g.dart b/coffee_quest/lib/services/crash_reporting/crash_reporting_provider.g.dart index 289bbe1..2858bd8 100644 --- a/coffee_quest/lib/services/crash_reporting/crash_reporting_provider.g.dart +++ b/coffee_quest/lib/services/crash_reporting/crash_reporting_provider.g.dart @@ -8,10 +8,13 @@ part of 'crash_reporting_provider.dart'; // GENERATED CODE - DO NOT MODIFY BY HAND // ignore_for_file: type=lint, type=warning +/// Provides the active [CrashReportingService] — No-Op until Firebase is on. @ProviderFor(crashReportingService) final crashReportingServiceProvider = CrashReportingServiceProvider._(); +/// Provides the active [CrashReportingService] — No-Op until Firebase is on. + final class CrashReportingServiceProvider extends $FunctionalProvider< @@ -20,6 +23,7 @@ final class CrashReportingServiceProvider CrashReportingService > with $Provider { + /// Provides the active [CrashReportingService] — No-Op until Firebase is on. CrashReportingServiceProvider._() : super( from: null, diff --git a/coffee_quest/lib/services/payments/payments_provider.g.dart b/coffee_quest/lib/services/payments/payments_provider.g.dart index b172ae0..62a5776 100644 --- a/coffee_quest/lib/services/payments/payments_provider.g.dart +++ b/coffee_quest/lib/services/payments/payments_provider.g.dart @@ -8,14 +8,18 @@ part of 'payments_provider.dart'; // GENERATED CODE - DO NOT MODIFY BY HAND // ignore_for_file: type=lint, type=warning +/// Provides the active [PaymentsService] — No-Op until payments go live. @ProviderFor(paymentsService) final paymentsServiceProvider = PaymentsServiceProvider._(); +/// Provides the active [PaymentsService] — No-Op until payments go live. + final class PaymentsServiceProvider extends $FunctionalProvider with $Provider { + /// Provides the active [PaymentsService] — No-Op until payments go live. PaymentsServiceProvider._() : super( from: null, diff --git a/coffee_quest/lib/services/remote_config/remote_config_provider.g.dart b/coffee_quest/lib/services/remote_config/remote_config_provider.g.dart index 827c5ac..f37868e 100644 --- a/coffee_quest/lib/services/remote_config/remote_config_provider.g.dart +++ b/coffee_quest/lib/services/remote_config/remote_config_provider.g.dart @@ -8,10 +8,13 @@ part of 'remote_config_provider.dart'; // GENERATED CODE - DO NOT MODIFY BY HAND // ignore_for_file: type=lint, type=warning +/// Provides the active [RemoteConfigService] — No-Op until Firebase is on. @ProviderFor(remoteConfigService) final remoteConfigServiceProvider = RemoteConfigServiceProvider._(); +/// Provides the active [RemoteConfigService] — No-Op until Firebase is on. + final class RemoteConfigServiceProvider extends $FunctionalProvider< @@ -20,6 +23,7 @@ final class RemoteConfigServiceProvider RemoteConfigService > with $Provider { + /// Provides the active [RemoteConfigService] — No-Op until Firebase is on. RemoteConfigServiceProvider._() : super( from: null, diff --git a/coffee_quest/lib/shared/models/lesson_model.freezed.dart b/coffee_quest/lib/shared/models/lesson_model.freezed.dart index 08af9d4..903bf1e 100644 --- a/coffee_quest/lib/shared/models/lesson_model.freezed.dart +++ b/coffee_quest/lib/shared/models/lesson_model.freezed.dart @@ -15,7 +15,7 @@ T _$identity(T value) => value; /// @nodoc mixin _$LessonModel { - String get id; String get moduleId; String get title; String get summary; int get xpReward; String? get cardId; List get steps; + String get id; String get moduleId; String get title; String get summary; int get xpReward; List get steps; String? get cardId; /// Create a copy of LessonModel /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -28,16 +28,16 @@ $LessonModelCopyWith get copyWith => _$LessonModelCopyWithImpl Object.hash(runtimeType,id,moduleId,title,summary,xpReward,cardId,const DeepCollectionEquality().hash(steps)); +int get hashCode => Object.hash(runtimeType,id,moduleId,title,summary,xpReward,const DeepCollectionEquality().hash(steps),cardId); @override String toString() { - return 'LessonModel(id: $id, moduleId: $moduleId, title: $title, summary: $summary, xpReward: $xpReward, cardId: $cardId, steps: $steps)'; + return 'LessonModel(id: $id, moduleId: $moduleId, title: $title, summary: $summary, xpReward: $xpReward, steps: $steps, cardId: $cardId)'; } @@ -48,7 +48,7 @@ abstract mixin class $LessonModelCopyWith<$Res> { factory $LessonModelCopyWith(LessonModel value, $Res Function(LessonModel) _then) = _$LessonModelCopyWithImpl; @useResult $Res call({ - String id, String moduleId, String title, String summary, int xpReward, String? cardId, List steps + String id, String moduleId, String title, String summary, int xpReward, List steps, String? cardId }); @@ -65,16 +65,16 @@ class _$LessonModelCopyWithImpl<$Res> /// Create a copy of LessonModel /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? moduleId = null,Object? title = null,Object? summary = null,Object? xpReward = null,Object? cardId = freezed,Object? steps = null,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? moduleId = null,Object? title = null,Object? summary = null,Object? xpReward = null,Object? steps = null,Object? cardId = freezed,}) { return _then(_self.copyWith( id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable as String,moduleId: null == moduleId ? _self.moduleId : moduleId // ignore: cast_nullable_to_non_nullable as String,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable as String,summary: null == summary ? _self.summary : summary // ignore: cast_nullable_to_non_nullable as String,xpReward: null == xpReward ? _self.xpReward : xpReward // ignore: cast_nullable_to_non_nullable -as int,cardId: freezed == cardId ? _self.cardId : cardId // ignore: cast_nullable_to_non_nullable -as String?,steps: null == steps ? _self.steps : steps // ignore: cast_nullable_to_non_nullable -as List, +as int,steps: null == steps ? _self.steps : steps // ignore: cast_nullable_to_non_nullable +as List,cardId: freezed == cardId ? _self.cardId : cardId // ignore: cast_nullable_to_non_nullable +as String?, )); } @@ -159,10 +159,10 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( String id, String moduleId, String title, String summary, int xpReward, String? cardId, List steps)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function( String id, String moduleId, String title, String summary, int xpReward, List steps, String? cardId)? $default,{required TResult orElse(),}) {final _that = this; switch (_that) { case _LessonModel() when $default != null: -return $default(_that.id,_that.moduleId,_that.title,_that.summary,_that.xpReward,_that.cardId,_that.steps);case _: +return $default(_that.id,_that.moduleId,_that.title,_that.summary,_that.xpReward,_that.steps,_that.cardId);case _: return orElse(); } @@ -180,10 +180,10 @@ return $default(_that.id,_that.moduleId,_that.title,_that.summary,_that.xpReward /// } /// ``` -@optionalTypeArgs TResult when(TResult Function( String id, String moduleId, String title, String summary, int xpReward, String? cardId, List steps) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function( String id, String moduleId, String title, String summary, int xpReward, List steps, String? cardId) $default,) {final _that = this; switch (_that) { case _LessonModel(): -return $default(_that.id,_that.moduleId,_that.title,_that.summary,_that.xpReward,_that.cardId,_that.steps);case _: +return $default(_that.id,_that.moduleId,_that.title,_that.summary,_that.xpReward,_that.steps,_that.cardId);case _: throw StateError('Unexpected subclass'); } @@ -200,10 +200,10 @@ return $default(_that.id,_that.moduleId,_that.title,_that.summary,_that.xpReward /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function( String id, String moduleId, String title, String summary, int xpReward, String? cardId, List steps)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String id, String moduleId, String title, String summary, int xpReward, List steps, String? cardId)? $default,) {final _that = this; switch (_that) { case _LessonModel() when $default != null: -return $default(_that.id,_that.moduleId,_that.title,_that.summary,_that.xpReward,_that.cardId,_that.steps);case _: +return $default(_that.id,_that.moduleId,_that.title,_that.summary,_that.xpReward,_that.steps,_that.cardId);case _: return null; } @@ -215,7 +215,7 @@ return $default(_that.id,_that.moduleId,_that.title,_that.summary,_that.xpReward @JsonSerializable() class _LessonModel implements LessonModel { - const _LessonModel({required this.id, required this.moduleId, required this.title, required this.summary, required this.xpReward, this.cardId, required final List steps}): _steps = steps; + const _LessonModel({required this.id, required this.moduleId, required this.title, required this.summary, required this.xpReward, required final List steps, this.cardId}): _steps = steps; factory _LessonModel.fromJson(Map json) => _$LessonModelFromJson(json); @override final String id; @@ -223,7 +223,6 @@ class _LessonModel implements LessonModel { @override final String title; @override final String summary; @override final int xpReward; -@override final String? cardId; final List _steps; @override List get steps { if (_steps is EqualUnmodifiableListView) return _steps; @@ -231,6 +230,7 @@ class _LessonModel implements LessonModel { return EqualUnmodifiableListView(_steps); } +@override final String? cardId; /// Create a copy of LessonModel /// with the given fields replaced by the non-null parameter values. @@ -245,16 +245,16 @@ Map toJson() { @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _LessonModel&&(identical(other.id, id) || other.id == id)&&(identical(other.moduleId, moduleId) || other.moduleId == moduleId)&&(identical(other.title, title) || other.title == title)&&(identical(other.summary, summary) || other.summary == summary)&&(identical(other.xpReward, xpReward) || other.xpReward == xpReward)&&(identical(other.cardId, cardId) || other.cardId == cardId)&&const DeepCollectionEquality().equals(other._steps, _steps)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _LessonModel&&(identical(other.id, id) || other.id == id)&&(identical(other.moduleId, moduleId) || other.moduleId == moduleId)&&(identical(other.title, title) || other.title == title)&&(identical(other.summary, summary) || other.summary == summary)&&(identical(other.xpReward, xpReward) || other.xpReward == xpReward)&&const DeepCollectionEquality().equals(other._steps, _steps)&&(identical(other.cardId, cardId) || other.cardId == cardId)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,id,moduleId,title,summary,xpReward,cardId,const DeepCollectionEquality().hash(_steps)); +int get hashCode => Object.hash(runtimeType,id,moduleId,title,summary,xpReward,const DeepCollectionEquality().hash(_steps),cardId); @override String toString() { - return 'LessonModel(id: $id, moduleId: $moduleId, title: $title, summary: $summary, xpReward: $xpReward, cardId: $cardId, steps: $steps)'; + return 'LessonModel(id: $id, moduleId: $moduleId, title: $title, summary: $summary, xpReward: $xpReward, steps: $steps, cardId: $cardId)'; } @@ -265,7 +265,7 @@ abstract mixin class _$LessonModelCopyWith<$Res> implements $LessonModelCopyWith factory _$LessonModelCopyWith(_LessonModel value, $Res Function(_LessonModel) _then) = __$LessonModelCopyWithImpl; @override @useResult $Res call({ - String id, String moduleId, String title, String summary, int xpReward, String? cardId, List steps + String id, String moduleId, String title, String summary, int xpReward, List steps, String? cardId }); @@ -282,16 +282,16 @@ class __$LessonModelCopyWithImpl<$Res> /// Create a copy of LessonModel /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? moduleId = null,Object? title = null,Object? summary = null,Object? xpReward = null,Object? cardId = freezed,Object? steps = null,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? moduleId = null,Object? title = null,Object? summary = null,Object? xpReward = null,Object? steps = null,Object? cardId = freezed,}) { return _then(_LessonModel( id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable as String,moduleId: null == moduleId ? _self.moduleId : moduleId // ignore: cast_nullable_to_non_nullable as String,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable as String,summary: null == summary ? _self.summary : summary // ignore: cast_nullable_to_non_nullable as String,xpReward: null == xpReward ? _self.xpReward : xpReward // ignore: cast_nullable_to_non_nullable -as int,cardId: freezed == cardId ? _self.cardId : cardId // ignore: cast_nullable_to_non_nullable -as String?,steps: null == steps ? _self._steps : steps // ignore: cast_nullable_to_non_nullable -as List, +as int,steps: null == steps ? _self._steps : steps // ignore: cast_nullable_to_non_nullable +as List,cardId: freezed == cardId ? _self.cardId : cardId // ignore: cast_nullable_to_non_nullable +as String?, )); } diff --git a/coffee_quest/lib/shared/models/lesson_model.g.dart b/coffee_quest/lib/shared/models/lesson_model.g.dart index 0344cc4..df0c6d7 100644 --- a/coffee_quest/lib/shared/models/lesson_model.g.dart +++ b/coffee_quest/lib/shared/models/lesson_model.g.dart @@ -12,10 +12,10 @@ _LessonModel _$LessonModelFromJson(Map json) => _LessonModel( title: json['title'] as String, summary: json['summary'] as String, xpReward: (json['xpReward'] as num).toInt(), - cardId: json['cardId'] as String?, steps: (json['steps'] as List) .map((e) => LessonStepModel.fromJson(e as Map)) .toList(), + cardId: json['cardId'] as String?, ); Map _$LessonModelToJson(_LessonModel instance) => @@ -25,6 +25,6 @@ Map _$LessonModelToJson(_LessonModel instance) => 'title': instance.title, 'summary': instance.summary, 'xpReward': instance.xpReward, - 'cardId': instance.cardId, 'steps': instance.steps, + 'cardId': instance.cardId, }; diff --git a/coffee_quest/lib/shared/repositories/content_repository.g.dart b/coffee_quest/lib/shared/repositories/content_repository.g.dart index 15d6ad1..bc80ba9 100644 --- a/coffee_quest/lib/shared/repositories/content_repository.g.dart +++ b/coffee_quest/lib/shared/repositories/content_repository.g.dart @@ -8,10 +8,13 @@ part of 'content_repository.dart'; // GENERATED CODE - DO NOT MODIFY BY HAND // ignore_for_file: type=lint, type=warning +/// Provides the singleton [ContentRepository]. @ProviderFor(contentRepository) final contentRepositoryProvider = ContentRepositoryProvider._(); +/// Provides the singleton [ContentRepository]. + final class ContentRepositoryProvider extends $FunctionalProvider< @@ -20,6 +23,7 @@ final class ContentRepositoryProvider ContentRepository > with $Provider { + /// Provides the singleton [ContentRepository]. ContentRepositoryProvider._() : super( from: null, diff --git a/coffee_quest/lib/shared/repositories/repository_providers.g.dart b/coffee_quest/lib/shared/repositories/repository_providers.g.dart index 14ad99d..5c7c687 100644 --- a/coffee_quest/lib/shared/repositories/repository_providers.g.dart +++ b/coffee_quest/lib/shared/repositories/repository_providers.g.dart @@ -8,10 +8,13 @@ part of 'repository_providers.dart'; // GENERATED CODE - DO NOT MODIFY BY HAND // ignore_for_file: type=lint, type=warning +/// Provides the [ProgressRepository]. @ProviderFor(progressRepository) final progressRepositoryProvider = ProgressRepositoryProvider._(); +/// Provides the [ProgressRepository]. + final class ProgressRepositoryProvider extends $FunctionalProvider< @@ -20,6 +23,7 @@ final class ProgressRepositoryProvider ProgressRepository > with $Provider { + /// Provides the [ProgressRepository]. ProgressRepositoryProvider._() : super( from: null, @@ -57,12 +61,17 @@ final class ProgressRepositoryProvider String _$progressRepositoryHash() => r'9a3620daa1db873d4d1a8416e2dc99b3eec569da'; +/// Provides the [CardRepository]. + @ProviderFor(cardRepository) final cardRepositoryProvider = CardRepositoryProvider._(); +/// Provides the [CardRepository]. + final class CardRepositoryProvider extends $FunctionalProvider with $Provider { + /// Provides the [CardRepository]. CardRepositoryProvider._() : super( from: null, @@ -98,9 +107,13 @@ final class CardRepositoryProvider String _$cardRepositoryHash() => r'3fbc209dd98e2db5e53bf745145d7b2d1b4e8e99'; +/// Provides the [SettingsRepository]. + @ProviderFor(settingsRepository) final settingsRepositoryProvider = SettingsRepositoryProvider._(); +/// Provides the [SettingsRepository]. + final class SettingsRepositoryProvider extends $FunctionalProvider< @@ -109,6 +122,7 @@ final class SettingsRepositoryProvider SettingsRepository > with $Provider { + /// Provides the [SettingsRepository]. SettingsRepositoryProvider._() : super( from: null, @@ -146,9 +160,13 @@ final class SettingsRepositoryProvider String _$settingsRepositoryHash() => r'c5a39438caec85b55a650dcd24bd66b30ea47e8f'; +/// Provides the [ModuleProgressRepository]. + @ProviderFor(moduleProgressRepository) final moduleProgressRepositoryProvider = ModuleProgressRepositoryProvider._(); +/// Provides the [ModuleProgressRepository]. + final class ModuleProgressRepositoryProvider extends $FunctionalProvider< @@ -157,6 +175,7 @@ final class ModuleProgressRepositoryProvider ModuleProgressRepository > with $Provider { + /// Provides the [ModuleProgressRepository]. ModuleProgressRepositoryProvider._() : super( from: null, diff --git a/coffee_quest/test/unit/features/onboarding/roasty_animation_test.dart b/coffee_quest/test/unit/features/onboarding/roasty_animation_test.dart new file mode 100644 index 0000000..bdd9f7a --- /dev/null +++ b/coffee_quest/test/unit/features/onboarding/roasty_animation_test.dart @@ -0,0 +1,158 @@ +import 'dart:math' as math; + +import 'package:coffee_quest/features/onboarding/presentation/widgets/roasty_animation.dart'; +import 'package:coffee_quest/features/onboarding/presentation/widgets/roasty_state.dart'; +import 'package:flutter_test/flutter_test.dart'; + +// Values below are pinned to the current easing formulas (copied 1:1 from the +// design bundle). They lock behavior: changing any extracted coefficient must +// fail a test here. +void main() { + group('roastyDuration', () { + test('returns the per-state controller duration', () { + expect( + roastyDuration(RoastyState.idle), + const Duration(seconds: 3, milliseconds: 200), + ); + expect( + roastyDuration(RoastyState.correct), + const Duration(milliseconds: 900), + ); + expect( + roastyDuration(RoastyState.wrong), + const Duration(milliseconds: 500), + ); + expect( + roastyDuration(RoastyState.lesson), + const Duration(milliseconds: 1100), + ); + expect( + roastyDuration(RoastyState.module), + const Duration(milliseconds: 900), + ); + expect( + roastyDuration(RoastyState.xp), + const Duration(milliseconds: 1300), + ); + expect( + roastyDuration(RoastyState.card), + const Duration(milliseconds: 1600), + ); + expect( + roastyDuration(RoastyState.sleep), + const Duration(milliseconds: 3400), + ); + expect( + roastyDuration(RoastyState.awake), + const Duration(milliseconds: 400), + ); + }); + }); + + group('roastyLoops', () { + test('loops only for idle, card, and sleep', () { + expect(roastyLoops(RoastyState.idle), isTrue); + expect(roastyLoops(RoastyState.card), isTrue); + expect(roastyLoops(RoastyState.sleep), isTrue); + for (final state in const [ + RoastyState.correct, + RoastyState.wrong, + RoastyState.lesson, + RoastyState.module, + RoastyState.xp, + RoastyState.awake, + ]) { + expect(roastyLoops(state), isFalse, reason: '$state should not loop'); + } + }); + }); + + group('roastyBodyOffset', () { + test('states without a translation animation return zero', () { + for (final state in const [ + RoastyState.card, + RoastyState.xp, + RoastyState.module, + RoastyState.sleep, + RoastyState.awake, + ]) { + expect(roastyBodyOffset(state, 0.5), Offset.zero, reason: '$state'); + } + }); + + test('correct hops to -10 at the quarter beat', () { + final offset = roastyBodyOffset(RoastyState.correct, 0.25); + expect(offset.dx, closeTo(0, 1e-9)); + expect(offset.dy, closeTo(-10, 1e-9)); + }); + + test('lesson jump follows its keyframes', () { + expect(roastyBodyOffset(RoastyState.lesson, 0.15).dy, closeTo(-9, 1e-9)); + expect(roastyBodyOffset(RoastyState.lesson, 0.3).dy, closeTo(-18, 1e-9)); + expect(roastyBodyOffset(RoastyState.lesson, 0.5).dy, closeTo(-8, 1e-9)); + expect(roastyBodyOffset(RoastyState.lesson, 0.7).dy, closeTo(-14, 1e-9)); + expect(roastyBodyOffset(RoastyState.lesson, 1).dy, closeTo(0, 1e-9)); + }); + + test('wrong shake decays with time', () { + // amplitude (1-t)*5 at the first sine peak (t = 1/16 → phase = π/2). + expect( + roastyBodyOffset(RoastyState.wrong, 0.0625).dx, + closeTo(4.6875, 1e-9), + ); + expect(roastyBodyOffset(RoastyState.wrong, 0.0625).dy, closeTo(0, 1e-9)); + expect(roastyBodyOffset(RoastyState.wrong, 1).dx, closeTo(0, 1e-9)); + }); + + test('idle breathe is zero at t=0 and dips mid-cycle', () { + expect(roastyBodyOffset(RoastyState.idle, 0).dy, closeTo(0, 1e-9)); + expect( + roastyBodyOffset(RoastyState.idle, 0.25).dy, + closeTo(-3 * math.sin(math.pi / 4), 1e-9), + ); + }); + }); + + group('roastyBodyScale', () { + test('module grow keyframes', () { + expect(roastyBodyScale(RoastyState.module, 0), closeTo(1, 1e-9)); + expect(roastyBodyScale(RoastyState.module, 0.4), closeTo(1.12, 1e-9)); + expect(roastyBodyScale(RoastyState.module, 1), closeTo(1.05, 1e-9)); + }); + + test('awake blink-pop keyframes', () { + expect(roastyBodyScale(RoastyState.awake, 0), closeTo(0.94, 1e-9)); + expect(roastyBodyScale(RoastyState.awake, 0.5), closeTo(1.04, 1e-9)); + expect(roastyBodyScale(RoastyState.awake, 1), closeTo(1, 1e-9)); + }); + + test('idle squash and default scale', () { + expect(roastyBodyScale(RoastyState.idle, 0.25), closeTo(1.005, 1e-9)); + expect(roastyBodyScale(RoastyState.card, 0.5), closeTo(1, 1e-9)); + }); + }); + + group('roastyBodyRotation', () { + test('correct tilts 3° on the hop peak', () { + expect( + roastyBodyRotation(RoastyState.correct, 0.25), + closeTo(3 * math.pi / 180, 1e-9), + ); + }); + + test('sleep holds a fixed 6° tilt regardless of t', () { + expect( + roastyBodyRotation(RoastyState.sleep, 0), + closeTo(6 * math.pi / 180, 1e-9), + ); + expect( + roastyBodyRotation(RoastyState.sleep, 0.9), + closeTo(6 * math.pi / 180, 1e-9), + ); + }); + + test('default states have no rotation', () { + expect(roastyBodyRotation(RoastyState.card, 0.5), closeTo(0, 1e-9)); + }); + }); +}