Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions lib/app/router.dart
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +103,25 @@ final routerProvider = Provider<GoRouter>((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 extra = state.extra;
if (extra is WorkoutSession) {
return RunRoutePage(run: extra);
}
final runId = state.pathParameters['runId'];
if (runId == null || runId.isEmpty) {
return const RunRouteMissingPage();
}
return RunRoutePage(runId: runId);
},
),
],
Comment thread
coderabbitai[bot] marked this conversation as resolved.
),
GoRoute(
path: Routes.coachChat,
Expand Down
8 changes: 8 additions & 0 deletions lib/domain/repositories/health_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ abstract interface class HealthRepository {
/// Requests platform permission to read health data.
Future<bool> requestPermissions();

/// Returns whether read permission has been granted for core health types.
Future<bool> 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<HealthSummary> summaries, Failure? failure})> getSummaries({
required int days,
Expand Down
42 changes: 31 additions & 11 deletions lib/features/character/presentation/pages/character_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,20 +17,36 @@ 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/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';

class CharacterPage extends ConsumerWidget {
const CharacterPage({super.key});

Future<void> _refreshCharacter(WidgetRef ref) async {
ref.invalidate(runnerCharacterProvider);
ref.invalidate(dailyQuestsProvider);
ref.invalidate(nexusLabProvider);
await Future.wait([
ref.read(runnerCharacterProvider.future),
ref.read(dailyQuestsProvider.future),
ref.read(nexusLabProvider.future),
]);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

@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(),
),
Expand Down Expand Up @@ -67,12 +83,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),
),
),
),
Expand Down Expand Up @@ -120,6 +135,7 @@ class CharacterPage extends ConsumerWidget {
},
),
],
),
);
}
}
Expand Down Expand Up @@ -170,17 +186,21 @@ 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)),
);
},
error: (error, _) {
error: (_, _) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Health connection failed: $error',
HealthPermissionFeedback.connectionFailedMessage(),
),
),
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
38 changes: 0 additions & 38 deletions lib/features/character/providers/adventure_provider.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -36,11 +31,6 @@ class AdventureSessionNotifier extends _$AdventureSessionNotifier {

@override
Future<AdventureViewState?> build() async {
ref.listen(
healthSummaryProvider,
(_, _) => unawaited(_syncMeasurableQuests()),
);

final character = await ref.read(runnerCharacterProvider.future);
if (character == null) return null;

Expand Down Expand Up @@ -74,8 +64,6 @@ class AdventureSessionNotifier extends _$AdventureSessionNotifier {
}
}

unawaited(_syncMeasurableQuests());

return _viewState(session);
}

Expand Down Expand Up @@ -140,7 +128,6 @@ class AdventureSessionNotifier extends _$AdventureSessionNotifier {
}

await _persist(session);
unawaited(_syncMeasurableQuests());
} on Failure catch (failure, stackTrace) {
state = AsyncError(failure, stackTrace);
} finally {
Expand Down Expand Up @@ -212,7 +199,6 @@ class AdventureSessionNotifier extends _$AdventureSessionNotifier {
}

await _persist(session);
unawaited(_syncMeasurableQuests());
} on Failure catch (failure, stackTrace) {
state = AsyncError(failure, stackTrace);
} finally {
Expand Down Expand Up @@ -262,28 +248,4 @@ class AdventureSessionNotifier extends _$AdventureSessionNotifier {
if (saveFailure != null) return;
ref.invalidate(runnerCharacterProvider);
}

Future<void> _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);
}
}
}
2 changes: 1 addition & 1 deletion lib/features/character/providers/adventure_provider.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

82 changes: 69 additions & 13 deletions lib/features/coach_chat/presentation/pages/coach_chat_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@ 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';
import 'package:kynos/features/coach_chat/presentation/widgets/model_setup_screen.dart';
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 {
const CoachChatPage({super.key});
Expand Down Expand Up @@ -69,24 +72,80 @@ class _CoachChatPageState extends ConsumerState<CoachChatPage> {
ref.read(coachChatProvider.notifier).sendMessage(text);
}

Future<void> _confirmClearConversation() async {
final confirmed = await showDialog<bool>(
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();

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…'),
duration: Duration(seconds: 3),
),
);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

final setupState = ref.watch(modelSetupProvider);

return setupState.when(
loading: () => ModelSetupScreen.checking(),
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();
},
Expand All @@ -106,10 +165,7 @@ class _CoachChatPageState extends ConsumerState<CoachChatPage> {
backgroundColor: context.kynosTheme.background,
body: Column(
children: [
CoachChatAppBar(
onClear: () =>
ref.read(coachChatProvider.notifier).clearConversation(),
),
CoachChatAppBar(onClear: _confirmClearConversation),
Expanded(
child: Center(
child: Padding(
Expand All @@ -123,14 +179,13 @@ class _CoachChatPageState extends ConsumerState<CoachChatPage> {
),
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'),
),
],
Expand All @@ -153,9 +208,7 @@ class _CoachChatPageState extends ConsumerState<CoachChatPage> {
resizeToAvoidBottomInset: true,
body: Column(
children: [
CoachChatAppBar(
onClear: () => ref.read(coachChatProvider.notifier).clearConversation(),
),
CoachChatAppBar(onClear: _confirmClearConversation),
Expanded(
child: messages.isEmpty
? CoachChatEmptyState(onSuggestionTap: _handleSend)
Expand All @@ -166,6 +219,9 @@ class _CoachChatPageState extends ConsumerState<CoachChatPage> {
focusNode: _focusNode,
isStreaming: isStreaming,
onSend: _handleSend,
onCancel: isStreaming
? () => ref.read(coachChatProvider.notifier).cancelGeneration()
: null,
),
],
),
Expand Down
Loading
Loading