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
3 changed files with 389 additions and 106 deletions

View file

@ -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';

View 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),
);
}
}

View 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);
});
}