From f5a0cba39167628f6dd7ed7fd55b4da16b566bed Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 6 Jul 2026 09:04:55 +0000 Subject: [PATCH 1/4] feat: implement 35 quality-of-life improvements across KYNOS Add consistent error recovery with KynosInlineErrorCard, confirmation dialogs for destructive actions, pull-to-refresh on major tabs, and health data refresh on app resume. Improve coach chat with persistence, stop streaming, copy messages, and AI reconnect after background. Support deep-linkable run routes, global quest auto-complete, onboarding polish, accessibility labels, and friendlier permission error copy. Co-authored-by: Youri Bontekoe --- lib/app/router.dart | 16 ++- .../presentation/pages/character_page.dart | 33 +++-- .../presentation/widgets/quest_card.dart | 2 +- .../providers/adventure_provider.dart | 38 ------ .../providers/adventure_provider.g.dart | 2 +- .../presentation/pages/coach_chat_page.dart | 73 +++++++++-- .../widgets/assistant_bubble.dart | 77 ++++++----- .../presentation/widgets/chat_input_bar.dart | 11 +- .../widgets/model_setup_screen.dart | 5 +- .../providers/coach_chat_provider.dart | 49 ++++++- .../providers/coach_chat_provider.g.dart | 2 +- .../providers/model_setup_provider.dart | 25 +++- .../providers/model_setup_provider.g.dart | 24 ++-- .../providers/model_setup_state.dart | 17 +++ .../coach_chat/utils/chat_history_codec.dart | 51 ++++++++ .../presentation/pages/dashboard_page.dart | 61 +++++++-- .../presentation/pages/run_history_page.dart | 8 +- .../presentation/pages/run_route_page.dart | 93 +++++++++---- .../presentation/widgets/activity_ring.dart | 28 ++-- .../widgets/connect_healthkit_card.dart | 13 +- .../widgets/daily_quest_teaser.dart | 29 ++++- .../widgets/last_run_preview.dart | 14 +- .../widgets/week_momentum_card.dart | 123 ++++++++++-------- .../presentation/nexus_lab_page.dart | 12 +- .../presentation/onboarding_page.dart | 36 ++++- .../pages/openrouter_model_picker_page.dart | 20 ++- .../presentation/pages/settings_page.dart | 57 +++++++- .../apple_health_export_preview_card.dart | 30 ++++- .../presentation/pages/training_page.dart | 17 ++- .../widgets/training_insight_cards.dart | 10 +- .../providers/ai_reconnect_provider.dart | 14 ++ .../providers/ai_reconnect_provider.g.dart | 67 ++++++++++ lib/shared/providers/health_providers.dart | 11 +- lib/shared/providers/health_providers.g.dart | 20 +-- .../measurable_quest_sync_provider.dart | 52 ++++++++ .../measurable_quest_sync_provider.g.dart | 68 ++++++++++ .../workout_session_lookup_provider.dart | 15 +++ .../workout_session_lookup_provider.g.dart | 94 +++++++++++++ .../utils/health_permission_feedback.dart | 14 ++ lib/shared/widgets/ai_lifecycle_guard.dart | 19 ++- lib/shared/widgets/kynos_user_bubble.dart | 54 +++++--- lib/shared/widgets/metric_tile.dart | 8 +- lib/shared/widgets/run_card.dart | 7 +- 43 files changed, 1133 insertions(+), 286 deletions(-) create mode 100644 lib/features/coach_chat/providers/model_setup_state.dart create mode 100644 lib/features/coach_chat/utils/chat_history_codec.dart create mode 100644 lib/shared/providers/ai_reconnect_provider.dart create mode 100644 lib/shared/providers/ai_reconnect_provider.g.dart create mode 100644 lib/shared/providers/measurable_quest_sync_provider.dart create mode 100644 lib/shared/providers/measurable_quest_sync_provider.g.dart create mode 100644 lib/shared/providers/workout_session_lookup_provider.dart create mode 100644 lib/shared/providers/workout_session_lookup_provider.g.dart create mode 100644 lib/shared/utils/health_permission_feedback.dart diff --git a/lib/app/router.dart b/lib/app/router.dart index ac995dc..b164842 100644 --- a/lib/app/router.dart +++ b/lib/app/router.dart @@ -103,9 +103,21 @@ final routerProvider = Provider((ref) { path: Routes.runRoute, builder: (context, state) { final run = state.extra; - if (run is! WorkoutSession) return const RunRouteMissingPage(); - return RunRoutePage(run: run); + if (run is WorkoutSession) return RunRoutePage(run: run); + return const RunRouteMissingPage(); }, + routes: [ + GoRoute( + path: ':runId', + builder: (context, state) { + final runId = state.pathParameters['runId']; + if (runId == null || runId.isEmpty) { + return const RunRouteMissingPage(); + } + return RunRoutePage(runId: runId); + }, + ), + ], ), GoRoute( path: Routes.coachChat, diff --git a/lib/features/character/presentation/pages/character_page.dart b/lib/features/character/presentation/pages/character_page.dart index c87a36c..ff874c8 100644 --- a/lib/features/character/presentation/pages/character_page.dart +++ b/lib/features/character/presentation/pages/character_page.dart @@ -18,19 +18,34 @@ import 'package:kynos/shared/providers/character_providers.dart'; import 'package:kynos/shared/providers/daily_quests_provider.dart'; import 'package:kynos/shared/providers/health_providers.dart'; import 'package:kynos/shared/utils/health_platform_labels.dart'; +import 'package:kynos/shared/providers/nexus_lab_provider.dart'; +import 'package:kynos/shared/utils/health_permission_feedback.dart'; +import 'package:kynos/shared/widgets/kynos_inline_error_card.dart'; import 'package:kynos/shared/widgets/kynos_section_header.dart'; import 'package:kynos/shared/widgets/kynos_skeleton.dart'; class CharacterPage extends ConsumerWidget { const CharacterPage({super.key}); + Future _refreshCharacter(WidgetRef ref) async { + ref.invalidate(runnerCharacterProvider); + ref.invalidate(dailyQuestsProvider); + ref.invalidate(nexusLabProvider); + await Future.wait([ + ref.read(runnerCharacterProvider.future), + ref.read(dailyQuestsProvider.future), + ]); + } + @override Widget build(BuildContext context, WidgetRef ref) { final kynos = context.kynosTheme; final characterAsync = ref.watch(runnerCharacterProvider); final questsAsync = ref.watch(dailyQuestsProvider); - return CustomScrollView( + return RefreshIndicator( + onRefresh: () => _refreshCharacter(ref), + child: CustomScrollView( physics: const AlwaysScrollableScrollPhysics( parent: BouncingScrollPhysics(), ), @@ -67,12 +82,11 @@ class CharacterPage extends ConsumerWidget { ), ), error: (_, _) => SliverFillRemaining( - child: Center( - child: Text( - 'Could not load character', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: kynos.secondaryLabel, - ), + child: Padding( + padding: const EdgeInsets.all(tokens.Spacing.md), + child: KynosInlineErrorCard( + message: 'Could not load character.', + onRetry: () => ref.invalidate(runnerCharacterProvider), ), ), ), @@ -120,6 +134,7 @@ class CharacterPage extends ConsumerWidget { }, ), ], + ), ); } } @@ -176,11 +191,11 @@ class EmptyCharacterState extends ConsumerWidget { SnackBar(content: Text(message)), ); }, - error: (error, _) { + error: (_, _) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - 'Health connection failed: $error', + HealthPermissionFeedback.connectionFailedMessage(), ), ), ); diff --git a/lib/features/character/presentation/widgets/quest_card.dart b/lib/features/character/presentation/widgets/quest_card.dart index b2b9554..32474f5 100644 --- a/lib/features/character/presentation/widgets/quest_card.dart +++ b/lib/features/character/presentation/widgets/quest_card.dart @@ -87,7 +87,7 @@ class QuestCard extends ConsumerWidget { ) .toList() ?? const []; - progress = ref.read(evaluateQuestProgressUseCaseProvider).progressFraction( + progress = ref.watch(evaluateQuestProgressUseCaseProvider).progressFraction( quest: quest, summary: summary, todayRuns: todayRuns, diff --git a/lib/features/character/providers/adventure_provider.dart b/lib/features/character/providers/adventure_provider.dart index 387eda1..7d61561 100644 --- a/lib/features/character/providers/adventure_provider.dart +++ b/lib/features/character/providers/adventure_provider.dart @@ -1,16 +1,11 @@ -import 'dart:async'; - import 'package:kynos/core/constants/gamification_constants.dart'; import 'package:kynos/core/errors/failures.dart'; import 'package:kynos/domain/entities/gamification/activity_resources.dart'; import 'package:kynos/domain/entities/gamification/adventure_session.dart'; import 'package:kynos/domain/entities/gamification/character_stats.dart'; import 'package:kynos/domain/entities/gamification/encounter_state.dart'; -import 'package:kynos/domain/entities/gamification/quest.dart'; import 'package:kynos/domain/entities/gamification/trail_node.dart'; import 'package:kynos/features/character/providers/character_provider.dart'; -import 'package:kynos/features/character/providers/quest_provider.dart'; -import 'package:kynos/shared/providers/daily_quests_provider.dart'; import 'package:kynos/shared/providers/gamification_providers.dart'; import 'package:kynos/shared/providers/health_providers.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -36,11 +31,6 @@ class AdventureSessionNotifier extends _$AdventureSessionNotifier { @override Future build() async { - ref.listen( - healthSummaryProvider, - (_, _) => unawaited(_syncMeasurableQuests()), - ); - final character = await ref.read(runnerCharacterProvider.future); if (character == null) return null; @@ -74,8 +64,6 @@ class AdventureSessionNotifier extends _$AdventureSessionNotifier { } } - unawaited(_syncMeasurableQuests()); - return _viewState(session); } @@ -140,7 +128,6 @@ class AdventureSessionNotifier extends _$AdventureSessionNotifier { } await _persist(session); - unawaited(_syncMeasurableQuests()); } on Failure catch (failure, stackTrace) { state = AsyncError(failure, stackTrace); } finally { @@ -212,7 +199,6 @@ class AdventureSessionNotifier extends _$AdventureSessionNotifier { } await _persist(session); - unawaited(_syncMeasurableQuests()); } on Failure catch (failure, stackTrace) { state = AsyncError(failure, stackTrace); } finally { @@ -262,28 +248,4 @@ class AdventureSessionNotifier extends _$AdventureSessionNotifier { if (saveFailure != null) return; ref.invalidate(runnerCharacterProvider); } - - Future _syncMeasurableQuests() async { - final quests = await ref.read(dailyQuestsProvider.future); - if (quests.isEmpty) return; - - final evaluator = ref.read(evaluateQuestProgressUseCaseProvider); - final summary = ref.read(healthSummaryProvider).value; - final runs = await ref.read(recentRunsProvider(days: 1, limit: 20).future); - final today = DateTime.now(); - final todayRuns = runs.where((r) => _isSameDay(r.start, today)).toList(); - - for (final quest in quests) { - if (quest.status != QuestStatus.active) continue; - if (quest.measurableObjective == null) continue; - if (!evaluator.isComplete( - quest: quest, - summary: summary, - todayRuns: todayRuns, - )) { - continue; - } - await ref.read(questProvider.notifier).completeQuest(quest.id); - } - } } diff --git a/lib/features/character/providers/adventure_provider.g.dart b/lib/features/character/providers/adventure_provider.g.dart index 6bc3344..3bc6dce 100644 --- a/lib/features/character/providers/adventure_provider.g.dart +++ b/lib/features/character/providers/adventure_provider.g.dart @@ -35,7 +35,7 @@ final class AdventureSessionNotifierProvider } String _$adventureSessionNotifierHash() => - r'88fc6d7c59036b763a658d37ca13b8c3b4a444d8'; + r'1d95d24675c2be63dc6f33ff4d2aa3518cbe6670'; abstract class _$AdventureSessionNotifier extends $AsyncNotifier { diff --git a/lib/features/coach_chat/presentation/pages/coach_chat_page.dart b/lib/features/coach_chat/presentation/pages/coach_chat_page.dart index a210ed5..a50db4b 100644 --- a/lib/features/coach_chat/presentation/pages/coach_chat_page.dart +++ b/lib/features/coach_chat/presentation/pages/coach_chat_page.dart @@ -4,6 +4,7 @@ import 'package:gap/gap.dart'; import 'package:go_router/go_router.dart'; import 'package:kynos/app/router.dart'; import 'package:kynos/core/theme/theme.dart'; +import 'package:kynos/domain/utils/ai_inference_error_policy.dart'; import 'package:kynos/features/coach_chat/presentation/widgets/chat_input_bar.dart'; import 'package:kynos/features/coach_chat/presentation/widgets/coach_chat_app_bar.dart'; import 'package:kynos/features/coach_chat/presentation/widgets/message_list.dart'; @@ -11,6 +12,7 @@ import 'package:kynos/features/coach_chat/presentation/widgets/model_setup_scree import 'package:kynos/features/coach_chat/providers/coach_chat_provider.dart'; import 'package:kynos/features/coach_chat/providers/coach_chat_seed_provider.dart'; import 'package:kynos/features/coach_chat/providers/model_setup_provider.dart'; +import 'package:kynos/shared/providers/ai_reconnect_provider.dart'; class CoachChatPage extends ConsumerStatefulWidget { const CoachChatPage({super.key}); @@ -69,8 +71,52 @@ class _CoachChatPageState extends ConsumerState { ref.read(coachChatProvider.notifier).sendMessage(text); } + Future _confirmClearConversation() async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Clear conversation?'), + content: const Text( + 'This removes all messages in the current coach chat session.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Clear'), + ), + ], + ), + ); + + if (confirmed != true || !mounted) return; + await ref.read(coachChatProvider.notifier).clearConversation(); + } + + String _setupErrorMessage(Object error) { + if (error is MissingHuggingFaceTokenException) { + return error.toString(); + } + return AiInferenceErrorPolicy.userFriendlyMessage(error); + } + @override Widget build(BuildContext context) { + ref.listen(aiReconnectStateProvider, (previous, next) { + if (!next || !mounted) return; + ref.read(aiReconnectStateProvider.notifier).clear(); + ref.read(modelSetupProvider.notifier).checkAndInstall(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Reconnecting on-device coach…'), + duration: Duration(seconds: 3), + ), + ); + }); + final setupState = ref.watch(modelSetupProvider); return setupState.when( @@ -78,15 +124,19 @@ class _CoachChatPageState extends ConsumerState { error: (e, _) { final missingToken = e is MissingHuggingFaceTokenException; return ModelSetupScreen.error( - message: e.toString(), + message: _setupErrorMessage(e), onRetry: () => ref.read(modelSetupProvider.notifier).checkAndInstall(), onSecondaryAction: missingToken ? () => context.push(Routes.settings) : null, secondaryActionLabel: missingToken ? 'Open Settings' : null, ); }, - data: (isReady) { - if (!isReady) return ModelSetupScreen.checking(); + data: (setup) { + if (!setup.isReady) { + return ModelSetupScreen.checking( + progressMessage: setup.progressMessage, + ); + } WidgetsBinding.instance.addPostFrameCallback((_) => _applyCoachSeed()); return _buildChat(); }, @@ -106,10 +156,7 @@ class _CoachChatPageState extends ConsumerState { backgroundColor: context.kynosTheme.background, body: Column( children: [ - CoachChatAppBar( - onClear: () => - ref.read(coachChatProvider.notifier).clearConversation(), - ), + CoachChatAppBar(onClear: _confirmClearConversation), Expanded( child: Center( child: Padding( @@ -123,14 +170,13 @@ class _CoachChatPageState extends ConsumerState { ), const Gap(Spacing.sm), Text( - '${chatState.error}', + 'Your chat history could not be restored.', textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyMedium, ), const Gap(Spacing.lg), FilledButton( - onPressed: () => - ref.invalidate(coachChatProvider), + onPressed: () => ref.invalidate(coachChatProvider), child: const Text('Retry'), ), ], @@ -153,9 +199,7 @@ class _CoachChatPageState extends ConsumerState { resizeToAvoidBottomInset: true, body: Column( children: [ - CoachChatAppBar( - onClear: () => ref.read(coachChatProvider.notifier).clearConversation(), - ), + CoachChatAppBar(onClear: _confirmClearConversation), Expanded( child: messages.isEmpty ? CoachChatEmptyState(onSuggestionTap: _handleSend) @@ -166,6 +210,9 @@ class _CoachChatPageState extends ConsumerState { focusNode: _focusNode, isStreaming: isStreaming, onSend: _handleSend, + onCancel: isStreaming + ? () => ref.read(coachChatProvider.notifier).cancelGeneration() + : null, ), ], ), diff --git a/lib/features/coach_chat/presentation/widgets/assistant_bubble.dart b/lib/features/coach_chat/presentation/widgets/assistant_bubble.dart index dbe59db..1d92138 100644 --- a/lib/features/coach_chat/presentation/widgets/assistant_bubble.dart +++ b/lib/features/coach_chat/presentation/widgets/assistant_bubble.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:gap/gap.dart'; import 'package:kynos/core/theme/theme.dart'; import 'package:kynos/features/coach_chat/presentation/widgets/typing_indicator.dart'; @@ -19,46 +20,58 @@ class AssistantBubble extends StatelessWidget { final bool hasError; final VoidCallback? onRetry; + Future _copyMessage(BuildContext context) async { + if (content.isEmpty) return; + await Clipboard.setData(ClipboardData(text: content)); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Message copied')), + ); + } + @override Widget build(BuildContext context) { return Align( alignment: Alignment.centerLeft, child: ConstrainedBox( constraints: BoxConstraints(maxWidth: MediaQuery.sizeOf(context).width * 0.84), - child: GlassCard( - borderRadius: Radius.lg, - padding: const EdgeInsets.symmetric(horizontal: Spacing.md, vertical: Spacing.sm), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (hasError) - Padding( - padding: const EdgeInsets.only(bottom: Spacing.sm), - child: KynosChip.accent( - label: 'On-device error', - color: Theme.of(context).colorScheme.error, - ), - ), - isStreaming && content.isEmpty - ? const TypingIndicator() - : Text( - content, - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - height: 1.5, - color: hasError - ? Theme.of(context).colorScheme.error - : null, - ), + child: GestureDetector( + onLongPress: content.isEmpty ? null : () => _copyMessage(context), + child: GlassCard( + borderRadius: Radius.lg, + padding: const EdgeInsets.symmetric(horizontal: Spacing.md, vertical: Spacing.sm), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (hasError) + Padding( + padding: const EdgeInsets.only(bottom: Spacing.sm), + child: KynosChip.accent( + label: 'On-device error', + color: Theme.of(context).colorScheme.error, ), - if (hasError && onRetry != null) ...[ - const Gap(Spacing.sm), - TextButton.icon( - onPressed: onRetry, - icon: const Icon(Icons.refresh_rounded, size: 18), - label: const Text('Retry'), - ), + ), + isStreaming && content.isEmpty + ? const TypingIndicator() + : SelectableText( + content, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + height: 1.5, + color: hasError + ? Theme.of(context).colorScheme.error + : null, + ), + ), + if (hasError && onRetry != null) ...[ + const Gap(Spacing.sm), + TextButton.icon( + onPressed: onRetry, + icon: const Icon(Icons.refresh_rounded, size: 18), + label: const Text('Retry'), + ), + ], ], - ], + ), ), ), ), diff --git a/lib/features/coach_chat/presentation/widgets/chat_input_bar.dart b/lib/features/coach_chat/presentation/widgets/chat_input_bar.dart index 8f6d21a..1dc6c45 100644 --- a/lib/features/coach_chat/presentation/widgets/chat_input_bar.dart +++ b/lib/features/coach_chat/presentation/widgets/chat_input_bar.dart @@ -10,12 +10,14 @@ class ChatInputBar extends StatelessWidget { required this.focusNode, required this.isStreaming, required this.onSend, + this.onCancel, }); final TextEditingController controller; final FocusNode focusNode; final bool isStreaming; final ValueChanged onSend; + final VoidCallback? onCancel; @override Widget build(BuildContext context) { @@ -41,10 +43,15 @@ class ChatInputBar extends StatelessWidget { ), ), ), + if (isStreaming && onCancel != null) + TextButton( + onPressed: onCancel, + child: const Text('Stop'), + ), LiquidGlassIconButton( icon: isStreaming ? Icons.hourglass_empty : Icons.send_rounded, - onPressed: - isStreaming ? null : () => onSend(controller.text), + tooltip: isStreaming ? 'Sending message' : 'Send message', + onPressed: isStreaming ? null : () => onSend(controller.text), size: 36, iconSize: 20, ), diff --git a/lib/features/coach_chat/presentation/widgets/model_setup_screen.dart b/lib/features/coach_chat/presentation/widgets/model_setup_screen.dart index faa7480..d1f9d1d 100644 --- a/lib/features/coach_chat/presentation/widgets/model_setup_screen.dart +++ b/lib/features/coach_chat/presentation/widgets/model_setup_screen.dart @@ -25,9 +25,10 @@ class ModelSetupScreen extends StatelessWidget { final bool isLoading; final bool showClose; - factory ModelSetupScreen.checking() => const ModelSetupScreen( + factory ModelSetupScreen.checking({String? progressMessage}) => + ModelSetupScreen( title: 'Preparing AI Coach', - subtitle: 'Checking for model...', + subtitle: progressMessage ?? 'Checking for model…', isLoading: true, ); diff --git a/lib/features/coach_chat/providers/coach_chat_provider.dart b/lib/features/coach_chat/providers/coach_chat_provider.dart index 8bb58cd..3aa54d1 100644 --- a/lib/features/coach_chat/providers/coach_chat_provider.dart +++ b/lib/features/coach_chat/providers/coach_chat_provider.dart @@ -1,10 +1,14 @@ +import 'dart:async'; + import 'package:kynos/domain/entities/ai_inference_backend.dart'; import 'package:kynos/domain/entities/chat_message.dart'; import 'package:kynos/domain/entities/health_summary.dart'; import 'package:kynos/domain/utils/ai_inference_error_policy.dart'; import 'package:kynos/domain/utils/coach_fallback_reply.dart'; +import 'package:kynos/features/coach_chat/utils/chat_history_codec.dart'; import 'package:kynos/shared/providers/ai_repository_providers.dart'; import 'package:kynos/shared/providers/health_providers.dart'; +import 'package:kynos/shared/providers/shared_preferences_provider.dart'; import 'package:logger/logger.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -15,17 +19,21 @@ const _coachHealthHistoryDays = 14; @Riverpod(keepAlive: true) class CoachChatNotifier extends _$CoachChatNotifier { final _logger = Logger(); + bool _cancelRequested = false; @override Future> build() async { - return const []; + final prefs = ref.read(sharedPreferencesProvider); + return ChatHistoryCodec.decode(prefs.getString(ChatHistoryCodec.prefsKey)); } Future sendMessage(String userMessage) async { + _cancelRequested = false; await _runInference(userMessage: userMessage); } Future retryMessage(String assistantId) async { + _cancelRequested = false; final msgs = state.value; if (msgs == null) return; @@ -49,6 +57,25 @@ class CoachChatNotifier extends _$CoachChatNotifier { ); } + void cancelGeneration() { + _cancelRequested = true; + final msgs = state.value; + if (msgs == null) return; + + final streamingIndex = msgs.indexWhere((m) => m.isStreaming); + if (streamingIndex == -1) return; + + final streaming = msgs[streamingIndex]; + final updated = streaming.copyWith( + isStreaming: false, + content: streaming.content.isEmpty + ? 'Generation stopped.' + : '${streaming.content}\n\n_(stopped)_', + ); + state = AsyncData(List.from(msgs)..[streamingIndex] = updated); + unawaited(_persist()); + } + Future _runInference({ required String userMessage, String? existingAssistantId, @@ -93,6 +120,8 @@ class CoachChatNotifier extends _$CoachChatNotifier { healthContext: healthContext, estimatedPromptTokens: estimatedTokens, )) { + if (_cancelRequested) break; + ref.read(lastAiInferenceBackendProvider.notifier).set( repository.lastBackend, ); @@ -106,8 +135,14 @@ class CoachChatNotifier extends _$CoachChatNotifier { ); state = AsyncData(List.from(msgs)..[idx] = updated); } + + if (_cancelRequested) return; + _finaliseMessage(assistantId, streaming: false, hasError: false); + await _persist(); } catch (e, st) { + if (_cancelRequested) return; + _logger.e('Inference error', error: e, stackTrace: st); healthContext ??= await _readHealthContextSafely(); @@ -124,6 +159,7 @@ class CoachChatNotifier extends _$CoachChatNotifier { content: '$friendly\n\n$fallback', userPromptForRetry: userMessage, ); + await _persist(); } } @@ -145,6 +181,7 @@ class CoachChatNotifier extends _$CoachChatNotifier { Future clearConversation() async { state = const AsyncData([]); await ref.read(aiCoachRepositoryProvider).resetSession(); + await _persist(); } void _finaliseMessage( @@ -168,6 +205,16 @@ class CoachChatNotifier extends _$CoachChatNotifier { ); state = AsyncData(List.from(msgs)..[idx] = updated); } + + Future _persist() async { + final messages = state.value; + if (messages == null) return; + final prefs = ref.read(sharedPreferencesProvider); + await prefs.setString( + ChatHistoryCodec.prefsKey, + ChatHistoryCodec.encode(messages), + ); + } } @Riverpod(keepAlive: true) diff --git a/lib/features/coach_chat/providers/coach_chat_provider.g.dart b/lib/features/coach_chat/providers/coach_chat_provider.g.dart index 14bb4be..f46d01a 100644 --- a/lib/features/coach_chat/providers/coach_chat_provider.g.dart +++ b/lib/features/coach_chat/providers/coach_chat_provider.g.dart @@ -33,7 +33,7 @@ final class CoachChatNotifierProvider CoachChatNotifier create() => CoachChatNotifier(); } -String _$coachChatNotifierHash() => r'7cf4262c0bd933ddd3c3485e9a267873c4d07697'; +String _$coachChatNotifierHash() => r'16768e1857de3ae17c2c2cc51e2318e759eeccea'; abstract class _$CoachChatNotifier extends $AsyncNotifier> { FutureOr> build(); diff --git a/lib/features/coach_chat/providers/model_setup_provider.dart b/lib/features/coach_chat/providers/model_setup_provider.dart index e7ca300..b516f61 100644 --- a/lib/features/coach_chat/providers/model_setup_provider.dart +++ b/lib/features/coach_chat/providers/model_setup_provider.dart @@ -1,4 +1,5 @@ import 'package:kynos/domain/repositories/ai_model_repository.dart'; +import 'package:kynos/features/coach_chat/providers/model_setup_state.dart'; import 'package:kynos/infrastructure/ai/gemma/gemma_runtime.dart'; import 'package:kynos/shared/providers/ai_repository_providers.dart'; import 'package:kynos/shared/providers/huggingface_token_provider.dart'; @@ -18,7 +19,8 @@ class MissingHuggingFaceTokenException implements Exception { @Riverpod(keepAlive: true) class ModelSetupNotifier extends _$ModelSetupNotifier { @override - AsyncValue build() => const AsyncData(false); + AsyncValue build() => + const AsyncData(ModelSetupState(phase: ModelSetupPhase.checking)); Future checkAndInstall() async { state = const AsyncLoading(); @@ -28,23 +30,38 @@ class ModelSetupNotifier extends _$ModelSetupNotifier { throw MissingHuggingFaceTokenException(); } + state = const AsyncData( + ModelSetupState( + phase: ModelSetupPhase.checking, + progressMessage: 'Initialising on-device runtime…', + ), + ); + final AiModelRepository repo = ref.read(aiModelRepositoryProvider); await repo.initialize(huggingFaceToken: hfToken); if (repo.hasActiveModel) { - state = const AsyncData(true); + state = const AsyncData(ModelSetupState(phase: ModelSetupPhase.ready)); return; } await GemmaRuntime.evictLegacyModelsIfNeeded(); if (repo.hasActiveModel) { - state = const AsyncData(true); + state = const AsyncData(ModelSetupState(phase: ModelSetupPhase.ready)); return; } + state = const AsyncData( + ModelSetupState( + phase: ModelSetupPhase.downloading, + progressMessage: + 'Downloading coach model — this may take several minutes on Wi‑Fi.', + ), + ); + await repo.installFromNetwork(url: GemmaRuntime.modelDownloadUrl, token: hfToken); - state = const AsyncData(true); + state = const AsyncData(ModelSetupState(phase: ModelSetupPhase.ready)); } catch (e, st) { state = AsyncError(e, st); } diff --git a/lib/features/coach_chat/providers/model_setup_provider.g.dart b/lib/features/coach_chat/providers/model_setup_provider.g.dart index b48b0ee..6ed1b2c 100644 --- a/lib/features/coach_chat/providers/model_setup_provider.g.dart +++ b/lib/features/coach_chat/providers/model_setup_provider.g.dart @@ -13,7 +13,7 @@ part of 'model_setup_provider.dart'; final modelSetupProvider = ModelSetupNotifierProvider._(); final class ModelSetupNotifierProvider - extends $NotifierProvider> { + extends $NotifierProvider> { ModelSetupNotifierProvider._() : super( from: null, @@ -33,28 +33,34 @@ final class ModelSetupNotifierProvider ModelSetupNotifier create() => ModelSetupNotifier(); /// {@macro riverpod.override_with_value} - Override overrideWithValue(AsyncValue value) { + Override overrideWithValue(AsyncValue value) { return $ProviderOverride( origin: this, - providerOverride: $SyncValueProvider>(value), + providerOverride: $SyncValueProvider>(value), ); } } String _$modelSetupNotifierHash() => - r'f39bac7d86cd6dc2f119dec56255edeea53969cc'; + r'af66026e75fb1686fe26f3d7d344b482f76767a3'; -abstract class _$ModelSetupNotifier extends $Notifier> { - AsyncValue build(); +abstract class _$ModelSetupNotifier + extends $Notifier> { + AsyncValue build(); @$mustCallSuper @override void runBuild() { - final ref = this.ref as $Ref, AsyncValue>; + final ref = + this.ref + as $Ref, AsyncValue>; final element = ref.element as $ClassProviderElement< - AnyNotifier, AsyncValue>, - AsyncValue, + AnyNotifier< + AsyncValue, + AsyncValue + >, + AsyncValue, Object?, Object? >; diff --git a/lib/features/coach_chat/providers/model_setup_state.dart b/lib/features/coach_chat/providers/model_setup_state.dart new file mode 100644 index 0000000..8accb4f --- /dev/null +++ b/lib/features/coach_chat/providers/model_setup_state.dart @@ -0,0 +1,17 @@ +enum ModelSetupPhase { + checking, + downloading, + ready, +} + +class ModelSetupState { + const ModelSetupState({ + required this.phase, + this.progressMessage, + }); + + final ModelSetupPhase phase; + final String? progressMessage; + + bool get isReady => phase == ModelSetupPhase.ready; +} diff --git a/lib/features/coach_chat/utils/chat_history_codec.dart b/lib/features/coach_chat/utils/chat_history_codec.dart new file mode 100644 index 0000000..c327414 --- /dev/null +++ b/lib/features/coach_chat/utils/chat_history_codec.dart @@ -0,0 +1,51 @@ +import 'dart:convert'; + +import 'package:kynos/domain/entities/chat_message.dart'; + +/// Serialises coach chat history to SharedPreferences JSON. +abstract final class ChatHistoryCodec { + static const prefsKey = 'coach_chat_history_v1'; + + static List decode(String? raw) { + if (raw == null || raw.isEmpty) return const []; + try { + final list = jsonDecode(raw) as List; + return list + .map((item) => _fromMap(item as Map)) + .where((m) => m.content.isNotEmpty && !m.isStreaming) + .toList(); + } on Object { + return const []; + } + } + + static String encode(List messages) { + final persisted = messages + .where((m) => m.content.isNotEmpty && !m.isStreaming) + .map(_toMap) + .toList(); + return jsonEncode(persisted); + } + + static Map _toMap(ChatMessage message) { + return { + 'id': message.id, + 'role': message.role.name, + 'content': message.content, + 'timestamp': message.timestamp.toIso8601String(), + 'hasError': message.hasError, + 'userPromptForRetry': message.userPromptForRetry, + }; + } + + static ChatMessage _fromMap(Map map) { + return ChatMessage( + id: map['id'] as String, + role: MessageRole.values.byName(map['role'] as String), + content: map['content'] as String, + timestamp: DateTime.parse(map['timestamp'] as String), + hasError: map['hasError'] as bool? ?? false, + userPromptForRetry: map['userPromptForRetry'] as String?, + ); + } +} diff --git a/lib/features/dashboard/presentation/pages/dashboard_page.dart b/lib/features/dashboard/presentation/pages/dashboard_page.dart index 0b6ccbb..4ba6c9b 100644 --- a/lib/features/dashboard/presentation/pages/dashboard_page.dart +++ b/lib/features/dashboard/presentation/pages/dashboard_page.dart @@ -23,9 +23,12 @@ import 'package:kynos/features/dashboard/presentation/widgets/trend_carousel.dar import 'package:kynos/features/dashboard/presentation/widgets/week_momentum_card.dart'; import 'package:kynos/features/dashboard/providers/dashboard_summary_provider.dart'; import 'package:kynos/features/dashboard/providers/post_run_debrief_provider.dart'; +import 'package:kynos/features/training/providers/training_insights_provider.dart'; import 'package:kynos/features/dashboard/providers/today_insights_provider.dart'; +import 'package:kynos/shared/providers/character_providers.dart'; import 'package:kynos/shared/providers/daily_quests_provider.dart'; import 'package:kynos/shared/providers/health_providers.dart'; +import 'package:kynos/shared/providers/nexus_lab_provider.dart'; import 'package:kynos/shared/widgets/kynos_card.dart'; import 'package:kynos/shared/widgets/kynos_chip.dart'; import 'package:kynos/shared/widgets/kynos_privacy_footer.dart'; @@ -68,14 +71,19 @@ class _DashboardPageState extends ConsumerState { Future _refreshDashboard() async { ref.invalidate(healthSummaryProvider); ref.invalidate(todayInsightsStateProvider); + ref.invalidate(trainingInsightsStateProvider); ref.invalidate(healthHistoryProvider(days: 28)); ref.invalidate(healthHistoryProvider(days: 30)); ref.invalidate(recentRunsProvider(days: 30, limit: 3)); ref.invalidate(dailyQuestsProvider); ref.invalidate(dashboardSummaryProvider); + ref.invalidate(runnerCharacterProvider); + ref.invalidate(nexusLabProvider); + ref.invalidate(postRunDebriefProvider); await Future.wait([ ref.read(healthSummaryProvider.future), ref.read(todayInsightsStateProvider.future), + ref.read(trainingInsightsStateProvider.future), ref.read(healthHistoryProvider(days: 28).future), ref.read(recentRunsProvider(days: 30, limit: 3).future), ref.read(dailyQuestsProvider.future), @@ -145,20 +153,44 @@ class _DashboardPageState extends ConsumerState { summary.requireValue == null; ref.listen(postRunDebriefProvider, (prev, next) { - final data = next.value; - if (data == null || !mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - 'Run debrief: ${data.debrief.highlight} (+${data.xpAmount} XP)', - ), - action: SnackBarAction( - label: 'Dismiss', - onPressed: () => - ref.read(postRunDebriefProvider.notifier).dismiss(), - ), - duration: const Duration(seconds: 8), - ), + next.whenOrNull( + data: (data) { + if (data == null || !mounted) return; + final debriefText = + 'Run debrief: ${data.debrief.highlight} (+${data.xpAmount} XP)'; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(debriefText), + action: SnackBarAction( + label: 'Read debrief', + onPressed: () { + ref.read(coachChatSeedProvider.notifier).setSeed( + 'Here is my post-run debrief: ${data.debrief.highlight}. ' + 'What should I focus on in recovery?', + ); + context.push(Routes.coachChat); + ref.read(postRunDebriefProvider.notifier).dismiss(); + }, + ), + duration: const Duration(seconds: 8), + ), + ); + }, + error: (error, _) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text( + 'Could not generate your post-run debrief. Pull to refresh to retry.', + ), + action: SnackBarAction( + label: 'Dismiss', + onPressed: () => + ref.read(postRunDebriefProvider.notifier).dismiss(), + ), + ), + ); + }, ); }); @@ -199,6 +231,7 @@ class _DashboardPageState extends ConsumerState { momentum: dash?.weeklyMomentum, isLoading: loadHistory.isLoading || dashboardSummaryAsync.isLoading, + onImportRun: () => context.push(Routes.healthImport), ), const Gap(tokens.Spacing.xl), const KynosSectionRow(title: 'Highlights'), diff --git a/lib/features/dashboard/presentation/pages/run_history_page.dart b/lib/features/dashboard/presentation/pages/run_history_page.dart index 8a7b0e5..ec45251 100644 --- a/lib/features/dashboard/presentation/pages/run_history_page.dart +++ b/lib/features/dashboard/presentation/pages/run_history_page.dart @@ -24,7 +24,12 @@ class RunHistoryPage extends ConsumerWidget { return Scaffold( backgroundColor: kynos.background, - body: CustomScrollView( + body: RefreshIndicator( + onRefresh: () async { + ref.invalidate(recentRunsProvider(days: 365, limit: 200)); + await ref.read(recentRunsProvider(days: 365, limit: 200).future); + }, + child: CustomScrollView( physics: const AlwaysScrollableScrollPhysics( parent: BouncingScrollPhysics(), ), @@ -127,6 +132,7 @@ class RunHistoryPage extends ConsumerWidget { ), ], ), + ), ); } } diff --git a/lib/features/dashboard/presentation/pages/run_route_page.dart b/lib/features/dashboard/presentation/pages/run_route_page.dart index 40f0233..c1bcc33 100644 --- a/lib/features/dashboard/presentation/pages/run_route_page.dart +++ b/lib/features/dashboard/presentation/pages/run_route_page.dart @@ -10,11 +10,75 @@ import 'package:kynos/core/theme/theme.dart'; import 'package:kynos/domain/entities/workout_route_point.dart'; import 'package:kynos/domain/entities/workout_session.dart'; import 'package:kynos/shared/providers/health_providers.dart'; +import 'package:kynos/shared/providers/workout_session_lookup_provider.dart'; import 'package:kynos/shared/utils/url_opener.dart'; +import 'package:kynos/shared/widgets/kynos_inline_error_card.dart'; import 'package:kynos/shared/widgets/kynos_skeleton.dart'; class RunRoutePage extends ConsumerWidget { - const RunRoutePage({super.key, required this.run}); + const RunRoutePage({super.key, this.run, this.runId}) + : assert(run != null || runId != null); + + final WorkoutSession? run; + final String? runId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final kynos = context.kynosTheme; + final resolvedRun = run; + if (resolvedRun != null) { + return _RunRouteScaffold(run: resolvedRun); + } + + final lookup = ref.watch(workoutSessionByIdProvider(runId!)); + return lookup.when( + loading: () => Scaffold( + backgroundColor: kynos.background, + appBar: AppBar( + title: const Text('Run Route'), + backgroundColor: kynos.background, + surfaceTintColor: Colors.transparent, + ), + body: const Padding( + padding: EdgeInsets.all(tokens.Spacing.md), + child: KynosSkeleton.tile(height: 300), + ), + ), + error: (_, _) => Scaffold( + backgroundColor: kynos.background, + appBar: AppBar( + title: const Text('Run Route'), + backgroundColor: kynos.background, + surfaceTintColor: Colors.transparent, + ), + body: Padding( + padding: const EdgeInsets.all(tokens.Spacing.md), + child: KynosInlineErrorCard( + message: 'Could not load this run.', + onRetry: () => ref.invalidate(workoutSessionByIdProvider(runId!)), + ), + ), + ), + data: (session) { + if (session == null) { + return Scaffold( + backgroundColor: kynos.background, + appBar: AppBar( + title: const Text('Run Route'), + backgroundColor: kynos.background, + surfaceTintColor: Colors.transparent, + ), + body: const Center(child: Text('Run not found on this device.')), + ); + } + return _RunRouteScaffold(run: session); + }, + ); + } +} + +class _RunRouteScaffold extends ConsumerWidget { + const _RunRouteScaffold({required this.run}); final WorkoutSession run; @@ -46,28 +110,11 @@ class RunRoutePage extends ConsumerWidget { padding: EdgeInsets.all(tokens.Spacing.md), child: KynosSkeleton.tile(height: 300), ), - error: (error, _) => Center( - child: Padding( - padding: const EdgeInsets.all(tokens.Spacing.md), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'Could not load workout route: $error', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: kynos.move, - ), - textAlign: TextAlign.center, - ), - const Gap(tokens.Spacing.md), - FilledButton( - onPressed: () => ref.invalidate( - runRouteProvider(workoutUuid: run.id), - ), - child: const Text('Retry'), - ), - ], - ), + error: (_, _) => Padding( + padding: const EdgeInsets.all(tokens.Spacing.md), + child: KynosInlineErrorCard( + message: 'Could not load the workout route. Try again in a moment.', + onRetry: () => ref.invalidate(runRouteProvider(workoutUuid: run.id)), ), ), ), diff --git a/lib/features/dashboard/presentation/widgets/activity_ring.dart b/lib/features/dashboard/presentation/widgets/activity_ring.dart index 03f3950..e4a3b28 100644 --- a/lib/features/dashboard/presentation/widgets/activity_ring.dart +++ b/lib/features/dashboard/presentation/widgets/activity_ring.dart @@ -28,18 +28,30 @@ class ActivityRing extends StatelessWidget { final progresses = ringProgresses ?? List.filled(colors.length, progress ?? 0); - return SizedBox( - width: size, - height: size, - child: CustomPaint( - painter: RingPainter( - ringProgresses: progresses, - strokeWidth: strokeWidth, - colors: colors, + return Semantics( + label: _semanticsLabel(progresses), + child: SizedBox( + width: size, + height: size, + child: CustomPaint( + painter: RingPainter( + ringProgresses: progresses, + strokeWidth: strokeWidth, + colors: colors, + ), ), ), ); } + + String _semanticsLabel(List progresses) { + final parts = []; + for (var i = 0; i < progresses.length; i++) { + final pct = (progresses[i] * 100).round(); + parts.add('Ring ${i + 1} $pct percent'); + } + return parts.isEmpty ? 'Activity rings' : 'Activity rings: ${parts.join(', ')}'; + } } class RingPainter extends CustomPainter { diff --git a/lib/features/dashboard/presentation/widgets/connect_healthkit_card.dart b/lib/features/dashboard/presentation/widgets/connect_healthkit_card.dart index e09fc1e..4023498 100644 --- a/lib/features/dashboard/presentation/widgets/connect_healthkit_card.dart +++ b/lib/features/dashboard/presentation/widgets/connect_healthkit_card.dart @@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart'; import 'package:kynos/app/router.dart'; import 'package:kynos/core/theme/theme.dart'; import 'package:kynos/shared/providers/health_providers.dart'; +import 'package:kynos/shared/utils/health_permission_feedback.dart'; import 'package:kynos/shared/utils/health_platform_labels.dart'; import 'package:kynos/shared/widgets/kynos_card.dart'; @@ -14,8 +15,6 @@ class ConnectHealthkitCard extends ConsumerWidget { String _platformLabel() => HealthPlatformLabels.platformName(); - String _settingsHint() => HealthPlatformLabels.settingsHint(); - @override Widget build(BuildContext context, WidgetRef ref) { final permissionState = ref.watch(healthPermissionsProvider); @@ -50,17 +49,19 @@ class ConnectHealthkitCard extends ConsumerWidget { ref.read(healthPermissionsProvider).whenOrNull( data: (granted) { final message = granted - ? '$platform connected.' - : '$platform permission not granted. ${_settingsHint()}'; + ? HealthPermissionFeedback.connectedMessage(platform) + : HealthPermissionFeedback.permissionDeniedMessage( + platform, + ); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(message)), ); }, - error: (error, _) { + error: (_, _) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - 'Health connection failed: $error', + HealthPermissionFeedback.connectionFailedMessage(), ), ), ); diff --git a/lib/features/dashboard/presentation/widgets/daily_quest_teaser.dart b/lib/features/dashboard/presentation/widgets/daily_quest_teaser.dart index 012657f..c73b9d2 100644 --- a/lib/features/dashboard/presentation/widgets/daily_quest_teaser.dart +++ b/lib/features/dashboard/presentation/widgets/daily_quest_teaser.dart @@ -33,7 +33,34 @@ class DailyQuestTeaser extends ConsumerWidget { onRetry: () => ref.invalidate(dailyQuestsProvider), ), data: (quests) { - if (quests.isEmpty) return const SizedBox.shrink(); + if (quests.isEmpty) { + return KynosCard( + padding: const EdgeInsets.all(Spacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'No daily quest yet.', + style: Theme.of(context).textTheme.titleMedium, + ), + const Gap(Spacing.xs), + Text( + 'Open Character to generate today\'s quest.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: kynos.secondaryLabel, + ), + ), + if (onViewCharacter != null) ...[ + const Gap(Spacing.sm), + TextButton( + onPressed: onViewCharacter, + child: const Text('View Character'), + ), + ], + ], + ), + ); + } final quest = quests.first; final isCompleted = quest.status == QuestStatus.completed; diff --git a/lib/features/dashboard/presentation/widgets/last_run_preview.dart b/lib/features/dashboard/presentation/widgets/last_run_preview.dart index 46aabe0..99a7c34 100644 --- a/lib/features/dashboard/presentation/widgets/last_run_preview.dart +++ b/lib/features/dashboard/presentation/widgets/last_run_preview.dart @@ -5,12 +5,14 @@ import 'package:go_router/go_router.dart'; import 'package:kynos/app/router.dart'; import 'package:kynos/core/theme/theme.dart'; import 'package:kynos/domain/entities/workout_session.dart'; +import 'package:kynos/shared/providers/health_providers.dart'; import 'package:kynos/shared/widgets/kynos_card.dart'; +import 'package:kynos/shared/widgets/kynos_inline_error_card.dart'; import 'package:kynos/shared/widgets/kynos_loading_line.dart'; import 'package:kynos/shared/widgets/run_card.dart'; /// Shows up to three recent runs on the Today tab. -class LastRunPreview extends StatelessWidget { +class LastRunPreview extends ConsumerWidget { const LastRunPreview({ super.key, required this.runsAsync, @@ -19,16 +21,14 @@ class LastRunPreview extends StatelessWidget { final AsyncValue> runsAsync; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { return runsAsync.when( loading: () => const KynosCard( child: KynosLoadingLine(label: 'Loading recent runs...'), ), - error: (_, _) => KynosCard( - child: Text( - 'Could not load recent runs.', - style: Theme.of(context).textTheme.bodyMedium, - ), + error: (_, _) => KynosInlineErrorCard( + message: 'Could not load recent runs.', + onRetry: () => ref.invalidate(recentRunsProvider(days: 30, limit: 3)), ), data: (runs) { if (runs.isEmpty) { diff --git a/lib/features/dashboard/presentation/widgets/week_momentum_card.dart b/lib/features/dashboard/presentation/widgets/week_momentum_card.dart index 8e39d23..e46ec67 100644 --- a/lib/features/dashboard/presentation/widgets/week_momentum_card.dart +++ b/lib/features/dashboard/presentation/widgets/week_momentum_card.dart @@ -14,15 +14,18 @@ class WeekMomentumCard extends StatelessWidget { super.key, required this.momentum, this.isLoading = false, + this.onImportRun, }); final WeeklyMomentum? momentum; final bool isLoading; + final VoidCallback? onImportRun; @override Widget build(BuildContext context) { final kynos = context.kynosTheme; final m = momentum; + final hasNoData = !isLoading && m == null; return KynosCard( padding: const EdgeInsets.all(tokens.Spacing.lg), @@ -57,62 +60,76 @@ class WeekMomentumCard extends StatelessWidget { valueColor: AlwaysStoppedAnimation(kynos.stand), ), ), - const Gap(tokens.Spacing.lg), - Row( - children: [ - Expanded( - child: MetricTile( - label: 'Week distance', - value: isLoading - ? null - : m != null && m.thisWeekDistanceKm > 0 - ? m.thisWeekDistanceKm.toStringAsFixed(1) - : '—', - unit: 'km', - accentColor: kynos.stand, - sublabel: formatWowBadge(m?.distanceDeltaPct), - sublabelColor: _wowColor(kynos, m?.distanceDeltaPct), + if (hasNoData) ...[ + const Gap(tokens.Spacing.md), + Text( + 'Connect health data or import a run to track weekly momentum.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: kynos.secondaryLabel, + ), + ), + if (onImportRun != null) ...[ + const Gap(tokens.Spacing.sm), + TextButton(onPressed: onImportRun, child: const Text('Import a run')), + ], + ] else ...[ + const Gap(tokens.Spacing.lg), + Row( + children: [ + Expanded( + child: MetricTile( + label: 'Week distance', + value: isLoading + ? null + : m != null && m.thisWeekDistanceKm > 0 + ? m.thisWeekDistanceKm.toStringAsFixed(1) + : '—', + unit: 'km', + accentColor: kynos.stand, + sublabel: formatWowBadge(m?.distanceDeltaPct), + sublabelColor: _wowColor(kynos, m?.distanceDeltaPct), + ), ), - ), - const Gap(tokens.Spacing.md), - Expanded( - child: MetricTile( - label: 'Runs', - value: isLoading - ? null - : m != null && m.thisWeekRuns > 0 - ? '${m.thisWeekRuns}' - : '—', - accentColor: kynos.exercise, - sublabel: formatWowBadge(m?.runsDeltaPct), - sublabelColor: _wowColor(kynos, m?.runsDeltaPct), + const Gap(tokens.Spacing.md), + Expanded( + child: MetricTile( + label: 'Runs', + value: isLoading + ? null + : m != null && m.thisWeekRuns > 0 + ? '${m.thisWeekRuns}' + : '—', + accentColor: kynos.exercise, + sublabel: formatWowBadge(m?.runsDeltaPct), + sublabelColor: _wowColor(kynos, m?.runsDeltaPct), + ), ), - ), - ], - ), - const Gap(tokens.Spacing.md), - Row( - children: [ - Expanded( - child: MetricTile( - label: 'Active kcal', - value: isLoading - ? null - : m != null && m.thisWeekActiveKcal > 0 - ? '${m.thisWeekActiveKcal.round()}' - : '—', - unit: 'kcal', - accentColor: kynos.energy, - sublabel: formatWowBadge(m?.kcalDeltaPct), - sublabelColor: _wowColor(kynos, m?.kcalDeltaPct), + ], + ), + const Gap(tokens.Spacing.md), + Row( + children: [ + Expanded( + child: MetricTile( + label: 'Active kcal', + value: isLoading + ? null + : m != null && m.thisWeekActiveKcal > 0 + ? '${m.thisWeekActiveKcal.round()}' + : '—', + unit: 'kcal', + accentColor: kynos.energy, + sublabel: formatWowBadge(m?.kcalDeltaPct), + sublabelColor: _wowColor(kynos, m?.kcalDeltaPct), + ), ), - ), - const Gap(tokens.Spacing.md), - Expanded( - child: _wowSummaryTile(context, kynos, m, isLoading), - ), - ], - ), + const Gap(tokens.Spacing.md), + Expanded( + child: _wowSummaryTile(context, kynos, m, isLoading), + ), + ], + ), + ], ], ), ); diff --git a/lib/features/nexus_lab/presentation/nexus_lab_page.dart b/lib/features/nexus_lab/presentation/nexus_lab_page.dart index 4ccf2f3..6747d5c 100644 --- a/lib/features/nexus_lab/presentation/nexus_lab_page.dart +++ b/lib/features/nexus_lab/presentation/nexus_lab_page.dart @@ -3,9 +3,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:gap/gap.dart'; import 'package:google_fonts/google_fonts.dart'; +import 'package:kynos/app/router.dart'; import 'package:kynos/core/theme/app_theme.dart'; import 'package:kynos/core/theme/spacing.dart' as tokens; import 'package:kynos/shared/providers/nexus_lab_provider.dart'; +import 'package:kynos/shared/utils/navigation_utils.dart'; import 'package:kynos/shared/widgets/kynos_card.dart'; import 'package:kynos/shared/widgets/metric_tile.dart'; @@ -18,7 +20,15 @@ class NexusLabPage extends ConsumerWidget { return Scaffold( backgroundColor: AppTheme.background, + appBar: AppBar( + title: const Text('KYNOS Lab'), + leading: IconButton( + icon: const Icon(Icons.close_rounded), + onPressed: () => popOrGo(context, Routes.training), + ), + ), body: SafeArea( + top: false, child: Padding( padding: const EdgeInsets.all(tokens.Spacing.md), child: state.when( @@ -51,8 +61,6 @@ class _Content extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('KYNOS Lab', style: Theme.of(context).textTheme.displaySmall), - const Gap(tokens.Spacing.xs), Text( 'On-device continual calibration for your gait coefficients.', style: Theme.of(context).textTheme.bodyMedium, diff --git a/lib/features/onboarding/presentation/onboarding_page.dart b/lib/features/onboarding/presentation/onboarding_page.dart index f7ffbbf..8754e19 100644 --- a/lib/features/onboarding/presentation/onboarding_page.dart +++ b/lib/features/onboarding/presentation/onboarding_page.dart @@ -48,9 +48,16 @@ class _OnboardingPageState extends ConsumerState { ), OnboardingItem( imagePath: 'assets/images/onboarding_health_data.png', - title: 'Ready to go?', + title: 'Connect Your Health', description: - 'Get started and connect your health data to build your baseline.', + 'Grant HealthKit access or import runs so KYNOS can build your readiness baseline.', + ), + OnboardingItem( + imagePath: 'assets/images/onboarding_health_data.png', + title: 'Set Up AI Coach', + description: + 'Add a HuggingFace token in Settings to download the on-device Gemma coach model. ' + 'You can also enable cloud coaching with OpenRouter later.', ), ]; @@ -73,17 +80,38 @@ class _OnboardingPageState extends ConsumerState { } Future _skipOnboarding() async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Skip setup?'), + content: const Text( + 'You can connect health data and set up the AI coach anytime in Settings.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Continue setup'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Skip for now'), + ), + ], + ), + ); + + if (confirmed != true || !mounted) return; await _finishOnboarding(); } Future _getStarted() async { - if (!mounted) return; - context.go(Routes.healthImport); if (!kIsWeb) { await ref.read(healthPermissionsProvider.notifier).request(); } if (!mounted) return; await ref.read(onboardingCompletedProvider.notifier).completeOnboarding(); + if (!mounted) return; + context.go(Routes.healthImport); } @override diff --git a/lib/features/settings/presentation/pages/openrouter_model_picker_page.dart b/lib/features/settings/presentation/pages/openrouter_model_picker_page.dart index 3cdfa91..28daa32 100644 --- a/lib/features/settings/presentation/pages/openrouter_model_picker_page.dart +++ b/lib/features/settings/presentation/pages/openrouter_model_picker_page.dart @@ -10,6 +10,7 @@ import 'package:kynos/features/settings/presentation/widgets/openrouter_model_ca import 'package:kynos/features/settings/providers/openrouter_models_provider.dart'; import 'package:kynos/features/settings/providers/settings_provider.dart'; import 'package:kynos/shared/widgets/kynos_chip.dart'; +import 'package:kynos/shared/widgets/kynos_inline_error_card.dart'; import 'package:kynos/shared/widgets/kynos_loading_line.dart'; import 'package:kynos/shared/widgets/kynos_section_header.dart'; @@ -76,11 +77,13 @@ class OpenRouterModelPickerPage extends ConsumerWidget { loading: () => const Center( child: KynosLoadingLine(label: 'Loading models...'), ), - error: (_, _) => const Center( + error: (_, _) => Center( child: Padding( - padding: EdgeInsets.all(tokens.Spacing.md), - child: Text( - 'Could not load models. Check your API key and try again.', + padding: const EdgeInsets.all(tokens.Spacing.md), + child: KynosInlineErrorCard( + message: + 'Could not load models. Check your API key and try again.', + onRetry: () => ref.invalidate(openRouterCatalogDataProvider), ), ), ), @@ -89,7 +92,11 @@ class OpenRouterModelPickerPage extends ConsumerWidget { return Center( child: Padding( padding: const EdgeInsets.all(tokens.Spacing.md), - child: Text(result.error!), + child: KynosInlineErrorCard( + message: result.error!, + onRetry: () => + ref.invalidate(openRouterCatalogDataProvider), + ), ), ); } @@ -194,6 +201,9 @@ class OpenRouterModelPickerPage extends ConsumerWidget { Navigator.pop(ctx); } if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Selected ${model.name}')), + ); context.pop(); } }, diff --git a/lib/features/settings/presentation/pages/settings_page.dart b/lib/features/settings/presentation/pages/settings_page.dart index 18cc9a5..3ac4c5f 100644 --- a/lib/features/settings/presentation/pages/settings_page.dart +++ b/lib/features/settings/presentation/pages/settings_page.dart @@ -14,6 +14,7 @@ import 'package:kynos/features/settings/providers/settings_provider.dart'; import 'package:kynos/shared/providers/health_providers.dart'; import 'package:kynos/shared/providers/huggingface_token_provider.dart'; import 'package:kynos/shared/providers/openrouter_api_key_provider.dart'; +import 'package:kynos/shared/utils/health_permission_feedback.dart'; import 'package:kynos/shared/utils/health_platform_labels.dart'; import 'package:kynos/shared/utils/url_opener.dart'; import 'package:kynos/shared/widgets/kynos_card.dart'; @@ -95,9 +96,37 @@ class _SettingsPageState extends ConsumerState { child: TextButton( onPressed: permissionState.isLoading ? null - : () => ref - .read(healthPermissionsProvider.notifier) - .request(), + : () async { + await ref + .read(healthPermissionsProvider.notifier) + .request(); + if (!context.mounted) return; + ref.read(healthPermissionsProvider).whenOrNull( + data: (granted) { + final platform = + HealthPlatformLabels.platformName(); + final message = granted + ? HealthPermissionFeedback.connectedMessage( + platform, + ) + : HealthPermissionFeedback + .permissionDeniedMessage(platform); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message)), + ); + }, + error: (_, _) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + HealthPermissionFeedback + .connectionFailedMessage(), + ), + ), + ); + }, + ); + }, child: Text( permissionState.isLoading ? 'Connecting…' @@ -345,6 +374,28 @@ class _SettingsPageState extends ConsumerState { } Future _replayOnboarding(BuildContext context) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Replay onboarding?'), + content: const Text( + 'This resets onboarding and returns you to the welcome flow.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Replay'), + ), + ], + ), + ); + + if (confirmed != true || !mounted) return; + await ref.read(onboardingCompletedProvider.notifier).resetOnboarding(); if (!context.mounted) return; context.go(Routes.onboarding); diff --git a/lib/features/settings/presentation/widgets/apple_health_export_preview_card.dart b/lib/features/settings/presentation/widgets/apple_health_export_preview_card.dart index 8ac1fe7..1255125 100644 --- a/lib/features/settings/presentation/widgets/apple_health_export_preview_card.dart +++ b/lib/features/settings/presentation/widgets/apple_health_export_preview_card.dart @@ -18,6 +18,34 @@ class AppleHealthExportPreviewCard extends StatelessWidget { final bool isImporting; final VoidCallback onImport; + Future _confirmImport(BuildContext context) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Import all health data?'), + content: Text( + 'This will import ${preview.recordCount} health records, ' + '${preview.summaries.length} daily summaries, and ' + '${preview.workouts.length} running workouts onto this device.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Import'), + ), + ], + ), + ); + + if (confirmed == true) { + onImport(); + } + } + @override Widget build(BuildContext context) { return Column( @@ -67,7 +95,7 @@ class AppleHealthExportPreviewCard extends StatelessWidget { ], const Gap(tokens.Spacing.md), FilledButton( - onPressed: isImporting ? null : onImport, + onPressed: isImporting ? null : () => _confirmImport(context), child: Text(isImporting ? 'Importing…' : 'Import all data'), ), ], diff --git a/lib/features/training/presentation/pages/training_page.dart b/lib/features/training/presentation/pages/training_page.dart index a403654..496d465 100644 --- a/lib/features/training/presentation/pages/training_page.dart +++ b/lib/features/training/presentation/pages/training_page.dart @@ -26,6 +26,18 @@ import 'package:kynos/shared/widgets/kynos_section_header.dart'; class TrainingPage extends ConsumerWidget { const TrainingPage({super.key}); + Future _refreshTraining(WidgetRef ref) async { + ref.invalidate(healthHistoryProvider(days: 30)); + ref.invalidate(recentRunsProvider(days: 365, limit: 60)); + ref.invalidate(trainingInsightsStateProvider); + ref.invalidate(nexusLabProvider); + await Future.wait([ + ref.read(healthHistoryProvider(days: 30).future), + ref.read(recentRunsProvider(days: 365, limit: 60).future), + ref.read(trainingInsightsStateProvider.future), + ]); + } + @override Widget build(BuildContext context, WidgetRef ref) { final history = ref.watch(healthHistoryProvider(days: 30)); @@ -35,7 +47,9 @@ class TrainingPage extends ConsumerWidget { final kynos = context.kynosTheme; - return CustomScrollView( + return RefreshIndicator( + onRefresh: () => _refreshTraining(ref), + child: CustomScrollView( physics: const AlwaysScrollableScrollPhysics( parent: BouncingScrollPhysics(), ), @@ -151,6 +165,7 @@ class TrainingPage extends ConsumerWidget { ), ), ], + ), ); } } diff --git a/lib/features/training/presentation/widgets/training_insight_cards.dart b/lib/features/training/presentation/widgets/training_insight_cards.dart index 508505c..7e5cce1 100644 --- a/lib/features/training/presentation/widgets/training_insight_cards.dart +++ b/lib/features/training/presentation/widgets/training_insight_cards.dart @@ -7,12 +7,13 @@ import 'package:kynos/features/training/presentation/widgets/training_insight_li import 'package:kynos/features/training/presentation/widgets/training_insight_text_card.dart'; import 'package:kynos/features/training/providers/training_insights_provider.dart'; import 'package:kynos/shared/widgets/kynos_card.dart'; +import 'package:kynos/shared/widgets/kynos_inline_error_card.dart'; import 'package:kynos/shared/widgets/kynos_loading_line.dart'; import 'package:kynos/shared/widgets/kynos_section_header.dart'; import 'package:logger/logger.dart'; /// AI-generated session intent, adjustments, and debrief cards. -class TrainingInsightsCards extends StatelessWidget { +class TrainingInsightsCards extends ConsumerWidget { const TrainingInsightsCards({super.key, required this.insightsState}); static final _logger = Logger(); @@ -20,7 +21,7 @@ class TrainingInsightsCards extends StatelessWidget { final AsyncValue insightsState; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { return insightsState.when( loading: () => const KynosCard( child: KynosLoadingLine(label: 'Building training insights...'), @@ -31,7 +32,10 @@ class TrainingInsightsCards extends StatelessWidget { error: error, stackTrace: stackTrace, ); - return const SizedBox.shrink(); + return KynosInlineErrorCard( + message: 'Could not load training insights.', + onRetry: () => ref.invalidate(trainingInsightsStateProvider), + ); }, data: (state) { final insights = state.insights; diff --git a/lib/shared/providers/ai_reconnect_provider.dart b/lib/shared/providers/ai_reconnect_provider.dart new file mode 100644 index 0000000..05851a0 --- /dev/null +++ b/lib/shared/providers/ai_reconnect_provider.dart @@ -0,0 +1,14 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'ai_reconnect_provider.g.dart'; + +/// Set when the on-device AI isolate was disposed on background; UI may re-init. +@Riverpod(keepAlive: true) +class AiReconnectState extends _$AiReconnectState { + @override + bool build() => false; + + void markNeedsReconnect() => state = true; + + void clear() => state = false; +} diff --git a/lib/shared/providers/ai_reconnect_provider.g.dart b/lib/shared/providers/ai_reconnect_provider.g.dart new file mode 100644 index 0000000..aaf9c9f --- /dev/null +++ b/lib/shared/providers/ai_reconnect_provider.g.dart @@ -0,0 +1,67 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'ai_reconnect_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// Set when the on-device AI isolate was disposed on background; UI may re-init. + +@ProviderFor(AiReconnectState) +final aiReconnectStateProvider = AiReconnectStateProvider._(); + +/// Set when the on-device AI isolate was disposed on background; UI may re-init. +final class AiReconnectStateProvider + extends $NotifierProvider { + /// Set when the on-device AI isolate was disposed on background; UI may re-init. + AiReconnectStateProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'aiReconnectStateProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$aiReconnectStateHash(); + + @$internal + @override + AiReconnectState create() => AiReconnectState(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(bool value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$aiReconnectStateHash() => r'7f6c1bd32f1e29f6d5de7fa333eb5293295b6720'; + +/// Set when the on-device AI isolate was disposed on background; UI may re-init. + +abstract class _$AiReconnectState extends $Notifier { + bool build(); + @$mustCallSuper + @override + void runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + bool, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} diff --git a/lib/shared/providers/health_providers.dart b/lib/shared/providers/health_providers.dart index 178ef70..dbb86a3 100644 --- a/lib/shared/providers/health_providers.dart +++ b/lib/shared/providers/health_providers.dart @@ -85,7 +85,16 @@ void invalidateHealthProviders(Ref ref) { @Riverpod(keepAlive: true) class HealthPermissionsNotifier extends _$HealthPermissionsNotifier { @override - AsyncValue build() => const AsyncData(false); + Future build() async { + if (kIsWeb) return false; + try { + final repo = ref.read(healthRepositoryProvider); + final result = await repo.getToday(); + return result.failure == null; + } on Object { + return false; + } + } Future request() async { if (kIsWeb) { diff --git a/lib/shared/providers/health_providers.g.dart b/lib/shared/providers/health_providers.g.dart index bc0462b..e8d30ef 100644 --- a/lib/shared/providers/health_providers.g.dart +++ b/lib/shared/providers/health_providers.g.dart @@ -374,7 +374,7 @@ final healthPermissionsProvider = HealthPermissionsNotifierProvider._(); /// Handles the HealthKit permission request triggered from the UI. final class HealthPermissionsNotifierProvider - extends $NotifierProvider> { + extends $AsyncNotifierProvider { /// Handles the HealthKit permission request triggered from the UI. HealthPermissionsNotifierProvider._() : super( @@ -393,31 +393,23 @@ final class HealthPermissionsNotifierProvider @$internal @override HealthPermissionsNotifier create() => HealthPermissionsNotifier(); - - /// {@macro riverpod.override_with_value} - Override overrideWithValue(AsyncValue value) { - return $ProviderOverride( - origin: this, - providerOverride: $SyncValueProvider>(value), - ); - } } String _$healthPermissionsNotifierHash() => - r'c7c62087fa89c33cd0be87f706a7384a246f886f'; + r'63fc18f40bfcbbd36c874deb0b19ba14eb5ae842'; /// Handles the HealthKit permission request triggered from the UI. -abstract class _$HealthPermissionsNotifier extends $Notifier> { - AsyncValue build(); +abstract class _$HealthPermissionsNotifier extends $AsyncNotifier { + FutureOr build(); @$mustCallSuper @override void runBuild() { - final ref = this.ref as $Ref, AsyncValue>; + final ref = this.ref as $Ref, bool>; final element = ref.element as $ClassProviderElement< - AnyNotifier, AsyncValue>, + AnyNotifier, bool>, AsyncValue, Object?, Object? diff --git a/lib/shared/providers/measurable_quest_sync_provider.dart b/lib/shared/providers/measurable_quest_sync_provider.dart new file mode 100644 index 0000000..604ae7a --- /dev/null +++ b/lib/shared/providers/measurable_quest_sync_provider.dart @@ -0,0 +1,52 @@ +import 'dart:async'; + +import 'package:kynos/domain/entities/gamification/quest.dart'; +import 'package:kynos/features/character/providers/quest_provider.dart'; +import 'package:kynos/shared/providers/daily_quests_provider.dart'; +import 'package:kynos/shared/providers/gamification_providers.dart'; +import 'package:kynos/shared/providers/health_providers.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'measurable_quest_sync_provider.g.dart'; + +bool _isSameDay(DateTime a, DateTime b) => + a.year == b.year && a.month == b.month && a.day == b.day; + +/// Keeps measurable daily quests in sync with health data app-wide. +@Riverpod(keepAlive: true) +class MeasurableQuestSync extends _$MeasurableQuestSync { + @override + void build() { + ref.listen(healthSummaryProvider, (_, _) { + unawaited(_syncMeasurableQuests()); + }); + ref.listen(recentRunsProvider(days: 1, limit: 20), (_, _) { + unawaited(_syncMeasurableQuests()); + }); + unawaited(_syncMeasurableQuests()); + } + + Future _syncMeasurableQuests() async { + final quests = await ref.read(dailyQuestsProvider.future); + if (quests.isEmpty) return; + + final evaluator = ref.read(evaluateQuestProgressUseCaseProvider); + final summary = ref.read(healthSummaryProvider).value; + final runs = await ref.read(recentRunsProvider(days: 1, limit: 20).future); + final today = DateTime.now(); + final todayRuns = runs.where((r) => _isSameDay(r.start, today)).toList(); + + for (final quest in quests) { + if (quest.status != QuestStatus.active) continue; + if (quest.measurableObjective == null) continue; + if (!evaluator.isComplete( + quest: quest, + summary: summary, + todayRuns: todayRuns, + )) { + continue; + } + await ref.read(questProvider.notifier).completeQuest(quest.id); + } + } +} diff --git a/lib/shared/providers/measurable_quest_sync_provider.g.dart b/lib/shared/providers/measurable_quest_sync_provider.g.dart new file mode 100644 index 0000000..d657223 --- /dev/null +++ b/lib/shared/providers/measurable_quest_sync_provider.g.dart @@ -0,0 +1,68 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'measurable_quest_sync_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// Keeps measurable daily quests in sync with health data app-wide. + +@ProviderFor(MeasurableQuestSync) +final measurableQuestSyncProvider = MeasurableQuestSyncProvider._(); + +/// Keeps measurable daily quests in sync with health data app-wide. +final class MeasurableQuestSyncProvider + extends $NotifierProvider { + /// Keeps measurable daily quests in sync with health data app-wide. + MeasurableQuestSyncProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'measurableQuestSyncProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$measurableQuestSyncHash(); + + @$internal + @override + MeasurableQuestSync create() => MeasurableQuestSync(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(void value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$measurableQuestSyncHash() => + r'eccfc6fd2a08fd1d39cefacd11157ea8f4ce5be0'; + +/// Keeps measurable daily quests in sync with health data app-wide. + +abstract class _$MeasurableQuestSync extends $Notifier { + void build(); + @$mustCallSuper + @override + void runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + void, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} diff --git a/lib/shared/providers/workout_session_lookup_provider.dart b/lib/shared/providers/workout_session_lookup_provider.dart new file mode 100644 index 0000000..a734999 --- /dev/null +++ b/lib/shared/providers/workout_session_lookup_provider.dart @@ -0,0 +1,15 @@ +import 'package:kynos/domain/entities/workout_session.dart'; +import 'package:kynos/shared/providers/health_providers.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'workout_session_lookup_provider.g.dart'; + +/// Resolves a [WorkoutSession] by id for deep-linked run routes. +@riverpod +Future workoutSessionById(Ref ref, String runId) async { + final runs = await ref.watch(recentRunsProvider(days: 365, limit: 200).future); + for (final run in runs) { + if (run.id == runId) return run; + } + return null; +} diff --git a/lib/shared/providers/workout_session_lookup_provider.g.dart b/lib/shared/providers/workout_session_lookup_provider.g.dart new file mode 100644 index 0000000..cff4e89 --- /dev/null +++ b/lib/shared/providers/workout_session_lookup_provider.g.dart @@ -0,0 +1,94 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'workout_session_lookup_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// Resolves a [WorkoutSession] by id for deep-linked run routes. + +@ProviderFor(workoutSessionById) +final workoutSessionByIdProvider = WorkoutSessionByIdFamily._(); + +/// Resolves a [WorkoutSession] by id for deep-linked run routes. + +final class WorkoutSessionByIdProvider + extends + $FunctionalProvider< + AsyncValue, + WorkoutSession?, + FutureOr + > + with $FutureModifier, $FutureProvider { + /// Resolves a [WorkoutSession] by id for deep-linked run routes. + WorkoutSessionByIdProvider._({ + required WorkoutSessionByIdFamily super.from, + required String super.argument, + }) : super( + retry: null, + name: r'workoutSessionByIdProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$workoutSessionByIdHash(); + + @override + String toString() { + return r'workoutSessionByIdProvider' + '' + '($argument)'; + } + + @$internal + @override + $FutureProviderElement $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + final argument = this.argument as String; + return workoutSessionById(ref, argument); + } + + @override + bool operator ==(Object other) { + return other is WorkoutSessionByIdProvider && other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$workoutSessionByIdHash() => + r'53628739e54a3d1454e14d49a89d292982d2ad32'; + +/// Resolves a [WorkoutSession] by id for deep-linked run routes. + +final class WorkoutSessionByIdFamily extends $Family + with $FunctionalFamilyOverride, String> { + WorkoutSessionByIdFamily._() + : super( + retry: null, + name: r'workoutSessionByIdProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + /// Resolves a [WorkoutSession] by id for deep-linked run routes. + + WorkoutSessionByIdProvider call(String runId) => + WorkoutSessionByIdProvider._(argument: runId, from: this); + + @override + String toString() => r'workoutSessionByIdProvider'; +} diff --git a/lib/shared/utils/health_permission_feedback.dart b/lib/shared/utils/health_permission_feedback.dart new file mode 100644 index 0000000..f6010a6 --- /dev/null +++ b/lib/shared/utils/health_permission_feedback.dart @@ -0,0 +1,14 @@ +import 'package:kynos/shared/utils/health_platform_labels.dart'; + +/// User-facing copy for health permission flows — never expose raw exceptions. +abstract final class HealthPermissionFeedback { + static String connectionFailedMessage() { + return 'Could not connect to health data. Try again, or import runs from Settings.'; + } + + static String permissionDeniedMessage(String platform) { + return '$platform permission not granted. ${HealthPlatformLabels.settingsHint()}'; + } + + static String connectedMessage(String platform) => '$platform connected.'; +} diff --git a/lib/shared/widgets/ai_lifecycle_guard.dart b/lib/shared/widgets/ai_lifecycle_guard.dart index 517850d..257b581 100644 --- a/lib/shared/widgets/ai_lifecycle_guard.dart +++ b/lib/shared/widgets/ai_lifecycle_guard.dart @@ -1,5 +1,9 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:kynos/shared/providers/health_providers.dart'; +import 'package:kynos/shared/providers/measurable_quest_sync_provider.dart'; +import 'package:kynos/shared/providers/ai_reconnect_provider.dart'; import 'package:kynos/shared/providers/ai_repository_providers.dart'; import 'package:logger/logger.dart'; @@ -34,6 +38,8 @@ class _AiLifecycleGuardState extends ConsumerState if (state == AppLifecycleState.paused || state == AppLifecycleState.detached) { _disposeLocalAi(); + } else if (state == AppLifecycleState.resumed) { + _onAppResumed(); } } @@ -49,6 +55,17 @@ class _AiLifecycleGuardState extends ConsumerState } } + void _onAppResumed() { + if (!kIsWeb) { + invalidateHealthProviders(ref as Ref); + ref.invalidate(healthPermissionsProvider); + } + ref.read(aiReconnectStateProvider.notifier).markNeedsReconnect(); + } + @override - Widget build(BuildContext context) => widget.child; + Widget build(BuildContext context) { + ref.watch(measurableQuestSyncProvider); + return widget.child; + } } diff --git a/lib/shared/widgets/kynos_user_bubble.dart b/lib/shared/widgets/kynos_user_bubble.dart index 9973221..6feb6c6 100644 --- a/lib/shared/widgets/kynos_user_bubble.dart +++ b/lib/shared/widgets/kynos_user_bubble.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:kynos/core/theme/theme.dart' hide Radius; /// User chat bubble — solid accent fill. @@ -10,34 +11,45 @@ class KynosUserBubble extends StatelessWidget { final String text; + Future _copyMessage(BuildContext context) async { + await Clipboard.setData(ClipboardData(text: text)); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Message copied')), + ); + } + @override Widget build(BuildContext context) { final kynos = context.kynosTheme; return Align( alignment: Alignment.centerRight, - child: Container( - constraints: BoxConstraints( - maxWidth: MediaQuery.sizeOf(context).width * 0.78, - ), - padding: const EdgeInsets.symmetric( - horizontal: Spacing.md, - vertical: Spacing.sm, - ), - decoration: BoxDecoration( - color: kynos.stand, - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(16), - topRight: Radius.circular(16), - bottomLeft: Radius.circular(16), - bottomRight: Radius.circular(4), + child: GestureDetector( + onLongPress: () => _copyMessage(context), + child: Container( + constraints: BoxConstraints( + maxWidth: MediaQuery.sizeOf(context).width * 0.78, + ), + padding: const EdgeInsets.symmetric( + horizontal: Spacing.md, + vertical: Spacing.sm, + ), + decoration: BoxDecoration( + color: kynos.stand, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + bottomLeft: Radius.circular(16), + bottomRight: Radius.circular(4), + ), + ), + child: SelectableText( + text, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: KynosColors.onAccent, + ), ), - ), - child: Text( - text, - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: KynosColors.onAccent, - ), ), ), ); diff --git a/lib/shared/widgets/metric_tile.dart b/lib/shared/widgets/metric_tile.dart index 5bbdb44..bb1ca8c 100644 --- a/lib/shared/widgets/metric_tile.dart +++ b/lib/shared/widgets/metric_tile.dart @@ -36,8 +36,14 @@ class MetricTile extends StatelessWidget { final theme = Theme.of(context); final accent = accentColor ?? kynos.stand; + final valueLabel = value == null + ? '$label, loading' + : unit != null + ? '$label, $value $unit' + : '$label, $value'; + return Semantics( - label: onTap != null ? '$label metric' : null, + label: valueLabel, button: onTap != null, child: GestureDetector( onTap: onTap, diff --git a/lib/shared/widgets/run_card.dart b/lib/shared/widgets/run_card.dart index 8d4cada..5798d4a 100644 --- a/lib/shared/widgets/run_card.dart +++ b/lib/shared/widgets/run_card.dart @@ -13,6 +13,10 @@ class RunCard extends StatelessWidget { final WorkoutSession run; + void _openRoute(BuildContext context) { + context.push('${Routes.runRoute}/${run.id}', extra: run); + } + @override Widget build(BuildContext context) { final kynos = context.kynosTheme; @@ -21,6 +25,7 @@ class RunCard extends StatelessWidget { final pace = _pacePerKm(run.duration, run.distanceMeters); return KynosCard( + onTap: () => _openRoute(context), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -79,7 +84,7 @@ class RunCard extends StatelessWidget { Align( alignment: Alignment.centerLeft, child: TextButton.icon( - onPressed: () => context.push(Routes.runRoute, extra: run), + onPressed: () => _openRoute(context), icon: const Icon(Icons.map_rounded, size: 16), label: const Text('View Route In App'), style: TextButton.styleFrom( From 42407ba138ae2cbb1fd1814c4ebbf14c8e4f0f9a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 6 Jul 2026 09:12:48 +0000 Subject: [PATCH 2/4] fix(ci): sort imports to satisfy directives_ordering lint Co-authored-by: Youri Bontekoe --- lib/features/character/presentation/pages/character_page.dart | 2 +- lib/features/dashboard/presentation/pages/dashboard_page.dart | 2 +- lib/shared/widgets/ai_lifecycle_guard.dart | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/features/character/presentation/pages/character_page.dart b/lib/features/character/presentation/pages/character_page.dart index ff874c8..4729142 100644 --- a/lib/features/character/presentation/pages/character_page.dart +++ b/lib/features/character/presentation/pages/character_page.dart @@ -17,9 +17,9 @@ import 'package:kynos/features/character/presentation/widgets/xp_bar.dart'; import 'package:kynos/shared/providers/character_providers.dart'; import 'package:kynos/shared/providers/daily_quests_provider.dart'; import 'package:kynos/shared/providers/health_providers.dart'; -import 'package:kynos/shared/utils/health_platform_labels.dart'; import 'package:kynos/shared/providers/nexus_lab_provider.dart'; import 'package:kynos/shared/utils/health_permission_feedback.dart'; +import 'package:kynos/shared/utils/health_platform_labels.dart'; import 'package:kynos/shared/widgets/kynos_inline_error_card.dart'; import 'package:kynos/shared/widgets/kynos_section_header.dart'; import 'package:kynos/shared/widgets/kynos_skeleton.dart'; diff --git a/lib/features/dashboard/presentation/pages/dashboard_page.dart b/lib/features/dashboard/presentation/pages/dashboard_page.dart index 4ba6c9b..1d6cd25 100644 --- a/lib/features/dashboard/presentation/pages/dashboard_page.dart +++ b/lib/features/dashboard/presentation/pages/dashboard_page.dart @@ -23,8 +23,8 @@ import 'package:kynos/features/dashboard/presentation/widgets/trend_carousel.dar import 'package:kynos/features/dashboard/presentation/widgets/week_momentum_card.dart'; import 'package:kynos/features/dashboard/providers/dashboard_summary_provider.dart'; import 'package:kynos/features/dashboard/providers/post_run_debrief_provider.dart'; -import 'package:kynos/features/training/providers/training_insights_provider.dart'; import 'package:kynos/features/dashboard/providers/today_insights_provider.dart'; +import 'package:kynos/features/training/providers/training_insights_provider.dart'; import 'package:kynos/shared/providers/character_providers.dart'; import 'package:kynos/shared/providers/daily_quests_provider.dart'; import 'package:kynos/shared/providers/health_providers.dart'; diff --git a/lib/shared/widgets/ai_lifecycle_guard.dart b/lib/shared/widgets/ai_lifecycle_guard.dart index 257b581..68b9b55 100644 --- a/lib/shared/widgets/ai_lifecycle_guard.dart +++ b/lib/shared/widgets/ai_lifecycle_guard.dart @@ -1,10 +1,10 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:kynos/shared/providers/health_providers.dart'; -import 'package:kynos/shared/providers/measurable_quest_sync_provider.dart'; import 'package:kynos/shared/providers/ai_reconnect_provider.dart'; import 'package:kynos/shared/providers/ai_repository_providers.dart'; +import 'package:kynos/shared/providers/health_providers.dart'; +import 'package:kynos/shared/providers/measurable_quest_sync_provider.dart'; import 'package:logger/logger.dart'; /// Disposes the on-device AI isolate when the app backgrounds to avoid stale LiteRT state. From 4a2d2432c2dd40c64615e8e27c24c4663a842669 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 6 Jul 2026 09:40:46 +0000 Subject: [PATCH 3/4] fix: address PR review findings for QoL improvements Verify and fix still-valid review comments: router extra passthrough, refresh await gaps, coach chat persistence/reconnect guards, health permission probe, workout lookup by id, cross-feature import boundary, accessibility duplicates, and settings picker snackbar timing. Co-authored-by: Youri Bontekoe --- lib/app/router.dart | 4 + .../repositories/health_repository.dart | 8 ++ .../presentation/pages/character_page.dart | 9 +- .../presentation/pages/coach_chat_page.dart | 11 ++- .../widgets/assistant_bubble.dart | 85 +++++++++++-------- .../providers/coach_chat_provider.dart | 8 +- .../providers/coach_chat_provider.g.dart | 2 +- .../providers/model_setup_provider.dart | 13 ++- .../providers/model_setup_provider.g.dart | 2 +- .../coach_chat/utils/chat_history_codec.dart | 33 +++++-- .../presentation/pages/dashboard_page.dart | 3 +- .../presentation/pages/run_route_page.dart | 53 +++++------- .../presentation/nexus_lab_page.dart | 1 + .../pages/openrouter_model_picker_page.dart | 8 +- .../presentation/pages/settings_page.dart | 10 ++- .../presentation/pages/training_page.dart | 1 + .../health/composite_health_repository.dart | 13 +++ .../health/drift_imported_health_store.dart | 8 ++ .../health/health_kit_repository.dart | 47 ++++++++++ .../health/imported_health_repository.dart | 18 ++++ .../health/imported_health_store.dart | 2 + .../health/prefs_imported_health_store.dart | 8 ++ .../providers/ai_repository_providers.dart | 2 + lib/shared/providers/health_providers.dart | 13 ++- lib/shared/providers/health_providers.g.dart | 2 +- .../measurable_quest_sync_provider.dart | 71 +++++++++++----- .../measurable_quest_sync_provider.g.dart | 2 +- .../providers/training_insights_provider.dart | 1 + .../workout_session_lookup_provider.dart | 9 +- .../workout_session_lookup_provider.g.dart | 2 +- lib/shared/widgets/ai_lifecycle_guard.dart | 5 +- lib/shared/widgets/metric_tile.dart | 8 +- .../assign_character_class_usecase_test.dart | 9 ++ ...port_apple_health_export_usecase_test.dart | 4 + .../generate_today_insights_usecase_test.dart | 9 ++ ...nerate_training_insights_usecase_test.dart | 13 +++ .../calibrate_gait_model_usecase_test.dart | 9 ++ .../composite_health_repository_test.dart | 13 +++ 38 files changed, 399 insertions(+), 120 deletions(-) create mode 100644 lib/shared/providers/training_insights_provider.dart diff --git a/lib/app/router.dart b/lib/app/router.dart index b164842..c170fad 100644 --- a/lib/app/router.dart +++ b/lib/app/router.dart @@ -110,6 +110,10 @@ final routerProvider = Provider((ref) { GoRoute( path: ':runId', builder: (context, state) { + final extra = state.extra; + if (extra is WorkoutSession) { + return RunRoutePage(run: extra); + } final runId = state.pathParameters['runId']; if (runId == null || runId.isEmpty) { return const RunRouteMissingPage(); diff --git a/lib/domain/repositories/health_repository.dart b/lib/domain/repositories/health_repository.dart index ba7b4ed..18517b1 100644 --- a/lib/domain/repositories/health_repository.dart +++ b/lib/domain/repositories/health_repository.dart @@ -10,6 +10,14 @@ abstract interface class HealthRepository { /// Requests platform permission to read health data. Future requestPermissions(); + /// Returns whether read permission has been granted for core health types. + Future hasPermissions(); + + /// Returns a single workout by platform or imported id, without a recency window. + Future<({WorkoutSession? workout, Failure? failure})> getWorkoutById({ + required String workoutId, + }); + /// Returns aggregated daily summaries for the past [days] days. Future<({List summaries, Failure? failure})> getSummaries({ required int days, diff --git a/lib/features/character/presentation/pages/character_page.dart b/lib/features/character/presentation/pages/character_page.dart index 4729142..0ea524e 100644 --- a/lib/features/character/presentation/pages/character_page.dart +++ b/lib/features/character/presentation/pages/character_page.dart @@ -34,6 +34,7 @@ class CharacterPage extends ConsumerWidget { await Future.wait([ ref.read(runnerCharacterProvider.future), ref.read(dailyQuestsProvider.future), + ref.read(nexusLabProvider.future), ]); } @@ -185,8 +186,12 @@ class EmptyCharacterState extends ConsumerWidget { ref.read(healthPermissionsProvider).whenOrNull( data: (granted) { final message = granted - ? '$platform connected.' - : '$platform permission not granted. ${HealthPlatformLabels.settingsHint()}'; + ? HealthPermissionFeedback.connectedMessage( + platform, + ) + : HealthPermissionFeedback.permissionDeniedMessage( + platform, + ); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(message)), ); diff --git a/lib/features/coach_chat/presentation/pages/coach_chat_page.dart b/lib/features/coach_chat/presentation/pages/coach_chat_page.dart index a50db4b..a8d8fde 100644 --- a/lib/features/coach_chat/presentation/pages/coach_chat_page.dart +++ b/lib/features/coach_chat/presentation/pages/coach_chat_page.dart @@ -12,6 +12,7 @@ import 'package:kynos/features/coach_chat/presentation/widgets/model_setup_scree import 'package:kynos/features/coach_chat/providers/coach_chat_provider.dart'; import 'package:kynos/features/coach_chat/providers/coach_chat_seed_provider.dart'; import 'package:kynos/features/coach_chat/providers/model_setup_provider.dart'; +import 'package:kynos/features/coach_chat/providers/model_setup_state.dart'; import 'package:kynos/shared/providers/ai_reconnect_provider.dart'; class CoachChatPage extends ConsumerStatefulWidget { @@ -108,7 +109,15 @@ class _CoachChatPageState extends ConsumerState { ref.listen(aiReconnectStateProvider, (previous, next) { if (!next || !mounted) return; ref.read(aiReconnectStateProvider.notifier).clear(); - ref.read(modelSetupProvider.notifier).checkAndInstall(); + + final setup = ref.read(modelSetupProvider); + final setupBusy = setup.isLoading || + setup.value?.phase == ModelSetupPhase.checking || + setup.value?.phase == ModelSetupPhase.downloading; + if (!setupBusy) { + ref.read(modelSetupProvider.notifier).checkAndInstall(); + } + ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Reconnecting on-device coach…'), diff --git a/lib/features/coach_chat/presentation/widgets/assistant_bubble.dart b/lib/features/coach_chat/presentation/widgets/assistant_bubble.dart index 1d92138..0a9cce8 100644 --- a/lib/features/coach_chat/presentation/widgets/assistant_bubble.dart +++ b/lib/features/coach_chat/presentation/widgets/assistant_bubble.dart @@ -31,47 +31,62 @@ class AssistantBubble extends StatelessWidget { @override Widget build(BuildContext context) { + final textStyle = Theme.of(context).textTheme.bodyLarge?.copyWith( + height: 1.5, + color: hasError ? Theme.of(context).colorScheme.error : null, + ); + return Align( alignment: Alignment.centerLeft, child: ConstrainedBox( constraints: BoxConstraints(maxWidth: MediaQuery.sizeOf(context).width * 0.84), - child: GestureDetector( - onLongPress: content.isEmpty ? null : () => _copyMessage(context), - child: GlassCard( - borderRadius: Radius.lg, - padding: const EdgeInsets.symmetric(horizontal: Spacing.md, vertical: Spacing.sm), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (hasError) - Padding( - padding: const EdgeInsets.only(bottom: Spacing.sm), - child: KynosChip.accent( - label: 'On-device error', - color: Theme.of(context).colorScheme.error, - ), + child: GlassCard( + borderRadius: Radius.lg, + padding: const EdgeInsets.symmetric(horizontal: Spacing.md, vertical: Spacing.sm), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (hasError) + Padding( + padding: const EdgeInsets.only(bottom: Spacing.sm), + child: KynosChip.accent( + label: 'On-device error', + color: Theme.of(context).colorScheme.error, ), - isStreaming && content.isEmpty - ? const TypingIndicator() - : SelectableText( - content, - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - height: 1.5, - color: hasError - ? Theme.of(context).colorScheme.error - : null, - ), - ), - if (hasError && onRetry != null) ...[ - const Gap(Spacing.sm), - TextButton.icon( - onPressed: onRetry, - icon: const Icon(Icons.refresh_rounded, size: 18), - label: const Text('Retry'), - ), - ], + ), + isStreaming && content.isEmpty + ? const TypingIndicator() + : SelectableText( + content, + style: textStyle, + contextMenuBuilder: content.isEmpty + ? null + : (menuContext, editableTextState) { + final items = editableTextState.contextMenuButtonItems; + return AdaptiveTextSelectionToolbar.buttonItems( + anchors: editableTextState.contextMenuAnchors, + buttonItems: [ + ...items, + ContextMenuButtonItem( + onPressed: () { + ContextMenuController.removeAny(); + _copyMessage(menuContext); + }, + label: 'Copy message', + ), + ], + ); + }, + ), + if (hasError && onRetry != null) ...[ + const Gap(Spacing.sm), + TextButton.icon( + onPressed: onRetry, + icon: const Icon(Icons.refresh_rounded, size: 18), + label: const Text('Retry'), + ), ], - ), + ], ), ), ), diff --git a/lib/features/coach_chat/providers/coach_chat_provider.dart b/lib/features/coach_chat/providers/coach_chat_provider.dart index 3aa54d1..87076ba 100644 --- a/lib/features/coach_chat/providers/coach_chat_provider.dart +++ b/lib/features/coach_chat/providers/coach_chat_provider.dart @@ -20,6 +20,7 @@ const _coachHealthHistoryDays = 14; class CoachChatNotifier extends _$CoachChatNotifier { final _logger = Logger(); bool _cancelRequested = false; + Future _persistChain = Future.value(); @override Future> build() async { @@ -206,7 +207,12 @@ class CoachChatNotifier extends _$CoachChatNotifier { state = AsyncData(List.from(msgs)..[idx] = updated); } - Future _persist() async { + Future _persist() { + _persistChain = _persistChain.then((_) => _persistNow()); + return _persistChain; + } + + Future _persistNow() async { final messages = state.value; if (messages == null) return; final prefs = ref.read(sharedPreferencesProvider); diff --git a/lib/features/coach_chat/providers/coach_chat_provider.g.dart b/lib/features/coach_chat/providers/coach_chat_provider.g.dart index f46d01a..0b83149 100644 --- a/lib/features/coach_chat/providers/coach_chat_provider.g.dart +++ b/lib/features/coach_chat/providers/coach_chat_provider.g.dart @@ -33,7 +33,7 @@ final class CoachChatNotifierProvider CoachChatNotifier create() => CoachChatNotifier(); } -String _$coachChatNotifierHash() => r'16768e1857de3ae17c2c2cc51e2318e759eeccea'; +String _$coachChatNotifierHash() => r'a9ed86adacc9440ec453e00daf38e6185e2cf434'; abstract class _$CoachChatNotifier extends $AsyncNotifier> { FutureOr> build(); diff --git a/lib/features/coach_chat/providers/model_setup_provider.dart b/lib/features/coach_chat/providers/model_setup_provider.dart index b516f61..41ff627 100644 --- a/lib/features/coach_chat/providers/model_setup_provider.dart +++ b/lib/features/coach_chat/providers/model_setup_provider.dart @@ -1,6 +1,5 @@ import 'package:kynos/domain/repositories/ai_model_repository.dart'; import 'package:kynos/features/coach_chat/providers/model_setup_state.dart'; -import 'package:kynos/infrastructure/ai/gemma/gemma_runtime.dart'; import 'package:kynos/shared/providers/ai_repository_providers.dart'; import 'package:kynos/shared/providers/huggingface_token_provider.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -18,11 +17,23 @@ class MissingHuggingFaceTokenException implements Exception { @Riverpod(keepAlive: true) class ModelSetupNotifier extends _$ModelSetupNotifier { + bool _installInProgress = false; + @override AsyncValue build() => const AsyncData(ModelSetupState(phase: ModelSetupPhase.checking)); Future checkAndInstall() async { + if (_installInProgress) return; + _installInProgress = true; + try { + await _checkAndInstallImpl(); + } finally { + _installInProgress = false; + } + } + + Future _checkAndInstallImpl() async { state = const AsyncLoading(); try { final hfToken = await ref.read(huggingFaceTokenManagerProvider.future); diff --git a/lib/features/coach_chat/providers/model_setup_provider.g.dart b/lib/features/coach_chat/providers/model_setup_provider.g.dart index 6ed1b2c..a732b99 100644 --- a/lib/features/coach_chat/providers/model_setup_provider.g.dart +++ b/lib/features/coach_chat/providers/model_setup_provider.g.dart @@ -42,7 +42,7 @@ final class ModelSetupNotifierProvider } String _$modelSetupNotifierHash() => - r'af66026e75fb1686fe26f3d7d344b482f76767a3'; + r'16bfc4cb80ea3d3a84977ca00fee347fbd55664e'; abstract class _$ModelSetupNotifier extends $Notifier> { diff --git a/lib/features/coach_chat/utils/chat_history_codec.dart b/lib/features/coach_chat/utils/chat_history_codec.dart index c327414..86c8be7 100644 --- a/lib/features/coach_chat/utils/chat_history_codec.dart +++ b/lib/features/coach_chat/utils/chat_history_codec.dart @@ -1,20 +1,43 @@ import 'dart:convert'; import 'package:kynos/domain/entities/chat_message.dart'; +import 'package:logger/logger.dart'; /// Serialises coach chat history to SharedPreferences JSON. abstract final class ChatHistoryCodec { static const prefsKey = 'coach_chat_history_v1'; + static final _logger = Logger(); static List decode(String? raw) { if (raw == null || raw.isEmpty) return const []; try { final list = jsonDecode(raw) as List; - return list - .map((item) => _fromMap(item as Map)) - .where((m) => m.content.isNotEmpty && !m.isStreaming) - .toList(); - } on Object { + final messages = []; + for (final item in list) { + try { + if (item is! Map) { + _logger.w('Skipping chat history entry: expected map, got ${item.runtimeType}'); + continue; + } + final message = _fromMap(item); + if (message.content.isNotEmpty && !message.isStreaming) { + messages.add(message); + } + } on Object catch (error, stackTrace) { + _logger.w( + 'Skipping malformed chat history entry', + error: error, + stackTrace: stackTrace, + ); + } + } + return messages; + } on Object catch (error, stackTrace) { + _logger.w( + 'Could not decode chat history payload', + error: error, + stackTrace: stackTrace, + ); return const []; } } diff --git a/lib/features/dashboard/presentation/pages/dashboard_page.dart b/lib/features/dashboard/presentation/pages/dashboard_page.dart index 1d6cd25..e39ac0d 100644 --- a/lib/features/dashboard/presentation/pages/dashboard_page.dart +++ b/lib/features/dashboard/presentation/pages/dashboard_page.dart @@ -24,11 +24,11 @@ import 'package:kynos/features/dashboard/presentation/widgets/week_momentum_card import 'package:kynos/features/dashboard/providers/dashboard_summary_provider.dart'; import 'package:kynos/features/dashboard/providers/post_run_debrief_provider.dart'; import 'package:kynos/features/dashboard/providers/today_insights_provider.dart'; -import 'package:kynos/features/training/providers/training_insights_provider.dart'; import 'package:kynos/shared/providers/character_providers.dart'; import 'package:kynos/shared/providers/daily_quests_provider.dart'; import 'package:kynos/shared/providers/health_providers.dart'; import 'package:kynos/shared/providers/nexus_lab_provider.dart'; +import 'package:kynos/shared/providers/training_insights_provider.dart'; import 'package:kynos/shared/widgets/kynos_card.dart'; import 'package:kynos/shared/widgets/kynos_chip.dart'; import 'package:kynos/shared/widgets/kynos_privacy_footer.dart'; @@ -164,6 +164,7 @@ class _DashboardPageState extends ConsumerState { action: SnackBarAction( label: 'Read debrief', onPressed: () { + if (!mounted || !context.mounted) return; ref.read(coachChatSeedProvider.notifier).setSeed( 'Here is my post-run debrief: ${data.debrief.highlight}. ' 'What should I focus on in recovery?', diff --git a/lib/features/dashboard/presentation/pages/run_route_page.dart b/lib/features/dashboard/presentation/pages/run_route_page.dart index c1bcc33..eb263b0 100644 --- a/lib/features/dashboard/presentation/pages/run_route_page.dart +++ b/lib/features/dashboard/presentation/pages/run_route_page.dart @@ -34,11 +34,7 @@ class RunRoutePage extends ConsumerWidget { return lookup.when( loading: () => Scaffold( backgroundColor: kynos.background, - appBar: AppBar( - title: const Text('Run Route'), - backgroundColor: kynos.background, - surfaceTintColor: Colors.transparent, - ), + appBar: _runRouteAppBar(context), body: const Padding( padding: EdgeInsets.all(tokens.Spacing.md), child: KynosSkeleton.tile(height: 300), @@ -46,11 +42,7 @@ class RunRoutePage extends ConsumerWidget { ), error: (_, _) => Scaffold( backgroundColor: kynos.background, - appBar: AppBar( - title: const Text('Run Route'), - backgroundColor: kynos.background, - surfaceTintColor: Colors.transparent, - ), + appBar: _runRouteAppBar(context), body: Padding( padding: const EdgeInsets.all(tokens.Spacing.md), child: KynosInlineErrorCard( @@ -63,11 +55,7 @@ class RunRoutePage extends ConsumerWidget { if (session == null) { return Scaffold( backgroundColor: kynos.background, - appBar: AppBar( - title: const Text('Run Route'), - backgroundColor: kynos.background, - surfaceTintColor: Colors.transparent, - ), + appBar: _runRouteAppBar(context), body: const Center(child: Text('Run not found on this device.')), ); } @@ -77,6 +65,25 @@ class RunRoutePage extends ConsumerWidget { } } +AppBar _runRouteAppBar(BuildContext context) { + final kynos = context.kynosTheme; + return AppBar( + title: const Text('Run Route'), + backgroundColor: kynos.background, + surfaceTintColor: Colors.transparent, + leading: IconButton( + icon: const Icon(Icons.arrow_back_rounded), + onPressed: () { + if (context.canPop()) { + context.pop(); + } else { + context.go(Routes.dashboard); + } + }, + ), + ); +} + class _RunRouteScaffold extends ConsumerWidget { const _RunRouteScaffold({required this.run}); @@ -89,21 +96,7 @@ class _RunRouteScaffold extends ConsumerWidget { return Scaffold( backgroundColor: kynos.background, - appBar: AppBar( - title: const Text('Run Route'), - backgroundColor: kynos.background, - surfaceTintColor: Colors.transparent, - leading: IconButton( - icon: const Icon(Icons.arrow_back_rounded), - onPressed: () { - if (context.canPop()) { - context.pop(); - } else { - context.go(Routes.dashboard); - } - }, - ), - ), + appBar: _runRouteAppBar(context), body: routeAsync.when( data: (points) => _RouteContent(run: run, points: points), loading: () => const Padding( diff --git a/lib/features/nexus_lab/presentation/nexus_lab_page.dart b/lib/features/nexus_lab/presentation/nexus_lab_page.dart index 6747d5c..320b66e 100644 --- a/lib/features/nexus_lab/presentation/nexus_lab_page.dart +++ b/lib/features/nexus_lab/presentation/nexus_lab_page.dart @@ -24,6 +24,7 @@ class NexusLabPage extends ConsumerWidget { title: const Text('KYNOS Lab'), leading: IconButton( icon: const Icon(Icons.close_rounded), + tooltip: 'Close KYNOS Lab', onPressed: () => popOrGo(context, Routes.training), ), ), diff --git a/lib/features/settings/presentation/pages/openrouter_model_picker_page.dart b/lib/features/settings/presentation/pages/openrouter_model_picker_page.dart index 28daa32..5d26277 100644 --- a/lib/features/settings/presentation/pages/openrouter_model_picker_page.dart +++ b/lib/features/settings/presentation/pages/openrouter_model_picker_page.dart @@ -93,7 +93,8 @@ class OpenRouterModelPickerPage extends ConsumerWidget { child: Padding( padding: const EdgeInsets.all(tokens.Spacing.md), child: KynosInlineErrorCard( - message: result.error!, + message: + 'Could not load models. Check your API key and try again.', onRetry: () => ref.invalidate(openRouterCatalogDataProvider), ), @@ -201,10 +202,7 @@ class OpenRouterModelPickerPage extends ConsumerWidget { Navigator.pop(ctx); } if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Selected ${model.name}')), - ); - context.pop(); + context.pop(model.name); } }, child: const Text('Select model'), diff --git a/lib/features/settings/presentation/pages/settings_page.dart b/lib/features/settings/presentation/pages/settings_page.dart index 3ac4c5f..6e84a1b 100644 --- a/lib/features/settings/presentation/pages/settings_page.dart +++ b/lib/features/settings/presentation/pages/settings_page.dart @@ -304,7 +304,15 @@ class _SettingsPageState extends ConsumerState { : 'Choose a model', ), trailing: Icon(Icons.chevron_right, color: kynos.tertiaryLabel), - onTap: () => context.push(Routes.openRouterModels), + onTap: () async { + final selected = + await context.push(Routes.openRouterModels); + if (selected != null && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Selected $selected')), + ); + } + }, contentPadding: EdgeInsets.zero, ), const Gap(tokens.Spacing.xs), diff --git a/lib/features/training/presentation/pages/training_page.dart b/lib/features/training/presentation/pages/training_page.dart index 496d465..1165522 100644 --- a/lib/features/training/presentation/pages/training_page.dart +++ b/lib/features/training/presentation/pages/training_page.dart @@ -35,6 +35,7 @@ class TrainingPage extends ConsumerWidget { ref.read(healthHistoryProvider(days: 30).future), ref.read(recentRunsProvider(days: 365, limit: 60).future), ref.read(trainingInsightsStateProvider.future), + ref.read(nexusLabProvider.future), ]); } diff --git a/lib/infrastructure/health/composite_health_repository.dart b/lib/infrastructure/health/composite_health_repository.dart index 39c3754..e0be955 100644 --- a/lib/infrastructure/health/composite_health_repository.dart +++ b/lib/infrastructure/health/composite_health_repository.dart @@ -22,6 +22,19 @@ class CompositeHealthRepository implements HealthRepository { @override Future requestPermissions() => _healthKit.requestPermissions(); + @override + Future hasPermissions() => _healthKit.hasPermissions(); + + @override + Future<({WorkoutSession? workout, Failure? failure})> getWorkoutById({ + required String workoutId, + }) async { + if (ImportedWorkoutIds.isImported(workoutId)) { + return _imported.getWorkoutById(workoutId: workoutId); + } + return _healthKit.getWorkoutById(workoutId: workoutId); + } + @override Future<({List summaries, Failure? failure})> getSummaries({ required int days, diff --git a/lib/infrastructure/health/drift_imported_health_store.dart b/lib/infrastructure/health/drift_imported_health_store.dart index 5b39db2..7dd3bd6 100644 --- a/lib/infrastructure/health/drift_imported_health_store.dart +++ b/lib/infrastructure/health/drift_imported_health_store.dart @@ -36,6 +36,14 @@ class DriftImportedHealthStore implements ImportedHealthStore { return rows.map(_toWorkoutSession).toList(); } + @override + Future getWorkoutById(String workoutId) async { + final row = await (_db.select(_db.importedWorkouts) + ..where((r) => r.id.equals(workoutId))) + .getSingleOrNull(); + return row == null ? null : _toWorkoutSession(row); + } + @override Future> getRoutePoints(String workoutId) async { final rows = await (_db.select(_db.importedRoutePoints) diff --git a/lib/infrastructure/health/health_kit_repository.dart b/lib/infrastructure/health/health_kit_repository.dart index cd0868b..e8b8ef3 100644 --- a/lib/infrastructure/health/health_kit_repository.dart +++ b/lib/infrastructure/health/health_kit_repository.dart @@ -104,6 +104,53 @@ class HealthKitRepository implements HealthRepository { return anyGroupGranted; } + @override + Future hasPermissions() async { + await _ensureConfigured(); + final types = _types; + if (types.isEmpty) return false; + + final permissions = types + .map((_) => HealthDataAccess.READ) + .toList(growable: false); + + try { + return await _health.hasPermissions(types, permissions: permissions) ?? + false; + } catch (e) { + _logger.w('HealthKit permission check failed: $e'); + return false; + } + } + + @override + Future<({WorkoutSession? workout, Failure? failure})> getWorkoutById({ + required String workoutId, + }) async { + try { + await _ensureConfigured(); + final now = DateTime.now(); + final startTime = now.subtract(const Duration(days: 365 * 10)); + final points = await _health.getHealthDataFromTypes( + types: const [HealthDataType.WORKOUT], + startTime: startTime, + endTime: now, + ); + + for (final point in points) { + if (point.uuid != workoutId || !isRunningWorkout(point)) continue; + return (workout: toWorkoutSession(point), failure: null); + } + + return (workout: null, failure: null); + } catch (e) { + return ( + workout: null, + failure: HealthDataFailure(e.toString()), + ); + } + } + Future _requestReadAccess(List types) async { if (types.isEmpty) return false; diff --git a/lib/infrastructure/health/imported_health_repository.dart b/lib/infrastructure/health/imported_health_repository.dart index e53a8fb..6dc2601 100644 --- a/lib/infrastructure/health/imported_health_repository.dart +++ b/lib/infrastructure/health/imported_health_repository.dart @@ -17,6 +17,24 @@ class ImportedHealthRepository implements HealthRepository { @override Future requestPermissions() async => false; + @override + Future hasPermissions() async => false; + + @override + Future<({WorkoutSession? workout, Failure? failure})> getWorkoutById({ + required String workoutId, + }) async { + try { + final workout = await _store.getWorkoutById(workoutId); + return (workout: workout, failure: null); + } catch (e) { + return ( + workout: null, + failure: StorageFailure(e.toString()), + ); + } + } + @override Future<({List summaries, Failure? failure})> getSummaries({ required int days, diff --git a/lib/infrastructure/health/imported_health_store.dart b/lib/infrastructure/health/imported_health_store.dart index 197d6cb..0238058 100644 --- a/lib/infrastructure/health/imported_health_store.dart +++ b/lib/infrastructure/health/imported_health_store.dart @@ -11,6 +11,8 @@ abstract interface class ImportedHealthStore { int? limit, }); + Future getWorkoutById(String workoutId); + Future> getRoutePoints(String workoutId); Future saveWorkout({ diff --git a/lib/infrastructure/health/prefs_imported_health_store.dart b/lib/infrastructure/health/prefs_imported_health_store.dart index 458897e..99944e7 100644 --- a/lib/infrastructure/health/prefs_imported_health_store.dart +++ b/lib/infrastructure/health/prefs_imported_health_store.dart @@ -48,6 +48,14 @@ class PrefsImportedHealthStore implements ImportedHealthStore { return routePointsFromJson(points); } + @override + Future getWorkoutById(String workoutId) async { + for (final workout in _readWorkouts()) { + if (workout.id == workoutId) return workout; + } + return null; + } + @override Future saveWorkout({ required WorkoutSession workout, diff --git a/lib/shared/providers/ai_repository_providers.dart b/lib/shared/providers/ai_repository_providers.dart index 48e0d2d..82d63d6 100644 --- a/lib/shared/providers/ai_repository_providers.dart +++ b/lib/shared/providers/ai_repository_providers.dart @@ -11,6 +11,8 @@ import 'package:kynos/shared/providers/openrouter_api_key_provider.dart'; export 'package:kynos/infrastructure/ai/ai_infrastructure_providers.dart' show aiModelRepositoryProvider; +export 'package:kynos/infrastructure/ai/gemma/gemma_runtime.dart' + show GemmaRuntime; final openRouterModelsRepositoryProvider = Provider( (ref) => OpenRouterModelsRepositoryImpl(), diff --git a/lib/shared/providers/health_providers.dart b/lib/shared/providers/health_providers.dart index dbb86a3..f20cd45 100644 --- a/lib/shared/providers/health_providers.dart +++ b/lib/shared/providers/health_providers.dart @@ -4,6 +4,7 @@ import 'package:kynos/domain/entities/workout_route_point.dart'; import 'package:kynos/domain/entities/workout_session.dart'; import 'package:kynos/domain/repositories/health_repository.dart'; import 'package:kynos/infrastructure/health/health_infrastructure_providers.dart'; +import 'package:logger/logger.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'health_providers.g.dart'; @@ -84,14 +85,20 @@ void invalidateHealthProviders(Ref ref) { /// Handles the HealthKit permission request triggered from the UI. @Riverpod(keepAlive: true) class HealthPermissionsNotifier extends _$HealthPermissionsNotifier { + final _logger = Logger(); + @override Future build() async { if (kIsWeb) return false; try { final repo = ref.read(healthRepositoryProvider); - final result = await repo.getToday(); - return result.failure == null; - } on Object { + return await repo.hasPermissions(); + } on Object catch (error, stackTrace) { + _logger.w( + 'Unexpected error checking health permissions', + error: error, + stackTrace: stackTrace, + ); return false; } } diff --git a/lib/shared/providers/health_providers.g.dart b/lib/shared/providers/health_providers.g.dart index e8d30ef..64a065c 100644 --- a/lib/shared/providers/health_providers.g.dart +++ b/lib/shared/providers/health_providers.g.dart @@ -396,7 +396,7 @@ final class HealthPermissionsNotifierProvider } String _$healthPermissionsNotifierHash() => - r'63fc18f40bfcbbd36c874deb0b19ba14eb5ae842'; + r'9032f26135d27f6225bc8d8d0d91935411293e7e'; /// Handles the HealthKit permission request triggered from the UI. diff --git a/lib/shared/providers/measurable_quest_sync_provider.dart b/lib/shared/providers/measurable_quest_sync_provider.dart index 604ae7a..1fc5c94 100644 --- a/lib/shared/providers/measurable_quest_sync_provider.dart +++ b/lib/shared/providers/measurable_quest_sync_provider.dart @@ -5,6 +5,7 @@ import 'package:kynos/features/character/providers/quest_provider.dart'; import 'package:kynos/shared/providers/daily_quests_provider.dart'; import 'package:kynos/shared/providers/gamification_providers.dart'; import 'package:kynos/shared/providers/health_providers.dart'; +import 'package:logger/logger.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'measurable_quest_sync_provider.g.dart'; @@ -15,38 +16,64 @@ bool _isSameDay(DateTime a, DateTime b) => /// Keeps measurable daily quests in sync with health data app-wide. @Riverpod(keepAlive: true) class MeasurableQuestSync extends _$MeasurableQuestSync { + final _logger = Logger(); + Future? _syncInFlight; + @override void build() { ref.listen(healthSummaryProvider, (_, _) { - unawaited(_syncMeasurableQuests()); + unawaited(_enqueueSync()); }); ref.listen(recentRunsProvider(days: 1, limit: 20), (_, _) { - unawaited(_syncMeasurableQuests()); + unawaited(_enqueueSync()); + }); + unawaited(_enqueueSync()); + } + + Future _enqueueSync() { + final inFlight = _syncInFlight; + if (inFlight != null) return inFlight; + + late final Future sync; + sync = _syncMeasurableQuests().whenComplete(() { + if (identical(_syncInFlight, sync)) { + _syncInFlight = null; + } }); - unawaited(_syncMeasurableQuests()); + _syncInFlight = sync; + return sync; } Future _syncMeasurableQuests() async { - final quests = await ref.read(dailyQuestsProvider.future); - if (quests.isEmpty) return; - - final evaluator = ref.read(evaluateQuestProgressUseCaseProvider); - final summary = ref.read(healthSummaryProvider).value; - final runs = await ref.read(recentRunsProvider(days: 1, limit: 20).future); - final today = DateTime.now(); - final todayRuns = runs.where((r) => _isSameDay(r.start, today)).toList(); - - for (final quest in quests) { - if (quest.status != QuestStatus.active) continue; - if (quest.measurableObjective == null) continue; - if (!evaluator.isComplete( - quest: quest, - summary: summary, - todayRuns: todayRuns, - )) { - continue; + try { + final quests = await ref.read(dailyQuestsProvider.future); + if (quests.isEmpty) return; + + final evaluator = ref.read(evaluateQuestProgressUseCaseProvider); + final summary = ref.read(healthSummaryProvider).value; + final runs = + await ref.read(recentRunsProvider(days: 1, limit: 20).future); + final today = DateTime.now(); + final todayRuns = runs.where((r) => _isSameDay(r.start, today)).toList(); + + for (final quest in quests) { + if (quest.status != QuestStatus.active) continue; + if (quest.measurableObjective == null) continue; + if (!evaluator.isComplete( + quest: quest, + summary: summary, + todayRuns: todayRuns, + )) { + continue; + } + await ref.read(questProvider.notifier).completeQuest(quest.id); } - await ref.read(questProvider.notifier).completeQuest(quest.id); + } on Object catch (error, stackTrace) { + _logger.w( + 'Measurable quest sync failed', + error: error, + stackTrace: stackTrace, + ); } } } diff --git a/lib/shared/providers/measurable_quest_sync_provider.g.dart b/lib/shared/providers/measurable_quest_sync_provider.g.dart index d657223..494f847 100644 --- a/lib/shared/providers/measurable_quest_sync_provider.g.dart +++ b/lib/shared/providers/measurable_quest_sync_provider.g.dart @@ -45,7 +45,7 @@ final class MeasurableQuestSyncProvider } String _$measurableQuestSyncHash() => - r'eccfc6fd2a08fd1d39cefacd11157ea8f4ce5be0'; + r'be5c80f827a1b46a3ed2de4fe942178cca33c6a5'; /// Keeps measurable daily quests in sync with health data app-wide. diff --git a/lib/shared/providers/training_insights_provider.dart b/lib/shared/providers/training_insights_provider.dart new file mode 100644 index 0000000..00bd4e4 --- /dev/null +++ b/lib/shared/providers/training_insights_provider.dart @@ -0,0 +1 @@ +export 'package:kynos/features/training/providers/training_insights_provider.dart'; diff --git a/lib/shared/providers/workout_session_lookup_provider.dart b/lib/shared/providers/workout_session_lookup_provider.dart index a734999..e018c95 100644 --- a/lib/shared/providers/workout_session_lookup_provider.dart +++ b/lib/shared/providers/workout_session_lookup_provider.dart @@ -7,9 +7,10 @@ part 'workout_session_lookup_provider.g.dart'; /// Resolves a [WorkoutSession] by id for deep-linked run routes. @riverpod Future workoutSessionById(Ref ref, String runId) async { - final runs = await ref.watch(recentRunsProvider(days: 365, limit: 200).future); - for (final run in runs) { - if (run.id == runId) return run; + final repository = ref.watch(healthRepositoryProvider); + final result = await repository.getWorkoutById(workoutId: runId); + if (result.failure != null) { + throw result.failure!; } - return null; + return result.workout; } diff --git a/lib/shared/providers/workout_session_lookup_provider.g.dart b/lib/shared/providers/workout_session_lookup_provider.g.dart index cff4e89..824c408 100644 --- a/lib/shared/providers/workout_session_lookup_provider.g.dart +++ b/lib/shared/providers/workout_session_lookup_provider.g.dart @@ -69,7 +69,7 @@ final class WorkoutSessionByIdProvider } String _$workoutSessionByIdHash() => - r'53628739e54a3d1454e14d49a89d292982d2ad32'; + r'595d1ffa51393fadd72d6845e9b1777a25f3f230'; /// Resolves a [WorkoutSession] by id for deep-linked run routes. diff --git a/lib/shared/widgets/ai_lifecycle_guard.dart b/lib/shared/widgets/ai_lifecycle_guard.dart index 68b9b55..3b368a4 100644 --- a/lib/shared/widgets/ai_lifecycle_guard.dart +++ b/lib/shared/widgets/ai_lifecycle_guard.dart @@ -57,7 +57,10 @@ class _AiLifecycleGuardState extends ConsumerState void _onAppResumed() { if (!kIsWeb) { - invalidateHealthProviders(ref as Ref); + ref.invalidate(healthSummaryProvider); + ref.invalidate(healthHistoryProvider); + ref.invalidate(recentRunsProvider); + ref.invalidate(importedWorkoutCountProvider); ref.invalidate(healthPermissionsProvider); } ref.read(aiReconnectStateProvider.notifier).markNeedsReconnect(); diff --git a/lib/shared/widgets/metric_tile.dart b/lib/shared/widgets/metric_tile.dart index bb1ca8c..43bb887 100644 --- a/lib/shared/widgets/metric_tile.dart +++ b/lib/shared/widgets/metric_tile.dart @@ -45,9 +45,10 @@ class MetricTile extends StatelessWidget { return Semantics( label: valueLabel, button: onTap != null, - child: GestureDetector( - onTap: onTap, - child: Container( + child: ExcludeSemantics( + child: GestureDetector( + onTap: onTap, + child: Container( padding: const EdgeInsets.all(Spacing.md), decoration: flat ? null @@ -123,6 +124,7 @@ class MetricTile extends StatelessWidget { ], ), ), + ), ), ); } diff --git a/test/domain/usecases/gamification/assign_character_class_usecase_test.dart b/test/domain/usecases/gamification/assign_character_class_usecase_test.dart index 62f1ca1..b6aa386 100644 --- a/test/domain/usecases/gamification/assign_character_class_usecase_test.dart +++ b/test/domain/usecases/gamification/assign_character_class_usecase_test.dart @@ -73,4 +73,13 @@ class _FakeHealthRepository implements HealthRepository { @override Future requestPermissions() async => true; + + @override + Future hasPermissions() async => true; + + @override + Future<({WorkoutSession? workout, Failure? failure})> getWorkoutById({ + required String workoutId, + }) async => + (workout: null, failure: null); } diff --git a/test/domain/usecases/health/import_apple_health_export_usecase_test.dart b/test/domain/usecases/health/import_apple_health_export_usecase_test.dart index 0dc0177..730e798 100644 --- a/test/domain/usecases/health/import_apple_health_export_usecase_test.dart +++ b/test/domain/usecases/health/import_apple_health_export_usecase_test.dart @@ -151,4 +151,8 @@ class _FailingImportedHealthStore implements ImportedHealthStore { @override Future workoutCount() => throw UnimplementedError(); + + @override + Future getWorkoutById(String workoutId) => + throw UnimplementedError(); } diff --git a/test/domain/usecases/insights/generate_today_insights_usecase_test.dart b/test/domain/usecases/insights/generate_today_insights_usecase_test.dart index e2bb281..1bb0a42 100644 --- a/test/domain/usecases/insights/generate_today_insights_usecase_test.dart +++ b/test/domain/usecases/insights/generate_today_insights_usecase_test.dart @@ -92,6 +92,15 @@ class _FakeHealthRepository implements HealthRepository { @override Future requestPermissions() async => true; + @override + Future hasPermissions() async => true; + + @override + Future<({WorkoutSession? workout, Failure? failure})> getWorkoutById({ + required String workoutId, + }) async => + (workout: null, failure: null); + @override Future<({Failure? failure, List runs})> getRecentRuns({ required int days, diff --git a/test/domain/usecases/insights/generate_training_insights_usecase_test.dart b/test/domain/usecases/insights/generate_training_insights_usecase_test.dart index fde1566..740aeea 100644 --- a/test/domain/usecases/insights/generate_training_insights_usecase_test.dart +++ b/test/domain/usecases/insights/generate_training_insights_usecase_test.dart @@ -120,6 +120,19 @@ class _FakeHealthRepository implements HealthRepository { @override Future requestPermissions() async => true; + @override + Future hasPermissions() async => true; + + @override + Future<({WorkoutSession? workout, Failure? failure})> getWorkoutById({ + required String workoutId, + }) async { + for (final run in _runs) { + if (run.id == workoutId) return (workout: run, failure: null); + } + return (workout: null, failure: null); + } + @override Future<({Failure? failure, List runs})> getRecentRuns({ required int days, diff --git a/test/domain/usecases/nexus_lab/calibrate_gait_model_usecase_test.dart b/test/domain/usecases/nexus_lab/calibrate_gait_model_usecase_test.dart index 0df16e9..991c8af 100644 --- a/test/domain/usecases/nexus_lab/calibrate_gait_model_usecase_test.dart +++ b/test/domain/usecases/nexus_lab/calibrate_gait_model_usecase_test.dart @@ -109,6 +109,15 @@ class _FakeHealthRepository implements HealthRepository { @override Future requestPermissions() async => true; + @override + Future hasPermissions() async => true; + + @override + Future<({WorkoutSession? workout, Failure? failure})> getWorkoutById({ + required String workoutId, + }) async => + (workout: null, failure: null); + @override Future<({Failure? failure, List runs})> getRecentRuns({ required int days, diff --git a/test/infrastructure/health/composite_health_repository_test.dart b/test/infrastructure/health/composite_health_repository_test.dart index cf9bf8b..d29c627 100644 --- a/test/infrastructure/health/composite_health_repository_test.dart +++ b/test/infrastructure/health/composite_health_repository_test.dart @@ -123,6 +123,19 @@ class _FakeHealthRepository implements HealthRepository { @override Future requestPermissions() async => true; + @override + Future hasPermissions() async => true; + + @override + Future<({WorkoutSession? workout, Failure? failure})> getWorkoutById({ + required String workoutId, + }) async { + for (final run in _runs) { + if (run.id == workoutId) return (workout: run, failure: null); + } + return (workout: null, failure: null); + } + @override Future<({List summaries, Failure? failure})> getSummaries({ required int days, From 84e9dc2eaec53bd8dd554b367aa7aa6548a14eed Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 6 Jul 2026 09:44:39 +0000 Subject: [PATCH 4/4] fix(ci): sync measurable quest sync provider generated hash Co-authored-by: Youri Bontekoe --- lib/shared/providers/measurable_quest_sync_provider.g.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/shared/providers/measurable_quest_sync_provider.g.dart b/lib/shared/providers/measurable_quest_sync_provider.g.dart index 494f847..23cced6 100644 --- a/lib/shared/providers/measurable_quest_sync_provider.g.dart +++ b/lib/shared/providers/measurable_quest_sync_provider.g.dart @@ -45,7 +45,7 @@ final class MeasurableQuestSyncProvider } String _$measurableQuestSyncHash() => - r'be5c80f827a1b46a3ed2de4fe942178cca33c6a5'; + r'26839fc34592a3663175715a22492962ad34c217'; /// Keeps measurable daily quests in sync with health data app-wide.