feat(ui): tabs folding into the title, in Learn's and Train's Profile (ADR 0030, D60)

halkyon_ui's FoldingTabs: the tab row under the app bar's title at the
top, scrolling away with the content, the chosen tab's name then small
under the title, which, tapped, scrolls back up to the row, as Strava's.
Learn's Profile (Profile, Statistics) is built on it, and Train's
Profile in tabs: Progress (your body, the last 12 weeks, this year),
Days, and Plans where the device doesn't record.

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-31 19:12:11 +01:00
commit 581cf1f1cd
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
8 changed files with 389 additions and 106 deletions

View file

@ -249,6 +249,7 @@
"loopFormIntro": "Dein Band braucht diese Angaben. Sie bleiben in „Dein Körper“, mit deinem Schlüssel versiegelt. Für einen Ruhe- oder Maximalpuls oder eine VO2max, die du nicht angegeben hast, nimmt das Band Polars Standardwerte, und „Dein Körper“ bleibt, wie er ist.",
"loopFormSave": "Band einrichten",
"homeWorkouts": "Workouts",
"profileTabProgress": "Fortschritt",
"homeDays": "Tage",
"homePlans": "Pläne",
"plansNew": "Neuer Plan",

View file

@ -312,6 +312,7 @@
"loopFormIntro": "Your band needs these. They're kept in Your body, sealed with your key. For a resting or highest heart rate or a VO2max you haven't given, the band takes Polar's defaults, and Your body stays as it is.",
"loopFormSave": "Set up the band",
"homeWorkouts": "Workouts",
"profileTabProgress": "Progress",
"homeDays": "Days",
"homePlans": "Plans",
"plansNew": "New plan",

View file

@ -1516,6 +1516,12 @@ abstract class AppLocalizations {
/// **'Workouts'**
String get homeWorkouts;
/// No description provided for @profileTabProgress.
///
/// In en, this message translates to:
/// **'Progress'**
String get profileTabProgress;
/// No description provided for @homeDays.
///
/// In en, this message translates to:

View file

@ -866,6 +866,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get homeWorkouts => 'Workouts';
@override
String get profileTabProgress => 'Fortschritt';
@override
String get homeDays => 'Tage';

View file

@ -860,6 +860,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get homeWorkouts => 'Workouts';
@override
String get profileTabProgress => 'Progress';
@override
String get homeDays => 'Days';

View file

@ -11,9 +11,8 @@ import 'package:material_ui/material_ui.dart';
/// The person's days as home lists them (ADR 0030, D37): newest first, each
/// with its steps and active time, and the night that ended it with its
/// sleep and Nightly Recharge. Pulling reads the band, where this phone
/// holds one. [header], where given, above them.
class const DaysList({final Widget? header, super.key})
extends StatelessWidget {
/// holds one.
class const DaysList({super.key}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
@ -25,7 +24,6 @@ class const DaysList({final Widget? header, super.key})
final list = state.days.isEmpty
? ListView(
children: [
?header,
Padding(
padding: const EdgeInsets.only(top: 64),
child: EmptyListMessage(
@ -39,7 +37,6 @@ class const DaysList({final Widget? header, super.key})
: ListView(
padding: const EdgeInsets.only(bottom: 88),
children: [
?header,
for (final day in state.days)
Center(
child: ConstrainedBox(

View file

@ -6,84 +6,57 @@ import 'package:halkyon_train_flutter/src/feature/plan/widget/plans_list.dart';
import 'package:halkyon_train_flutter/src/feature/profile/widget/body_card.dart';
import 'package:halkyon_train_flutter/src/feature/workout/widget/progress_cards.dart';
import 'package:halkyon_train_flutter/src/settings/settings_screen.dart';
import 'package:halkyon_train_flutter/src/tabs/tab_screen.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
/// Profile (ADR 0030, D60): *Your body* ([BodyCard]), the last 12 weeks
/// ([WeeksCard]) and this year ([YearCard]) over the person's days from the
/// Loop (D37), and the cog to Settings. Where the device doesn't record, and so has no
/// Record tab, the interval plans (D42) are here beside the days.
/// Profile (ADR 0030, D60) in tabs folding into the title as Strava's
/// ([FoldingTabs]): Progress, with *Your body* ([BodyCard]), the last 12
/// weeks ([WeeksCard]) and this year ([YearCard]); the person's days from
/// the Loop (D37); and, where the device doesn't record and so has no Record
/// tab, the interval plans (D42). The cog leads to Settings.
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;
const settings = SettingsButton();
if (records) {
return TabScreen(
return TopGradient(
background: Theme.of(context).colorScheme.surface,
child: FoldingTabs(
title: l10n.shellTabProfile,
actions: const [settings],
body: const DaysList(header: _Progress(daysHeading: true)),
);
}
return DefaultTabController(
length: 2,
child: TabScreen(
title: l10n.shellTabProfile,
actions: const [settings],
bottom: TabBar(
tabs: [
Tab(text: l10n.homeDays),
Tab(text: l10n.homePlans),
],
),
body: const TabBarView(
children: [
DaysList(header: _Progress(daysHeading: false)),
PlansList(),
],
),
actions: const [SettingsButton()],
tabs: [
FoldingTab(label: l10n.profileTabProgress, body: const _Progress()),
FoldingTab(label: l10n.homeDays, body: const DaysList()),
if (!records)
FoldingTab(label: l10n.homePlans, body: const PlansList()),
],
),
);
}
}
/// Profile's cards over the days, a heading of their own unless a tab
/// names them ([daysHeading]).
class const _Progress({required final bool daysHeading})
extends StatelessWidget {
/// Profile's cards, one under the other, as wide as readable text.
class const _Progress() extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: readableContentMaxWidth),
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const BodyCard(),
const SizedBox(height: 12),
const WeeksCard(),
const SizedBox(height: 12),
const YearCard(),
if (!daysHeading) const SizedBox(height: 8),
if (daysHeading)
Padding(
padding: const EdgeInsets.fromLTRB(4, 16, 4, 4),
child: Text(
AppLocalizations.of(context).homeDays,
style: theme.textTheme.titleSmall?.copyWith(
color: theme.colorScheme.primary,
),
),
),
],
),
Widget build(BuildContext context) => Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: readableContentMaxWidth),
child: ListView(
// Room at the end for the navigation bar floating over the foot.
padding: EdgeInsets.fromLTRB(
12,
8,
12,
12 + FloatingNavigationBar.coverOf(context),
),
children: const [
BodyCard(),
SizedBox(height: 12),
WeeksCard(),
SizedBox(height: 12),
YearCard(),
],
),
);
}
),
);
}

View file

@ -355,14 +355,33 @@ void main() {
expect(startFor(), findsOneWidget);
});
testWidgets("a phone's Profile has the tabs Progress and Days (D60)", (
tester,
) async {
await start(tester, usedBefore: true, recording: phone());
await goTab(tester, 'Profile');
final profileTabs = find.descendant(
of: find.byType(FoldingTabs),
matching: find.byType(Tab),
);
expect(
[for (final tab in tester.widgetList<Tab>(profileTabs)) tab.text],
['Progress', 'Days'],
);
expect(find.byType(WeeksCard), findsOneWidget);
});
testWidgets("a device that doesn't record has no Record tab, and its "
'plans beside the days in Profile', (tester) async {
await start(tester, usedBefore: true);
expect(tabs(tester), ['Home', 'Maps', 'Groups', 'Profile']);
await goTab(tester, 'Profile');
expect(find.text('Days'), findsOneWidget);
expect(find.text('Plans'), findsOneWidget);
expect(find.widgetWithText(Tab, 'Progress'), findsOneWidget);
expect(find.widgetWithText(Tab, 'Days'), findsOneWidget);
expect(find.widgetWithText(Tab, 'Plans'), findsOneWidget);
});
testWidgets('Groups says they come later', (tester) async {
@ -2281,12 +2300,19 @@ void main() {
),
);
/// Profile's Days tab.
Future<void> openDays(WidgetTester tester) async {
await goTab(tester, 'Profile');
await tester.tap(find.widgetWithText(Tab, 'Days'));
await tester.pumpAndSettle();
}
testWidgets('without a band, the days tab says where days come from', (
tester,
) async {
await start(tester, usedBefore: true, days: days);
await goTab(tester, 'Profile');
await openDays(tester);
expect(find.text('No days yet'), findsOneWidget);
});
@ -2296,7 +2322,7 @@ void main() {
await keepTuesday(tester);
await start(tester, usedBefore: true, days: days);
await goTab(tester, 'Profile');
await openDays(tester);
expect(find.text('Tuesday, October 27'), findsOneWidget);
expect(find.text('9876 steps'), findsOneWidget);