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
22 changed files with 1190 additions and 5 deletions

View file

@ -70,6 +70,27 @@
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<!-- A GPX file opened with Train or shared to it (ADR 0030, D45):
by its type where the sender names one, by its name where
a file manager says only that it is a file. -->
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<action android:name="android.intent.action.SEND"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:mimeType="application/gpx+xml"/>
<data android:mimeType="application/gpx"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="content"/>
<data android:scheme="file"/>
<data android:host="*"/>
<data android:mimeType="*/*"/>
<data android:pathPattern=".*\\.gpx"/>
<data android:pathPattern=".*\\..*\\.gpx"/>
</intent-filter>
<!-- Health Connect's link to Train's rationale, before Android 14. -->
<intent-filter>
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE"/>
@ -102,6 +123,16 @@
android:name=".RecorderService"
android:exported="false"
android:foregroundServiceType="location|health|connectedDevice" />
<!-- A route's GPX handed to the share sheet (ADR 0030, D45). -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.files"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/shared_files"/>
</provider>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data

View file

@ -27,14 +27,20 @@ class MainActivity : FlutterActivity() {
/** The Polar Loop's side (ADR 0030, D34). */
private val loop by lazy { LoopBridge(this) }
/** Routes' GPX files, in and out (ADR 0030, D45). */
private val files by lazy { RouteFiles(this) }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
health.onIntent(intent)
// Not again where Android makes the activity anew with the same intent.
if (savedInstanceState == null) files.onIntent(intent)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
health.onIntent(intent)
files.onIntent(intent)
}
@Deprecated("FlutterActivity has no result registry")
@ -94,6 +100,8 @@ class MainActivity : FlutterActivity() {
MethodChannel(messenger, "app.halkyon.train/loop").setMethodCallHandler(loop)
EventChannel(messenger, "app.halkyon.train/loop/state").setStreamHandler(loop.state)
EventChannel(messenger, "app.halkyon.train/loop/search").setStreamHandler(loop.search)
MethodChannel(messenger, "app.halkyon.train/files").setMethodCallHandler(files)
EventChannel(messenger, "app.halkyon.train/files/opened").setStreamHandler(files.opened)
EventChannel(messenger, "app.halkyon.train/recorder/lines").setStreamHandler(
object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {

View file

@ -0,0 +1,118 @@
package app.halkyon.train
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.provider.OpenableColumns
import androidx.core.content.FileProvider
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.io.File
/**
* Routes' files (ADR 0030, D45): a GPX file opened with Train or shared to it,
* handed to the app as it listens (now, or once it does), and a route shared
* out through Android's share sheet.
*/
class RouteFiles(private val activity: Activity) : MethodChannel.MethodCallHandler {
companion object {
/** A route's file is far smaller; anything larger isn't read. */
const val MAX_BYTES = 20L * 1024 * 1024
}
private var sink: EventChannel.EventSink? = null
private val waiting = mutableListOf<Map<String, Any>>()
val opened =
object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
sink = events
waiting.forEach(events::success)
waiting.clear()
}
override fun onCancel(arguments: Any?) {
sink = null
}
}
/** Takes in the file [intent] opens or shares, if any. */
fun onIntent(intent: Intent?) {
val uri =
when (intent?.action) {
Intent.ACTION_VIEW -> intent.data
Intent.ACTION_SEND ->
@Suppress("DEPRECATION")
(intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM))
else -> null
} ?: return
val file = read(uri) ?: return
sink?.success(file) ?: waiting.add(file)
}
private fun read(uri: Uri): Map<String, Any>? =
try {
val resolver = activity.contentResolver
var name = uri.lastPathSegment ?: "route.gpx"
var size = -1L
resolver.query(uri, null, null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME).takeIf { it >= 0 }?.let {
cursor.getString(it)?.let { shown -> name = shown }
}
cursor.getColumnIndex(OpenableColumns.SIZE).takeIf { it >= 0 }?.let {
size = cursor.getLong(it)
}
}
}
if (size > MAX_BYTES) {
null
} else {
resolver.openInputStream(uri)?.use { input ->
val bytes = input.readUpTo(MAX_BYTES + 1)
if (bytes.size > MAX_BYTES) null else mapOf("name" to name, "bytes" to bytes)
}
}
} catch (_: Exception) {
null
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
if (call.method != "share") {
result.notImplemented()
return
}
val name = File(call.argument<String>("name")!!).name
val bytes = call.argument<ByteArray>("bytes")!!
// A folder of its own, emptied each time: only the file shared last stays.
val folder = File(activity.cacheDir, "shared").apply {
deleteRecursively()
mkdirs()
}
val file = File(folder, name).apply { writeBytes(bytes) }
val uri = FileProvider.getUriForFile(activity, "${activity.packageName}.files", file)
val send =
Intent(Intent.ACTION_SEND).apply {
type = call.argument<String>("mimeType") ?: "application/gpx+xml"
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
activity.startActivity(Intent.createChooser(send, null))
result.success(null)
}
}
/** Up to [limit] bytes of the stream. */
private fun java.io.InputStream.readUpTo(limit: Long): ByteArray {
val out = java.io.ByteArrayOutputStream()
val buffer = ByteArray(64 * 1024)
var total = 0L
while (total < limit) {
val read = read(buffer, 0, minOf(buffer.size.toLong(), limit - total).toInt())
if (read < 0) break
out.write(buffer, 0, read)
total += read
}
return out.toByteArray()
}

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- What Train shares through its FileProvider (ADR 0030, D45): the route
file it writes for the share sheet, and nothing else of its cache. -->
<paths>
<cache-path name="shared" path="shared/"/>
</paths>

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),

View file

@ -17,6 +17,9 @@ dependencies:
# What a route's map kept offline was made for (ADR 0030, D45).
crypto: ^3.0.7
equatable: ^3.0.0
# A GPX file brought in (ADR 0030, D45), the platform's own dialog, as
# Learn and halkyon_app open and save files.
file_selector: ^1.1.0
flutter:
sdk: flutter
flutter_bloc: ^9.1.1
@ -65,6 +68,8 @@ dependencies:
# Reads a style kept offline (D45), which vector_map_tiles reads only from
# the network; the version vector_map_tiles takes.
vector_tile_renderer: ^6.1.0
# Routes as GPX, written and read (D45).
xml: ^7.0.1
dev_dependencies:
flutter_driver:

View file

@ -31,6 +31,7 @@ import 'package:halkyon_train_flutter/src/feature/recording/data/device_store.da
import 'package:halkyon_train_flutter/src/feature/recording/data/recorded_workout.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platform.dart';
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.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/model/route.dart';
@ -46,6 +47,7 @@ import '../support/fake_firmware_relay.dart';
import '../support/fake_health_platform.dart';
import '../support/fake_loop_platform.dart';
import '../support/fake_recorder_platform.dart';
import '../support/fake_route_files.dart';
import '../support/fake_routing_gateway.dart';
import '../support/memory_content_key_gateway.dart';
import '../support/memory_day_gateway.dart';
@ -88,6 +90,7 @@ void main() {
RouteRepository? routes,
RoutingGateway? routing,
OfflineMaps? offlineMaps,
RouteFiles? routeFiles,
}) async {
tester.view.physicalSize = const Size(412, 900);
tester.view.devicePixelRatio = 1;
@ -208,6 +211,7 @@ void main() {
// No tiles: widget tests reach no network.
mapTiles: (source) => const SizedBox.shrink(),
offlineMaps: offlineMaps,
routeFiles: routeFiles,
);
},
),
@ -1945,6 +1949,7 @@ void main() {
WidgetTester tester, {
({double lat, double lon})? at,
OfflineMaps? offlineMaps,
RouteFiles? routeFiles,
}) async {
await start(
tester,
@ -1952,6 +1957,7 @@ void main() {
routes: routes,
routing: routing,
offlineMaps: offlineMaps,
routeFiles: routeFiles,
recording: at == null
? null
: (
@ -2112,6 +2118,112 @@ void main() {
expect((await tester.runAsync(routes.all))!.single.id, id);
});
group('as GPX (D45)', () {
const gpx = '''
<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1">
<trk><name>Along the Isar</name><trkseg>
<trkpt lat="48.137" lon="11.575"><ele>519</ele></trkpt>
<trkpt lat="48.130" lon="11.578"><ele>530</ele></trkpt>
<trkpt lat="48.120" lon="11.583"><ele>512</ele></trkpt>
</trkseg></trk>
</gpx>''';
final file = (name: 'isar.gpx', bytes: utf8.encode(gpx));
testWidgets('a file picked is kept as drawn: its way, distance and '
"climb the file's, nothing planned, saved under its name", (
tester,
) async {
final files = FakeRouteFiles()..picked = file;
await openRoutes(tester, routeFiles: files);
await tester.tap(find.text('Bring in a GPX file'));
await tester.pumpAndSettle();
expect(find.text('Bring in Along the Isar'), findsOneWidget);
await tester.tap(find.text('Keep it as drawn'));
await tester.pumpAndSettle();
expect(find.text('New route'), findsOneWidget);
expect(find.text('11 m up'), findsOneWidget);
expect(routing.asked, isEmpty);
await tester.tap(find.widgetWithText(TextButton, 'Save'));
await tester.runAsync(pumpEventQueue);
await tester.pumpAndSettle();
expect(find.text('Along the Isar'), findsOneWidget);
final [route] =
await tester.runAsync(routes.all) ?? const <StoredRoute>[];
expect(route.content?.planned?.points, hasLength(3));
expect(route.content?.planned?.elevations, [519, 530, 512]);
});
testWidgets('a file Android hands Train is planned along the paths '
'through its points', (tester) async {
final files = FakeRouteFiles();
await openRoutes(tester, routeFiles: files);
files.open(file);
await tester.pumpAndSettle();
await tester.tap(find.text('Plan it along the paths'));
await tester.pumpAndSettle();
expect(routing.asked.single.waypoints, const [
LatLng(48.137, 11.575),
LatLng(48.120, 11.583),
]);
expect(find.text('New route'), findsOneWidget);
});
testWidgets('a file without a route says so, and opens nothing', (
tester,
) async {
final files = FakeRouteFiles()
..picked = (name: 'notes.txt', bytes: utf8.encode('Buy milk'));
await openRoutes(tester, routeFiles: files);
await tester.tap(find.text('Bring in a GPX file'));
await tester.pumpAndSettle();
expect(
find.text('This file holds no route Train can read'),
findsOneWidget,
);
expect(find.text('New route'), findsOneWidget, reason: 'the tile');
expect(find.text('Keep it as drawn'), findsNothing);
});
testWidgets('a route is shared as GPX under its name, or saved where '
'the device has no share sheet', (tester) async {
await routes.save(
RouteContent(
name: 'Isar: loop',
profile: RouteProfile.run,
waypoints: const [LatLng(48.137, 11.575), LatLng(48.12, 11.58)],
planned: straightRoute(const [
LatLng(48.137, 11.575),
LatLng(48.12, 11.58),
]),
),
);
final files = FakeRouteFiles();
await openRoutes(tester, routeFiles: files);
await tester.tap(find.text('Isar: loop'));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Share as GPX'));
await tester.pumpAndSettle();
final shared = files.shared.single;
expect(shared.name, 'Isar loop.gpx');
final text = utf8.decode(shared.bytes);
expect(text, contains('<name>Isar: loop</name>'));
expect('<trkpt'.allMatches(text), hasLength(2));
});
testWidgets('without a share sheet it says Save', (tester) async {
await openRoutes(tester, routeFiles: FakeRouteFiles(shares: false));
await tester.tap(find.text('New route'));
await tester.pumpAndSettle();
expect(find.byTooltip('Save as GPX'), findsOneWidget);
expect(find.byTooltip('Share as GPX'), findsNothing);
});
});
testWidgets("a saved route's map is kept offline on request, its tiles "
'counted, marked in the list, and let go again (D45)', (tester) async {
final id = await routes.save(

View file

@ -0,0 +1,173 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_train_flutter/src/feature/route/data/gpx.dart';
import 'package:halkyon_train_flutter/src/feature/route/model/route.dart';
import 'package:latlong2/latlong.dart';
/// Routes as GPX (ADR 0030, D45): out with their way, waypoints and turns,
/// in from Train's own files and anyone's, kept as drawn or planned along
/// the paths.
void main() {
const route = RouteContent(
name: 'Isar & <Auen>',
profile: RouteProfile.run,
waypoints: [LatLng(48.137154, 11.576124), LatLng(48.1201, 11.5832)],
planned: RouteGeometry(
points: [
LatLng(48.137154, 11.576124),
LatLng(48.13, 11.58),
LatLng(48.1201, 11.5832),
],
elevations: [519.5, 524, 512],
distanceMetres: 2050,
hints: [
TurnHint(index: 1, kind: TurnKind.right, metres: 1200),
TurnHint(index: 2, kind: TurnKind.beeline, metres: 0),
],
),
);
String? name(TurnHint hint) =>
hint.kind == TurnKind.beeline ? null : 'Turn right';
test(
'a route goes out as GPX 1.1: its way a track with elevations, its '
'waypoints and its turns named, a beeline left out, its name escaped',
() {
final gpx = gpxOf(route, turnName: name);
expect(gpx, contains('xmlns="http://www.topografix.com/GPX/1/1"'));
expect(gpx, contains('<name>Isar &amp; &lt;Auen></name>'));
expect(gpx, contains('<trkpt lat="48.130000" lon="11.580000">'));
expect(gpx, contains('<ele>524.0</ele>'));
expect('<type>waypoint</type>'.allMatches(gpx), hasLength(2));
expect('<type>turn</type>'.allMatches(gpx), hasLength(1));
expect(gpx, contains('<name>Turn right</name>'));
},
);
test("Train's own file comes back as it went: its name, way, elevations "
'and waypoints', () {
final back = readGpx(gpxOf(route, turnName: name), fallbackName: 'File')!;
expect(back.name, route.name);
expect(back.points, route.planned!.points);
expect(back.elevations, route.planned!.elevations);
expect(back.waypoints, route.waypoints);
});
test("anyone's track comes in: its segments joined, GPX 1.0 too, without "
'elevations where some lack them, named by the file where it has no '
'name', () {
const text = '''
<?xml version="1.0"?>
<gpx version="1.0" xmlns="http://www.topografix.com/GPX/1/0">
<wpt lat="48.2" lon="11.6"><name>Café</name></wpt>
<trk>
<trkseg>
<trkpt lat="48.1" lon="11.5"><ele>500</ele></trkpt>
<trkpt lat="48.11" lon="11.5"/>
</trkseg>
<trkseg>
<trkpt lat="48.12" lon="11.5"><ele>510</ele></trkpt>
<trkpt lat="not" lon="11.5"/>
</trkseg>
</trk>
</gpx>''';
final gpx = readGpx(text, fallbackName: 'ride.gpx')!;
expect(gpx.name, 'ride.gpx');
expect(gpx.points, const [
LatLng(48.1, 11.5),
LatLng(48.11, 11.5),
LatLng(48.12, 11.5),
]);
expect(gpx.elevations, isEmpty);
expect(gpx.waypoints, isEmpty, reason: 'a café is no waypoint');
});
test('a route without a track is its route points', () {
const text = '''
<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1">
<rte><name>Loop</name>
<rtept lat="48.1" lon="11.5"/><rtept lat="48.2" lon="11.6"/>
</rte>
</gpx>''';
final gpx = readGpx(text, fallbackName: 'File')!;
expect(gpx.name, 'Loop');
expect(gpx.points, const [LatLng(48.1, 11.5), LatLng(48.2, 11.6)]);
});
test("what isn't GPX, or has no way of two points, is nothing", () {
expect(readGpx('not xml', fallbackName: 'f'), isNull);
expect(readGpx('<kml><trk/></kml>', fallbackName: 'f'), isNull);
expect(
readGpx(
'<gpx><trk><trkseg><trkpt lat="1" lon="2"/></trkseg></trk></gpx>',
fallbackName: 'f',
),
isNull,
);
expect(
readGpx(
'<gpx><trk><trkseg><trkpt lat="91" lon="2"/><trkpt lat="1" lon="200"/>'
'</trkseg></trk></gpx>',
fallbackName: 'f',
),
isNull,
);
});
test('kept as drawn: the way as it is, its distance and climb worked out, '
'no turns', () {
const gpx = GpxRoute(
name: 'Hill',
points: [LatLng(48, 11), LatLng(48.01, 11), LatLng(48.02, 11)],
elevations: [500, 540, 520],
);
final drawn = drawnRoute(gpx, RouteProfile.hike);
expect(drawn.profile, RouteProfile.hike);
expect(drawn.waypoints, const [LatLng(48, 11), LatLng(48.02, 11)]);
expect(drawn.planned!.points, gpx.points);
expect(drawn.planned!.distanceMetres, closeTo(2224, 5));
expect(drawn.planned!.ascentMetres, 40);
expect(drawn.planned!.descentMetres, 20);
expect(drawn.planned!.hints, isEmpty);
});
test('planned along the paths: through its own waypoints, else through at '
'most 50 points of its way, a kilometre apart or more', () {
const own = GpxRoute(
name: 'Own',
points: [LatLng(48, 11), LatLng(48.5, 11)],
waypoints: [LatLng(48, 11), LatLng(48.2, 11), LatLng(48.5, 11)],
);
expect(snappedRoute(own, RouteProfile.run).waypoints, own.waypoints);
expect(snappedRoute(own, RouteProfile.run).planned, isNull);
// 200 km north in 2,000 steps of 100 m.
final long = GpxRoute(
name: 'Long',
points: [for (var i = 0; i <= 2000; i++) LatLng(48 + i * 0.0009, 11)],
);
final spread = snappedRoute(long, RouteProfile.gravel).waypoints;
expect(spread.length, lessThanOrEqualTo(maxWaypoints));
expect(spread.first, long.points.first);
expect(spread.last, long.points.last);
// 3 km: a waypoint every kilometre.
final short = GpxRoute(
name: 'Short',
points: [for (var i = 0; i <= 30; i++) LatLng(48 + i * 0.0009, 11)],
);
const distance = Distance();
final few = snappedRoute(short, RouteProfile.run).waypoints;
expect(few.length, inInclusiveRange(3, 4));
for (var i = 1; i < few.length; i++) {
expect(
distance.as(LengthUnit.Meter, few[i - 1], few[i]),
greaterThanOrEqualTo(499),
);
}
});
}

Some files were not shown because too many files have changed in this diff Show more