fix(train): a recording's screen opens as it starts, and pages run on beneath the floating bar (ADR 0030, D21)
An indoor row started showed its notification but not its screen: the Record tab's card pushed the screen only after the service had started, and gave up silently where the card was gone by then. Train's frame now opens it as the recording's phase turns to recording; one found running as the app opens leaves the tabs as they were. The notification tapped opens the recording's screen, not merely the app (RecordingOpenings.kt). A workout's page kept a SafeArea at its foot, whose inset under the floating bar is the bar itself: its content stopped at the bar's top, a plain band behind it. It runs on beneath the bar as the tabs do, and the tabs' lists and a day's page end with the body's bottom padding rather than a fixed 88 px, which left their last item under the bar. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
e26ba47fb9
commit
1aa679fe3e
11 changed files with 258 additions and 20 deletions
|
|
@ -62,7 +62,11 @@ class const DayScreen({required final DateTime date, super.key})
|
|||
);
|
||||
}
|
||||
return ListView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
// Ending clear of the navigation bar floating over the foot.
|
||||
padding: EdgeInsets.only(
|
||||
top: 8,
|
||||
bottom: 8 + MediaQuery.paddingOf(context).bottom,
|
||||
),
|
||||
children: [
|
||||
for (final section in sections)
|
||||
Center(
|
||||
|
|
|
|||
|
|
@ -35,7 +35,11 @@ class const DaysList({super.key}) extends StatelessWidget {
|
|||
],
|
||||
)
|
||||
: ListView(
|
||||
padding: const EdgeInsets.only(bottom: 88),
|
||||
// Room for the screen's button, and for what floats over the foot
|
||||
// (the navigation bar on a phone), the body's bottom padding.
|
||||
padding: EdgeInsets.only(
|
||||
bottom: 88 + MediaQuery.paddingOf(context).bottom,
|
||||
),
|
||||
children: [
|
||||
for (final day in state.days)
|
||||
Center(
|
||||
|
|
|
|||
|
|
@ -25,7 +25,11 @@ class const PlansList({final Widget? header, super.key})
|
|||
}
|
||||
final plans = state.visible;
|
||||
return ListView(
|
||||
padding: const EdgeInsets.only(bottom: 88),
|
||||
// Room for the screen's button, and for what floats over the foot
|
||||
// (the navigation bar on a phone), the body's bottom padding.
|
||||
padding: EdgeInsets.only(
|
||||
bottom: 88 + MediaQuery.paddingOf(context).bottom,
|
||||
),
|
||||
children: [
|
||||
?header,
|
||||
for (final row in [
|
||||
|
|
|
|||
|
|
@ -12,6 +12,10 @@ import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
|||
class AndroidRecorderPlatform() implements RecorderPlatform {
|
||||
static const _channel = MethodChannel('app.halkyon.train/recorder');
|
||||
static const _lines = EventChannel('app.halkyon.train/recorder/lines');
|
||||
static const _openings = EventChannel('app.halkyon.train/recorder/opened');
|
||||
|
||||
@override
|
||||
Stream<void> openings() => _openings.receiveBroadcastStream().map((_) {});
|
||||
static const _scans = {
|
||||
DeviceKind.strap: EventChannel('app.halkyon.train/recorder/straps'),
|
||||
DeviceKind.rower: EventChannel('app.halkyon.train/recorder/rowers'),
|
||||
|
|
|
|||
|
|
@ -161,4 +161,8 @@ abstract interface class RecorderPlatform() {
|
|||
/// The devices of [kind] nearby, each as it is seen, until the stream is
|
||||
/// cancelled; [BluetoothOff] where it is off.
|
||||
Stream<FoundDevice> scan(DeviceKind kind);
|
||||
|
||||
/// Each tap on the recording's notification, which asks for the
|
||||
/// recording's screen (D21), one that opened the app included.
|
||||
Stream<void> openings();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,9 +25,16 @@ import 'package:latlong2/latlong.dart';
|
|||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// Opens the recording screen over the tabs (D60).
|
||||
Future<void> openRecordingScreen(BuildContext context) =>
|
||||
context.push(TrainPaths.recording);
|
||||
/// Opens the recording screen over the tabs (D60), unless it shows
|
||||
/// already.
|
||||
Future<void> openRecordingScreen(BuildContext context) async {
|
||||
final router = GoRouter.of(context);
|
||||
if (router.routerDelegate.currentConfiguration.uri.path ==
|
||||
TrainPaths.recording) {
|
||||
return;
|
||||
}
|
||||
await router.push<void>(TrainPaths.recording);
|
||||
}
|
||||
|
||||
/// A recording under way, or stopped (ADR 0030, D21): the moving time, the
|
||||
/// distance and the pace now, GPS's state, the plan's step and Lap where it
|
||||
|
|
|
|||
|
|
@ -29,7 +29,11 @@ class const RoutesList({super.key}) extends StatelessWidget {
|
|||
}
|
||||
final routes = state.visible;
|
||||
return ListView(
|
||||
padding: const EdgeInsets.only(bottom: 88),
|
||||
// Room for the screen's button, and for what floats over the foot
|
||||
// (the navigation bar on a phone), the body's bottom padding.
|
||||
padding: EdgeInsets.only(
|
||||
bottom: 88 + MediaQuery.paddingOf(context).bottom,
|
||||
),
|
||||
children: [
|
||||
for (final row in [
|
||||
ListTile(
|
||||
|
|
|
|||
|
|
@ -206,7 +206,13 @@ class const _Workout({
|
|||
final started = content.startedAt.toLocal();
|
||||
final hasTrack = analysis.track.isNotEmpty;
|
||||
final details = ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
|
||||
// Running on beneath the floating bar, ending clear of it.
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
16,
|
||||
16,
|
||||
16,
|
||||
32 + MediaQuery.paddingOf(context).bottom,
|
||||
),
|
||||
children: [
|
||||
if (hasTrack) ...[_MapNote(map: map), const SizedBox(height: 16)],
|
||||
Row(
|
||||
|
|
@ -340,8 +346,11 @@ class const _Workout({
|
|||
],
|
||||
],
|
||||
);
|
||||
// The sides kept clear; the foot not, as the page runs on beneath the
|
||||
// navigation bar floating over it, as the tabs do.
|
||||
return SafeArea(
|
||||
top: false,
|
||||
bottom: false,
|
||||
child: Column(
|
||||
children: [
|
||||
if (map case MapFrom(:final source) when hasTrack)
|
||||
|
|
|
|||
|
|
@ -66,7 +66,11 @@ class const WorkoutsList({
|
|||
}
|
||||
return ListView(
|
||||
// Room for the button to record over the last row.
|
||||
padding: const EdgeInsets.only(bottom: 88),
|
||||
// Room for the screen's button, and for what floats over the foot
|
||||
// (the navigation bar on a phone), the body's bottom padding.
|
||||
padding: EdgeInsets.only(
|
||||
bottom: 88 + MediaQuery.paddingOf(context).bottom,
|
||||
),
|
||||
children: [
|
||||
?header,
|
||||
for (final row in rows)
|
||||
|
|
|
|||
|
|
@ -3,11 +3,11 @@ import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
|
|||
import 'package:halkyon_train_flutter/src/feature/plan/widget/plans_list.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/bloc/recording_bloc.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/widget/record_sheet.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:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/widget/workout_texts.dart';
|
||||
import 'package:halkyon_train_flutter/src/tabs/tab_screen.dart';
|
||||
import 'package:halkyon_train_flutter/src/tabs/train_shell.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
|
|
@ -90,8 +90,9 @@ class const ActivityCard({
|
|||
);
|
||||
}
|
||||
|
||||
/// The Record sheet for [activity]; started, the recording's screen, or
|
||||
/// why it couldn't start.
|
||||
/// The Record sheet for [activity]; why it couldn't start, where it
|
||||
/// couldn't. Started, Train's frame opens the recording's screen
|
||||
/// ([TrainShell]), whatever became of this card meanwhile.
|
||||
Future<void> _record(BuildContext context) async {
|
||||
final bloc = context.read<RecordingBloc>();
|
||||
final started = startedOrRefused(bloc);
|
||||
|
|
@ -113,8 +114,6 @@ class const ActivityCard({
|
|||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(refused)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await openRecordingScreen(context);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
|
|
@ -8,6 +10,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/model/recording.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/route/widget/route_import.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
|
@ -87,15 +90,66 @@ class const TrainShell({
|
|||
if (!records) {
|
||||
return listening;
|
||||
}
|
||||
return BlocListener<RecordingBloc, RecordingState>(
|
||||
listenWhen: (before, now) =>
|
||||
before.phase != now.phase && now.phase == RecordingPhase.cutOff,
|
||||
listener: (context, state) => _offerCutOff(context, state.recording!),
|
||||
child: listening,
|
||||
return MultiBlocListener(
|
||||
listeners: [
|
||||
BlocListener<RecordingBloc, RecordingState>(
|
||||
listenWhen: (before, now) =>
|
||||
before.phase != now.phase && now.phase == RecordingPhase.cutOff,
|
||||
listener: (context, state) => _offerCutOff(context, state.recording!),
|
||||
),
|
||||
// Started: the recording's screen, from here rather than from the
|
||||
// card that asked, which may be gone by then; one found running as
|
||||
// the app opens (from loading) leaves the tabs as they were.
|
||||
BlocListener<RecordingBloc, RecordingState>(
|
||||
listenWhen: (before, now) =>
|
||||
now.phase == RecordingPhase.recording &&
|
||||
(before.phase == RecordingPhase.idle ||
|
||||
before.phase == RecordingPhase.cutOff),
|
||||
listener: (context, _) => unawaited(openRecordingScreen(context)),
|
||||
),
|
||||
],
|
||||
child: _RecordingOpenings(child: listening),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The recording's notification tapped: its screen, while there is a
|
||||
/// recording (D21).
|
||||
class const _RecordingOpenings({required final Widget child})
|
||||
extends StatefulWidget {
|
||||
@override
|
||||
State<_RecordingOpenings> createState() => _RecordingOpeningsState();
|
||||
}
|
||||
|
||||
class _RecordingOpeningsState() extends State<_RecordingOpenings> {
|
||||
StreamSubscription<void>? _openings;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_openings = PersonScope.of<TrainPerson>(context).recording?.platform
|
||||
.openings()
|
||||
.listen((_) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
if (context.read<RecordingBloc>().state.phase
|
||||
case RecordingPhase.recording || RecordingPhase.stopped) {
|
||||
unawaited(openRecordingScreen(context));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
unawaited(_openings?.cancel());
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => widget.child;
|
||||
}
|
||||
|
||||
/// Offers [recording], found cut off, to be saved up to its last sample or
|
||||
/// discarded, which asks first (D18).
|
||||
Future<void> _offerCutOff(BuildContext context, Recording recording) async {
|
||||
|
|
|
|||
Loading…
Reference in a new issue