feat(train): routes out and in as GPX (ADR 0030, D45)

A route's page shares it as GPX 1.1 (Android's share sheet, else saved
where the person chooses): its way a track with elevations, its
waypoints and turns as named points. A GPX file comes in from the Routes
tab or, on Android, opened with Train or shared to it; it is planned
along the paths through its points or kept as drawn, then named and
saved as a new route.

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-29 10:44:47 +01:00
commit 5ff9b2e053
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
13 changed files with 1190 additions and 5 deletions

View file

@ -405,6 +405,15 @@
"routeAscent": "{metres} m bergauf",
"routeDescent": "{metres} m bergab",
"routeSummary": "{distance} · {ascent}",
"routesBringIn": "GPX-Datei hereinholen",
"routeBringInTitle": "{name} hereinholen",
"routeBringInSnap": "Entlang der Wege planen",
"routeBringInSnapHint": "Durch ihre Punkte, mit dem Profil deiner Wahl; ihre Abzweigungen angesagt",
"routeBringInDrawn": "So lassen, wie sie ist",
"routeBringInDrawnHint": "Ihr Weg, wie ihn die Datei hat, ohne Ansagen",
"routeNotGpx": "In dieser Datei steckt keine Route, die Train lesen kann",
"routeShareGpx": "Als GPX teilen",
"routeSaveGpx": "Als GPX speichern",
"routeOffline": "Karte offline behalten",
"routeOfflineOff": "Die Karte entlang des Wegs, 2 km zu beiden Seiten, auf diesem Gerät für unterwegs ohne Netz",
"routeOfflineLoading": "Karte wird gespeichert: {done} von {total} Kacheln",

View file

@ -513,6 +513,17 @@
"@routeDescent": {"placeholders": {"metres": {"type": "int"}}},
"routeSummary": "{distance} · {ascent}",
"@routeSummary": {"description": "A route in the list: its distance and climb.", "placeholders": {"distance": {"type": "String", "example": "12.3 km"}, "ascent": {"type": "String", "example": "120 m up"}}},
"routesBringIn": "Bring in a GPX file",
"@routesBringIn": {"description": "In the Routes tab: a route brought in from a GPX file (ADR 0030, D45)."},
"routeBringInTitle": "Bring in {name}",
"@routeBringInTitle": {"placeholders": {"name": {"type": "String", "example": "Isar loop"}}},
"routeBringInSnap": "Plan it along the paths",
"routeBringInSnapHint": "Through its points, with the profile you choose; its turns told",
"routeBringInDrawn": "Keep it as drawn",
"routeBringInDrawnHint": "Its way as the file has it, no turns told",
"routeNotGpx": "This file holds no route Train can read",
"routeShareGpx": "Share as GPX",
"routeSaveGpx": "Save as GPX",
"routeOffline": "Keep the map offline",
"@routeOffline": {"description": "A switch on a saved route: its map kept on this device (ADR 0030, D45)."},
"routeOfflineOff": "Its map along the way, 2 km either side, on this device for when there's no network",

View file

@ -2452,6 +2452,60 @@ abstract class AppLocalizations {
/// **'{distance} · {ascent}'**
String routeSummary(String distance, String ascent);
/// In the Routes tab: a route brought in from a GPX file (ADR 0030, D45).
///
/// In en, this message translates to:
/// **'Bring in a GPX file'**
String get routesBringIn;
/// No description provided for @routeBringInTitle.
///
/// In en, this message translates to:
/// **'Bring in {name}'**
String routeBringInTitle(String name);
/// No description provided for @routeBringInSnap.
///
/// In en, this message translates to:
/// **'Plan it along the paths'**
String get routeBringInSnap;
/// No description provided for @routeBringInSnapHint.
///
/// In en, this message translates to:
/// **'Through its points, with the profile you choose; its turns told'**
String get routeBringInSnapHint;
/// No description provided for @routeBringInDrawn.
///
/// In en, this message translates to:
/// **'Keep it as drawn'**
String get routeBringInDrawn;
/// No description provided for @routeBringInDrawnHint.
///
/// In en, this message translates to:
/// **'Its way as the file has it, no turns told'**
String get routeBringInDrawnHint;
/// No description provided for @routeNotGpx.
///
/// In en, this message translates to:
/// **'This file holds no route Train can read'**
String get routeNotGpx;
/// No description provided for @routeShareGpx.
///
/// In en, this message translates to:
/// **'Share as GPX'**
String get routeShareGpx;
/// No description provided for @routeSaveGpx.
///
/// In en, this message translates to:
/// **'Save as GPX'**
String get routeSaveGpx;
/// A switch on a saved route: its map kept on this device (ADR 0030, D45).
///
/// In en, this message translates to:

View file

@ -1464,6 +1464,38 @@ class AppLocalizationsDe extends AppLocalizations {
return '$distance · $ascent';
}
@override
String get routesBringIn => 'GPX-Datei hereinholen';
@override
String routeBringInTitle(String name) {
return '$name hereinholen';
}
@override
String get routeBringInSnap => 'Entlang der Wege planen';
@override
String get routeBringInSnapHint =>
'Durch ihre Punkte, mit dem Profil deiner Wahl; ihre Abzweigungen angesagt';
@override
String get routeBringInDrawn => 'So lassen, wie sie ist';
@override
String get routeBringInDrawnHint =>
'Ihr Weg, wie ihn die Datei hat, ohne Ansagen';
@override
String get routeNotGpx =>
'In dieser Datei steckt keine Route, die Train lesen kann';
@override
String get routeShareGpx => 'Als GPX teilen';
@override
String get routeSaveGpx => 'Als GPX speichern';
@override
String get routeOffline => 'Karte offline behalten';

View file

@ -1456,6 +1456,37 @@ class AppLocalizationsEn extends AppLocalizations {
return '$distance · $ascent';
}
@override
String get routesBringIn => 'Bring in a GPX file';
@override
String routeBringInTitle(String name) {
return 'Bring in $name';
}
@override
String get routeBringInSnap => 'Plan it along the paths';
@override
String get routeBringInSnapHint =>
'Through its points, with the profile you choose; its turns told';
@override
String get routeBringInDrawn => 'Keep it as drawn';
@override
String get routeBringInDrawnHint =>
'Its way as the file has it, no turns told';
@override
String get routeNotGpx => 'This file holds no route Train can read';
@override
String get routeShareGpx => 'Share as GPX';
@override
String get routeSaveGpx => 'Save as GPX';
@override
String get routeOffline => 'Keep the map offline';

View file

@ -12,6 +12,7 @@ import 'package:halkyon_train_flutter/src/feature/plan/widget/plan_speech.dart';
import 'package:halkyon_train_flutter/src/feature/recording/bloc/recording_bloc.dart';
import 'package:halkyon_train_flutter/src/feature/recording/widget/recording_texts.dart';
import 'package:halkyon_train_flutter/src/feature/route/bloc/routes_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/route/widget/route_import.dart';
import 'package:halkyon_train_flutter/src/feature/route/widget/route_speech.dart';
import 'package:halkyon_train_flutter/src/feature/workout/bloc/workouts_cubit.dart';
import 'package:halkyon_train_flutter/src/home/home_screen.dart';
@ -114,7 +115,10 @@ class const TrainPersonApp({
),
// Health Connect links to Train's rationale from its own screens, and
// what other apps wrote there is read as Train comes to the front (D31).
home: const HealthConnectListener(child: HomeScreen()),
// GPX files Android hands Train are brought in as routes (D45).
home: const HealthConnectListener(
child: RouteFilesListener(child: HomeScreen()),
),
);
}

View file

@ -27,6 +27,7 @@ import 'package:halkyon_train_flutter/src/feature/profile/data/sync_profile_gate
import 'package:halkyon_train_flutter/src/feature/recording/data/android_recorder_platform.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/device_store.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platform.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/routing_gateway.dart';
import 'package:halkyon_train_flutter/src/feature/route/data/sync_route_gateway.dart';
@ -88,6 +89,10 @@ class TrainPerson(
/// be (widget tests but those of it).
final OfflineMaps? offlineMaps,
/// Routes' GPX files in and out (D45); null in widget tests but those of
/// it.
final RouteFiles? routeFiles,
/// Where this device records; null where it doesn't (iOS until 5d, the
/// desktop, which shows and plans, D17).
final RecordingDevice? recording,
@ -405,6 +410,7 @@ Future<TrainPerson> _openPersonData(
maps: maps,
mapTiles: keptVectorTiles(offlineMaps),
offlineMaps: offlineMaps,
routeFiles: PlatformRouteFiles(PlatformFileSaver()),
recording: recorder != null
? (
platform: recorder,

View file

@ -26,14 +26,23 @@ class RouteEditorCubit(
var _asked = 0;
/// Asks whether the instance plans routes, and starts a new route where
/// the phone is.
/// the phone is; a new one brought in with waypoints and no way (a GPX
/// file to be planned along the paths, D45) is planned at once.
Future<void> open() async {
final offered = await _routing.offered();
if (isClosed) {
return;
}
emit(state._with(route: state.route, offered: offered));
if (!offered || state.id != null || state.route.waypoints.isNotEmpty) {
final route = state.route;
if (offered &&
state.id == null &&
route.waypoints.length > 1 &&
route.planned == null) {
await _replan(route);
return;
}
if (!offered || state.id != null || route.waypoints.isNotEmpty) {
return;
}
final here = await _here?.call();

View file

@ -0,0 +1,259 @@
import 'dart:math';
import 'package:halkyon_train_flutter/src/feature/route/model/route.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_analysis.dart'
show climbThresholdMetres;
import 'package:latlong2/latlong.dart';
import 'package:xml/xml.dart';
/// What a GPX file holds of a route (ADR 0030, D45): its name, its way's
/// points with their elevations where it has them, and the waypoints it was
/// planned through where it says (Train's own files, a route's `rtept`).
final class const GpxRoute({
required final String name,
required final List<LatLng> points,
final List<double> elevations = const [],
final List<LatLng> waypoints = const [],
});
/// A route as GPX 1.1 (D45), for CoMaps or anything that reads it: its way
/// as a track with the elevations, its waypoints as `wpt` of the type
/// `waypoint` and its turns as `wpt` of the type `turn`, each named by
/// [turnName] (null: left out).
String gpxOf(
RouteContent route, {
required String? Function(TurnHint) turnName,
}) {
final planned = route.planned;
final builder = XmlBuilder()
..processing('xml', 'version="1.0" encoding="UTF-8"');
void point(
String tag,
LatLng at, {
double? elevation,
String? name,
String? type,
}) {
builder.element(
tag,
attributes: {
'lat': at.latitude.toStringAsFixed(6),
'lon': at.longitude.toStringAsFixed(6),
},
nest: () {
if (elevation != null) {
builder.element('ele', nest: elevation.toStringAsFixed(1));
}
if (name != null) {
builder.element('name', nest: name);
}
if (type != null) {
builder.element('type', nest: type);
}
},
);
}
builder.element(
'gpx',
attributes: {
'version': '1.1',
'creator': 'Halkyon Train',
'xmlns': 'http://www.topografix.com/GPX/1/1',
},
nest: () {
builder.element(
'metadata',
nest: () => builder.element('name', nest: route.name),
);
for (final (index, waypoint) in route.waypoints.indexed) {
point('wpt', waypoint, name: '${index + 1}', type: 'waypoint');
}
if (planned != null) {
for (final hint in planned.hints) {
final name = turnName(hint);
if (name != null && hint.index < planned.points.length) {
point('wpt', planned.points[hint.index], name: name, type: 'turn');
}
}
}
builder.element(
'trk',
nest: () {
builder
..element('name', nest: route.name)
..element(
'trkseg',
nest: () {
final points = planned?.points ?? route.waypoints;
for (final (index, at) in points.indexed) {
point(
'trkpt',
at,
elevation: planned?.elevations.elementAtOrNull(index),
);
}
},
);
},
);
},
);
return builder.buildDocument().toXmlString(pretty: true);
}
/// The route in [text], a GPX file of any version: the way of its first
/// track (its segments joined), else of its first route; the waypoints of
/// Train's own (`wpt` of the type `waypoint`), else a route's points where
/// a track is the way. Null where [text] isn't GPX or has no way of two
/// points or more; [fallbackName] names one without a name.
GpxRoute? readGpx(String text, {required String fallbackName}) {
final XmlDocument document;
try {
document = XmlDocument.parse(text);
} on XmlException {
return null;
}
final gpx = document.rootElement;
if (gpx.localName != 'gpx') {
return null;
}
Iterable<XmlElement> all(XmlElement parent, String name) =>
parent.childElements.where((child) => child.localName == name);
String? textOf(XmlElement parent, String name) =>
all(parent, name).firstOrNull?.innerText.trim();
LatLng? at(XmlElement element) {
final lat = double.tryParse(element.getAttribute('lat') ?? '');
final lon = double.tryParse(element.getAttribute('lon') ?? '');
if (lat == null || lon == null || lat.abs() > 90 || lon.abs() > 180) {
return null;
}
return LatLng(lat, lon);
}
final track = all(gpx, 'trk').firstOrNull;
final route = all(gpx, 'rte').firstOrNull;
final wayPoints = [
if (track != null)
for (final segment in all(track, 'trkseg')) ...all(segment, 'trkpt')
else if (route != null)
...all(route, 'rtept'),
];
final points = <LatLng>[];
final elevations = <double>[];
var everyElevation = true;
for (final element in wayPoints) {
final point = at(element);
if (point == null) {
continue;
}
points.add(point);
final elevation = double.tryParse(textOf(element, 'ele') ?? '');
if (elevation == null) {
everyElevation = false;
} else {
elevations.add(elevation);
}
}
if (points.length < 2) {
return null;
}
final ours = [
for (final element in all(gpx, 'wpt'))
if (textOf(element, 'type') == 'waypoint') ?at(element),
];
final metadata = all(gpx, 'metadata').firstOrNull;
final name = [
if (metadata != null) textOf(metadata, 'name'),
if (track != null) textOf(track, 'name'),
if (route != null) textOf(route, 'name'),
].nonNulls.where((name) => name.isNotEmpty).firstOrNull;
return GpxRoute(
name: name ?? fallbackName,
points: points,
elevations: everyElevation ? elevations : const [],
waypoints: ours.length > 1
? ours
: track != null && route != null
? [for (final element in all(route, 'rtept')) ?at(element)]
: const [],
);
}
/// How many waypoints the instance plans through at most (D44).
const maxWaypoints = 50;
/// [gpx] as a route kept as drawn (D45): its way the file's, its distance
/// and climb worked out here, no turns told; its waypoints the file's, else
/// the way's ends.
RouteContent drawnRoute(GpxRoute gpx, RouteProfile profile) {
const distance = Distance();
var metres = 0.0;
for (var i = 1; i < gpx.points.length; i++) {
metres += distance.as(LengthUnit.Meter, gpx.points[i - 1], gpx.points[i]);
}
return RouteContent(
name: gpx.name,
profile: profile,
waypoints: gpx.waypoints.length > 1 && gpx.waypoints.length <= maxWaypoints
? gpx.waypoints
: [gpx.points.first, gpx.points.last],
planned: RouteGeometry(
points: gpx.points,
elevations: gpx.elevations,
distanceMetres: metres.round(),
ascentMetres: _climb(gpx.elevations),
descentMetres: _climb([...gpx.elevations.reversed]),
),
);
}
/// [gpx] as a route the instance plans along the paths (D45): through the
/// file's waypoints, else through points of its way spread along it (at most
/// [maxWaypoints], none closer than a kilometre but the ends).
RouteContent snappedRoute(GpxRoute gpx, RouteProfile profile) => RouteContent(
name: gpx.name,
profile: profile,
waypoints: gpx.waypoints.length > 1 && gpx.waypoints.length <= maxWaypoints
? gpx.waypoints
: _spread(gpx.points),
);
List<LatLng> _spread(List<LatLng> points) {
const distance = Distance();
final along = <double>[0];
for (var i = 1; i < points.length; i++) {
along.add(
along.last + distance.as(LengthUnit.Meter, points[i - 1], points[i]),
);
}
final step = max<double>(1000, along.last / (maxWaypoints - 1));
final chosen = <LatLng>[points.first];
var next = step;
for (var i = 1; i < points.length - 1; i++) {
if (along[i] >= next && along.last - along[i] >= step / 2) {
chosen.add(points[i]);
next = along[i] + step;
}
}
return chosen..add(points.last);
}
/// The climbs above [climbThresholdMetres] along [elevations], added up, as
/// a recorded workout's are.
int _climb(List<double> elevations) {
if (elevations.isEmpty) {
return 0;
}
var reference = elevations.first;
var gain = 0.0;
for (final elevation in elevations.skip(1)) {
if (elevation > reference + climbThresholdMetres) {
gain += elevation - reference;
reference = elevation;
} else if (elevation < reference - climbThresholdMetres) {
reference = elevation;
}
}
return gain.round();
}

View file

@ -0,0 +1,87 @@
import 'dart:io';
import 'package:file_selector/file_selector.dart';
import 'package:flutter/services.dart';
import 'package:halkyon_app/halkyon_app.dart' show FileSaver;
/// A file the person handed Train: its name and what it holds.
typedef OpenedFile = ({String name, Uint8List bytes});
/// The media type of a GPX file.
const gpxMimeType = 'application/gpx+xml';
/// Routes' files on the device (ADR 0030, D45): GPX opened with Train or
/// shared to it, one the person picks, and a route handed on.
abstract interface class RouteFiles() {
/// The files opened with Train or shared to it (Android's), each once.
Stream<OpenedFile> get opened;
/// Whether [share] hands a file to the system's share sheet (Android)
/// rather than saving it where the person chooses.
bool get shares;
/// A GPX file the person picks; null where they cancel.
Future<OpenedFile?> pick();
/// Hands [bytes] on as the GPX file [name]; false where the person
/// cancelled saving it.
Future<bool> share({required String name, required Uint8List bytes});
}
/// [RouteFiles] on the platform: Android's through the app's own
/// `RouteFiles.kt` (the share sheet, the files Android hands Train), the
/// file dialog `file_selector`'s, and elsewhere a route saved with
/// [_saver].
class PlatformRouteFiles(final FileSaver _saver) implements RouteFiles {
static const _channel = MethodChannel('app.halkyon.train/files');
static const _opened = EventChannel('app.halkyon.train/files/opened');
@override
late final Stream<OpenedFile> opened = Platform.isAndroid
? _opened.receiveBroadcastStream().map(
(event) => switch (event) {
{'name': final String name, 'bytes': final Uint8List bytes} => (
name: name,
bytes: bytes,
),
_ => throw const FormatException('An opened file unread'),
},
)
: const Stream.empty();
@override
bool get shares => Platform.isAndroid;
@override
Future<OpenedFile?> pick() async {
final file = await openFile(
acceptedTypeGroups: [
const XTypeGroup(
label: 'GPX',
extensions: ['gpx'],
// Android names a GPX file by its type alone, which many senders
// leave as a file's.
mimeTypes: [gpxMimeType, 'application/octet-stream', 'text/xml'],
uniformTypeIdentifiers: ['com.topografix.gpx', 'public.xml'],
),
],
);
if (file == null) {
return null;
}
return (name: file.name, bytes: await file.readAsBytes());
}
@override
Future<bool> share({required String name, required Uint8List bytes}) async {
if (!shares) {
return await _saver.save(name: name, bytes: bytes, mimeType: gpxMimeType);
}
await _channel.invokeMethod<void>('share', {
'name': name,
'bytes': bytes,
'mimeType': gpxMimeType,
});
return true;
}
}

View file

@ -0,0 +1,127 @@
import 'dart:async';
import 'dart:convert';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
import 'package:halkyon_train_flutter/src/app/train_dependencies.dart';
import 'package:halkyon_train_flutter/src/feature/route/data/gpx.dart';
import 'package:halkyon_train_flutter/src/feature/route/data/route_files.dart';
import 'package:halkyon_train_flutter/src/feature/route/model/route.dart';
import 'package:halkyon_train_flutter/src/feature/route/widget/route_screen.dart';
import 'package:halkyon_train_flutter/src/feature/route/widget/route_speech.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
import 'package:path/path.dart' as p;
/// Brings [file] in as a new route (ADR 0030, D45): asks whether it is
/// planned along the paths or kept as drawn, then opens it to be named and
/// saved; says so where the file holds no route.
Future<void> bringInRoute(BuildContext context, OpenedFile file) async {
final l10n = AppLocalizations.of(context);
final gpx = readGpx(
utf8.decode(file.bytes, allowMalformed: true),
fallbackName: p.basenameWithoutExtension(file.name),
);
if (gpx == null) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n.routeNotGpx)));
return;
}
final snapped = await showModalBottomSheet<bool>(
context: context,
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
title: Text(
l10n.routeBringInTitle(gpx.name),
style: Theme.of(context).textTheme.titleMedium,
),
),
ListTile(
leading: const Icon(LucideIcons.route),
title: Text(l10n.routeBringInSnap),
subtitle: Text(l10n.routeBringInSnapHint),
onTap: () => Navigator.of(context).pop(true),
),
ListTile(
leading: const Icon(LucideIcons.penLine),
title: Text(l10n.routeBringInDrawn),
subtitle: Text(l10n.routeBringInDrawnHint),
onTap: () => Navigator.of(context).pop(false),
),
],
),
),
);
if (snapped == null || !context.mounted) {
return;
}
await openRouteScreen(
context,
snapped
? snappedRoute(gpx, RouteProfile.run)
: drawnRoute(gpx, RouteProfile.run),
);
}
/// Picks a GPX file and brings it in ([bringInRoute]).
Future<void> pickRoute(BuildContext context) async {
final files = PersonScope.of<TrainPerson>(context).routeFiles;
final file = await files?.pick();
if (file != null && context.mounted) {
await bringInRoute(context, file);
}
}
/// Hands [route] on as GPX: to the share sheet on Android, else saved where
/// the person chooses; its turns named in the app's language.
Future<void> shareRoute(BuildContext context, RouteContent route) async {
final l10n = AppLocalizations.of(context);
final files = PersonScope.of<TrainPerson>(context).routeFiles;
if (files == null) {
return;
}
final name = route.name.trim().replaceAll(RegExp(r'[\\/:*?"<>|]'), ' ');
await files.share(
name: '${name.isEmpty ? 'route' : name}.gpx',
bytes: utf8.encode(gpxOf(route, turnName: (hint) => turnText(l10n, hint))),
);
}
/// Over [child], which is under the navigator: brings in each GPX file
/// Android hands Train (D45).
class const RouteFilesListener({required final Widget child, super.key})
extends StatefulWidget {
@override
State<RouteFilesListener> createState() => _RouteFilesListenerState();
}
class _RouteFilesListenerState() extends State<RouteFilesListener> {
StreamSubscription<OpenedFile>? _opened;
@override
void didChangeDependencies() {
super.didChangeDependencies();
final files = PersonScope.of<TrainPerson>(context).routeFiles;
if (files == null || _opened != null) {
return;
}
_opened = files.opened.listen((file) {
if (mounted) {
unawaited(bringInRoute(context, file));
}
});
}
@override
void dispose() {
unawaited(_opened?.cancel());
super.dispose();
}
@override
Widget build(BuildContext context) => widget.child;
}

View file

@ -11,6 +11,7 @@ import 'package:halkyon_train_flutter/src/feature/route/bloc/route_editor_cubit.
import 'package:halkyon_train_flutter/src/feature/route/bloc/routes_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/route/data/route_analysis.dart';
import 'package:halkyon_train_flutter/src/feature/route/model/route.dart';
import 'package:halkyon_train_flutter/src/feature/route/widget/route_import.dart';
import 'package:halkyon_train_flutter/src/feature/route/widget/route_texts.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_analysis.dart'
show ChartPoint;
@ -87,11 +88,27 @@ class const RouteScreen({required final Future<MapSource?> maps, super.key})
final l10n = AppLocalizations.of(context);
return BlocBuilder<RouteEditorCubit, RouteEditorState>(
buildWhen: (before, now) =>
before.id != now.id || before.canSave != now.canSave,
before.id != now.id ||
before.canSave != now.canSave ||
(before.route.planned == null) != (now.route.planned == null),
builder: (context, state) => Scaffold(
appBar: AppBar(
title: Text(state.id == null ? l10n.routeNewTitle : l10n.routeTitle),
actions: [
if (PersonScope.of<TrainPerson>(context).routeFiles
case final files?)
IconButton(
tooltip: files.shares ? l10n.routeShareGpx : l10n.routeSaveGpx,
icon: Icon(
files.shares ? LucideIcons.share2 : LucideIcons.download,
),
onPressed: state.route.planned == null
? null
: () => shareRoute(
context,
context.read<RouteEditorCubit>().state.route,
),
),
if (state.id != null)
IconButton(
tooltip: l10n.routeDelete,

View file

@ -5,6 +5,7 @@ import 'package:halkyon_train_flutter/src/app/train_dependencies.dart';
import 'package:halkyon_train_flutter/src/feature/map/data/offline_maps.dart';
import 'package:halkyon_train_flutter/src/feature/route/bloc/routes_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/route/model/route.dart';
import 'package:halkyon_train_flutter/src/feature/route/widget/route_import.dart';
import 'package:halkyon_train_flutter/src/feature/route/widget/route_screen.dart';
import 'package:halkyon_train_flutter/src/feature/route/widget/route_texts.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
@ -43,6 +44,12 @@ class const RoutesList({super.key}) extends StatelessWidget {
),
),
),
if (PersonScope.of<TrainPerson>(context).routeFiles != null)
ListTile(
leading: const Icon(LucideIcons.fileInput),
title: Text(l10n.routesBringIn),
onTap: () => pickRoute(context),
),
if (routes.isEmpty)
Padding(
padding: const EdgeInsets.only(top: 48),