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
24 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"/>
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
</intent-filter>
|
</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. -->
|
<!-- Health Connect's link to Train's rationale, before Android 14. -->
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE"/>
|
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE"/>
|
||||||
|
|
@ -102,6 +123,16 @@
|
||||||
android:name=".RecorderService"
|
android:name=".RecorderService"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:foregroundServiceType="location|health|connectedDevice" />
|
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.
|
<!-- Don't delete the meta-data below.
|
||||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
<meta-data
|
<meta-data
|
||||||
|
|
|
||||||
|
|
@ -27,14 +27,20 @@ class MainActivity : FlutterActivity() {
|
||||||
/** The Polar Loop's side (ADR 0030, D34). */
|
/** The Polar Loop's side (ADR 0030, D34). */
|
||||||
private val loop by lazy { LoopBridge(this) }
|
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?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
health.onIntent(intent)
|
health.onIntent(intent)
|
||||||
|
// Not again where Android makes the activity anew with the same intent.
|
||||||
|
if (savedInstanceState == null) files.onIntent(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
health.onIntent(intent)
|
health.onIntent(intent)
|
||||||
|
files.onIntent(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Deprecated("FlutterActivity has no result registry")
|
@Deprecated("FlutterActivity has no result registry")
|
||||||
|
|
@ -94,6 +100,8 @@ class MainActivity : FlutterActivity() {
|
||||||
MethodChannel(messenger, "app.halkyon.train/loop").setMethodCallHandler(loop)
|
MethodChannel(messenger, "app.halkyon.train/loop").setMethodCallHandler(loop)
|
||||||
EventChannel(messenger, "app.halkyon.train/loop/state").setStreamHandler(loop.state)
|
EventChannel(messenger, "app.halkyon.train/loop/state").setStreamHandler(loop.state)
|
||||||
EventChannel(messenger, "app.halkyon.train/loop/search").setStreamHandler(loop.search)
|
EventChannel(messenger, "app.halkyon.train/loop/search").setStreamHandler(loop.search)
|
||||||
|
MethodChannel(messenger, "app.halkyon.train/files").setMethodCallHandler(files)
|
||||||
|
EventChannel(messenger, "app.halkyon.train/files/opened").setStreamHandler(files.opened)
|
||||||
EventChannel(messenger, "app.halkyon.train/recorder/lines").setStreamHandler(
|
EventChannel(messenger, "app.halkyon.train/recorder/lines").setStreamHandler(
|
||||||
object : EventChannel.StreamHandler {
|
object : EventChannel.StreamHandler {
|
||||||
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
|
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",
|
"routeAscent": "{metres} m bergauf",
|
||||||
"routeDescent": "{metres} m bergab",
|
"routeDescent": "{metres} m bergab",
|
||||||
"routeSummary": "{distance} · {ascent}",
|
"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",
|
"routeOffline": "Karte offline behalten",
|
||||||
"routeOfflineOff": "Die Karte entlang des Wegs, 2 km zu beiden Seiten, auf diesem Gerät für unterwegs ohne Netz",
|
"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",
|
"routeOfflineLoading": "Karte wird gespeichert: {done} von {total} Kacheln",
|
||||||
|
|
|
||||||
|
|
@ -513,6 +513,17 @@
|
||||||
"@routeDescent": {"placeholders": {"metres": {"type": "int"}}},
|
"@routeDescent": {"placeholders": {"metres": {"type": "int"}}},
|
||||||
"routeSummary": "{distance} · {ascent}",
|
"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"}}},
|
"@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": "Keep the map offline",
|
||||||
"@routeOffline": {"description": "A switch on a saved route: its map kept on this device (ADR 0030, D45)."},
|
"@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",
|
"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}'**
|
/// **'{distance} · {ascent}'**
|
||||||
String routeSummary(String distance, String 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).
|
/// A switch on a saved route: its map kept on this device (ADR 0030, D45).
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -1464,6 +1464,38 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
return '$distance · $ascent';
|
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
|
@override
|
||||||
String get routeOffline => 'Karte offline behalten';
|
String get routeOffline => 'Karte offline behalten';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1456,6 +1456,37 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
return '$distance · $ascent';
|
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
|
@override
|
||||||
String get routeOffline => 'Keep the map offline';
|
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/bloc/recording_bloc.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/recording/widget/recording_texts.dart';
|
import 'package:halkyon_train_flutter/src/feature/recording/widget/recording_texts.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/route/bloc/routes_cubit.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/route/widget/route_speech.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/workout/bloc/workouts_cubit.dart';
|
import 'package:halkyon_train_flutter/src/feature/workout/bloc/workouts_cubit.dart';
|
||||||
import 'package:halkyon_train_flutter/src/home/home_screen.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
|
// 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).
|
// 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/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/device_store.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platform.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/route_repository.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/route/data/routing_gateway.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';
|
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).
|
/// be (widget tests but those of it).
|
||||||
final OfflineMaps? offlineMaps,
|
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
|
/// Where this device records; null where it doesn't (iOS until 5d, the
|
||||||
/// desktop, which shows and plans, D17).
|
/// desktop, which shows and plans, D17).
|
||||||
final RecordingDevice? recording,
|
final RecordingDevice? recording,
|
||||||
|
|
@ -405,6 +410,7 @@ Future<TrainPerson> _openPersonData(
|
||||||
maps: maps,
|
maps: maps,
|
||||||
mapTiles: keptVectorTiles(offlineMaps),
|
mapTiles: keptVectorTiles(offlineMaps),
|
||||||
offlineMaps: offlineMaps,
|
offlineMaps: offlineMaps,
|
||||||
|
routeFiles: PlatformRouteFiles(PlatformFileSaver()),
|
||||||
recording: recorder != null
|
recording: recorder != null
|
||||||
? (
|
? (
|
||||||
platform: recorder,
|
platform: recorder,
|
||||||
|
|
|
||||||
|
|
@ -26,14 +26,23 @@ class RouteEditorCubit(
|
||||||
var _asked = 0;
|
var _asked = 0;
|
||||||
|
|
||||||
/// Asks whether the instance plans routes, and starts a new route where
|
/// 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 {
|
Future<void> open() async {
|
||||||
final offered = await _routing.offered();
|
final offered = await _routing.offered();
|
||||||
if (isClosed) {
|
if (isClosed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
emit(state._with(route: state.route, offered: offered));
|
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;
|
return;
|
||||||
}
|
}
|
||||||
final here = await _here?.call();
|
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/bloc/routes_cubit.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/route/data/route_analysis.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/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/route/widget/route_texts.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_analysis.dart'
|
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_analysis.dart'
|
||||||
show ChartPoint;
|
show ChartPoint;
|
||||||
|
|
@ -87,11 +88,27 @@ class const RouteScreen({required final Future<MapSource?> maps, super.key})
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
return BlocBuilder<RouteEditorCubit, RouteEditorState>(
|
return BlocBuilder<RouteEditorCubit, RouteEditorState>(
|
||||||
buildWhen: (before, now) =>
|
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(
|
builder: (context, state) => Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(state.id == null ? l10n.routeNewTitle : l10n.routeTitle),
|
title: Text(state.id == null ? l10n.routeNewTitle : l10n.routeTitle),
|
||||||
actions: [
|
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)
|
if (state.id != null)
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: l10n.routeDelete,
|
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/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/bloc/routes_cubit.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/route/model/route.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_screen.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/route/widget/route_texts.dart';
|
import 'package:halkyon_train_flutter/src/feature/route/widget/route_texts.dart';
|
||||||
import 'package:halkyon_ui/halkyon_ui.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)
|
if (routes.isEmpty)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 48),
|
padding: const EdgeInsets.only(top: 48),
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,9 @@ dependencies:
|
||||||
# What a route's map kept offline was made for (ADR 0030, D45).
|
# What a route's map kept offline was made for (ADR 0030, D45).
|
||||||
crypto: ^3.0.7
|
crypto: ^3.0.7
|
||||||
equatable: ^3.0.0
|
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:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_bloc: ^9.1.1
|
flutter_bloc: ^9.1.1
|
||||||
|
|
@ -65,6 +68,8 @@ dependencies:
|
||||||
# Reads a style kept offline (D45), which vector_map_tiles reads only from
|
# Reads a style kept offline (D45), which vector_map_tiles reads only from
|
||||||
# the network; the version vector_map_tiles takes.
|
# the network; the version vector_map_tiles takes.
|
||||||
vector_tile_renderer: ^6.1.0
|
vector_tile_renderer: ^6.1.0
|
||||||
|
# Routes as GPX, written and read (D45).
|
||||||
|
xml: ^7.0.1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_driver:
|
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/recorded_workout.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platform.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/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/route_repository.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/route/data/routing_gateway.dart';
|
import 'package:halkyon_train_flutter/src/feature/route/data/routing_gateway.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/route/model/route.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_health_platform.dart';
|
||||||
import '../support/fake_loop_platform.dart';
|
import '../support/fake_loop_platform.dart';
|
||||||
import '../support/fake_recorder_platform.dart';
|
import '../support/fake_recorder_platform.dart';
|
||||||
|
import '../support/fake_route_files.dart';
|
||||||
import '../support/fake_routing_gateway.dart';
|
import '../support/fake_routing_gateway.dart';
|
||||||
import '../support/memory_content_key_gateway.dart';
|
import '../support/memory_content_key_gateway.dart';
|
||||||
import '../support/memory_day_gateway.dart';
|
import '../support/memory_day_gateway.dart';
|
||||||
|
|
@ -88,6 +90,7 @@ void main() {
|
||||||
RouteRepository? routes,
|
RouteRepository? routes,
|
||||||
RoutingGateway? routing,
|
RoutingGateway? routing,
|
||||||
OfflineMaps? offlineMaps,
|
OfflineMaps? offlineMaps,
|
||||||
|
RouteFiles? routeFiles,
|
||||||
}) async {
|
}) async {
|
||||||
tester.view.physicalSize = const Size(412, 900);
|
tester.view.physicalSize = const Size(412, 900);
|
||||||
tester.view.devicePixelRatio = 1;
|
tester.view.devicePixelRatio = 1;
|
||||||
|
|
@ -208,6 +211,7 @@ void main() {
|
||||||
// No tiles: widget tests reach no network.
|
// No tiles: widget tests reach no network.
|
||||||
mapTiles: (source) => const SizedBox.shrink(),
|
mapTiles: (source) => const SizedBox.shrink(),
|
||||||
offlineMaps: offlineMaps,
|
offlineMaps: offlineMaps,
|
||||||
|
routeFiles: routeFiles,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
@ -1945,6 +1949,7 @@ void main() {
|
||||||
WidgetTester tester, {
|
WidgetTester tester, {
|
||||||
({double lat, double lon})? at,
|
({double lat, double lon})? at,
|
||||||
OfflineMaps? offlineMaps,
|
OfflineMaps? offlineMaps,
|
||||||
|
RouteFiles? routeFiles,
|
||||||
}) async {
|
}) async {
|
||||||
await start(
|
await start(
|
||||||
tester,
|
tester,
|
||||||
|
|
@ -1952,6 +1957,7 @@ void main() {
|
||||||
routes: routes,
|
routes: routes,
|
||||||
routing: routing,
|
routing: routing,
|
||||||
offlineMaps: offlineMaps,
|
offlineMaps: offlineMaps,
|
||||||
|
routeFiles: routeFiles,
|
||||||
recording: at == null
|
recording: at == null
|
||||||
? null
|
? null
|
||||||
: (
|
: (
|
||||||
|
|
@ -2112,6 +2118,112 @@ void main() {
|
||||||
expect((await tester.runAsync(routes.all))!.single.id, id);
|
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 "
|
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 {
|
'counted, marked in the list, and let go again (D45)', (tester) async {
|
||||||
final id = await routes.save(
|
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