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
18 changed files with 258 additions and 20 deletions
File diff suppressed because one or more lines are too long
|
|
@ -33,17 +33,22 @@ class MainActivity : FlutterActivity() {
|
||||||
/** Music from Jellyfin: the network, and the tempo of what is downloaded (ADR 0030, D47). */
|
/** Music from Jellyfin: the network, and the tempo of what is downloaded (ADR 0030, D47). */
|
||||||
private val music by lazy { MusicBridge(this) }
|
private val music by lazy { MusicBridge(this) }
|
||||||
|
|
||||||
|
/** The recording's notification tapped: the recording's screen (ADR 0030, D21). */
|
||||||
|
private val recordingOpenings = RecordingOpenings()
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
health.onIntent(intent)
|
health.onIntent(intent)
|
||||||
// Not again where Android makes the activity anew with the same intent.
|
// Not again where Android makes the activity anew with the same intent.
|
||||||
if (savedInstanceState == null) files.onIntent(intent)
|
if (savedInstanceState == null) files.onIntent(intent)
|
||||||
|
recordingOpenings.onIntent(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
health.onIntent(intent)
|
health.onIntent(intent)
|
||||||
files.onIntent(intent)
|
files.onIntent(intent)
|
||||||
|
recordingOpenings.onIntent(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Deprecated("FlutterActivity has no result registry")
|
@Deprecated("FlutterActivity has no result registry")
|
||||||
|
|
@ -118,6 +123,8 @@ class MainActivity : FlutterActivity() {
|
||||||
EventChannel(messenger, "app.halkyon.train/loop/search").setStreamHandler(loop.search)
|
EventChannel(messenger, "app.halkyon.train/loop/search").setStreamHandler(loop.search)
|
||||||
MethodChannel(messenger, "app.halkyon.train/files").setMethodCallHandler(files)
|
MethodChannel(messenger, "app.halkyon.train/files").setMethodCallHandler(files)
|
||||||
EventChannel(messenger, "app.halkyon.train/files/opened").setStreamHandler(files.opened)
|
EventChannel(messenger, "app.halkyon.train/files/opened").setStreamHandler(files.opened)
|
||||||
|
EventChannel(messenger, "app.halkyon.train/recorder/opened")
|
||||||
|
.setStreamHandler(recordingOpenings.opened)
|
||||||
MethodChannel(messenger, "app.halkyon.train/music").setMethodCallHandler(music)
|
MethodChannel(messenger, "app.halkyon.train/music").setMethodCallHandler(music)
|
||||||
EventChannel(messenger, "app.halkyon.train/music/fetched").setStreamHandler(music.fetched)
|
EventChannel(messenger, "app.halkyon.train/music/fetched").setStreamHandler(music.fetched)
|
||||||
EventChannel(messenger, "app.halkyon.train/recorder/lines").setStreamHandler(
|
EventChannel(messenger, "app.halkyon.train/recorder/lines").setStreamHandler(
|
||||||
|
|
|
||||||
|
|
@ -832,6 +832,8 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
||||||
|
|
||||||
private fun openApp(): PendingIntent? =
|
private fun openApp(): PendingIntent? =
|
||||||
packageManager.getLaunchIntentForPackage(packageName)?.let {
|
packageManager.getLaunchIntentForPackage(packageName)?.let {
|
||||||
|
// Onto the recording's screen, not wherever the app was (D21).
|
||||||
|
it.putExtra(RecordingOpenings.EXTRA_OPEN_RECORDING, true)
|
||||||
PendingIntent.getActivity(this, 0, it, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
|
PendingIntent.getActivity(this, 0, it, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
package app.halkyon.train
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import io.flutter.plugin.common.EventChannel
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The recording's notification tapped (ADR 0030, D21): the app opens on the
|
||||||
|
* recording's screen, not wherever it was. The tap is handed to the app as it
|
||||||
|
* listens (now, or once it does, after a cold start).
|
||||||
|
*/
|
||||||
|
class RecordingOpenings {
|
||||||
|
companion object {
|
||||||
|
/** The extra of the notification's intent that asks for the recording. */
|
||||||
|
const val EXTRA_OPEN_RECORDING = "app.halkyon.train.OPEN_RECORDING"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var sink: EventChannel.EventSink? = null
|
||||||
|
private var waiting = false
|
||||||
|
|
||||||
|
val opened =
|
||||||
|
object : EventChannel.StreamHandler {
|
||||||
|
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
|
||||||
|
sink = events
|
||||||
|
if (waiting) {
|
||||||
|
waiting = false
|
||||||
|
events.success(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCancel(arguments: Any?) {
|
||||||
|
sink = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Takes in [intent]'s ask for the recording, if it asks. */
|
||||||
|
fun onIntent(intent: Intent?) {
|
||||||
|
if (intent?.getBooleanExtra(EXTRA_OPEN_RECORDING, false) != true) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Asked once: the activity made anew with the same intent asks no more.
|
||||||
|
intent.removeExtra(EXTRA_OPEN_RECORDING)
|
||||||
|
sink?.success(null) ?: run { waiting = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -62,7 +62,11 @@ class const DayScreen({required final DateTime date, super.key})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return ListView(
|
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: [
|
children: [
|
||||||
for (final section in sections)
|
for (final section in sections)
|
||||||
Center(
|
Center(
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,11 @@ class const DaysList({super.key}) extends StatelessWidget {
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
: ListView(
|
: 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: [
|
children: [
|
||||||
for (final day in state.days)
|
for (final day in state.days)
|
||||||
Center(
|
Center(
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,11 @@ class const PlansList({final Widget? header, super.key})
|
||||||
}
|
}
|
||||||
final plans = state.visible;
|
final plans = state.visible;
|
||||||
return ListView(
|
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: [
|
children: [
|
||||||
?header,
|
?header,
|
||||||
for (final row in [
|
for (final row in [
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,10 @@ import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
||||||
class AndroidRecorderPlatform() implements RecorderPlatform {
|
class AndroidRecorderPlatform() implements RecorderPlatform {
|
||||||
static const _channel = MethodChannel('app.halkyon.train/recorder');
|
static const _channel = MethodChannel('app.halkyon.train/recorder');
|
||||||
static const _lines = EventChannel('app.halkyon.train/recorder/lines');
|
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 = {
|
static const _scans = {
|
||||||
DeviceKind.strap: EventChannel('app.halkyon.train/recorder/straps'),
|
DeviceKind.strap: EventChannel('app.halkyon.train/recorder/straps'),
|
||||||
DeviceKind.rower: EventChannel('app.halkyon.train/recorder/rowers'),
|
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
|
/// The devices of [kind] nearby, each as it is seen, until the stream is
|
||||||
/// cancelled; [BluetoothOff] where it is off.
|
/// cancelled; [BluetoothOff] where it is off.
|
||||||
Stream<FoundDevice> scan(DeviceKind kind);
|
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:lucide_icons_flutter/lucide_icons.dart';
|
||||||
import 'package:material_ui/material_ui.dart';
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
/// Opens the recording screen over the tabs (D60).
|
/// Opens the recording screen over the tabs (D60), unless it shows
|
||||||
Future<void> openRecordingScreen(BuildContext context) =>
|
/// already.
|
||||||
context.push(TrainPaths.recording);
|
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
|
/// 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
|
/// 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;
|
final routes = state.visible;
|
||||||
return ListView(
|
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: [
|
children: [
|
||||||
for (final row in [
|
for (final row in [
|
||||||
ListTile(
|
ListTile(
|
||||||
|
|
|
||||||
|
|
@ -206,7 +206,13 @@ class const _Workout({
|
||||||
final started = content.startedAt.toLocal();
|
final started = content.startedAt.toLocal();
|
||||||
final hasTrack = analysis.track.isNotEmpty;
|
final hasTrack = analysis.track.isNotEmpty;
|
||||||
final details = ListView(
|
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: [
|
children: [
|
||||||
if (hasTrack) ...[_MapNote(map: map), const SizedBox(height: 16)],
|
if (hasTrack) ...[_MapNote(map: map), const SizedBox(height: 16)],
|
||||||
Row(
|
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(
|
return SafeArea(
|
||||||
top: false,
|
top: false,
|
||||||
|
bottom: false,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
if (map case MapFrom(:final source) when hasTrack)
|
if (map case MapFrom(:final source) when hasTrack)
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,11 @@ class const WorkoutsList({
|
||||||
}
|
}
|
||||||
return ListView(
|
return ListView(
|
||||||
// Room for the button to record over the last row.
|
// 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: [
|
children: [
|
||||||
?header,
|
?header,
|
||||||
for (final row in rows)
|
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/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/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/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/recording/widget/recording_texts.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.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/feature/workout/widget/workout_texts.dart';
|
||||||
import 'package:halkyon_train_flutter/src/tabs/tab_screen.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:halkyon_ui/halkyon_ui.dart';
|
||||||
import 'package:material_ui/material_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
|
/// The Record sheet for [activity]; why it couldn't start, where it
|
||||||
/// why it couldn't start.
|
/// couldn't. Started, Train's frame opens the recording's screen
|
||||||
|
/// ([TrainShell]), whatever became of this card meanwhile.
|
||||||
Future<void> _record(BuildContext context) async {
|
Future<void> _record(BuildContext context) async {
|
||||||
final bloc = context.read<RecordingBloc>();
|
final bloc = context.read<RecordingBloc>();
|
||||||
final started = startedOrRefused(bloc);
|
final started = startedOrRefused(bloc);
|
||||||
|
|
@ -113,8 +114,6 @@ class const ActivityCard({
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text(refused)),
|
SnackBar(content: Text(refused)),
|
||||||
);
|
);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
await openRecordingScreen(context);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:halkyon_app/halkyon_app.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/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/recording_bar.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_train_flutter/src/feature/route/widget/route_import.dart';
|
||||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
|
@ -87,15 +90,66 @@ class const TrainShell({
|
||||||
if (!records) {
|
if (!records) {
|
||||||
return listening;
|
return listening;
|
||||||
}
|
}
|
||||||
return BlocListener<RecordingBloc, RecordingState>(
|
return MultiBlocListener(
|
||||||
listenWhen: (before, now) =>
|
listeners: [
|
||||||
before.phase != now.phase && now.phase == RecordingPhase.cutOff,
|
BlocListener<RecordingBloc, RecordingState>(
|
||||||
listener: (context, state) => _offerCutOff(context, state.recording!),
|
listenWhen: (before, now) =>
|
||||||
child: listening,
|
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
|
/// Offers [recording], found cut off, to be saved up to its last sample or
|
||||||
/// discarded, which asks first (D18).
|
/// discarded, which asks first (D18).
|
||||||
Future<void> _offerCutOff(BuildContext context, Recording recording) async {
|
Future<void> _offerCutOff(BuildContext context, Recording recording) async {
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platfo
|
||||||
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_bar.dart';
|
||||||
|
import 'package:halkyon_train_flutter/src/feature/recording/widget/recording_screen.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';
|
||||||
|
|
@ -1431,6 +1432,37 @@ void main() {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('a recording started opens its screen once; its '
|
||||||
|
'notification tapped opens it again from any tab', (tester) async {
|
||||||
|
await withClock(Clock(() => at), () async {
|
||||||
|
await startRecording(tester);
|
||||||
|
await tester.tap(startFor('Walking'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(sheetStart);
|
||||||
|
await pass(tester, 2);
|
||||||
|
expect(find.byType(RecordingScreen), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.pageBack();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await goTab(tester, 'Home');
|
||||||
|
expect(find.byType(RecordingScreen), findsNothing);
|
||||||
|
|
||||||
|
platform.openNotification();
|
||||||
|
await pass(tester);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byType(RecordingScreen), findsOneWidget);
|
||||||
|
|
||||||
|
platform.openNotification();
|
||||||
|
await pass(tester);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(
|
||||||
|
find.byType(RecordingScreen),
|
||||||
|
findsOneWidget,
|
||||||
|
reason: 'not a second one over the first',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('a run plays the metronome chosen, Music not offered '
|
testWidgets('a run plays the metronome chosen, Music not offered '
|
||||||
'without kept tracks, a ride neither (D48)', (tester) async {
|
'without kept tracks, a ride neither (D48)', (tester) async {
|
||||||
await withClock(Clock(() => at), () async {
|
await withClock(Clock(() => at), () async {
|
||||||
|
|
@ -2198,6 +2230,11 @@ void main() {
|
||||||
expect(platform.started.single.rower, rower.address);
|
expect(platform.started.single.rower, rower.address);
|
||||||
expect(platform.started.single.autoPause, AutoPauseBy.strokes);
|
expect(platform.started.single.autoPause, AutoPauseBy.strokes);
|
||||||
expect(platform.scanned, [DeviceKind.rower]);
|
expect(platform.scanned, [DeviceKind.rower]);
|
||||||
|
expect(
|
||||||
|
find.byType(RecordingScreen),
|
||||||
|
findsOneWidget,
|
||||||
|
reason: 'the recording on screen, not in the notification alone',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -3232,6 +3269,41 @@ void main() {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('home and a workout’s page run on beneath the floating bar, '
|
||||||
|
'their lists ending clear of it', (tester) async {
|
||||||
|
for (var day = 1; day <= 12; day++) {
|
||||||
|
await saveRun(DateTime(2026, 10, day, 7, 30));
|
||||||
|
}
|
||||||
|
await startWith(tester);
|
||||||
|
tester.view.padding = const FakeViewPadding(bottom: 48);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
final bar = find.byType(FloatingNavigationBar);
|
||||||
|
final screen = tester.view.physicalSize.height;
|
||||||
|
|
||||||
|
Future<void> endsClearOfTheBar(Finder list) async {
|
||||||
|
expect(
|
||||||
|
tester.getBottomLeft(list).dy,
|
||||||
|
screen,
|
||||||
|
reason: 'the list runs on beneath the bar',
|
||||||
|
);
|
||||||
|
await tester.drag(list, const Offset(0, -5000));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
final items = find.descendant(of: list, matching: find.byType(Text));
|
||||||
|
expect(
|
||||||
|
tester.getBottomLeft(items.last).dy,
|
||||||
|
lessThan(tester.getTopLeft(bar).dy),
|
||||||
|
reason: 'its end scrolls out from under the bar',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await endsClearOfTheBar(find.byType(Scrollable).first);
|
||||||
|
await tester.drag(find.byType(Scrollable).first, const Offset(0, 5000));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Morning run').first);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await endsClearOfTheBar(find.byType(Scrollable).last);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('above them, this week: its time, distance and workouts, and '
|
testWidgets('above them, this week: its time, distance and workouts, and '
|
||||||
'its days from Monday in British English (D60)', (tester) async {
|
'its days from Monday in British English (D60)', (tester) async {
|
||||||
tester.platformDispatcher.localesTestValue = const [Locale('en', 'GB')];
|
tester.platformDispatcher.localesTestValue = const [Locale('en', 'GB')];
|
||||||
|
|
|
||||||
|
|
@ -159,6 +159,14 @@ class FakeRecorderPlatform({
|
||||||
Future<({double lat, double lon})?> position() async =>
|
Future<({double lat, double lon})?> position() async =>
|
||||||
allows.contains(RecorderPermission.location) ? at : null;
|
allows.contains(RecorderPermission.location) ? at : null;
|
||||||
|
|
||||||
|
final _openings = StreamController<void>.broadcast();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<void> openings() => _openings.stream;
|
||||||
|
|
||||||
|
/// The recording's notification tapped.
|
||||||
|
void openNotification() => _openings.add(null);
|
||||||
|
|
||||||
/// A recording that runs on, as when the app comes back to one.
|
/// A recording that runs on, as when the app comes back to one.
|
||||||
void runOn(String path) => _file = File(path);
|
void runOn(String path) => _file = File(path);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -342,6 +342,14 @@ Strava. What was found, 27 Oct:
|
||||||
than in a snack bar, which would cover the button it speaks of. The service holds a
|
than in a snack bar, which would cover the button it speaks of. The service holds a
|
||||||
partial wake lock while it records (at most 12 hours), so that its clock and file run
|
partial wake lock while it records (at most 12 hours), so that its clock and file run
|
||||||
with the screen off where no GPS fix wakes the processor.
|
with the screen off where no GPS fix wakes the processor.
|
||||||
|
**Opened from Train's frame** (1 Nov 2026, the developer's report: an indoor row
|
||||||
|
started, its notification showed, its screen didn't): a recording started opens its
|
||||||
|
screen from `TrainShell`, as the recording's phase turns from idle or cut off to
|
||||||
|
recording, not from the Record tab's card, which had pushed it only after the
|
||||||
|
service had started and gave up silently where it was gone by then; one found
|
||||||
|
running as the app opens leaves the tabs as they were. The notification tapped opens
|
||||||
|
the recording's screen (`RecordingOpenings.kt`, `RecorderPlatform.openings`), not
|
||||||
|
merely the app, and never a second over the first.
|
||||||
- **D22 OpenFreeMap first, the PMTiles sidecar with route planning** (recommended, over
|
- **D22 OpenFreeMap first, the PMTiles sidecar with route planning** (recommended, over
|
||||||
the sidecar now, which no one else ever sees the areas viewed through but needs Train's
|
the sidecar now, which no one else ever sees the areas viewed through but needs Train's
|
||||||
server and the sidecar deployed on learn.halkyon.eu before anything else of Train is
|
server and the sidecar deployed on learn.halkyon.eu before anything else of Train is
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue