feat(train): routes kept as sealed rows, planned on the map (ADR 0030, D44)

Routes are synced `route` rows (the model `SavedRoute`, as Flutter has a
`Route`), sealed as plans are; the migration `routes` only creates the
table, its space index and its cascade to the sync engine's spaces. A
route's payload holds its name, activity, profile, waypoints and the way
planned, points and elevations delta-coded and gzipped.

Home gets a fourth tab, Routes, and the planner: waypoints tapped on the
map, held and dragged, the last undone, a new route's first where the
phone is (GPS through the recorder's channel), the way planned by the
instance's BRouter after each change, an earlier answer dropped; its
distance, climb, descent, elevation and surfaces under the map.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-29 00:37:44 +01:00
commit aea99c68ca
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -3,8 +3,14 @@ package app.halkyon.train
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import android.os.Build
import android.os.Bundle
import android.os.CancellationSignal
import android.os.Handler
import android.os.Looper
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel
@ -67,6 +73,7 @@ class MainActivity : FlutterActivity() {
"stop" -> result.success(RecorderService.stop().let { null })
"show" -> result.success(RecorderService.show(call.arguments as String).let { null })
"isRecording" -> result.success(RecorderService.isRecording())
"position" -> position(result)
else -> result.notImplemented()
}
}
@ -166,7 +173,59 @@ class MainActivity : FlutterActivity() {
else -> emptyList()
}
/**
* Where the phone is, for a route's first waypoint (ADR 0030, D44): GPS's
* last fix where it is at most two minutes old, else a new one, waited
* for 15 s at most; null without the location permission or a fix.
*/
@Suppress("MissingPermission") // Checked first.
private fun position(result: MethodChannel.Result) {
if (checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
result.success(null)
return
}
val manager = getSystemService(LocationManager::class.java)
var answered = false
fun answer(location: Location?) {
if (answered) return
answered = true
result.success(location?.let { mapOf("lat" to it.latitude, "lon" to it.longitude) })
}
val last = manager.getLastKnownLocation(LocationManager.GPS_PROVIDER)
if (last != null && System.currentTimeMillis() - last.time <= FRESH_MS) {
answer(last)
return
}
if (!manager.isProviderEnabled(LocationManager.GPS_PROVIDER)) {
answer(last)
return
}
val handler = Handler(Looper.getMainLooper())
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val cancel = CancellationSignal()
handler.postDelayed({
cancel.cancel()
answer(last)
}, WAIT_MS)
manager.getCurrentLocation(LocationManager.GPS_PROVIDER, cancel, mainExecutor) { answer(it ?: last) }
} else {
val listener = object : LocationListener {
override fun onLocationChanged(location: Location) {
manager.removeUpdates(this)
answer(location)
}
}
handler.postDelayed({
manager.removeUpdates(listener)
answer(last)
}, WAIT_MS)
manager.requestLocationUpdates(LocationManager.GPS_PROVIDER, 0L, 0f, listener, Looper.getMainLooper())
}
}
private companion object {
const val RECORDER_REQUEST = 1
const val FRESH_MS = 120_000L
const val WAIT_MS = 15_000L
}
}