feat(train): a recording under way above the bar on every tab, and a saved one opens its page (ADR 0030, D60)
halkyon_ui's NavigationFrame takes a status, standing above the floating bar below medium windows and at the foot of the screen's panel from there on. Train's RecordingBar fills it while a recording runs or waits to be saved: its activity, moving time and distance, paused or stopped, a tap back to it. Saving now opens the workout's page in Home's tab. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
25191055ae
commit
7473366f66
15 changed files with 301 additions and 15 deletions
File diff suppressed because one or more lines are too long
|
|
@ -566,5 +566,7 @@
|
||||||
"groupsLaterHint": "Mit anderen trainieren, in euren eigenen Gruppen, kommt in einer späteren Version von Train.",
|
"groupsLaterHint": "Mit anderen trainieren, in euren eigenen Gruppen, kommt in einer späteren Version von Train.",
|
||||||
"settingsTitle": "Einstellungen",
|
"settingsTitle": "Einstellungen",
|
||||||
"settingsTraining": "Training",
|
"settingsTraining": "Training",
|
||||||
"settingsVersion": "Version {version} (Build {build})"
|
"settingsVersion": "Version {version} (Build {build})",
|
||||||
|
"recordingBarStopped": "Angehalten: speichern oder verwerfen",
|
||||||
|
"recordingBarDistance": "{km} km"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -714,5 +714,8 @@
|
||||||
"settingsTitle": "Settings",
|
"settingsTitle": "Settings",
|
||||||
"settingsTraining": "Training",
|
"settingsTraining": "Training",
|
||||||
"settingsVersion": "Version {version} (build {build})",
|
"settingsVersion": "Version {version} (build {build})",
|
||||||
"@settingsVersion": {"placeholders": {"version": {"type": "String"}, "build": {"type": "String"}}}
|
"@settingsVersion": {"placeholders": {"version": {"type": "String"}, "build": {"type": "String"}}},
|
||||||
|
"recordingBarStopped": "Stopped: save or discard it",
|
||||||
|
"recordingBarDistance": "{km} km",
|
||||||
|
"@recordingBarDistance": {"placeholders": {"km": {"type": "String"}}}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3423,6 +3423,18 @@ abstract class AppLocalizations {
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Version {version} (build {build})'**
|
/// **'Version {version} (build {build})'**
|
||||||
String settingsVersion(String version, String build);
|
String settingsVersion(String version, String build);
|
||||||
|
|
||||||
|
/// No description provided for @recordingBarStopped.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Stopped: save or discard it'**
|
||||||
|
String get recordingBarStopped;
|
||||||
|
|
||||||
|
/// No description provided for @recordingBarDistance.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{km} km'**
|
||||||
|
String recordingBarDistance(String km);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AppLocalizationsDelegate
|
class _AppLocalizationsDelegate
|
||||||
|
|
|
||||||
|
|
@ -2052,4 +2052,12 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
String settingsVersion(String version, String build) {
|
String settingsVersion(String version, String build) {
|
||||||
return 'Version $version (Build $build)';
|
return 'Version $version (Build $build)';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingBarStopped => 'Angehalten: speichern oder verwerfen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String recordingBarDistance(String km) {
|
||||||
|
return '$km km';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2040,6 +2040,14 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
String settingsVersion(String version, String build) {
|
String settingsVersion(String version, String build) {
|
||||||
return 'Version $version (build $build)';
|
return 'Version $version (build $build)';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingBarStopped => 'Stopped: save or discard it';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String recordingBarDistance(String km) {
|
||||||
|
return '$km km';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The translations for English, as used in the United Kingdom (`en_GB`).
|
/// The translations for English, as used in the United Kingdom (`en_GB`).
|
||||||
|
|
|
||||||
|
|
@ -297,8 +297,9 @@ class RecordingBloc(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final (content, series) = recordedWorkout(recording);
|
final (content, series) = recordedWorkout(recording);
|
||||||
|
final String saved;
|
||||||
try {
|
try {
|
||||||
await _workouts.save(content, series);
|
saved = await _workouts.save(content, series);
|
||||||
} on Object catch (error) {
|
} on Object catch (error) {
|
||||||
// The file stays, to be saved once it can be.
|
// The file stays, to be saved once it can be.
|
||||||
log('A recording not saved: ${error.runtimeType}', name: 'Recording');
|
log('A recording not saved: ${error.runtimeType}', name: 'Recording');
|
||||||
|
|
@ -311,6 +312,7 @@ class RecordingBloc(
|
||||||
phase: RecordingPhase.idle,
|
phase: RecordingPhase.idle,
|
||||||
lastActivity: recording.activity,
|
lastActivity: recording.activity,
|
||||||
devices: _measured(recording),
|
devices: _measured(recording),
|
||||||
|
saved: saved,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -473,6 +475,10 @@ final class const RecordingState({
|
||||||
/// The person's devices on this device, those they chose: the strap and
|
/// The person's devices on this device, those they chose: the strap and
|
||||||
/// the rower (D27, D28), the bike's sensors (D49).
|
/// the rower (D27, D28), the bike's sensors (D49).
|
||||||
final Map<DeviceKind, KnownDevice> devices = const {},
|
final Map<DeviceKind, KnownDevice> devices = const {},
|
||||||
|
|
||||||
|
/// The workout the recording just ended was saved as, for the screen
|
||||||
|
/// to open (D60); null otherwise.
|
||||||
|
final String? saved,
|
||||||
}) with Equatable {
|
}) with Equatable {
|
||||||
KnownDevice? get strap => devices[DeviceKind.strap];
|
KnownDevice? get strap => devices[DeviceKind.strap];
|
||||||
KnownDevice? get rower => devices[DeviceKind.rower];
|
KnownDevice? get rower => devices[DeviceKind.rower];
|
||||||
|
|
@ -525,6 +531,7 @@ final class const RecordingState({
|
||||||
bluetoothRefused,
|
bluetoothRefused,
|
||||||
rowerMissing,
|
rowerMissing,
|
||||||
devices,
|
devices,
|
||||||
|
saved,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
|
||||||
|
import 'package:halkyon_train_flutter/src/feature/recording/bloc/recording_bloc.dart';
|
||||||
|
import 'package:halkyon_train_flutter/src/feature/recording/widget/recording_screen.dart';
|
||||||
|
import 'package:halkyon_train_flutter/src/feature/recording/widget/recording_texts.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
|
/// Whether [state] has a recording the person may want back to: one under
|
||||||
|
/// way, or stopped and neither saved nor discarded.
|
||||||
|
bool showsRecordingBar(RecordingState state) =>
|
||||||
|
state.phase == RecordingPhase.recording ||
|
||||||
|
state.phase == RecordingPhase.stopped;
|
||||||
|
|
||||||
|
/// The recording under way above the navigation bar on every tab (ADR 0030,
|
||||||
|
/// D60, as Strava's): its activity, its moving time and distance, paused or
|
||||||
|
/// stopped, leading back to it.
|
||||||
|
class const RecordingBar({super.key}) extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
final locale = Localizations.localeOf(context).toString();
|
||||||
|
return BlocBuilder<RecordingBloc, RecordingState>(
|
||||||
|
builder: (context, state) {
|
||||||
|
final live = state.live;
|
||||||
|
final activity =
|
||||||
|
live?.activity ?? state.recording?.activity ?? state.lastActivity;
|
||||||
|
final metres = live?.distanceMetres;
|
||||||
|
final detail = switch (state.phase) {
|
||||||
|
RecordingPhase.stopped => l10n.recordingBarStopped,
|
||||||
|
_ when live == null => '',
|
||||||
|
_ when live.paused => l10n.recordingPausedByHand,
|
||||||
|
_ => [
|
||||||
|
clockTime(live.movingTime),
|
||||||
|
if (metres != null)
|
||||||
|
l10n.recordingBarDistance(kilometres(locale, metres)),
|
||||||
|
].join(' · '),
|
||||||
|
};
|
||||||
|
return Material(
|
||||||
|
color: scheme.primaryContainer,
|
||||||
|
shape: const StadiumBorder(),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () => openRecordingScreen(context),
|
||||||
|
child: Semantics(
|
||||||
|
button: true,
|
||||||
|
label: l10n.recordBack,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 12, 12, 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
LucideIcons.circleDot,
|
||||||
|
size: 20,
|
||||||
|
color: state.phase == RecordingPhase.recording
|
||||||
|
? scheme.error
|
||||||
|
: scheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
activityName(l10n, activity),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
color: scheme.onPrimaryContainer,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
detail,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(color: scheme.onPrimaryContainer),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Icon(
|
||||||
|
LucideIcons.chevronRight,
|
||||||
|
color: scheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -33,15 +33,20 @@ Future<void> openRecordingScreen(BuildContext context) =>
|
||||||
/// distance and the pace now, GPS's state, the plan's step and Lap where it
|
/// distance and the pace now, GPS's state, the plan's step and Lap where it
|
||||||
/// runs one (D42), the route's next turn or the way back to it where it
|
/// runs one (D42), the route's next turn or the way back to it where it
|
||||||
/// follows one (D45), Pause or Resume and Stop held down, and swiped to, a
|
/// follows one (D45), Pause or Resume and Stop held down, and swiped to, a
|
||||||
/// map of the track so far (D45); once stopped, Save or Discard. It closes
|
/// map of the track so far (D45); once stopped, Save or Discard. Saved, the
|
||||||
/// once the recording is saved or discarded; back leaves the recording
|
/// workout's page takes its place; discarded, it closes. Back leaves the
|
||||||
/// running.
|
/// recording running.
|
||||||
class const RecordingScreen({super.key}) extends StatelessWidget {
|
class const RecordingScreen({super.key}) extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) =>
|
Widget build(BuildContext context) =>
|
||||||
BlocConsumer<RecordingBloc, RecordingState>(
|
BlocConsumer<RecordingBloc, RecordingState>(
|
||||||
listenWhen: (before, now) => now.phase == RecordingPhase.idle,
|
listenWhen: (before, now) => now.phase == RecordingPhase.idle,
|
||||||
listener: (context, state) => Navigator.of(context).maybePop(),
|
// Saved, the workout opens in Home in its place, as Strava shows the
|
||||||
|
// activity (D60); discarded, the screen closes.
|
||||||
|
listener: (context, state) => switch (state.saved) {
|
||||||
|
final id? => context.go(TrainPaths.workout(id)),
|
||||||
|
null => Navigator.of(context).maybePop(),
|
||||||
|
},
|
||||||
builder: (context, state) => Scaffold(
|
builder: (context, state) => Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(
|
title: Text(
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import 'package:halkyon_train_flutter/src/feature/health/widget/health_screen.da
|
||||||
import 'package:halkyon_train_flutter/src/feature/recording/bloc/recording_bloc.dart';
|
import 'package:halkyon_train_flutter/src/feature/recording/bloc/recording_bloc.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
|
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/recording/widget/record_sheet.dart';
|
import 'package:halkyon_train_flutter/src/feature/recording/widget/record_sheet.dart';
|
||||||
|
import 'package:halkyon_train_flutter/src/feature/recording/widget/recording_bar.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/recording/widget/recording_screen.dart';
|
import 'package:halkyon_train_flutter/src/feature/recording/widget/recording_screen.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/route/widget/route_import.dart';
|
import 'package:halkyon_train_flutter/src/feature/route/widget/route_import.dart';
|
||||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||||
|
|
@ -20,7 +21,8 @@ import 'package:material_ui/material_ui.dart';
|
||||||
/// the device records), Groups and Profile. Every tab keeps its own stack of
|
/// the device records), Groups and Profile. Every tab keeps its own stack of
|
||||||
/// screens; the editors and the recording cover them. A recording found cut
|
/// screens; the editors and the recording cover them. A recording found cut
|
||||||
/// off as the app opens is offered to be saved or discarded, whichever tab
|
/// off as the app opens is offered to be saved or discarded, whichever tab
|
||||||
/// shows; GPX files Android hands Train are brought in as routes (D45), and
|
/// shows, and one under way stands above the bar on every tab
|
||||||
|
/// ([RecordingBar]); GPX files Android hands Train are brought in as routes (D45), and
|
||||||
/// Health Connect's links to Train's rationale are followed (D31).
|
/// Health Connect's links to Train's rationale are followed (D31).
|
||||||
class const TrainShell({
|
class const TrainShell({
|
||||||
/// The tabs' navigators, from `StatefulShellRoute`.
|
/// The tabs' navigators, from `StatefulShellRoute`.
|
||||||
|
|
@ -35,7 +37,14 @@ class const TrainShell({
|
||||||
// where the device records.
|
// where the device records.
|
||||||
final branches = [0, 1, if (records) 2, 3, 4];
|
final branches = [0, 1, if (records) 2, 3, 4];
|
||||||
final selected = branches.indexOf(shell.currentIndex);
|
final selected = branches.indexOf(shell.currentIndex);
|
||||||
|
// The recording under way on every tab, while there is one (D60).
|
||||||
|
final recording =
|
||||||
|
records &&
|
||||||
|
context.select<RecordingBloc, bool>(
|
||||||
|
(bloc) => showsRecordingBar(bloc.state),
|
||||||
|
);
|
||||||
final frame = NavigationFrame(
|
final frame = NavigationFrame(
|
||||||
|
status: recording ? const RecordingBar() : null,
|
||||||
appName: l10n.appTitle,
|
appName: l10n.appTitle,
|
||||||
icon: trainIcon,
|
icon: trainIcon,
|
||||||
selectedIndex: selected < 0 ? 0 : selected,
|
selectedIndex: selected < 0 ? 0 : selected,
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ import 'package:halkyon_train_flutter/src/feature/recording/data/device_store.da
|
||||||
import 'package:halkyon_train_flutter/src/feature/recording/data/recorded_workout.dart';
|
import 'package:halkyon_train_flutter/src/feature/recording/data/recorded_workout.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platform.dart';
|
import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platform.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
|
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
|
||||||
|
import 'package:halkyon_train_flutter/src/feature/recording/widget/recording_bar.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/route/data/place_search_gateway.dart';
|
import 'package:halkyon_train_flutter/src/feature/route/data/place_search_gateway.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/route/data/route_files.dart';
|
import 'package:halkyon_train_flutter/src/feature/route/data/route_files.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/route/data/route_repository.dart';
|
import 'package:halkyon_train_flutter/src/feature/route/data/route_repository.dart';
|
||||||
|
|
@ -1444,9 +1445,22 @@ void main() {
|
||||||
await pass(tester);
|
await pass(tester);
|
||||||
expect(find.text('Save this workout?'), findsOneWidget);
|
expect(find.text('Save this workout?'), findsOneWidget);
|
||||||
|
|
||||||
|
// Saved, its page opens, in Home's tab (D60).
|
||||||
await tester.tap(find.text('Save'));
|
await tester.tap(find.text('Save'));
|
||||||
await pass(tester, 2);
|
await pass(tester, 2);
|
||||||
expect(recordButton(), findsOneWidget);
|
final ride = find.textContaining(RegExp('ride', caseSensitive: false));
|
||||||
|
expect(
|
||||||
|
find.descendant(of: find.byType(AppBar), matching: ride),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
await tester.pageBack();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(
|
||||||
|
find.descendant(of: find.byType(AppBar), matching: ride),
|
||||||
|
findsNothing,
|
||||||
|
);
|
||||||
|
expect(ride, findsWidgets);
|
||||||
|
expect(recordButton(), findsNothing);
|
||||||
final saved = (await workouts.all()).single.content!;
|
final saved = (await workouts.all()).single.content!;
|
||||||
expect(saved.activity, Activity.cycling);
|
expect(saved.activity, Activity.cycling);
|
||||||
expect(saved.distanceMetres, 100);
|
expect(saved.distanceMetres, 100);
|
||||||
|
|
@ -1476,6 +1490,52 @@ void main() {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('left, the recording stands above the bar on every tab, and '
|
||||||
|
'leads back to it (D60)', (tester) async {
|
||||||
|
var now = at;
|
||||||
|
await withClock(Clock(() => now), () async {
|
||||||
|
await startRecording(tester);
|
||||||
|
await tester.tap(recordButton());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Start'));
|
||||||
|
await pass(tester, 2);
|
||||||
|
platform
|
||||||
|
..write('p @ 0 0 500 -')
|
||||||
|
..now = at.add(const Duration(seconds: 30))
|
||||||
|
..write('p @ ${(100 * 89.932).round()} 0 500 -');
|
||||||
|
now = at.add(const Duration(seconds: 30));
|
||||||
|
await pass(tester);
|
||||||
|
|
||||||
|
await tester.pageBack();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
final bar = find.byType(RecordingBar);
|
||||||
|
expect(bar, findsOneWidget);
|
||||||
|
expect(
|
||||||
|
find.descendant(of: bar, matching: find.text('0:30 · 0.10 km')),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
for (final tab in ['Home', 'Maps', 'Groups', 'Profile']) {
|
||||||
|
await goTab(tester, tab);
|
||||||
|
expect(bar, findsOneWidget, reason: tab);
|
||||||
|
}
|
||||||
|
|
||||||
|
await tester.tap(bar);
|
||||||
|
await pass(tester, 2);
|
||||||
|
expect(find.text('Stop'), findsOneWidget);
|
||||||
|
expect(platform.started, hasLength(1));
|
||||||
|
|
||||||
|
// Paused, it says so.
|
||||||
|
await tester.tap(find.text('Pause'));
|
||||||
|
await pass(tester);
|
||||||
|
await tester.pageBack();
|
||||||
|
await pass(tester);
|
||||||
|
expect(
|
||||||
|
find.descendant(of: bar, matching: find.text('Paused')),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('without location it records nothing, and says why', (
|
testWidgets('without location it records nothing, and says why', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
|
|
|
||||||
|
|
@ -1491,6 +1491,12 @@ Strava. What was found, 27 Oct:
|
||||||
(31 Oct, the developer's), where an account sheet had held the account and the
|
(31 Oct, the developer's), where an account sheet had held the account and the
|
||||||
training's screens: grouped in cards, the training's screens first, the account near
|
training's screens: grouped in cards, the training's screens first, the account near
|
||||||
the foot, About Halkyon Train last and the version small below it.
|
the foot, About Halkyon Train last and the version small below it.
|
||||||
|
A recording under way, left for another tab, stands above the bar on every tab
|
||||||
|
(below the rail's screen from medium windows on), as Strava's: its activity, its
|
||||||
|
moving time and distance, paused or stopped, a tap leading back to it
|
||||||
|
(`RecordingBar`, `NavigationFrame.status`); over the Record tab's button alone, which
|
||||||
|
had been the one way back. A workout just saved opens its page, in Home's tab, where
|
||||||
|
the recording had gone back to the tab it started from (both the developer's, 31 Oct).
|
||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,8 @@ LeanCode's lints.
|
||||||
`TopGradient`, `ScreenTint`, `ScreenColor`, `TintFrame`, `FramedScreen`
|
`TopGradient`, `ScreenTint`, `ScreenColor`, `TintFrame`, `FramedScreen`
|
||||||
and `TabVisibility`; `FloatingNavigationBar`, and `NavigationFrame`, every
|
and `TabVisibility`; `FloatingNavigationBar`, and `NavigationFrame`, every
|
||||||
app's frame around its tabs (the rail with the app's icon, the floating
|
app's frame around its tabs (the rail with the app's icon, the floating
|
||||||
bar, a tick on every choice), whichever router drives it;
|
bar, a tick on every choice, a status such as a recording under way above
|
||||||
|
the bar), whichever router drives it;
|
||||||
`AppLoadingIndicator`, `ButtonLoadingIndicator`, `AppRefreshIndicator`;
|
`AppLoadingIndicator`, `ButtonLoadingIndicator`, `AppRefreshIndicator`;
|
||||||
`SectionCard`, `SliverCentered`, `EmptyListMessage`, `CountBadge`,
|
`SectionCard`, `SliverCentered`, `EmptyListMessage`, `CountBadge`,
|
||||||
`TitleWithKicker`, `CollapsingRow`, `ScrollHidingFab`, `liftWhileDragged`,
|
`TitleWithKicker`, `CollapsingRow`, `ScrollHidingFab`, `liftWhileDragged`,
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,12 @@ class const NavigationFrame({
|
||||||
/// top-level screens and the actions of those below in a toolbar, never
|
/// top-level screens and the actions of those below in a toolbar, never
|
||||||
/// both at once (as Vaia does). The rail of wider windows stays.
|
/// both at once (as Vaia does). The rail of wider windows stays.
|
||||||
final bool hidesNavigationBar = false,
|
final bool hidesNavigationBar = false,
|
||||||
|
|
||||||
|
/// What stands above the bar on every tab while it matters (Train's
|
||||||
|
/// recording under way, as Strava's): floating above the bar below
|
||||||
|
/// medium windows, at the foot of the screen's panel from there on; none
|
||||||
|
/// while null.
|
||||||
|
final Widget? status,
|
||||||
super.key,
|
super.key,
|
||||||
}) extends StatefulWidget {
|
}) extends StatefulWidget {
|
||||||
@override
|
@override
|
||||||
|
|
@ -102,7 +108,15 @@ class _NavigationFrameState() extends State<NavigationFrame> {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: widget.child,
|
child: switch (widget.status) {
|
||||||
|
final status? => Column(
|
||||||
|
children: [
|
||||||
|
Expanded(child: widget.child),
|
||||||
|
Padding(padding: const EdgeInsets.all(12), child: status),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
null => widget.child,
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -142,9 +156,30 @@ class _NavigationFrameState() extends State<NavigationFrame> {
|
||||||
// Without the bar, a strip as high as the system's own bar, in
|
// Without the bar, a strip as high as the system's own bar, in
|
||||||
// the screen's background, keeps every screen inside clear of
|
// the screen's background, keeps every screen inside clear of
|
||||||
// it.
|
// it.
|
||||||
bottomNavigationBar: widget.hidesNavigationBar
|
bottomNavigationBar: switch (widget.status) {
|
||||||
? SizedBox(height: MediaQuery.paddingOf(context).bottom)
|
final status? => Column(
|
||||||
: bar,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(
|
||||||
|
FloatingNavigationBar.sideMargin,
|
||||||
|
0,
|
||||||
|
FloatingNavigationBar.sideMargin,
|
||||||
|
FloatingNavigationBar.lift,
|
||||||
|
),
|
||||||
|
child: status,
|
||||||
|
),
|
||||||
|
if (widget.hidesNavigationBar)
|
||||||
|
SizedBox(height: MediaQuery.paddingOf(context).bottom)
|
||||||
|
else
|
||||||
|
bar,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
null when widget.hidesNavigationBar => SizedBox(
|
||||||
|
height: MediaQuery.paddingOf(context).bottom,
|
||||||
|
),
|
||||||
|
null => bar,
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -160,4 +160,40 @@ void main() {
|
||||||
expect(find.byType(FloatingNavigationBar), findsNothing);
|
expect(find.byType(FloatingNavigationBar), findsNothing);
|
||||||
expect(tester.getBottomLeft(find.byType(Scaffold).last).dy, 800 - 24);
|
expect(tester.getBottomLeft(find.byType(Scaffold).last).dy, 800 - 24);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('a status stands above the bar below medium windows, and at '
|
||||||
|
"the foot of the screen's panel from there on", (tester) async {
|
||||||
|
for (final width in const [400.0, 800.0]) {
|
||||||
|
tester.view.physicalSize = Size(width, 800);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
testApp(
|
||||||
|
NavigationFrame(
|
||||||
|
appName: 'Halkyon Test',
|
||||||
|
icon: icon,
|
||||||
|
selectedIndex: 0,
|
||||||
|
onSelected: (_) {},
|
||||||
|
status: const Text('Recording'),
|
||||||
|
destinations: const [
|
||||||
|
(label: 'Home', icon: Icon(Icons.home), selectedIcon: null),
|
||||||
|
(label: 'Maps', icon: Icon(Icons.map), selectedIcon: null),
|
||||||
|
],
|
||||||
|
child: const Scaffold(body: Text('The tab')),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final status = tester.getBottomLeft(find.text('Recording')).dy;
|
||||||
|
final tab = tester.getTopLeft(find.text('The tab')).dy;
|
||||||
|
expect(status, greaterThan(tab), reason: '$width');
|
||||||
|
if (width < 600) {
|
||||||
|
expect(
|
||||||
|
status,
|
||||||
|
lessThan(tester.getTopLeft(find.byType(NavigationBar)).dy),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue