feat(train): Home, Maps, Record, Groups and Profile in Learn's frame, washed in carrot (ADR 0030, D60)

Train's tabs are Strava's five where one screen with four tabs at its
top had held workouts, days, plans and routes: Home the workouts, Maps
the routes, Record recording and the interval plans, Groups a screen
saying they come later, Profile the days and the way to the account.
A device that doesn't record has no Record tab, its plans beside the
days in Profile.

The frame is halkyon_ui's NavigationFrame, driven by go_router as
Learn's: a workout's page opens in Home and a day's in Profile with the
tabs beside them; the route and plan editors and the recording cover
them, their content handed over as it is. Every tab washes down from the
top in carrot, the warm end of Train's icon, as Learn's in cobalt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-30 23:52:48 +01:00
commit d4d749eb9f
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
23 changed files with 800 additions and 202 deletions

File diff suppressed because one or more lines are too long

View file

@ -556,5 +556,12 @@
"recordWheelTyped": "{mm} mm, wie du ihn eingegeben hast",
"recordWheelDefault": "{mm} mm, bis GPS ihn auf 2 km Fahrt gemessen hat",
"recordWheelHint": "In Millimetern, wie auf dem Reifen (700×25C: 2105); leer, damit GPS ihn misst",
"recordWheelSave": "Speichern"
"recordWheelSave": "Speichern",
"shellTabHome": "Startseite",
"shellTabMaps": "Karten",
"shellTabRecord": "Aufzeichnen",
"shellTabGroups": "Gruppen",
"shellTabProfile": "Profil",
"groupsLaterTitle": "Gruppen kommen später",
"groupsLaterHint": "Mit anderen trainieren, in euren eigenen Gruppen, kommt in einer späteren Version von Train."
}

View file

@ -703,5 +703,12 @@
"recordWheelDefault": "{mm} mm until GPS has measured it on a ride of 2 km",
"@recordWheelDefault": {"placeholders": {"mm": {"type": "int"}}},
"recordWheelHint": "In millimetres, as the tyre says (700×25C: 2105); empty to have GPS measure it",
"recordWheelSave": "Save"
"recordWheelSave": "Save",
"shellTabHome": "Home",
"shellTabMaps": "Maps",
"shellTabRecord": "Record",
"shellTabGroups": "Groups",
"shellTabProfile": "Profile",
"groupsLaterTitle": "Groups come later",
"groupsLaterHint": "Training with others, in groups of your own, comes in a later version of Train."
}

View file

@ -3363,6 +3363,48 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Save'**
String get recordWheelSave;
/// No description provided for @shellTabHome.
///
/// In en, this message translates to:
/// **'Home'**
String get shellTabHome;
/// No description provided for @shellTabMaps.
///
/// In en, this message translates to:
/// **'Maps'**
String get shellTabMaps;
/// No description provided for @shellTabRecord.
///
/// In en, this message translates to:
/// **'Record'**
String get shellTabRecord;
/// No description provided for @shellTabGroups.
///
/// In en, this message translates to:
/// **'Groups'**
String get shellTabGroups;
/// No description provided for @shellTabProfile.
///
/// In en, this message translates to:
/// **'Profile'**
String get shellTabProfile;
/// No description provided for @groupsLaterTitle.
///
/// In en, this message translates to:
/// **'Groups come later'**
String get groupsLaterTitle;
/// No description provided for @groupsLaterHint.
///
/// In en, this message translates to:
/// **'Training with others, in groups of your own, comes in a later version of Train.'**
String get groupsLaterHint;
}
class _AppLocalizationsDelegate

View file

@ -2019,4 +2019,26 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get recordWheelSave => 'Speichern';
@override
String get shellTabHome => 'Startseite';
@override
String get shellTabMaps => 'Karten';
@override
String get shellTabRecord => 'Aufzeichnen';
@override
String get shellTabGroups => 'Gruppen';
@override
String get shellTabProfile => 'Profil';
@override
String get groupsLaterTitle => 'Gruppen kommen später';
@override
String get groupsLaterHint =>
'Mit anderen trainieren, in euren eigenen Gruppen, kommt in einer späteren Version von Train.';
}

View file

@ -2007,6 +2007,28 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get recordWheelSave => 'Save';
@override
String get shellTabHome => 'Home';
@override
String get shellTabMaps => 'Maps';
@override
String get shellTabRecord => 'Record';
@override
String get shellTabGroups => 'Groups';
@override
String get shellTabProfile => 'Profile';
@override
String get groupsLaterTitle => 'Groups come later';
@override
String get groupsLaterHint =>
'Training with others, in groups of your own, comes in a later version of Train.';
}
/// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -5,8 +5,8 @@ import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
import 'package:halkyon_train_flutter/src/app/train_dependencies.dart';
import 'package:halkyon_train_flutter/src/app/train_frame.dart';
import 'package:halkyon_train_flutter/src/app/train_languages.dart';
import 'package:halkyon_train_flutter/src/app/train_router.dart';
import 'package:halkyon_train_flutter/src/feature/day/bloc/days_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/health/widget/health_screen.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/cadence_model.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/music_downloads.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/recorder_music.dart';
@ -16,11 +16,9 @@ import 'package:halkyon_train_flutter/src/feature/recording/bloc/recording_bloc.
import 'package:halkyon_train_flutter/src/feature/recording/widget/announce_speech.dart';
import 'package:halkyon_train_flutter/src/feature/recording/widget/recording_texts.dart';
import 'package:halkyon_train_flutter/src/feature/route/bloc/routes_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/route/widget/route_import.dart';
import 'package:halkyon_train_flutter/src/feature/route/widget/route_speech.dart';
import 'package:halkyon_train_flutter/src/feature/workout/bloc/workouts_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
import 'package:halkyon_train_flutter/src/home/home_screen.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
@ -64,15 +62,31 @@ class const TrainApp({
}
/// A signed-in person's app, built anew for every person ([SessionGate]):
/// their [PersonScope] around its navigator, so every screen and sheet is
/// inside it, and the home.
/// their [PersonScope] around its router's navigator, so every screen and
/// sheet is inside it, and the tabs ([buildTrainRouter]). Every screen washes
/// down from the top in carrot, the warm end of Train's icon (ADR 0030,
/// D60), as Learn's in cobalt.
class const TrainPersonApp({
/// Whose app it is.
required final SignedIn person,
super.key,
}) extends StatelessWidget {
}) extends StatefulWidget {
@override
Widget build(BuildContext context) => MaterialApp(
State<TrainPersonApp> createState() => _TrainPersonAppState();
}
class _TrainPersonAppState() extends State<TrainPersonApp> {
final _router = buildTrainRouter();
@override
void dispose() {
_router.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => MaterialApp.router(
routerConfig: _router,
onGenerateTitle: (context) => AppLocalizations.of(context).appTitle,
theme: appTheme(Brightness.light),
darkTheme: appTheme(Brightness.dark),
@ -83,7 +97,7 @@ class const TrainPersonApp({
// account sheet's tiles and the badge on its button. The workouts above
// the navigator, so home and a workout's page share their deletions.
builder: (context, child) => PersonScope(
person: person,
person: widget.person,
child: MultiBlocProvider(
providers: [
BlocProvider(
@ -115,15 +129,14 @@ class const TrainPersonApp({
},
),
],
child: _Recording(child: child!),
child: _Recording(
child: ScreenColor(
color: PaletteColor.carrot.background,
child: child!,
),
),
),
),
// Health Connect links to Train's rationale from its own screens, and
// what other apps wrote there is read as Train comes to the front (D31).
// GPX files Android hands Train are brought in as routes (D45).
home: const HealthConnectListener(
child: RouteFilesListener(child: HomeScreen()),
),
);
}

View file

@ -0,0 +1,37 @@
import 'package:halkyon_train_flutter/src/feature/day/model/day.dart';
/// The app's locations (ADR 0030, D60): the five tabs, a workout's page in
/// Home and a day's in Profile, and the editors and the recording over the
/// tabs.
abstract final class TrainPaths() {
/// Home, where the app opens: the workouts.
static const home = '/';
/// Maps: the routes.
static const maps = '/maps';
/// Record, where the device records, with the interval plans.
static const record = '/record';
/// Groups, to come.
static const groups = '/groups';
/// Profile: the days.
static const profile = '/profile';
/// A workout's page, in Home.
static String workout(String id) => '/workouts/$id';
/// The page of the day [date], in Profile, by the band's name for it.
static String day(DateTime date) => '/profile/days/${dayKey(date)}';
/// A route, to plan, over the tabs: the stored one [id], or a new one.
static String route([String? id]) => '/routes/${id ?? 'new'}';
/// An interval plan, to write, over the tabs: the stored one [id], or a
/// new one.
static String plan([String? id]) => '/plans/${id ?? 'new'}';
/// The recording under way or stopped, over the tabs.
static const recording = '/recording';
}

View file

@ -0,0 +1,119 @@
import 'package:go_router/go_router.dart';
import 'package:halkyon_train_flutter/src/app/train_paths.dart';
import 'package:halkyon_train_flutter/src/feature/day/model/day.dart';
import 'package:halkyon_train_flutter/src/feature/day/widget/day_screen.dart';
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
import 'package:halkyon_train_flutter/src/feature/plan/widget/plan_screen.dart';
import 'package:halkyon_train_flutter/src/feature/recording/widget/recording_screen.dart';
import 'package:halkyon_train_flutter/src/feature/route/model/route.dart';
import 'package:halkyon_train_flutter/src/feature/route/widget/route_screen.dart';
import 'package:halkyon_train_flutter/src/feature/workout/widget/workout_screen.dart';
import 'package:halkyon_train_flutter/src/tabs/groups_tab.dart';
import 'package:halkyon_train_flutter/src/tabs/home_tab.dart';
import 'package:halkyon_train_flutter/src/tabs/maps_tab.dart';
import 'package:halkyon_train_flutter/src/tabs/profile_tab.dart';
import 'package:halkyon_train_flutter/src/tabs/record_tab.dart';
import 'package:halkyon_train_flutter/src/tabs/train_shell.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
/// The router of one person's app (ADR 0030, D60), as Learn's: the five tabs
/// in a shell, each with its own stack of screens, and the editors and the
/// recording over them. Every route is built inside the person's scope, so
/// screens read their repositories from `PersonScope`. A route or plan is
/// handed to its editor as it is (`extra`): a new one exists nowhere else
/// yet.
GoRouter buildTrainRouter() => GoRouter(
routes: [
StatefulShellRoute(
builder: (context, state, shell) => TrainShell(shell: shell),
navigatorContainerBuilder: (context, shell, tabs) =>
TabsStack(index: shell.currentIndex, children: tabs),
// In the order of the shell's destinations; Record's branch stays
// where the device doesn't record, its tab left out.
branches: [
StatefulShellBranch(
routes: [
GoRoute(
path: TrainPaths.home,
builder: (context, state) => const HomeTab(),
routes: [
GoRoute(
path: 'workouts/:id',
builder: (context, state) =>
workoutPage(context, state.pathParameters['id']!),
),
],
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
path: TrainPaths.maps,
builder: (context, state) => const MapsTab(),
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
path: TrainPaths.record,
builder: (context, state) => const RecordTab(),
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
path: TrainPaths.groups,
builder: (context, state) => const GroupsTab(),
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
path: TrainPaths.profile,
builder: (context, state) => const ProfileTab(),
routes: [
GoRoute(
path: 'days/:date',
builder: (context, state) => dayPage(
context,
dateOfKey(state.pathParameters['date'])!,
),
),
],
),
],
),
],
),
GoRoute(
path: '/routes/:id',
builder: (context, state) => routePage(
context,
state.extra! as RouteContent,
id: _stored(state),
),
),
GoRoute(
path: '/plans/:id',
builder: (context, state) => planPage(
context,
state.extra! as PlanContent,
id: _stored(state),
),
),
GoRoute(
path: TrainPaths.recording,
builder: (context, state) => const RecordingScreen(),
),
],
);
/// The stored row an editor's location names; null for a new one.
String? _stored(GoRouterState state) => switch (state.pathParameters['id']) {
'new' => null,
final id => id,
};

View file

@ -1,7 +1,9 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
import 'package:halkyon_train_flutter/src/app/train_dependencies.dart';
import 'package:halkyon_train_flutter/src/app/train_paths.dart';
import 'package:halkyon_train_flutter/src/feature/day/bloc/day_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/day/model/day.dart';
import 'package:halkyon_train_flutter/src/feature/day/widget/day_texts.dart';
@ -12,16 +14,16 @@ import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:intl/intl.dart';
import 'package:material_ui/material_ui.dart';
/// Opens the page of the day [date].
Future<void> openDayScreen(BuildContext context, DateTime date) {
/// Opens the page of the day [date], in Profile (D60).
Future<void> openDayScreen(BuildContext context, DateTime date) =>
context.push(TrainPaths.day(date));
/// The page of the day [date], as the router builds it.
Widget dayPage(BuildContext context, DateTime date) {
final train = PersonScope.of<TrainPerson>(context);
return Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (context) => BlocProvider(
create: (context) => DayCubit(train.days, date)..open(),
child: DayScreen(date: date),
),
),
return BlocProvider(
create: (context) => DayCubit(train.days, date)..open(),
child: DayScreen(date: date),
);
}

View file

@ -1,7 +1,9 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
import 'package:halkyon_train_flutter/src/app/train_dependencies.dart';
import 'package:halkyon_train_flutter/src/app/train_paths.dart';
import 'package:halkyon_train_flutter/src/feature/plan/bloc/plan_editor_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/plan/bloc/plans_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
@ -14,27 +16,20 @@ import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// Opens [plan] to be changed, or made where [id] is null.
/// Opens [plan] to be changed, or made where [id] is null, over the tabs
/// (D60).
Future<void> openPlanScreen(
BuildContext context,
PlanContent plan, {
String? id,
}) {
}) => context.push(TrainPaths.plan(id), extra: plan);
/// The editor of [plan], stored as [id] or new, as the router builds it.
Widget planPage(BuildContext context, PlanContent plan, {String? id}) {
final train = PersonScope.of<TrainPerson>(context);
final plans = context.read<PlansCubit>();
return Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (context) => MultiBlocProvider(
providers: [
BlocProvider.value(value: plans),
BlocProvider(
create: (context) =>
PlanEditorCubit(train.plans, plan: plan, id: id),
),
],
child: const PlanScreen(),
),
),
return BlocProvider(
create: (context) => PlanEditorCubit(train.plans, plan: plan, id: id),
child: const PlanScreen(),
);
}

View file

@ -2,10 +2,12 @@ import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:go_router/go_router.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_train_client/halkyon_train_client.dart' show MapSource;
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
import 'package:halkyon_train_flutter/src/app/train_dependencies.dart';
import 'package:halkyon_train_flutter/src/app/train_paths.dart';
import 'package:halkyon_train_flutter/src/feature/map/widget/map_tiles.dart';
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
import 'package:halkyon_train_flutter/src/feature/plan/widget/plan_texts.dart';
@ -23,18 +25,9 @@ import 'package:latlong2/latlong.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// Opens the recording screen over [context]'s, with its bloc.
Future<void> openRecordingScreen(BuildContext context) {
final bloc = context.read<RecordingBloc>();
return Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (context) => BlocProvider.value(
value: bloc,
child: const RecordingScreen(),
),
),
);
}
/// Opens the recording screen over the tabs (D60).
Future<void> openRecordingScreen(BuildContext context) =>
context.push(TrainPaths.recording);
/// A recording under way, or stopped (ADR 0030, D21): the moving time, the
/// distance and the pace now, GPS's state, the plan's step and Lap where it

View file

@ -1,9 +1,11 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:go_router/go_router.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_train_client/halkyon_train_client.dart' show MapSource;
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
import 'package:halkyon_train_flutter/src/app/train_dependencies.dart';
import 'package:halkyon_train_flutter/src/app/train_paths.dart';
import 'package:halkyon_train_flutter/src/feature/map/data/offline_maps.dart';
import 'package:halkyon_train_flutter/src/feature/map/widget/map_tiles.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platform.dart';
@ -36,37 +38,35 @@ const routeActivities = [
Activity.cycling,
];
/// Opens [route] to be changed, or planned where [id] is null; a new one
/// starts where the phone is, where the device can tell (D44).
/// Opens [route] to be changed, or planned where [id] is null, over the tabs
/// (D60).
Future<void> openRouteScreen(
BuildContext context,
RouteContent route, {
String? id,
}) {
}) => context.push(TrainPaths.route(id), extra: route);
/// The planner of [route], stored as [id] or new, as the router builds it; a
/// new one starts where the phone is, where the device can tell (D44).
Widget routePage(BuildContext context, RouteContent route, {String? id}) {
final train = PersonScope.of<TrainPerson>(context);
final routes = context.read<RoutesCubit>();
final platform = train.recording?.platform;
return Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (context) => MultiBlocProvider(
providers: [
BlocProvider.value(value: routes),
BlocProvider(
create: (context) => RouteEditorCubit(
train.routes,
train.routing,
route: route,
id: id,
here: platform == null ? null : () => _here(platform),
)..open(),
),
BlocProvider(
create: (context) => PlaceSearchCubit(train.placeSearch)..open(),
),
],
child: RouteScreen(maps: train.maps.source()),
return MultiBlocProvider(
providers: [
BlocProvider(
create: (context) => RouteEditorCubit(
train.routes,
train.routing,
route: route,
id: id,
here: platform == null ? null : () => _here(platform),
)..open(),
),
),
BlocProvider(
create: (context) => PlaceSearchCubit(train.placeSearch)..open(),
),
],
child: RouteScreen(maps: train.maps.source()),
);
}

View file

@ -1,9 +1,11 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:go_router/go_router.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_train_client/halkyon_train_client.dart' show MapSource;
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
import 'package:halkyon_train_flutter/src/app/train_dependencies.dart';
import 'package:halkyon_train_flutter/src/app/train_paths.dart';
import 'package:halkyon_train_flutter/src/feature/map/widget/map_tiles.dart';
import 'package:halkyon_train_flutter/src/feature/plan/widget/plan_texts.dart';
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
@ -22,23 +24,16 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart' hide Split;
import 'package:url_launcher/url_launcher.dart';
/// Opens the page of the workout [id] over [context]'s.
Future<void> openWorkoutScreen(BuildContext context, String id) {
/// Opens the page of the workout [id], in Home (D60).
Future<void> openWorkoutScreen(BuildContext context, String id) =>
context.push(TrainPaths.workout(id));
/// The page of the workout [id], as the router builds it.
Widget workoutPage(BuildContext context, String id) {
final train = PersonScope.of<TrainPerson>(context);
final workouts = context.read<WorkoutsCubit>();
return Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (context) => MultiBlocProvider(
providers: [
BlocProvider.value(value: workouts),
BlocProvider(
create: (context) =>
WorkoutCubit(train.workouts, train.maps, id)..open(),
),
],
child: const WorkoutScreen(),
),
),
return BlocProvider(
create: (context) => WorkoutCubit(train.workouts, train.maps, id)..open(),
child: const WorkoutScreen(),
);
}

View file

@ -0,0 +1,24 @@
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
import 'package:halkyon_train_flutter/src/tabs/tab_screen.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// Groups (ADR 0030, D60): training with others, to come; until then, what
/// it will be.
class const GroupsTab({super.key}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return TabScreen(
title: l10n.shellTabGroups,
body: Center(
child: EmptyListMessage(
icon: LucideIcons.users,
title: l10n.groupsLaterTitle,
hint: l10n.groupsLaterHint,
),
),
);
}
}

View file

@ -0,0 +1,37 @@
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
import 'package:halkyon_train_flutter/src/app/train_dependencies.dart';
import 'package:halkyon_train_flutter/src/feature/workout/widget/workouts_list.dart';
import 'package:halkyon_train_flutter/src/tabs/tab_screen.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// Home (ADR 0030, D60): the person's workouts (D24), a reset of their keys
/// waiting above them (`KeysResetBanner`).
class const HomeTab({super.key}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final records = PersonScope.of<TrainPerson>(context).recording != null;
return TabScreen(
title: l10n.shellTabHome,
body: Column(
children: [
const KeysResetBanner(),
Expanded(
child: WorkoutsList(
empty: Center(
child: EmptyListMessage(
icon: LucideIcons.activity,
title: l10n.homeEmptyTitle,
hint: records ? l10n.homeRecordHint : l10n.homeEmptyHint,
),
),
),
),
],
),
);
}
}

View file

@ -0,0 +1,13 @@
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
import 'package:halkyon_train_flutter/src/feature/route/widget/routes_list.dart';
import 'package:halkyon_train_flutter/src/tabs/tab_screen.dart';
import 'package:material_ui/material_ui.dart';
/// Maps (ADR 0030, D60): the person's routes (D44), and planning another.
class const MapsTab({super.key}) extends StatelessWidget {
@override
Widget build(BuildContext context) => TabScreen(
title: AppLocalizations.of(context).shellTabMaps,
body: const RoutesList(),
);
}

View file

@ -0,0 +1,48 @@
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
import 'package:halkyon_train_flutter/src/account/account_sheet.dart';
import 'package:halkyon_train_flutter/src/app/train_dependencies.dart';
import 'package:halkyon_train_flutter/src/feature/day/widget/days_list.dart';
import 'package:halkyon_train_flutter/src/feature/plan/widget/plans_list.dart';
import 'package:halkyon_train_flutter/src/tabs/tab_screen.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// Profile (ADR 0030, D60): the person's days from the Loop (D37), and the
/// way to their account. Where the device doesn't record, and so has no
/// Record tab, the interval plans (D42) are here beside the days.
class const ProfileTab({super.key}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final records = PersonScope.of<TrainPerson>(context).recording != null;
final account = IconButton(
tooltip: l10n.accountTitle,
icon: const WaitingAccountsBadge(
child: Icon(LucideIcons.circleUserRound),
),
onPressed: () => showAccountSheet(context),
);
if (records) {
return TabScreen(
title: l10n.shellTabProfile,
actions: [account],
body: const DaysList(),
);
}
return DefaultTabController(
length: 2,
child: TabScreen(
title: l10n.shellTabProfile,
actions: [account],
bottom: TabBar(
tabs: [
Tab(text: l10n.homeDays),
Tab(text: l10n.homePlans),
],
),
body: const TabBarView(children: [DaysList(), PlansList()]),
),
);
}
}

View file

@ -0,0 +1,17 @@
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
import 'package:halkyon_train_flutter/src/feature/plan/widget/plans_list.dart';
import 'package:halkyon_train_flutter/src/tabs/tab_screen.dart';
import 'package:halkyon_train_flutter/src/tabs/train_shell.dart';
import 'package:material_ui/material_ui.dart';
/// Record (ADR 0030, D60), where the device records (D21): recording a
/// workout, or back to the one under way, and the interval plans to record
/// it by (D42).
class const RecordTab({super.key}) extends StatelessWidget {
@override
Widget build(BuildContext context) => TabScreen(
title: AppLocalizations.of(context).shellTabRecord,
body: const PlansList(),
floatingActionButton: const RecordButton(),
);
}

View file

@ -0,0 +1,49 @@
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
/// A tab's screen (ADR 0030, D60): its [title] in a transparent app bar over
/// Train's wash from the top ([TopGradient], in the carrot the app's
/// [ScreenColor] gives), as Learn's tabs wash in cobalt.
class const TabScreen({
/// The tab's name, in the app bar.
required final String title,
/// The tab's content.
required final Widget body,
/// The app bar's actions.
final List<Widget> actions = const [],
/// Below the title: a tab row.
final PreferredSizeWidget? bottom,
/// The screen's button, above the navigation bar.
final Widget? floatingActionButton,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) => TopGradient(
background: Theme.of(context).colorScheme.surface,
child: Scaffold(
backgroundColor: Colors.transparent,
appBar: AppBar(
backgroundColor: Colors.transparent,
systemOverlayStyle: TopGradient.overlayStyle(context),
title: Text(title),
actions: [...actions, const SizedBox(width: 4)],
bottom: bottom,
),
body: body,
// Clear of the navigation bar floating over the foot on a phone.
floatingActionButton: switch (floatingActionButton) {
final button? => Padding(
padding: EdgeInsets.only(
bottom: FloatingNavigationBar.coverOf(context),
),
child: button,
),
null => null,
},
),
);
}

Some files were not shown because too many files have changed in this diff Show more