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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ CoachChatPage aiIsolateEntryPoint()
## Roadmap

- [x] `v0.1` — Flutter scaffold, design system, architecture rules
- [ ] `v0.2` — HealthKit integration + dashboard metrics
- [ ] `v0.3` — AI coach chat with streaming Gemma output
- [ ] `v0.4` — Nexus Lab biomechanics training loop
- [x] `v0.2` — HealthKit integration + dashboard metrics
- [x] `v0.3` — AI coach chat with streaming Gemma output
- [x] `v0.4` — Nexus Lab biomechanics training loop
- [ ] `v1.0` — App Store submission
46 changes: 46 additions & 0 deletions lib/app/not_found_page.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:kynos/app/router.dart';
import 'package:kynos/core/theme/spacing.dart' as tokens;
import 'package:kynos/core/theme/theme.dart';

/// Friendly 404 page for unknown routes.
class NotFoundPage extends StatelessWidget {
const NotFoundPage({super.key});

@override
Widget build(BuildContext context) {
final kynos = context.kynosTheme;
return Scaffold(
backgroundColor: kynos.background,
body: Center(
child: Padding(
padding: const EdgeInsets.all(tokens.Spacing.xl),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.explore_off_outlined, size: 48, color: kynos.tertiaryLabel),
const Gap(tokens.Spacing.lg),
Text(
'Page not found',
style: Theme.of(context).textTheme.headlineSmall,
),
const Gap(tokens.Spacing.sm),
Text(
'The page you requested does not exist.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium,
),
const Gap(tokens.Spacing.lg),
FilledButton(
onPressed: () => context.go(Routes.dashboard),
child: const Text('Go to Today'),
),
],
),
),
),
);
}
}
69 changes: 61 additions & 8 deletions lib/app/router.dart
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:kynos/app/not_found_page.dart';
import 'package:kynos/app/shell_navigation_scope.dart';
import 'package:kynos/app/shell_page.dart';
import 'package:kynos/domain/entities/workout_session.dart';
import 'package:kynos/features/coach_chat/presentation/pages/coach_chat_page.dart';
import 'package:kynos/features/dashboard/presentation/pages/run_history_page.dart';
import 'package:kynos/features/dashboard/presentation/pages/run_route_missing_page.dart';
import 'package:kynos/features/dashboard/presentation/pages/run_route_page.dart';
import 'package:kynos/features/nexus_lab/presentation/nexus_lab_page.dart';
import 'package:kynos/features/onboarding/presentation/onboarding_page.dart';
import 'package:kynos/features/onboarding/providers/onboarding_provider.dart';
import 'package:kynos/features/settings/presentation/pages/health_import_page.dart';
Expand All @@ -16,35 +21,79 @@ import 'package:kynos/features/settings/presentation/pages/settings_page.dart';
abstract final class Routes {
static const onboarding = '/onboarding';
static const dashboard = '/';
static const training = '/training';
static const character = '/character';
static const runRoute = '/run-route';
static const runHistory = '/run-history';
static const coachChat = '/coach';
static const settings = '/settings';
static const nexusLab = '/nexus-lab';
static const openRouterModels = '/settings/openrouter-models';
static const healthImport = '/settings/import';
static const manualRun = '/settings/manual-run';
}

class _RouterRefreshNotifier extends ChangeNotifier {
_RouterRefreshNotifier(Ref ref) {
ref.listen(onboardingCompletedProvider, (_, _) => notifyListeners());
}
}

final routerProvider = Provider<GoRouter>((ref) {
final hasCompletedOnboarding = ref.watch(onboardingCompletedProvider);
final refresh = _RouterRefreshNotifier(ref);

return GoRouter(
initialLocation: Routes.onboarding,
initialLocation:
hasCompletedOnboarding ? Routes.dashboard : Routes.onboarding,
refreshListenable: refresh,
redirect: (context, state) {
final hasCompletedOnboarding = ref.watch(onboardingCompletedProvider);
final completed = ref.read(onboardingCompletedProvider);
final isOnboarding = state.matchedLocation == Routes.onboarding;
final isImportHandoff = state.matchedLocation == Routes.healthImport;

if (!hasCompletedOnboarding && !isOnboarding) return Routes.onboarding;
if (hasCompletedOnboarding && isOnboarding) return Routes.dashboard;
if (!completed && !isOnboarding && !isImportHandoff) {
return Routes.onboarding;
}
if (completed && isOnboarding) return Routes.dashboard;

return null;
},
errorBuilder: (context, state) => const NotFoundPage(),
routes: [
GoRoute(
path: Routes.onboarding,
builder: (context, state) => const OnboardingPage(),
),
GoRoute(
path: Routes.dashboard,
builder: (context, state) => const ShellPage(),
StatefulShellRoute.indexedStack(
builder: (context, state, navigationShell) =>
ShellPage(navigationShell: navigationShell),
branches: [
StatefulShellBranch(
routes: [
GoRoute(
path: Routes.dashboard,
builder: (context, state) => const DashboardTab(),
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
path: Routes.training,
builder: (context, state) => const TrainingTab(),
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
path: Routes.character,
builder: (context, state) => const CharacterTab(),
),
],
),
],
),
GoRoute(
path: Routes.runHistory,
Expand All @@ -54,14 +103,18 @@ final routerProvider = Provider<GoRouter>((ref) {
path: Routes.runRoute,
builder: (context, state) {
final run = state.extra;
if (run is! WorkoutSession) return const ShellPage();
if (run is! WorkoutSession) return const RunRouteMissingPage();
return RunRoutePage(run: run);
},
),
GoRoute(
path: Routes.coachChat,
builder: (context, state) => const CoachChatPage(),
),
GoRoute(
path: Routes.nexusLab,
builder: (context, state) => const NexusLabPage(),
),
GoRoute(
path: Routes.settings,
builder: (context, state) => const SettingsPage(),
Expand Down
41 changes: 41 additions & 0 deletions lib/app/shell_navigation_scope.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:kynos/app/router.dart';
import 'package:kynos/features/dashboard/presentation/pages/dashboard_page.dart';

/// Exposes shell branch switching to descendant tab routes.
class ShellNavigationScope extends InheritedWidget {
const ShellNavigationScope({
super.key,
required this.goToBranch,
required super.child,
});

final ValueChanged<int> goToBranch;

static ShellNavigationScope? maybeOf(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<ShellNavigationScope>();
}

@override
bool updateShouldNotify(ShellNavigationScope oldWidget) =>
goToBranch != oldWidget.goToBranch;
}

/// Today tab — uses shell branch navigation to preserve nested stacks.
class DashboardTab extends StatelessWidget {
const DashboardTab({super.key});

@override
Widget build(BuildContext context) {
final shell = ShellNavigationScope.maybeOf(context);
return DashboardPage(
onViewTraining: shell != null
? () => shell.goToBranch(1)
: () => context.go(Routes.training),
onViewCharacter: shell != null
? () => shell.goToBranch(2)
: () => context.go(Routes.character),
);
}
}
85 changes: 53 additions & 32 deletions lib/app/shell_page.dart
Original file line number Diff line number Diff line change
@@ -1,55 +1,76 @@
import 'package:flutter/material.dart';
import 'package:kynos/core/theme/app_theme.dart';
import 'package:kynos/core/theme/layout.dart';
import 'package:go_router/go_router.dart';
import 'package:kynos/app/shell_navigation_scope.dart';
import 'package:kynos/core/theme/theme.dart';
import 'package:kynos/features/character/presentation/pages/character_page.dart';
import 'package:kynos/features/dashboard/presentation/pages/dashboard_page.dart';
import 'package:kynos/features/training/presentation/pages/training_page.dart';
import 'package:kynos/shared/widgets/kynos_bottom_nav.dart';
import 'package:kynos/shared/widgets/nav_icon.dart';
import 'package:kynos/shared/widgets/responsive_center.dart';

/// Root app shell — floating bottom nav with three focused tabs.
class ShellPage extends StatefulWidget {
const ShellPage({super.key});
class ShellPage extends StatelessWidget {
const ShellPage({super.key, required this.navigationShell});

@override
State<ShellPage> createState() => _ShellState();
}

class _ShellState extends State<ShellPage> {
int _index = 0;
final StatefulNavigationShell navigationShell;

static const _navItems = [
KynosBottomNavItem(label: 'Today', icon: NavIconPaths.today),
KynosBottomNavItem(label: 'Training', icon: NavIconPaths.training),
KynosBottomNavItem(label: 'Character', icon: NavIconPaths.character),
];

void _onTabSelected(int index) {
navigationShell.goBranch(
index,
initialLocation: index == navigationShell.currentIndex,
);
}

@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppTheme.background,
extendBody: true,
body: Padding(
padding: EdgeInsets.only(
bottom: LayoutTokens.shellNavExtent(context),
),
child: IndexedStack(
index: _index,
children: [
DashboardPage(
onViewTraining: () => setState(() => _index = 1),
onViewCharacter: () => setState(() => _index = 2),
final kynos = context.kynosTheme;
return ShellNavigationScope(
goToBranch: _onTabSelected,
child: Scaffold(
backgroundColor: kynos.background,
extendBody: true,
body: ResponsiveCenter(
child: Padding(
padding: EdgeInsets.only(
bottom: LayoutTokens.shellNavExtent(context),
),
const TrainingPage(),
const CharacterPage(),
],
child: navigationShell,
),
),
bottomNavigationBar: ResponsiveCenter(
child: KynosBottomNav(
items: _navItems,
selectedIndex: navigationShell.currentIndex,
onSelected: _onTabSelected,
),
),
),
bottomNavigationBar: KynosBottomNav(
items: _navItems,
selectedIndex: _index,
onSelected: (i) => setState(() => _index = i),
),
);
}
}

/// Training tab — extracted for [StatefulShellRoute].
class TrainingTab extends StatelessWidget {
const TrainingTab({super.key});

@override
Widget build(BuildContext context) {
return const TrainingPage();
}
}

/// Character tab — extracted for [StatefulShellRoute].
class CharacterTab extends StatelessWidget {
const CharacterTab({super.key});

@override
Widget build(BuildContext context) {
return const CharacterPage();
}
}
6 changes: 6 additions & 0 deletions lib/core/constants/app_constants.dart
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ abstract final class AppConstants {
/// All biometric data stays on-device; this flag disables any cloud path.
static const bool zeroKnowledgeMode = true;

/// Public privacy policy URL.
static const String privacyPolicyUrl = 'https://kynos.app/privacy';

/// Public terms of service URL.
static const String termsOfServiceUrl = 'https://kynos.app/terms';

// ── Export ────────────────────────────────────────────────────────────────
static const String exportFormatParquet = 'parquet';
static const String exportFormatProtobuf = 'protobuf';
Expand Down
19 changes: 19 additions & 0 deletions lib/core/theme/layout.dart
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,23 @@ abstract final class LayoutTokens {
tokens.Spacing.md,
tokens.Spacing.sm,
);

/// Max content width for web and wide viewports.
static const double maxContentWidth = 560;

/// Bottom padding to clear the coach chat input bar.
static const double chatInputClearance =
tokens.Spacing.xxxl * 2 + tokens.Spacing.md;

/// Height for horizontal run-card carousels on the dashboard.
static const double runCarouselHeight =
tokens.Spacing.xxxl * 2 + tokens.Spacing.xl + tokens.Spacing.md;

/// Width for horizontal run-card carousel tiles.
static const double runCarouselTileWidth =
tokens.Spacing.xxxl * 5 + tokens.Spacing.lg;

/// Height for dashboard trend carousel charts.
static const double trendCarouselHeight =
tokens.Spacing.xxxl * 4 + tokens.Spacing.md;
}
Loading
Loading