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:
parent
a552b7a0fa
commit
581cf1f1cd
9 changed files with 389 additions and 106 deletions
File diff suppressed because one or more lines are too long
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -866,6 +866,9 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@override
|
||||
String get homeWorkouts => 'Workouts';
|
||||
|
||||
@override
|
||||
String get profileTabProgress => 'Fortschritt';
|
||||
|
||||
@override
|
||||
String get homeDays => 'Tage';
|
||||
|
||||
|
|
|
|||
|
|
@ -860,6 +860,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get homeWorkouts => 'Workouts';
|
||||
|
||||
@override
|
||||
String get profileTabProgress => 'Progress';
|
||||
|
||||
@override
|
||||
String get homeDays => 'Days';
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue