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>
2026-10-31 19:12:11 +01:00
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
|
feat(ui): Profile's tab row slides back in on any scroll up, the tab's name fading under the title
FoldingTabs, as Strava's Groups (the developer's review, 9 Nov 2026):
the tab row is a floating header now, following the finger out as the
content scrolls down and back in as it scrolls up however little, wherever
the content is; before, it came back only at the top. Folded is read from
how much of the row shows. The chosen tab's name fades in and out under
the title as its room opens and closes, where it appeared at once: the
title keeps one tree folded or not, as a tree changing under it made its
switcher start over without a fade.
Whats-New: Profile's tabs slide back in as soon as you scroll up, and the tab's name fades in under the title as they go.
Whats-New-De: Die Tabs im Profil gleiten zurück, sobald du nach oben scrollst, und der Name des Tabs blendet unter dem Titel ein, wenn sie verschwinden.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-09 23:18:14 +01:00
|
|
|
testWidgets('scrolled down far, a little back up brings the tabs back '
|
|
|
|
|
"over the content, as Strava's do (the developer's review, 9 Nov "
|
|
|
|
|
'2026)', (tester) async {
|
|
|
|
|
await show(tester);
|
|
|
|
|
await tester.drag(find.text('Progress 3'), const Offset(0, -1200));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
expect(tabRowShows(tester), isFalse);
|
|
|
|
|
|
|
|
|
|
await tester.drag(find.text('Progress 22'), const Offset(0, 120));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
|
|
|
|
|
expect(tabRowShows(tester), isTrue);
|
|
|
|
|
expect(inBar('Progress'), findsNothing);
|
|
|
|
|
expect(
|
|
|
|
|
find.text('Progress 0'),
|
|
|
|
|
findsNothing,
|
|
|
|
|
reason: 'the content stays where it was scrolled to',
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await tester.drag(find.text('Progress 22'), const Offset(0, -200));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
expect(tabRowShows(tester), isFalse, reason: 'and goes again down');
|
|
|
|
|
expect(inBar('Progress'), findsOneWidget);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
testWidgets("the chosen tab's name fades in under the title as the tabs "
|
|
|
|
|
'go', (tester) async {
|
|
|
|
|
await show(tester);
|
|
|
|
|
await tester.drag(find.text('Progress 3'), const Offset(0, -400));
|
|
|
|
|
// The row's frame, the title's, then midway through the fade.
|
|
|
|
|
await tester.pump();
|
|
|
|
|
await tester.pump();
|
|
|
|
|
await tester.pump(const Duration(milliseconds: 80));
|
|
|
|
|
|
|
|
|
|
final opacity = tester
|
|
|
|
|
.widgetList<FadeTransition>(
|
|
|
|
|
find.ancestor(
|
|
|
|
|
of: inBar('Progress'),
|
|
|
|
|
matching: find.byType(FadeTransition),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
.map((fade) => fade.opacity.value)
|
|
|
|
|
.reduce((a, b) => a * b);
|
|
|
|
|
expect(opacity, inExclusiveRange(0, 1));
|
|
|
|
|
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
expect(inBar('Progress'), findsOneWidget);
|
|
|
|
|
});
|
|
|
|
|
|
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>
2026-10-31 19:12:11 +01:00
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
}
|