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
23 changed files with 1190 additions and 5 deletions
File diff suppressed because one or more lines are too long
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue