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
16 changed files with 389 additions and 106 deletions
|
|
@ -8,7 +8,7 @@ Halkyon's apps and the packages they share, in one repository and one pub worksp
|
|||
|
||||
- **`apps/<app>/`**: each app's packages, as `serverpod create` lays them out (`halkyon_<app>_server`, `_client`, `_flutter`), plus what is the app's alone (Learn's curator, the scripts writing Learn's assets in `apps/learn/tool/`, run from `apps/learn`; the apps' icons are the root's `tool/app_icons.py`, ADR 0031). Learn is the app that runs; Todo is still Serverpod's scaffold, and Train a shell like it (ADR 0030); Meditate is to come.
|
||||
- **`packages/`**: what the apps share, each with its README and licence, which the apps take by path (`path: ../../../packages/<package>`), so a change to a shared package and to the apps using it is one commit:
|
||||
- `halkyon_ui`: Halkyon's look, the theme around cobalt and copper, Noto Sans bundled, the window-size classes and the widgets every screen is built of, with texts of its own (English, German).
|
||||
- `halkyon_ui`: Halkyon's look, the theme around cobalt and copper, Noto Sans bundled, the window-size classes and the widgets every screen is built of (the floating bar and rail, `FoldingTabs`' tab row folding into the title), with texts of its own (English, German).
|
||||
- `halkyon_base_server`, `halkyon_base_client`: the Serverpod module every Halkyon server includes (ADR 0014): signing in (a password with TOTP and the resets of ADR 0017, SimpleLogin on `halkyon_oidc`, email codes proving an address; no anonymous accounts since ADR 0020, D25, nor GitHub since D36), an account's ways in and its deletion with a hook for each app's own data, the pruning, the instance's basics, people's handles and public profiles, who may sign up (`SIGN_UP`: open, approval, closed) with the accounts waiting for approval and the instance's operators who approve them (Halkyon Learn's ADR 0021, step 1), the operator's assistants forwarded on halkyon_llm with their grants, each person's budget, the efforts they allow and counted figures, and the instance's privacy page made from its settings (step 2), and the instance's register (ADR 0016): one account per person across an operator's services, held by one service (Learn's server on learn.halkyon.eu) and asked by the others, its members (`ACCOUNT_REGISTER_URL`), all sharing a secret (`INSTANCE_SECRET`); its README says how.
|
||||
- `halkyon_app`: every app's shell on the module's client (ADR 0014): the instances a device keeps accounts on and the first start, signing in and switching person, the gates building an app's dependencies per instance and per person, the sync service and where it stands, the account section of an app's settings (`AccountTiles`), the wait screen of an account waiting for approval and the operator's card, badge and screen (ADR 0021), the assistants of an app's settings (`AssistantsCard`: granted, by provider, agreed to, what is left of the person's budget, used, and the person's own keys) and the operator's grants from that screen (`AssistantGrantsScreen`: several assistants to several people at once, each with one budget, D57 to D63), with the repositories asking them through the module, at an effort the operator allows, or a person's own key directly (step 2) and what an app's warning before a private studyset goes names (`providerTermsOf`, `OwnAssistant.onThisMachine`, step 3), About with the instance's source link (`showAppAbout`), the language every app picks (`resolveAppLocale`) and countries' and subdivisions' names (Unicode CLDR's, bundled), with texts that name the app.
|
||||
- `halkyon_core` (low-level primitives in pure Dart: ids, a logger, `SerialQueue`, `CoalescingRunner`) and `halkyon_interop` (contracts between apps, pure Dart).
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
|
|||
- **The look is one theme, every Halkyon app's**: the theme, its fonts, the window sizes and the shared widgets come from `halkyon_ui` (`packages/halkyon_ui`, decided 2026-10-18). Change them there, not here. `appTheme(brightness, extensions:)` with hand-written light and dark colour schemes around the brand's cobalt and copper, which Learn calls as `learnTheme` (`core/theme/`) with its own colours beside; `Theme.of(context).extras` (`ThemeExtras`) for the states Material has no role for, and `Theme.of(context).learnColors` (`LearnColors`) for Learn's own (the four FSRS ratings, a starred star); `PaletteColor` for the 20 colours a person can give content; snack bars floating above the navigation bar as Material 3 places them, in its inverse colours (dark in the light theme), lined up with the floating bar, 560 wide and centred from medium windows on (`FittedSnackBars`, decided 2026-10-12; Flutter's default is Material 2's fixed bar across the foot); `TopGradient` for a colour washing down from the top of every tab's first screen and Settings (cobalt) and of every screen of a studyset (its colour, own or inherited, which `_inStudyset` in the router hands down as `ScreenColor`): the studyset, studying it, its cards, writing them, its notes, paths and dictionary; behind a clear app bar, in dark mode a deep shade (42 % of the colour over black), in light mode a tint, held to WCAG AA for the theme's text. From medium windows on, a `TintFrame` takes the colour of the screen in view (`ScreenTint`, claimed by the `TopGradient` whose tab shows and whose route is on top), fading out by 60 % of the height: the rail sits on it, transparent, and the screen is a rounded panel inset beside it, its own wash half as strong; screens over the tabs (studying, the card browser and composer, Settings) sit in the same frame without the rail (`FramedScreen`). As Vaia does, decided 2026-10-10. The PDF reader and the reading timeline stay plain. halkyon_ui's `theme_test.dart` holds text to WCAG AA contrast (4.5:1) on each palette colour and on the dark theme's primary colour and surface, and Learn's `learn_theme_test.dart` a starred star to 3:1 on the cards. **Fonts are bundled, never fetched** (halkyon_ui's `app_fonts.dart`; the CJK fonts are the app's own, `assets/fonts/ATTRIBUTION.md`, their licence registered by `core/theme/cjk_fonts.dart`, under the families halkyon_ui's fallback names): Noto Sans, from halkyon_ui, is the app's font on every device (Latin, Greek with polytonic, Cyrillic); Noto Sans JP, SC, TC and KR fill in Japanese, Chinese and Korean, Japanese first unless a studyset's language puts its own first (`StudysetLanguageScope`, which every studyset route wraps its screen in through `_inStudyset`, and the library's and Explore's cards each around their studyset; Han characters take the first listed font's shapes); each is one variable font holding every weight, and their OFL licences join the licence list. Scripts none covers (Arabic, Hebrew, Thai, …) fall back to the device's own fonts. A studyset's languages (`frontLanguage`, `backLanguage`, BCP 47) are given together or inherited together from the nearest studyset above (`languagesOf`). Never add `google_fonts` or anything else that downloads fonts. Layouts follow Material 3's window-size classes (`context.windowSize`), and running text stays within `readableContentMaxWidth`. Icons are Lucide (`lucide_icons_flutter`), except a starred star and the marks of a choice not yet checked (filled box and circle), which need the fill Lucide lacks; the Material icons left in older screens go when those are restyled. Shared widgets come from halkyon_ui (`SectionCard`, `AppLoadingIndicator` (every spinner is Material 3 Expressive's, in buttons `ButtonLoadingIndicator`, pulling a list down `AppRefreshIndicator`, never Flutter's older circular one), `confirmDestructiveAction`, `FoundById`, `TitleWithKicker`, `showUndoSnackBar`, `ScrollHidingFab`, `PaletteColorPicker`, `CountStepper`, `requiredDecoration` (every required field: while empty its outline stronger, in the primary colour, and "Required" below it; never an asterisk) and `FieldInfoButton`, `ReorderToggle`, `ReorderGrip`, `CollapsingRow`, `CountBadge`, `liftWhileDragged`, `LaunchScreen` and `BrandWordmark` ("Halkyon" over an app's word), `SliverCentered` and `EmptyListMessage` (what an empty list shows: icon, "No … yet", and which menus add some), and the sheets below), with texts of their own (`HalkyonUiLocalizations`); Learn's own are in `core/widget/` (`ConflictVersions`, the versions of a conflict each editor shows, with Keep, its save status beside it `SaveStatusLabel` in `core/editor/`; `ContentCountsLine`; `AppLaunchScreen`, what shows while the app opens: the kingfisher, "Halkyon" over "Learn", whose wordmark the first start shows too).
|
||||
- **A studyset's options are the same wherever a menu of it opens** (`studysetOptions` and `doStudysetOption` in `studyset_actions.dart`, decided 2026-10-12): its card in the library, its own screen, a topic beside it. Import content runs where the studyset's blocs are, on its screen: from elsewhere it opens the screen, which asks what to import as it opens (`?import=content`).
|
||||
- **A studyset's content is in three parts** (`feature/studyset/widget/studyset_parts.dart`, decided 2026-10-12, as Vaia has it): Material (its learning paths, flashcards, notes and files in tabs, the paths first as the way through the rest (decided 2026-10-13: a path is material too, which the plan refers to), each saying how many it holds, an empty one nothing so the four fit a phone more often, each list whole with its tools at the end of the tabs' row, which fold one by one where room runs short rather than cutting the tabs off (`CollapsingRow`: Browse's words first, then the filter into the ⋮ menu, then Browse; as the studyset's own actions fold into icons); decided 2026-10-12 after a trial of sections one below the other, the first three of each behind Show all, as Vaia stacks them: the lists run to hundreds of cards, and tabs are Material 3's way within a part; the tab is kept as the studyset's part, while the material shows, and opened for the first time it opens on the first tab with something in it; a tab with none of its own while the studyset's topics hold some says that it is in them, each topic holding some with how much, everything inside it counted, opening it, Browse all for flashcards, and how to add some here after all, decided 2026-10-14 after people testing the app looked for a grouping studyset's material in its empty tabs; the tabs' counts stay its own), Progress (always, nothing yet where nothing was studied, so the switch never changes as reviews are read, decided 2026-10-12: how much is learnt, what is due today, the study time this week from the reviews' durations, each at most `Review.maxStudyTime`, `StudysetActivityCubit`; how many steps of its learning paths are done and the PDFs not read to their end, begun first, each opening the reader, decided 2026-10-17: `MaterialProgressCubit`; all of it across its topics) and Study plan (the paths finished; Next up: studying for the exam while one is ahead and its pace leaves cards for the day, then the cards due, then the next step of each path under way, or the first path not finished when none is; how far each path has come, its steps done in a bar, each opening the path; managing the paths is the material's; at the top the exam the person prepares for, below; `PathListState.stepsDone`). `StudysetPartSwitch` chooses between them in the header: below expanded windows in a line of its own under the title, from there on in the path's line, which the topics leave free there, centred above the material's column (at the line's end where that column is narrower than the switch; the path folds before it); it ticks as the navigation bar does. **A studyset opens on the part it was left on, on this device** (`StudysetTabs`, `studyset_tabs.json` in the person's folder, read as their data opens, decided 2026-10-11; a tab kept before the parts counts as its part); opened there for the first time, on the study plan while a path is unfinished, else on the material, showing that it loads until it knows rather than one part and then another. Material added or imported shows in its part (`ContentTabCubit`).
|
||||
- **Statistics** (`feature/statistics/`, decided 2026-10-12, as Vaia has them): the Profile tab's second tab. The time studied in all, per day (every day from the first studied to today counted) and the most in one day, and by days, weeks or months (ten, ten, twelve bars) the study time and the ratings, as bars the app draws itself (`BarChart`, no chart package); a tap on a bar says what it holds. Computed on the device from every review (`studyStatistics`, `StatisticsCubit`), every rating counted, crammed ones too, a fresh start not, and **a review counts as at most a minute of study** (`Review.maxStudyTime`, as Anki does by default: a card left on the screen over lunch isn't an hour of study; stored durations stay as they were), here and in a studyset's Progress. What waits in Recently deleted counts nowhere, here or on Home.
|
||||
- **Statistics** (`feature/statistics/`, decided 2026-10-12, as Vaia has them): the Profile tab's second tab (the tab row folding into the title as the content scrolls, halkyon_ui's `FoldingTabs`, as Strava's). The time studied in all, per day (every day from the first studied to today counted) and the most in one day, and by days, weeks or months (ten, ten, twelve bars) the study time and the ratings, as bars the app draws itself (`BarChart`, no chart package); a tap on a bar says what it holds. Computed on the device from every review (`studyStatistics`, `StatisticsCubit`), every rating counted, crammed ones too, a fresh start not, and **a review counts as at most a minute of study** (`Review.maxStudyTime`, as Anki does by default: a card left on the screen over lunch isn't an hour of study; stored durations stay as they were), here and in a studyset's Progress. What waits in Recently deleted counts nowhere, here or on Home.
|
||||
- **Lists that can grow long are built as they scroll into view**: a studyset's parts are `CustomScrollView`s holding each list's slivers (`SliverList`, `SliverReorderableList`), and what a list says in place of its rows sits in a `SliverCentered`. Never a `shrinkWrap` list inside another scrolling view: that builds every row at once, and 1,000 cards then froze the window for seconds, long enough for the desktop to offer to kill the app. Rows are put in another order in a Reorder mode (`ReorderToggle` beside the list; a `ReorderGrip` in place of each row's options; positions from `core/sync/fractional_position.dart`), never with Flutter's default drag handles, which on the desktop lie over a row's trailing end, its … button. Flashcards have no Reorder (decided 2026-10-08): they keep the order they were created or imported in, which new cards and cramming follow, and sub-studysets put lessons in order; no one drags a card among a thousand. **The card browser** (`feature/card/widget/card_browser_screen.dart`, decided 2026-10-09: the older design, gaps filled) opens from Browse beside the flashcards' tab, over the tabs (`/studysets/:id/flashcards`, `?subtree=1` with everything inside, which a line below the title says; the title has how many cards beside it): grid or detail (one card beside the list, which follows it; on a phone above a strip), as last left per studyset on the device (`DeviceSettings.detailedBrowsers`); search over front, back, reading, meanings, hints, explanation and tag names (katakana as hiragana, romaji finding kana, accents, tone marks and umlauts left out, so strasse finds Straße and xue xi xué xí; `card_browser.dart`); sort (own order, newest, oldest, A–Z by reading, by bare letters, due first); a filter sheet (kind, one tag, state: new, learning, due, buried, reported); and choosing cards (a toggle; shift-click or long-press for a range) to move (`showCardMoveSheet`), tag, untag, start afresh or delete with Undo. It reads every card of the studysets, as lists do (schedules from the review repository's cache).
|
||||
- **Forms and pickers open as sheets**: `showAdaptiveSheet` with a `SheetScaffold` (title and close button on top, the body scrolling with a scrollbar, always shown on the desktop, and Material 3's lines under the title and above the footer while some of the body lies beyond them, the action pinned below) shows a bottom sheet below expanded windows and a dialog from there on. A sheet that asks before discarding changes (a `PopScope`) passes `swipeable: false`: a bottom sheet swiped away closes without asking its `PopScope` (Flutter issue 152120, open with v3.47). A sheet offering an Undo passes `hasSnackBars: true`, so its snack bars show on it rather than behind its barrier. A sheet running something that mustn't be cut off (an import) is `busy` meanwhile: its close button is greyed and back does nothing; it is shown with `swipeable: false`, since a swipe closes a bottom sheet regardless. A screen whose blocs import (a studyset's PDFs and paths) greys its back button and path likewise, as leaving would close them; an import of paths shows the study plan at once, how far it has come above the paths (`PathImportProgress`). A picker that changes something outside it reports every change as it happens (`onChanged`) rather than as its result, since a sheet swiped away has none. What needs the whole window to be written or picked (a studyset's description, a step's feedback, a map question's region: `editRichText`, `pickRegion`, decided 2026-10-12) is a field in the form (`ChevronField`: the value, a chevron) opening a full-window route with Save in its app bar, where nothing scrolls around a map, so the wheel alone zooms it; a country's own map (Japan's prefectures) is offered there only once the country, or a region within it, is picked.
|
||||
- **Every text field has a limit** (decided 2026-10-11): `TextLimits` (halkyon_ui) by what it holds (a name 40, a secondary title 60, a title 120, an item 200, a search 200, a citation's part 500, an email address 254, a message or a web address 2000, a passage 5000), or the instance's own where the server checks it (`PublicProfile.max…`, `maxSlugLength`), with `buildCounter: nearLimitCounter`. Text can still be longer where it came from elsewhere, so every place showing a name cuts it (`maxLines` and an ellipsis, in a `Flexible` within a row); `test/app/long_names_test.dart` shows names beyond every limit on the main screens of a phone and a desktop, and fails on any overflow: add a screen there when it shows names.
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ import 'package:material_ui/material_ui.dart';
|
|||
/// The profile tab, in two tabs as Vaia has it (decided 2026-10-12): the
|
||||
/// profile (the ways the person signs in, SimpleLogin, ADR 0020, and their
|
||||
/// password and authenticators, ADR 0017) and the person's statistics.
|
||||
/// Settings are behind the gear.
|
||||
/// Settings are behind the gear. The tab row folds into the title as the
|
||||
/// content scrolls ([FoldingTabs], as Strava's).
|
||||
class const ProfileScreen({super.key}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -26,38 +27,24 @@ class const ProfileScreen({super.key}) extends StatelessWidget {
|
|||
child: TopGradient(
|
||||
color: Brand.primary,
|
||||
background: Theme.of(context).colorScheme.surfaceContainerLow,
|
||||
child: DefaultTabController(
|
||||
length: 2,
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
systemOverlayStyle: TopGradient.overlayStyle(context),
|
||||
title: Text(l10n.profileTitle),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const WaitingAccountsBadge(
|
||||
child: SyncTroubleBadge(
|
||||
child: Icon(LucideIcons.settings),
|
||||
),
|
||||
),
|
||||
tooltip: l10n.profileSettingsTooltip,
|
||||
onPressed: () => context.push(RoutePaths.settings),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
bottom: TabBar(
|
||||
isScrollable: true,
|
||||
tabAlignment: TabAlignment.start,
|
||||
dividerColor: Colors.transparent,
|
||||
tabs: [
|
||||
Tab(text: l10n.profileTabProfile),
|
||||
Tab(text: l10n.profileTabStatistics),
|
||||
],
|
||||
child: FoldingTabs(
|
||||
title: l10n.profileTitle,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const WaitingAccountsBadge(
|
||||
child: SyncTroubleBadge(child: Icon(LucideIcons.settings)),
|
||||
),
|
||||
tooltip: l10n.profileSettingsTooltip,
|
||||
onPressed: () => context.push(RoutePaths.settings),
|
||||
),
|
||||
body: const TabBarView(children: [_Account(), StatisticsView()]),
|
||||
),
|
||||
],
|
||||
tabs: [
|
||||
FoldingTab(label: l10n.profileTabProfile, body: const _Account()),
|
||||
FoldingTab(
|
||||
label: l10n.profileTabStatistics,
|
||||
body: const StatisticsView(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
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);
|
||||
|
|
|
|||
|
|
@ -1528,6 +1528,13 @@ Strava. What was found, 27 Oct:
|
|||
in Profile's tab), shown while the band gave figures for today, and so for someone
|
||||
with a Loop alone; above the week, which waits for the first workout, and above
|
||||
Home's message while there is none.
|
||||
Profile's tabs fold into the title as Strava's (halkyon_ui's `FoldingTabs`, Learn's
|
||||
Profile on it too): Progress (the cards above), Days, and Plans where the device
|
||||
doesn't record; at the top the tab row shows under the title, scrolled down it
|
||||
scrolls away with the content and the chosen tab's name shows small under the
|
||||
title, which, tapped, scrolls back up to the row. The row alone is the scroll view's
|
||||
header, nothing pinned, so the tabs' lists need no room kept for it and stay plain
|
||||
`ListView`s.
|
||||
|
||||
## Consequences
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ export 'src/widget/count_stepper.dart';
|
|||
export 'src/widget/empty_list_message.dart';
|
||||
export 'src/widget/field_label.dart';
|
||||
export 'src/widget/floating_navigation_bar.dart';
|
||||
export 'src/widget/folding_tabs.dart';
|
||||
export 'src/widget/found_by_id.dart';
|
||||
export 'src/widget/keyboard_back_on_resume.dart';
|
||||
export 'src/widget/launch_screen.dart';
|
||||
|
|
|
|||
155
packages/halkyon_ui/lib/src/widget/folding_tabs.dart
Normal file
155
packages/halkyon_ui/lib/src/widget/folding_tabs.dart
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import 'package:halkyon_ui/src/widget/top_gradient.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// One of [FoldingTabs]' tabs: its [label] in the tab row and [body], a
|
||||
/// scroll view taking the screen's primary scroll controller (as a
|
||||
/// `ListView` without a controller of its own does).
|
||||
class const FoldingTab({
|
||||
/// The tab's name.
|
||||
required final String label,
|
||||
|
||||
/// The tab's content.
|
||||
required final Widget body,
|
||||
});
|
||||
|
||||
/// A screen of tabs whose row folds into the title, as Strava's do: at the
|
||||
/// top the tabs show under the app bar's [title]; scrolled down, they
|
||||
/// scroll away with the content and the chosen tab's name shows small under
|
||||
/// the title, which, tapped, scrolls back up to them. The app bar is
|
||||
/// transparent, for a [TopGradient] around the screen.
|
||||
class const FoldingTabs({
|
||||
/// The screen's name, in the app bar.
|
||||
required final String title,
|
||||
|
||||
/// The tabs, in order.
|
||||
required final List<FoldingTab> tabs,
|
||||
|
||||
/// The app bar's actions.
|
||||
final List<Widget> actions = const [],
|
||||
super.key,
|
||||
}) extends StatefulWidget {
|
||||
this : assert(tabs.length > 1, 'Tabs fold only where there are several');
|
||||
|
||||
@override
|
||||
State<FoldingTabs> createState() => _FoldingTabsState();
|
||||
}
|
||||
|
||||
class _FoldingTabsState() extends State<FoldingTabs> {
|
||||
final _outer = ScrollController();
|
||||
|
||||
/// Whether the tab row has scrolled away.
|
||||
bool _folded = false;
|
||||
|
||||
/// The tab row's height, as laid out last.
|
||||
double _rowHeight = kTextTabBarHeight;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_outer.addListener(_scrolled);
|
||||
}
|
||||
|
||||
void _scrolled() {
|
||||
// Folded once the row is all but gone: as long as some of it shows,
|
||||
// it names the tab itself.
|
||||
final folded = _outer.offset >= _rowHeight - 1;
|
||||
if (folded != _folded) {
|
||||
setState(() => _folded = folded);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_outer.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _unfold() => _outer.animateTo(
|
||||
0,
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final row = TabBar(
|
||||
isScrollable: true,
|
||||
tabAlignment: TabAlignment.start,
|
||||
dividerColor: Colors.transparent,
|
||||
tabs: [for (final tab in widget.tabs) Tab(text: tab.label)],
|
||||
);
|
||||
_rowHeight = row.preferredSize.height;
|
||||
return DefaultTabController(
|
||||
length: widget.tabs.length,
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
systemOverlayStyle: TopGradient.overlayStyle(context),
|
||||
title: _Title(
|
||||
title: widget.title,
|
||||
labels: [for (final tab in widget.tabs) tab.label],
|
||||
folded: _folded,
|
||||
onTap: _unfold,
|
||||
),
|
||||
actions: [...widget.actions, const SizedBox(width: 4)],
|
||||
),
|
||||
// The row alone in the header, nothing pinned there, so the tabs'
|
||||
// content needs no room kept for it: it starts right under the row,
|
||||
// and under the app bar once the row is gone.
|
||||
body: NestedScrollView(
|
||||
controller: _outer,
|
||||
headerSliverBuilder: (context, _) => [
|
||||
SliverToBoxAdapter(child: row),
|
||||
],
|
||||
body: TabBarView(children: [for (final tab in widget.tabs) tab.body]),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The app bar's title, with the chosen tab's name small beneath it while
|
||||
/// the row is folded away; then tapping it unfolds the row.
|
||||
class const _Title({
|
||||
required final String title,
|
||||
required final List<String> labels,
|
||||
required final bool folded,
|
||||
required final VoidCallback onTap,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final controller = DefaultTabController.of(context);
|
||||
final column = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(title),
|
||||
AnimatedSize(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
alignment: Alignment.topLeft,
|
||||
child: folded
|
||||
? ListenableBuilder(
|
||||
listenable: controller,
|
||||
builder: (context, _) => Text(
|
||||
labels[controller.index],
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
);
|
||||
if (!folded) {
|
||||
return column;
|
||||
}
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: Semantics(button: true, child: column),
|
||||
);
|
||||
}
|
||||
}
|
||||
123
packages/halkyon_ui/test/widget/folding_tabs_test.dart
Normal file
123
packages/halkyon_ui/test/widget/folding_tabs_test.dart
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import '../support/test_app.dart';
|
||||
|
||||
void main() {
|
||||
Widget list(String name) => ListView(
|
||||
children: [
|
||||
for (var i = 0; i < 40; i++)
|
||||
SizedBox(height: 60, child: Text('$name $i')),
|
||||
],
|
||||
);
|
||||
|
||||
Future<void> show(WidgetTester tester) async {
|
||||
tester.view.physicalSize = const Size(400, 800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpWidget(
|
||||
testApp(
|
||||
FoldingTabs(
|
||||
title: 'Profile',
|
||||
actions: const [Icon(Icons.settings)],
|
||||
tabs: [
|
||||
FoldingTab(label: 'Progress', body: list('Progress')),
|
||||
FoldingTab(label: 'Days', body: list('Day')),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The texts in the app bar.
|
||||
Finder inBar(String text) =>
|
||||
find.descendant(of: find.byType(AppBar), matching: find.text(text));
|
||||
|
||||
bool tabRowShows(WidgetTester tester) {
|
||||
final row = find.byType(TabBar);
|
||||
return row.evaluate().isNotEmpty &&
|
||||
tester.getRect(row).bottom >
|
||||
tester.getRect(find.byType(AppBar)).bottom + 1;
|
||||
}
|
||||
|
||||
testWidgets('at the top, the tabs show under the title, which stands alone', (
|
||||
tester,
|
||||
) async {
|
||||
await show(tester);
|
||||
|
||||
expect(inBar('Profile'), findsOneWidget);
|
||||
expect(tabRowShows(tester), isTrue);
|
||||
expect(inBar('Progress'), findsNothing);
|
||||
expect(find.text('Progress 0'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('scrolled down, the tabs go and the chosen one shows small '
|
||||
'under the title', (tester) async {
|
||||
await show(tester);
|
||||
|
||||
await tester.drag(find.text('Progress 3'), const Offset(0, -400));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tabRowShows(tester), isFalse);
|
||||
expect(inBar('Profile'), findsOneWidget);
|
||||
expect(inBar('Progress'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('scrolled a little, the title stays alone', (tester) async {
|
||||
await show(tester);
|
||||
|
||||
await tester.drag(find.text('Progress 3'), const Offset(0, -10));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tabRowShows(tester), isTrue);
|
||||
expect(inBar('Progress'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('scrolled back up, the tabs come back and the title stands '
|
||||
'alone again', (tester) async {
|
||||
await show(tester);
|
||||
await tester.drag(find.text('Progress 3'), const Offset(0, -400));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.drag(find.text('Progress 8'), const Offset(0, 2000));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tabRowShows(tester), isTrue);
|
||||
expect(inBar('Progress'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('the small name follows the tab swiped to', (tester) async {
|
||||
await show(tester);
|
||||
await tester.drag(find.text('Progress 3'), const Offset(0, -400));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.fling(find.text('Progress 8'), const Offset(-300, 0), 1000);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Day 0'), findsOneWidget);
|
||||
expect(inBar('Days'), findsOneWidget);
|
||||
expect(inBar('Progress'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('the folded title tapped brings the tabs back', (tester) async {
|
||||
await show(tester);
|
||||
await tester.drag(find.text('Progress 3'), const Offset(0, -400));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(inBar('Profile'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tabRowShows(tester), isTrue);
|
||||
expect(inBar('Progress'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('a tab tapped shows its content', (tester) async {
|
||||
await show(tester);
|
||||
|
||||
await tester.tap(find.widgetWithText(Tab, 'Days'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Day 0'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue