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:
parent
a2997e87af
commit
5ff9b2e053
13 changed files with 1190 additions and 5 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Reference in a new issue