summaryrefslogtreecommitdiff
path: root/android-app/app/src/main
diff options
context:
space:
mode:
Diffstat (limited to 'android-app/app/src/main')
-rw-r--r--android-app/app/src/main/AndroidManifest.xml30
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt28
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt109
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/NavApplication.kt4
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/gps/DeviceGpsProvider.kt3
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookRepository.kt18
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookStorage.kt53
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/logbook/VoiceLogViewModel.kt15
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/SavedTracksFragment.kt55
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/TackDetector.kt139
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/TrackColors.kt38
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/TrackDetailSheet.kt302
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt16
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/TrackStorage.kt152
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportFragment.kt20
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportGenerator.kt114
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportViewModel.kt16
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt41
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt72
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/voicelog/VoiceLogFragment.kt278
-rw-r--r--android-app/app/src/main/res/layout/dialog_add_track_note.xml69
-rw-r--r--android-app/app/src/main/res/layout/fragment_saved_tracks.xml29
-rw-r--r--android-app/app/src/main/res/layout/fragment_trip_report.xml62
-rw-r--r--android-app/app/src/main/res/layout/fragment_voice_log.xml359
-rw-r--r--android-app/app/src/main/res/layout/item_log_entry.xml12
-rw-r--r--android-app/app/src/main/res/layout/item_logbook_entry.xml50
-rw-r--r--android-app/app/src/main/res/layout/layout_instruments_sheet.xml17
-rw-r--r--android-app/app/src/main/res/layout/layout_track_detail_sheet.xml198
-rwxr-xr-xandroid-app/app/src/main/res/values/colors.xml2
-rw-r--r--android-app/app/src/main/res/xml/file_paths.xml4
30 files changed, 1709 insertions, 596 deletions
diff --git a/android-app/app/src/main/AndroidManifest.xml b/android-app/app/src/main/AndroidManifest.xml
index a10d503..1701e17 100644
--- a/android-app/app/src/main/AndroidManifest.xml
+++ b/android-app/app/src/main/AndroidManifest.xml
@@ -3,7 +3,6 @@
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
- <uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-permission android:name="android.permission.INTERNET" />
@@ -24,6 +23,15 @@
<service
android:name=".LocationService"
android:foregroundServiceType="location" />
+ <provider
+ android:name="androidx.core.content.FileProvider"
+ android:authorities="${applicationId}.fileprovider"
+ android:exported="false"
+ android:grantUriPermissions="true">
+ <meta-data
+ android:name="android.support.FILE_PROVIDER_PATHS"
+ android:resource="@xml/file_paths" />
+ </provider>
<activity
android:name=".MainActivity"
android:exported="true"
@@ -57,20 +65,17 @@
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="application/xml" />
</intent-filter>
- <!-- Google Files on Android 16 sends application/octet-stream for .gpx because
- MimeTypeMap has no entry for that extension. The ExternalStorageProvider URI
- encodes the full file path, so pathSuffix=".gpx" keeps this targeted.
- Code double-checks via OpenableColumns.DISPLAY_NAME before parsing. -->
+ <!-- Many file managers send application/octet-stream with no path hints.
+ pathSuffix cannot match content URIs (opaque paths); code checks
+ OpenableColumns.DISPLAY_NAME instead. -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
- <data android:scheme="content" android:mimeType="application/octet-stream"
- android:pathSuffix=".gpx" />
+ <data android:mimeType="application/octet-stream" />
</intent-filter>
- <!-- Share-sheet filters (ACTION_SEND): file URI is in EXTRA_STREAM, not
- intent data, so pathSuffix can't help — these cover apps that type .gpx
- correctly. Code checks display name for the XML variants. -->
+ <!-- Share-sheet filters (ACTION_SEND): file URI is in EXTRA_STREAM.
+ Code checks display name for XML and octet-stream variants. -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
@@ -91,6 +96,11 @@
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="application/xml" />
</intent-filter>
+ <intent-filter>
+ <action android:name="android.intent.action.SEND" />
+ <category android:name="android.intent.category.DEFAULT" />
+ <data android:mimeType="application/octet-stream" />
+ </intent-filter>
</activity>
</application>
</manifest>
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt b/android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt
index 18e0907..9440d49 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt
@@ -236,8 +236,8 @@ class LocationService : Service() {
ACTION_START_FOREGROUND_SERVICE -> {
startForeground(NOTIFICATION_ID, createNotification())
serviceScope.launch {
- _currentPowerMode.emit(PowerMode.FULL)
- startLocationUpdatesInternal(PowerMode.FULL)
+ _currentPowerMode.emit(PowerMode.ECONOMY)
+ startLocationUpdatesInternal(PowerMode.ECONOMY)
}
barometerSensorManager.start()
val flags = (application as NavApplication).featureFlags
@@ -251,6 +251,8 @@ class LocationService : Service() {
nmeaStreamManager.stop()
stopSelf()
}
+ ACTION_START_ECONOMY -> setPowerMode(PowerMode.ECONOMY)
+ ACTION_START_FULL -> setPowerMode(PowerMode.FULL)
ACTION_START_ANCHOR_WATCH -> {
val radius = intent.getDoubleExtra(EXTRA_WATCH_RADIUS, AnchorWatchState.DEFAULT_WATCH_CIRCLE_RADIUS_METERS)
serviceScope.launch {
@@ -260,7 +262,7 @@ class LocationService : Service() {
}
ACTION_STOP_ANCHOR_WATCH -> {
stopAnchorWatch()
- setPowerMode(PowerMode.FULL)
+ setPowerMode(PowerMode.ECONOMY)
}
ACTION_UPDATE_WATCH_RADIUS -> {
val radius = intent.getDoubleExtra(EXTRA_WATCH_RADIUS, AnchorWatchState.DEFAULT_WATCH_CIRCLE_RADIUS_METERS)
@@ -297,7 +299,11 @@ class LocationService : Service() {
@SuppressLint("MissingPermission")
private fun startLocationUpdatesInternal(powerMode: PowerMode) {
- val locationRequest = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, powerMode.gpsUpdateIntervalMillis)
+ val priority = if (powerMode == PowerMode.FULL)
+ Priority.PRIORITY_HIGH_ACCURACY
+ else
+ Priority.PRIORITY_BALANCED_POWER_ACCURACY
+ val locationRequest = LocationRequest.Builder(priority, powerMode.gpsUpdateIntervalMillis)
.setMinUpdateIntervalMillis(powerMode.gpsUpdateIntervalMillis / 2)
.build()
fusedLocationClient.requestLocationUpdates(
@@ -370,14 +376,16 @@ class LocationService : Service() {
return EnvironmentalSnapshot(
windSpeedKt = trueWind?.speedKt,
windDirectionDeg = trueWind?.directionDeg,
- currentSpeedKt = null, // TODO: Pull from latest forecast
- currentDirectionDeg = null
+ currentSpeedKt = _currentSpeedKt.value,
+ currentDirectionDeg = _currentDirectionDeg.value
)
}
companion object {
const val ACTION_START_FOREGROUND_SERVICE = "ACTION_START_FOREGROUND_SERVICE"
const val ACTION_STOP_FOREGROUND_SERVICE = "ACTION_STOP_FOREGROUND_SERVICE"
+ const val ACTION_START_ECONOMY = "ACTION_START_ECONOMY"
+ const val ACTION_START_FULL = "ACTION_START_FULL"
const val ACTION_START_ANCHOR_WATCH = "ACTION_START_ANCHOR_WATCH"
const val ACTION_STOP_ANCHOR_WATCH = "ACTION_STOP_ANCHOR_WATCH"
const val ACTION_UPDATE_WATCH_RADIUS = "ACTION_UPDATE_WATCH_RADIUS"
@@ -423,6 +431,14 @@ class LocationService : Service() {
private val _nmeaBoatSpeedDataFlow = MutableSharedFlow<BoatSpeedData>(replay = 1)
val nmeaBoatSpeedData: SharedFlow<BoatSpeedData> get() = _nmeaBoatSpeedDataFlow
+ private val _currentSpeedKt = MutableStateFlow<Double?>(null)
+ private val _currentDirectionDeg = MutableStateFlow<Double?>(null)
+
+ fun setCurrentConditions(speedKt: Double?, directionDeg: Double?) {
+ _currentSpeedKt.value = speedKt
+ _currentDirectionDeg.value = directionDeg
+ }
+
private val _currentPowerMode = MutableStateFlow(PowerMode.FULL)
val currentPowerMode: StateFlow<PowerMode> get() = _currentPowerMode
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt b/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt
index 8609456..e4bc9b5 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt
@@ -3,6 +3,7 @@ package org.terst.nav
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
+import android.provider.DocumentsContract
import android.provider.OpenableColumns
import android.util.Log
import kotlinx.coroutines.Dispatchers
@@ -112,6 +113,8 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
private val viewModel: MainViewModel by viewModels()
private var pendingServiceStart = false
+ private var serviceStarted = false
+ private var overlayShowing = false
override fun onResume() {
super.onResume()
@@ -119,9 +122,17 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
if (pendingServiceStart) {
pendingServiceStart = false
startServices()
+ } else {
+ restoreLocationMode()
}
}
+ override fun onPause() {
+ super.onPause()
+ if (!NavApplication.isTesting) mapView?.onPause()
+ throttleLocationIfIdle()
+ }
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
MapLibre.getInstance(this)
@@ -293,14 +304,6 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
}
}
- // "Saved Tracks" button in the instrument sheet
- findViewById<View>(R.id.btn_saved_tracks).setOnClickListener {
- showOverlay(SavedTracksFragment())
- bottomSheetBehavior.isHideable = true
- bottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN
- // Note: do NOT set selectedItemId here — it triggers the nav listener
- // which calls hideOverlays(), immediately undoing showOverlay().
- }
}
/** Updates all static unit label TextViews to match current [unitPrefs]. */
@@ -330,6 +333,7 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
}
private fun applyConditions(c: MarineConditions) {
+ LocationService.setCurrentConditions(c.currentSpeedKt, c.currentDirDeg)
instrumentHandler?.updateConditions(
twsKt = c.windSpeedKt,
twsBearingDeg = c.windDirDeg?.toFloat(),
@@ -416,19 +420,42 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
}
private fun showOverlay(fragment: androidx.fragment.app.Fragment) {
+ overlayShowing = true
fragmentContainer.visibility = View.VISIBLE
fabRecordTrack.visibility = View.GONE
fabLayers.visibility = View.GONE
+ fabRecenter.visibility = View.GONE
supportFragmentManager.beginTransaction()
.replace(R.id.fragment_container, fragment)
.commit()
}
+ fun showSavedTracks() {
+ showOverlay(SavedTracksFragment())
+ }
+
+ fun showTrackDetail() {
+ overlayShowing = true
+ fragmentContainer.visibility = View.VISIBLE
+ fabRecordTrack.visibility = View.GONE
+ fabLayers.visibility = View.GONE
+ fabRecenter.visibility = View.GONE
+ supportFragmentManager.beginTransaction()
+ .replace(R.id.fragment_container, org.terst.nav.track.TrackDetailSheet.newInstance())
+ .addToBackStack("track_detail")
+ .commit()
+ }
+
internal fun hideOverlays() {
+ overlayShowing = false
fragmentContainer.visibility = View.GONE
fabRecordTrack.visibility = View.VISIBLE
fabLayers.visibility = View.VISIBLE
- // Re-apply cached conditions in case they arrived while the overlay was covering the sheet
+ val following = mapHandler?.isFollowing?.value ?: true
+ fabRecenter.visibility = if (following) View.GONE else View.VISIBLE
+ fabRecenter.alpha = 1f
+ bottomSheetBehavior.isHideable = false
+ bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
lastConditions?.let { applyConditions(it) }
}
@@ -449,6 +476,25 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
finishAffinity()
}
+ private fun locationServiceIntent(action: String) =
+ Intent(this, LocationService::class.java).apply { this.action = action }
+
+ /** Drop to economy rate when the app goes to the background and nothing needs precision. */
+ private fun throttleLocationIfIdle() {
+ if (!serviceStarted) return
+ if (!viewModel.isRecording.value && !LocationService.anchorWatchState.value.isActive) {
+ startService(locationServiceIntent(LocationService.ACTION_START_ECONOMY))
+ }
+ }
+
+ /** On return to foreground: restore full accuracy if recording, otherwise economy. */
+ private fun restoreLocationMode() {
+ if (!serviceStarted) return
+ val action = if (viewModel.isRecording.value) LocationService.ACTION_START_FULL
+ else LocationService.ACTION_START_ECONOMY
+ startService(locationServiceIntent(action))
+ }
+
override fun onActivateMob() {
lifecycleScope.launch {
LocationService.locationFlow.firstOrNull()?.let { gpsData ->
@@ -512,13 +558,29 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions ->
if (permissions[Manifest.permission.ACCESS_FINE_LOCATION] == true) {
pendingServiceStart = true
- // The MediaStore query in init{} may have thrown SecurityException while
- // the permission dialog was active. Reload now that the system is settled.
viewModel.reloadPastTracks()
}
}
+ private val safPickerLauncher =
+ registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
+ if (result.resultCode == RESULT_OK) {
+ result.data?.data?.let { treeUri -> viewModel.onSafDirectorySelected(treeUri) }
+ }
+ }
+
+ fun launchSafPicker() {
+ val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
+ putExtra(DocumentsContract.EXTRA_INITIAL_URI,
+ android.provider.MediaStore.Files.getContentUri("external"))
+ }
+ }
+ safPickerLauncher.launch(intent)
+ }
+
private fun startServices() {
+ serviceStarted = true
val intent = Intent(this, LocationService::class.java).apply {
action = LocationService.ACTION_START_FOREGROUND_SERVICE
}
@@ -548,12 +610,14 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
lifecycleScope.launch {
mapHandler!!.isFollowing.collect { following ->
mapCrosshair.visibility = if (following) View.GONE else View.VISIBLE
- if (following) {
- fadeOut(fabRecenter, gone = true)
- fadeIn(fabRecordTrack)
- } else {
- fadeOut(fabRecordTrack, gone = true)
- fadeIn(fabRecenter)
+ if (!overlayShowing) {
+ if (following) {
+ fadeOut(fabRecenter, gone = true)
+ fadeIn(fabRecordTrack)
+ } else {
+ fadeOut(fabRecordTrack, gone = true)
+ fadeIn(fabRecenter)
+ }
}
}
}
@@ -658,6 +722,13 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
private fun observeDataSources() {
startThermalMonitoring()
+ lifecycleScope.launch {
+ viewModel.isRecording.collect { recording ->
+ val action = if (recording) LocationService.ACTION_START_FULL
+ else LocationService.ACTION_START_ECONOMY
+ startService(locationServiceIntent(action))
+ }
+ }
var conditionsLoaded = false
lifecycleScope.launch {
LocationService.locationFlow.collect { gpsData ->
@@ -760,10 +831,11 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
}
}
- // When a saved track is selected: zoom to it and show tack markers
+ // When a saved track is selected: zoom to it, highlight it, and show tack markers
lifecycleScope.launch {
viewModel.selectedTrack.collect { track ->
val style = loadedStyleFlow.value ?: return@collect
+ mapHandler?.setFocusedTrackPoints(track?.points)
if (track != null) {
mapHandler?.zoomToTrackBounds(track.points)
mapHandler?.updateTackLayer(style, track.tacks)
@@ -801,7 +873,6 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
}
override fun onStart() { super.onStart(); if (!NavApplication.isTesting) mapView?.onStart() }
- override fun onPause() { super.onPause(); if (!NavApplication.isTesting) mapView?.onPause() }
override fun onStop() { super.onStop(); if (!NavApplication.isTesting) mapView?.onStop() }
override fun onDestroy() { super.onDestroy(); if (!NavApplication.isTesting) mapView?.onDestroy(); stopThermalAlarm() }
override fun onLowMemory() { super.onLowMemory(); if (!NavApplication.isTesting) mapView?.onLowMemory() }
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/NavApplication.kt b/android-app/app/src/main/kotlin/org/terst/nav/NavApplication.kt
index 0bec116..0fe5708 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/NavApplication.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/NavApplication.kt
@@ -16,7 +16,8 @@ class NavApplication : Application() {
private set
companion object {
- val logbookRepository = org.terst.nav.logbook.InMemoryLogbookRepository()
+ lateinit var logbookRepository: org.terst.nav.logbook.LogbookRepository
+ private set
lateinit var trackRepository: org.terst.nav.track.TrackRepository
lateinit var boatProfileRepository: org.terst.nav.tripreport.BoatProfileRepository
lateinit var vesselRepository: org.terst.nav.vessel.VesselRepository
@@ -35,6 +36,7 @@ class NavApplication : Application() {
override fun onCreate() {
super.onCreate()
featureFlags = FeatureFlags(this)
+ logbookRepository = org.terst.nav.logbook.LogbookRepository(org.terst.nav.logbook.LogbookStorage(this))
trackRepository = org.terst.nav.track.TrackRepository(this)
boatProfileRepository = org.terst.nav.tripreport.BoatProfileRepository(this)
vesselRepository = org.terst.nav.vessel.VesselRepository(this)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/gps/DeviceGpsProvider.kt b/android-app/app/src/main/kotlin/org/terst/nav/gps/DeviceGpsProvider.kt
index f2a4e59..a8d46e4 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/gps/DeviceGpsProvider.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/gps/DeviceGpsProvider.kt
@@ -56,7 +56,7 @@ class DeviceGpsProvider(
locationManager.requestLocationUpdates(
LocationManager.GPS_PROVIDER,
updateIntervalMs,
- 0f,
+ MIN_UPDATE_DISTANCE_METERS,
locationListener,
Looper.getMainLooper()
)
@@ -83,5 +83,6 @@ class DeviceGpsProvider(
companion object {
private const val FIX_LOST_TIMEOUT_MS = 10_000L
+ private const val MIN_UPDATE_DISTANCE_METERS = 5f
}
}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookRepository.kt b/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookRepository.kt
new file mode 100644
index 0000000..0b4693e
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookRepository.kt
@@ -0,0 +1,18 @@
+package org.terst.nav.logbook
+
+class LogbookRepository(private val storage: LogbookStorage) {
+
+ private val entries = mutableListOf<LogEntry>().apply { addAll(storage.loadAll()) }
+ private var nextId = (entries.maxOfOrNull { it.id } ?: 0L) + 1L
+
+ val photoDir get() = storage.photoDir
+
+ fun save(entry: LogEntry): LogEntry {
+ val saved = entry.copy(id = nextId++)
+ entries.add(saved)
+ storage.saveAll(entries)
+ return saved
+ }
+
+ fun getAll(): List<LogEntry> = entries.toList()
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookStorage.kt b/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookStorage.kt
new file mode 100644
index 0000000..51f1f09
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookStorage.kt
@@ -0,0 +1,53 @@
+package org.terst.nav.logbook
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.net.Uri
+import android.os.Environment
+import com.squareup.moshi.Moshi
+import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
+import org.terst.nav.ui.NavLogger
+import java.io.File
+import java.io.FileOutputStream
+
+class LogbookStorage(private val context: Context) {
+
+ private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
+ private val adapter = moshi.adapter(LogEntryList::class.java)
+
+ private val storageFile: File
+ get() = File(context.getExternalFilesDir(null) ?: context.filesDir, "nav_logbook.json")
+
+ val photoDir: File
+ get() = File(context.getExternalFilesDir(Environment.DIRECTORY_PICTURES) ?: context.filesDir, "Nav")
+ .also { it.mkdirs() }
+
+ fun loadAll(): List<LogEntry> = runCatching {
+ if (!storageFile.exists()) return emptyList()
+ adapter.fromJson(storageFile.readText())?.entries ?: emptyList()
+ }.getOrElse { e ->
+ NavLogger.e("logbook", "loadAll failed: ${e.message}")
+ emptyList()
+ }
+
+ fun saveAll(entries: List<LogEntry>): Boolean = runCatching {
+ storageFile.parentFile?.mkdirs()
+ storageFile.writeText(adapter.toJson(LogEntryList(entries)))
+ true
+ }.getOrElse { false }
+
+ fun saveBitmap(bitmap: Bitmap): String? = runCatching {
+ val dest = File(photoDir, "log_${System.currentTimeMillis()}.jpg")
+ FileOutputStream(dest).use { bitmap.compress(Bitmap.CompressFormat.JPEG, 90, it) }
+ dest.absolutePath
+ }.getOrElse { null }
+
+ fun copyFromUri(uri: Uri): String? = runCatching {
+ val dest = File(photoDir, "log_${System.currentTimeMillis()}.jpg")
+ val stream = context.contentResolver.openInputStream(uri) ?: return null
+ stream.use { input -> FileOutputStream(dest).use { output -> input.copyTo(output) } }
+ dest.absolutePath
+ }.getOrElse { null }
+}
+
+data class LogEntryList(val entries: List<LogEntry>)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/logbook/VoiceLogViewModel.kt b/android-app/app/src/main/kotlin/org/terst/nav/logbook/VoiceLogViewModel.kt
index 0a68ba8..67e1062 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/logbook/VoiceLogViewModel.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/logbook/VoiceLogViewModel.kt
@@ -3,11 +3,14 @@ package org.terst.nav.logbook
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
-class VoiceLogViewModel(private val repository: InMemoryLogbookRepository) {
+class VoiceLogViewModel(private val repository: LogbookRepository) {
private val _state = MutableStateFlow<VoiceLogState>(VoiceLogState.Idle)
val state: StateFlow<VoiceLogState> = _state
+ private val _entries = MutableStateFlow<List<LogEntry>>(repository.getAll().reversed())
+ val entries: StateFlow<List<LogEntry>> = _entries
+
fun onListeningStarted() {
_state.value = VoiceLogState.Listening
}
@@ -20,20 +23,18 @@ class VoiceLogViewModel(private val repository: InMemoryLogbookRepository) {
_state.value = VoiceLogState.Error(message)
}
- /**
- * Save an entry with the given text and optional photo path.
- * Called directly by the fragment from the Save button so the user
- * can have edited the recognized text in the EditText beforehand.
- */
- fun save(text: String, photoPath: String? = null) {
+ fun save(text: String, photoPath: String? = null, lat: Double? = null, lon: Double? = null) {
if (text.isBlank() && photoPath == null) return
val entry = LogEntry(
timestampMs = System.currentTimeMillis(),
text = text.ifBlank { "(photo only)" },
entryType = EntryType.GENERAL,
+ lat = lat,
+ lon = lon,
photoPath = photoPath
)
val saved = repository.save(entry)
+ _entries.value = repository.getAll().reversed()
_state.value = VoiceLogState.Saved(saved)
}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/SavedTracksFragment.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/SavedTracksFragment.kt
index b2181c9..fca83cd 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/track/SavedTracksFragment.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/track/SavedTracksFragment.kt
@@ -1,5 +1,6 @@
package org.terst.nav.track
+import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
@@ -11,6 +12,8 @@ import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
+import com.google.android.material.button.MaterialButton
+import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import org.terst.nav.MainActivity
import org.terst.nav.R
@@ -32,36 +35,66 @@ class SavedTracksFragment : Fragment() {
(requireActivity() as MainActivity).hideOverlays()
}
- val rv = view.findViewById<RecyclerView>(R.id.rv_saved_tracks)
- val empty = view.findViewById<TextView>(R.id.tv_empty)
+ val rv = view.findViewById<RecyclerView>(R.id.rv_saved_tracks)
+ val layoutEmpty = view.findViewById<View>(R.id.layout_empty)
+ val tvEmpty = view.findViewById<TextView>(R.id.tv_empty)
+ val btnSetup = view.findViewById<MaterialButton>(R.id.btn_setup_storage)
+
rv.layoutManager = LinearLayoutManager(requireContext())
rv.addItemDecoration(DividerItemDecoration(requireContext(), DividerItemDecoration.VERTICAL))
val adapter = SavedTrackAdapter { track ->
viewModel.selectTrack(track)
- parentFragmentManager
- .beginTransaction()
+ parentFragmentManager.beginTransaction()
.replace(R.id.fragment_container, TrackDetailSheet.newInstance())
- .addToBackStack(null)
+ .addToBackStack("track_detail")
.commit()
}
rv.adapter = adapter
viewLifecycleOwner.lifecycleScope.launch {
- viewModel.savedTracks.collect { tracks ->
- adapter.submitList(tracks)
- empty.visibility = if (tracks.isEmpty()) View.VISIBLE else View.GONE
- rv.visibility = if (tracks.isEmpty()) View.GONE else View.VISIBLE
+ viewModel.savedTracks
+ .combine(viewModel.isLoadingTracks) { tracks, loading -> tracks to loading }
+ .collect { (tracks, loading) ->
+ adapter.submitList(tracks)
+ layoutEmpty.visibility = if (!loading && tracks.isEmpty()) View.VISIBLE else View.GONE
+ rv.visibility = if (!loading && tracks.isNotEmpty()) View.VISIBLE else View.GONE
+ }
+ }
+
+ // SAF storage setup prompt — only relevant on API 29+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewModel.safState.collect { state ->
+ when (state) {
+ SafState.NOT_CONFIGURED -> {
+ tvEmpty.text = "No saved tracks yet.\nRecord a trip to see it here."
+ btnSetup.text = "Set up track storage"
+ btnSetup.visibility = View.VISIBLE
+ }
+ SafState.PERMISSION_LOST -> {
+ tvEmpty.text = "Track storage needs re-authorization."
+ btnSetup.text = "Restore track access"
+ btnSetup.visibility = View.VISIBLE
+ }
+ SafState.CONFIGURED -> {
+ btnSetup.visibility = View.GONE
+ }
+ }
+ }
+ }
+ btnSetup.setOnClickListener {
+ (requireActivity() as MainActivity).launchSafPicker()
}
}
}
}
-private val DATE_FMT: DateTimeFormatter =
+internal val DATE_FMT: DateTimeFormatter =
DateTimeFormatter.ofPattern("EEE dd MMM yyyy · HH:mm", Locale.getDefault())
.withZone(ZoneId.systemDefault())
-class SavedTrackAdapter(
+internal class SavedTrackAdapter(
private val onSelect: (SavedTrack) -> Unit
) : RecyclerView.Adapter<SavedTrackAdapter.VH>() {
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/TackDetector.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TackDetector.kt
index 6616acf..429449c 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/track/TackDetector.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TackDetector.kt
@@ -1,48 +1,143 @@
package org.terst.nav.track
import kotlin.math.abs
+import kotlin.math.atan2
+import kotlin.math.cos
+import kotlin.math.sin
+/**
+ * Detects tacks and jibes in a recorded GPS track.
+ *
+ * A tack or jibe has three phases:
+ * [── settled leg A (≥30 s) ──][─ guard (15 s) ─][apex][─ guard (15 s) ─][── settled leg B (≥30 s) ──]
+ *
+ * For each GPS fix as candidate apex: collect before/after settle windows (excluding the guard zone),
+ * require MIN_PTS fixes in each, compute circularMean + circularMAD, reject if MAD > STAB_MAX,
+ * accept if heading delta ≥ MIN_DELTA (no upper cap — 180° crash-tacks are valid).
+ *
+ * De-duplicate: group candidates within MIN_GAP_MS, keep max-delta per group.
+ * Refine position: within the maneuver zone, find the point with the greatest instantaneous
+ * heading rate of change as the map pin.
+ *
+ * No SOG gate: circularMAD stability check handles noise at any speed.
+ * A boat at anchor has effectively random COG (MAD ≈ 90°) and is always rejected.
+ * A real settled tack leg has MAD typically 5–15° and passes.
+ * Mode: CRUISING — conservative (prefers missing a tack over reporting a phantom).
+ * Race mode (shorter windows, wider MAD) is a future backlog item.
+ */
object TackDetector {
- private const val HALF_WIN = 2
- private const val MIN_DELTA = 60.0
- private const val MAX_DELTA = 140.0
- private const val SKIP_AFTER = 5
- private const val STABILITY_MAX = 30.0
+ private const val T_SETTLE = 30_000L // ms — stable heading window required before/after
+ private const val T_MANEUVER = 30_000L // ms — guard zone around apex (±15 s each side)
+ private const val STAB_MAX = 20.0 // ° — max circularMAD in settle windows
+ private const val MIN_DELTA = 75.0 // ° — minimum heading change to count as a maneuver
+ private const val MIN_GAP_MS = 60_000L // ms — minimum time between accepted events
+ private const val START_SKIP_MS = 60_000L // ms — skip first 60 s (cold-start noise)
+ private const val MIN_PTS = 3 // minimum GPS fixes required in each settle window
+
+ private data class Candidate(
+ val index: Int,
+ val timestampMs: Long,
+ val delta: Double,
+ val cogBefore: Double,
+ val cogAfter: Double
+ )
fun detectTacks(points: List<TrackPoint>): List<TackEvent> {
- if (points.size < 2 * HALF_WIN + 1) return emptyList()
+ if (points.size < MIN_PTS) return emptyList()
+ val t0 = points.first().timestampMs
- val results = mutableListOf<TackEvent>()
- var nextCandidate = 0
+ val raw = mutableListOf<Candidate>()
+
+ for (i in points.indices) {
+ val t = points[i].timestampMs
+ if (t - t0 < START_SKIP_MS) continue
+
+ val beforeEnd = t - T_MANEUVER / 2
+ val beforeStart = beforeEnd - T_SETTLE
+ val before = points.subList(lowerBound(points, beforeStart), lowerBound(points, beforeEnd))
+ if (before.size < MIN_PTS) continue
+
+ val afterStart = t + T_MANEUVER / 2
+ val afterEnd = afterStart + T_SETTLE
+ val after = points.subList(upperBound(points, afterStart), upperBound(points, afterEnd))
+ if (after.size < MIN_PTS) continue
- for (i in HALF_WIN until points.size - HALF_WIN) {
- if (i < nextCandidate) continue
+ val cogBefore = circularMean(before.map { it.cogDeg })
+ val spreadBefore = circularMAD(before.map { it.cogDeg }, cogBefore)
+ if (spreadBefore > STAB_MAX) continue
- val spreadBefore = abs(angleDiff(points[i - 2].cogDeg, points[i - 1].cogDeg))
- val spreadAfter = abs(angleDiff(points[i + 1].cogDeg, points[i + 2].cogDeg))
- if (spreadBefore > STABILITY_MAX || spreadAfter > STABILITY_MAX) continue
+ val cogAfter = circularMean(after.map { it.cogDeg })
+ val spreadAfter = circularMAD(after.map { it.cogDeg }, cogAfter)
+ if (spreadAfter > STAB_MAX) continue
- val cogBefore = circularMean(points[i - 2].cogDeg, points[i - 1].cogDeg)
- val cogAfter = circularMean(points[i + 1].cogDeg, points[i + 2].cogDeg)
val delta = abs(angleDiff(cogBefore, cogAfter))
+ if (delta < MIN_DELTA) continue
+
+ raw += Candidate(i, t, delta, cogBefore, cogAfter)
+ }
+ if (raw.isEmpty()) return emptyList()
+
+ // De-duplicate: if consecutive raw candidates are within MIN_GAP_MS of each other, they
+ // belong to the same maneuver. Keep the max-delta candidate per group.
+ // Compare against the PREVIOUS candidate (adjacent comparison) so a long stream of
+ // close candidates from one maneuver stays in a single group regardless of total span.
+ val results = mutableListOf<TackEvent>()
+ var best: Candidate? = null
+ var prevMs = Long.MIN_VALUE / 2
- if (delta in MIN_DELTA..MAX_DELTA) {
- results += TackEvent(i, points[i].lat, points[i].lon, cogBefore, cogAfter)
- nextCandidate = i + SKIP_AFTER
+ for (c in raw) {
+ if (best != null && c.timestampMs - prevMs >= MIN_GAP_MS) {
+ results += buildTackEvent(points, best!!)
+ best = c
+ } else {
+ if (best == null || c.delta > best!!.delta) best = c
}
+ prevMs = c.timestampMs
}
+ best?.let { results += buildTackEvent(points, it) }
return results
}
+ private fun buildTackEvent(points: List<TrackPoint>, c: Candidate): TackEvent {
+ // Refine map pin: find max instantaneous heading rate within maneuver zone
+ val maneuvRange = (c.timestampMs - T_MANEUVER / 2)..(c.timestampMs + T_MANEUVER / 2)
+ var bestIdx = c.index
+ var bestRate = 0.0
+ for (i in 1 until points.size) {
+ if (points[i].timestampMs !in maneuvRange) continue
+ val rate = abs(angleDiff(points[i - 1].cogDeg, points[i].cogDeg))
+ if (rate > bestRate) { bestRate = rate; bestIdx = i }
+ }
+ return TackEvent(bestIdx, points[bestIdx].lat, points[bestIdx].lon, c.cogBefore, c.cogAfter)
+ }
+
+ // Binary search helpers — points must be sorted by timestampMs (always true for GPS tracks).
+ // lowerBound: first index where ts >= ms; upperBound: first index where ts > ms.
+ private fun lowerBound(points: List<TrackPoint>, ms: Long): Int {
+ var lo = 0; var hi = points.size
+ while (lo < hi) { val mid = (lo + hi) ushr 1; if (points[mid].timestampMs < ms) lo = mid + 1 else hi = mid }
+ return lo
+ }
+
+ private fun upperBound(points: List<TrackPoint>, ms: Long): Int {
+ var lo = 0; var hi = points.size
+ while (lo < hi) { val mid = (lo + hi) ushr 1; if (points[mid].timestampMs <= ms) lo = mid + 1 else hi = mid }
+ return lo
+ }
+
internal fun angleDiff(from: Double, to: Double): Double {
var diff = to - from
- while (diff > 180) diff -= 360
+ while (diff > 180) diff -= 360
while (diff < -180) diff += 360
return diff
}
- private fun circularMean(a: Double, b: Double): Double {
- val half = angleDiff(a, b) / 2.0
- return ((a + half) % 360.0 + 360.0) % 360.0
+ private fun circularMean(angles: List<Double>): Double {
+ val sinSum = angles.sumOf { sin(Math.toRadians(it)) }
+ val cosSum = angles.sumOf { cos(Math.toRadians(it)) }
+ return ((Math.toDegrees(atan2(sinSum, cosSum)) % 360.0) + 360.0) % 360.0
}
+
+ private fun circularMAD(angles: List<Double>, mean: Double): Double =
+ angles.map { abs(angleDiff(it, mean)) }.average()
}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackColors.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackColors.kt
new file mode 100644
index 0000000..5e5e85d
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackColors.kt
@@ -0,0 +1,38 @@
+package org.terst.nav.track
+
+import org.maplibre.android.style.expressions.Expression
+import org.maplibre.geojson.Feature
+import org.maplibre.geojson.LineString
+import org.maplibre.geojson.Point
+
+/**
+ * Breaks [points] into consecutive 2-point LineString Features.
+ * Each feature carries a numeric "speed" property (knots) for data-driven coloring.
+ */
+fun speedSegments(points: List<TrackPoint>): List<Feature> =
+ (0 until points.size - 1).map { i ->
+ val sog = (points[i].sogKnots + points[i + 1].sogKnots) / 2.0
+ Feature.fromGeometry(
+ LineString.fromLngLats(listOf(
+ Point.fromLngLat(points[i].lon, points[i].lat),
+ Point.fromLngLat(points[i + 1].lon, points[i + 1].lat)
+ ))
+ ).apply {
+ addNumberProperty("speed", sog)
+ }
+ }
+
+/**
+ * Returns a step expression mapping the "speed" numeric property to a color.
+ * Uses Expression.step() which reads the numeric property directly, avoiding
+ * the silent failure of Expression.get("color") for string properties on LineLayer
+ * in MapLibre Android 11.x.
+ */
+fun speedColorExpression(): Expression = Expression.step(
+ Expression.get("speed"),
+ Expression.literal("#2196F3"), // < 4 kt: blue
+ Expression.stop(4, Expression.literal("#00BCD4")), // 4–7 kt: cyan
+ Expression.stop(7, Expression.literal("#4CAF50")), // 7–10 kt: green
+ Expression.stop(10, Expression.literal("#FFC107")), // 10–13 kt: amber
+ Expression.stop(13, Expression.literal("#F44336")) // 13+ kt: red
+)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackDetailSheet.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackDetailSheet.kt
index c1764a8..7c26a01 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackDetailSheet.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackDetailSheet.kt
@@ -1,17 +1,52 @@
package org.terst.nav.track
+import android.app.Activity
+import android.app.AlertDialog
+import android.content.Intent
+import android.graphics.BitmapFactory
+import android.net.Uri
import android.os.Bundle
+import android.provider.MediaStore
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
+import android.widget.Button
+import android.widget.EditText
+import android.widget.FrameLayout
+import android.widget.ImageView
import android.widget.TextView
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.core.content.FileProvider
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
+import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.maplibre.android.camera.CameraUpdateFactory
+import org.maplibre.android.geometry.LatLng
+import org.maplibre.android.geometry.LatLngBounds
+import org.maplibre.android.maps.MapLibreMap
+import org.maplibre.android.maps.MapView
+import org.maplibre.android.maps.Style
+import org.maplibre.android.style.layers.CircleLayer
+import org.maplibre.android.style.layers.LineLayer
+import org.maplibre.android.style.layers.PropertyFactory
+import org.maplibre.android.style.sources.GeoJsonSource
+import org.maplibre.geojson.Feature
+import org.maplibre.geojson.FeatureCollection
+import org.maplibre.geojson.LineString
+import org.maplibre.geojson.Point
+import org.terst.nav.NavApplication
import org.terst.nav.R
+import org.terst.nav.logbook.EntryType
+import org.terst.nav.logbook.LogEntry
import org.terst.nav.ui.MainViewModel
+import java.io.File
+import java.io.FileOutputStream
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@@ -25,12 +60,61 @@ data class LogEvent(
val timestampMs: Long,
val icon: String,
val label: String,
- val detail: String
+ val detail: String,
+ val photoPath: String? = null
)
class TrackDetailSheet : Fragment() {
private val viewModel: MainViewModel by activityViewModels()
+ private lateinit var trackMapView: MapView
+ private var trackMap: MapLibreMap? = null
+
+ // Note-adding state
+ private var pendingNotePhotoPath: String? = null
+ private var noteDialogPhotoFrame: FrameLayout? = null
+ private var noteDialogPhotoView: ImageView? = null
+ private var logEventAdapter: LogEventAdapter? = null
+ private var currentTrack: SavedTrack? = null
+
+ private val noteCameraLauncher = registerForActivityResult(
+ ActivityResultContracts.StartActivityForResult()
+ ) { result ->
+ if (result.resultCode == Activity.RESULT_OK) {
+ val path = pendingNotePhotoPath ?: return@registerForActivityResult
+ val bm = BitmapFactory.decodeFile(path)
+ if (bm != null) noteDialogPhotoView?.setImageBitmap(bm)
+ else noteDialogPhotoView?.setImageURI(Uri.fromFile(File(path)))
+ noteDialogPhotoFrame?.visibility = View.VISIBLE
+ } else {
+ pendingNotePhotoPath?.let { File(it).delete() }
+ pendingNotePhotoPath = null
+ }
+ }
+
+ private val noteGalleryLauncher = registerForActivityResult(
+ ActivityResultContracts.GetContent()
+ ) { uri: Uri? ->
+ if (uri != null) {
+ lifecycleScope.launch {
+ val path = withContext(Dispatchers.IO) {
+ runCatching {
+ val dir = NavApplication.logbookRepository.photoDir
+ val dest = File(dir, "log_${System.currentTimeMillis()}.jpg")
+ requireContext().contentResolver.openInputStream(uri)?.use { inp ->
+ FileOutputStream(dest).use { out -> inp.copyTo(out) }
+ }
+ dest.absolutePath
+ }.getOrNull()
+ }
+ if (path != null) {
+ pendingNotePhotoPath = path
+ noteDialogPhotoView?.setImageURI(Uri.fromFile(File(path)))
+ noteDialogPhotoFrame?.visibility = View.VISIBLE
+ }
+ }
+ }
+ }
companion object {
fun newInstance() = TrackDetailSheet()
@@ -44,6 +128,7 @@ class TrackDetailSheet : Fragment() {
parentFragmentManager.popBackStack()
return
}
+ currentTrack = track
view.findViewById<View>(R.id.btn_back).setOnClickListener {
parentFragmentManager.popBackStack()
@@ -67,12 +152,158 @@ class TrackDetailSheet : Fragment() {
val rv = view.findViewById<RecyclerView>(R.id.rv_log_entries)
rv.layoutManager = LinearLayoutManager(requireContext())
rv.addItemDecoration(DividerItemDecoration(requireContext(), DividerItemDecoration.VERTICAL))
- rv.adapter = LogEventAdapter(events) { event ->
- viewModel.panToTrackPoint(event.lat, event.lon)
+ logEventAdapter = LogEventAdapter(events) { event ->
+ trackMap?.easeCamera(CameraUpdateFactory.newLatLng(LatLng(event.lat, event.lon)), 300)
+ }
+ rv.adapter = logEventAdapter
+
+ view.findViewById<View>(R.id.btn_add_note).setOnClickListener {
+ showAddNoteDialog(track)
}
+
+ trackMapView = view.findViewById(R.id.track_map_view)
+ trackMapView.onCreate(savedInstanceState)
+ trackMapView.getMapAsync { map ->
+ trackMap = map
+ map.setStyle(Style.Builder().fromUri("https://tiles.openfreemap.org/styles/bright")) { style ->
+ drawTrack(style, track.points)
+ drawTacks(style, track.tacks)
+ val notePoints = events.filter { it.photoPath != null || it.icon == "📝" }
+ drawNotes(style, notePoints)
+ zoomToTrack(map, track.points)
+ }
+ }
+ }
+
+ private fun showAddNoteDialog(track: SavedTrack) {
+ val dialogView = LayoutInflater.from(requireContext())
+ .inflate(R.layout.dialog_add_track_note, null)
+ val etNote = dialogView.findViewById<EditText>(R.id.et_note_text)
+ noteDialogPhotoFrame = dialogView.findViewById(R.id.frame_note_photo)
+ noteDialogPhotoView = dialogView.findViewById(R.id.iv_note_photo)
+ pendingNotePhotoPath = null
+ noteDialogPhotoFrame?.visibility = View.GONE
+
+ dialogView.findViewById<Button>(R.id.btn_note_camera).setOnClickListener {
+ val file = File(NavApplication.logbookRepository.photoDir, "log_${System.currentTimeMillis()}.jpg")
+ pendingNotePhotoPath = file.absolutePath
+ val uri = FileProvider.getUriForFile(
+ requireContext(), "${requireContext().packageName}.fileprovider", file
+ )
+ val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE).apply {
+ putExtra(MediaStore.EXTRA_OUTPUT, uri)
+ addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
+ }
+ noteCameraLauncher.launch(intent)
+ }
+
+ dialogView.findViewById<Button>(R.id.btn_note_gallery).setOnClickListener {
+ noteGalleryLauncher.launch("image/*")
+ }
+
+ dialogView.findViewById<Button>(R.id.btn_note_remove_photo).setOnClickListener {
+ pendingNotePhotoPath?.let { File(it).delete() }
+ pendingNotePhotoPath = null
+ noteDialogPhotoView?.setImageDrawable(null)
+ noteDialogPhotoFrame?.visibility = View.GONE
+ }
+
+ AlertDialog.Builder(requireContext())
+ .setTitle("Add note to track")
+ .setView(dialogView)
+ .setPositiveButton("Save") { _, _ ->
+ val text = etNote.text?.toString()?.trim() ?: ""
+ val path = pendingNotePhotoPath
+ if (text.isBlank() && path == null) return@setPositiveButton
+ val anchor = track.points.lastOrNull()
+ NavApplication.logbookRepository.save(
+ LogEntry(
+ timestampMs = track.endMs,
+ text = text.ifBlank { "(photo only)" },
+ entryType = EntryType.GENERAL,
+ lat = anchor?.lat,
+ lon = anchor?.lon,
+ photoPath = path
+ )
+ )
+ pendingNotePhotoPath = null
+ logEventAdapter?.update(buildLogEvents(track))
+ }
+ .setNegativeButton("Cancel") { _, _ ->
+ pendingNotePhotoPath?.let { File(it).delete() }
+ pendingNotePhotoPath = null
+ }
+ .show()
+ }
+
+ private fun drawTrack(style: Style, points: List<TrackPoint>) {
+ if (points.size < 2) return
+ val source = GeoJsonSource("track-detail-source",
+ Feature.fromGeometry(LineString.fromLngLats(points.map { Point.fromLngLat(it.lon, it.lat) })))
+ style.addSource(source)
+ style.addLayer(LineLayer("track-detail-layer", "track-detail-source").apply {
+ setProperties(
+ PropertyFactory.lineColor("#2B8FC4"),
+ PropertyFactory.lineWidth(4f),
+ PropertyFactory.lineCap("round"),
+ PropertyFactory.lineJoin("round")
+ )
+ })
+ }
+
+ private fun drawTacks(style: Style, tacks: List<TackEvent>) {
+ if (tacks.isEmpty()) return
+ val features = tacks.map { Feature.fromGeometry(Point.fromLngLat(it.lon, it.lat)) }
+ val source = GeoJsonSource("tack-detail-source", FeatureCollection.fromFeatures(features))
+ style.addSource(source)
+ style.addLayer(CircleLayer("tack-detail-layer", "tack-detail-source").apply {
+ setProperties(
+ PropertyFactory.circleColor("#FDD835"),
+ PropertyFactory.circleRadius(7f),
+ PropertyFactory.circleStrokeColor("#F57F17"),
+ PropertyFactory.circleStrokeWidth(2f)
+ )
+ })
+ }
+
+ private fun drawNotes(style: Style, notes: List<LogEvent>) {
+ if (notes.isEmpty()) return
+ val features = notes.map { Feature.fromGeometry(Point.fromLngLat(it.lon, it.lat)) }
+ val source = GeoJsonSource("notes-detail-source", FeatureCollection.fromFeatures(features))
+ style.addSource(source)
+ style.addLayer(CircleLayer("notes-detail-layer", "notes-detail-source").apply {
+ setProperties(
+ PropertyFactory.circleColor("#4CAF50"),
+ PropertyFactory.circleRadius(7f),
+ PropertyFactory.circleStrokeColor("#1B5E20"),
+ PropertyFactory.circleStrokeWidth(2f)
+ )
+ })
+ }
+
+ private fun zoomToTrack(map: MapLibreMap, points: List<TrackPoint>) {
+ if (points.size < 2) return
+ val bounds = LatLngBounds.Builder().apply {
+ points.forEach { include(LatLng(it.lat, it.lon)) }
+ }.build()
+ map.easeCamera(CameraUpdateFactory.newLatLngBounds(bounds, 80), 600)
+ }
+
+ override fun onStart() { super.onStart(); if (::trackMapView.isInitialized) trackMapView.onStart() }
+ override fun onResume() { super.onResume(); if (::trackMapView.isInitialized) trackMapView.onResume() }
+ override fun onPause() { super.onPause(); if (::trackMapView.isInitialized) trackMapView.onPause() }
+ override fun onStop() { super.onStop(); if (::trackMapView.isInitialized) trackMapView.onStop() }
+ override fun onLowMemory() { super.onLowMemory(); if (::trackMapView.isInitialized) trackMapView.onLowMemory() }
+
+ override fun onSaveInstanceState(outState: Bundle) {
+ super.onSaveInstanceState(outState)
+ if (::trackMapView.isInitialized) trackMapView.onSaveInstanceState(outState)
}
override fun onDestroyView() {
+ noteDialogPhotoFrame = null
+ noteDialogPhotoView = null
+ if (::trackMapView.isInitialized) trackMapView.onDestroy()
super.onDestroyView()
viewModel.clearSelectedTrack()
}
@@ -89,27 +320,28 @@ class TrackDetailSheet : Fragment() {
for (t in tacks) {
val p = points[t.index]
val delta = TackDetector.angleDiff(t.cogBefore, t.cogAfter)
- val type = if (abs(delta) > 70) "Tack" else "Jibe"
val dir = if (delta > 0) "→ stbd" else "→ port"
+ // Classify as tack/jibe using true wind angle if available; otherwise label as maneuver.
+ val twa = if (p.isTrueWind) p.windAngleDeg else null
+ val type = when {
+ twa == null -> "Maneuver"
+ twa < 90.0 || twa > 270.0 -> "Tack"
+ else -> "Jibe"
+ }
events += LogEvent(p.lat, p.lon, p.timestampMs, "⬡",
"$type $dir",
"%.0f° → %.0f° (Δ%.0f°)".format(t.cogBefore, t.cogAfter, abs(delta)))
}
- for (i in 1 until points.size - 1) {
- val prev = points[i - 1].sogKnots
- val curr = points[i].sogKnots
- val next = points[i + 1].sogKnots
- val rise = curr - prev
- val fall = curr - next
- val p = points[i]
- if (rise > 1.5 && rise > fall) {
- events += LogEvent(p.lat, p.lon, p.timestampMs, "↑",
- "Speed up", "%.1f → %.1f kt".format(prev, curr))
- } else if (fall > 1.5 && fall > rise) {
- events += LogEvent(p.lat, p.lon, p.timestampMs, "↓",
- "Slow down", "%.1f → %.1f kt".format(curr, next))
- }
+ // Log entries saved during or associated with this track's time window
+ val logEntries = NavApplication.logbookRepository.getAll()
+ .filter { it.timestampMs in track.startMs..track.endMs }
+ for (entry in logEntries) {
+ val lat = entry.lat ?: nearestTrackPoint(points, entry.timestampMs)?.lat ?: continue
+ val lon = entry.lon ?: nearestTrackPoint(points, entry.timestampMs)?.lon ?: continue
+ val icon = if (entry.photoPath != null) "📷" else "📝"
+ val detail = entry.text.take(80)
+ events += LogEvent(lat, lon, entry.timestampMs, icon, "Note", detail, entry.photoPath)
}
points.lastOrNull()?.let { p ->
@@ -119,6 +351,9 @@ class TrackDetailSheet : Fragment() {
return events.sortedBy { it.timestampMs }
}
+ private fun nearestTrackPoint(points: List<TrackPoint>, timestampMs: Long): TrackPoint? =
+ points.minByOrNull { abs(it.timestampMs - timestampMs) }
+
private fun formatPointDetail(p: TrackPoint): String {
val parts = mutableListOf("%.1f kt %.0f°".format(p.sogKnots, p.cogDeg))
p.windSpeedKnots?.let { parts += "W %.0f kt".format(it) }
@@ -132,15 +367,21 @@ private val TIME_FMT: DateTimeFormatter =
.withZone(ZoneId.systemDefault())
private class LogEventAdapter(
- private val events: List<LogEvent>,
+ private var events: List<LogEvent>,
private val onTap: (LogEvent) -> Unit
) : RecyclerView.Adapter<LogEventAdapter.VH>() {
+ fun update(newEvents: List<LogEvent>) {
+ events = newEvents
+ notifyDataSetChanged()
+ }
+
inner class VH(view: View) : RecyclerView.ViewHolder(view) {
val tvIcon = view.findViewById<TextView>(R.id.tv_log_icon)
val tvTime = view.findViewById<TextView>(R.id.tv_log_time)
val tvLabel = view.findViewById<TextView>(R.id.tv_log_label)
val tvDetail = view.findViewById<TextView>(R.id.tv_log_detail)
+ val ivThumb = view.findViewById<ImageView>(R.id.iv_log_thumb)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH =
@@ -155,5 +396,28 @@ private class LogEventAdapter(
holder.tvLabel.text = e.label
holder.tvDetail.text = e.detail
holder.itemView.setOnClickListener { onTap(e) }
+
+ val path = e.photoPath
+ if (path != null && File(path).exists()) {
+ val bm = decodeThumbnail(path, 160, 120)
+ if (bm != null) {
+ holder.ivThumb.setImageBitmap(bm)
+ holder.ivThumb.visibility = View.VISIBLE
+ } else {
+ holder.ivThumb.setImageDrawable(null)
+ holder.ivThumb.visibility = View.GONE
+ }
+ } else {
+ holder.ivThumb.setImageDrawable(null)
+ holder.ivThumb.visibility = View.GONE
+ }
}
}
+
+private fun decodeThumbnail(path: String, targetW: Int, targetH: Int): android.graphics.Bitmap? {
+ val opts = BitmapFactory.Options().apply { inJustDecodeBounds = true }
+ BitmapFactory.decodeFile(path, opts)
+ if (opts.outWidth <= 0) return null
+ val scale = maxOf(opts.outWidth / targetW, opts.outHeight / targetH).coerceAtLeast(1)
+ return BitmapFactory.decodeFile(path, BitmapFactory.Options().apply { inSampleSize = scale })
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt
index bd79985..c40c9c2 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt
@@ -1,6 +1,7 @@
package org.terst.nav.track
import android.content.Context
+import android.net.Uri
import android.util.Log
import kotlin.math.atan2
import kotlin.math.cos
@@ -144,10 +145,21 @@ class TrackRepository(context: Context) {
* Returns all completed tracks as [SavedTrack] wrappers (summary + tacks pre-computed).
* Internally delegates to [getPastTracks]; the first call loads from disk.
*/
- suspend fun getSavedTracks(): List<SavedTrack> =
+ suspend fun getSavedTracks(): List<SavedTrack> = withContext(Dispatchers.IO) {
getPastTracks()
.filter { it.isNotEmpty() }
- .map { it.toSavedTrack() }
+ .mapNotNull { points ->
+ runCatching { points.toSavedTrack() }
+ .onFailure { e -> NavLogger.e("track", "toSavedTrack failed: ${e.javaClass.simpleName}: ${e.message}") }
+ .getOrNull()
+ }
+ }
+
+ fun safState(): SafState = storage.safState()
+
+ fun initSafDirectory(treeUri: Uri) {
+ storage.initSafDirectory(treeUri)
+ }
companion object {
fun haversineNm(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackStorage.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackStorage.kt
index 59d7e53..405bba8 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackStorage.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackStorage.kt
@@ -2,12 +2,15 @@ package org.terst.nav.track
import android.content.ContentValues
import android.content.Context
+import android.content.Intent
import android.media.MediaScannerConnection
import android.net.Uri
import android.os.Build
import android.os.Environment
+import android.provider.DocumentsContract
import android.provider.MediaStore
import android.util.Log
+import androidx.documentfile.provider.DocumentFile
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeoutOrNull
import org.terst.nav.ui.NavLogger
@@ -19,16 +22,24 @@ import java.time.format.DateTimeFormatter
private const val TAG = "TrackStorage"
private const val PREF_NAME = "nav_track_uris"
private const val PREF_KEY = "uris"
+private const val PREF_SAF_TREE_URI = "saf_tree_uri"
+
+enum class SafState { CONFIGURED, PERMISSION_LOST, NOT_CONFIGURED }
/**
* Persists completed tracks as GPX files in the shared Documents/Nav/ folder.
*
- * Files written here survive app uninstall because they live in user-owned
- * shared storage rather than app-private storage.
+ * On API 29+, the primary mechanism is Storage Access Framework (SAF): the user
+ * grants access to a directory once via ACTION_OPEN_DOCUMENT_TREE; the permission
+ * survives reboots and is re-grantable with a single tap after reinstall, making
+ * track files genuinely persistent across uninstalls.
+ *
+ * Fallback chain (API 29+):
+ * 1. SAF via DocumentFile (preferred — survives reinstall with one re-auth tap)
+ * 2. MediaStore via persisted URIs (works for tracks saved before SAF was configured)
+ * 3. MediaStore fallback query by OWNER_PACKAGE_NAME (migration path)
*
- * API 29+: uses MediaStore (no permission required for Documents/).
- * API < 29: writes directly to Environment.DIRECTORY_DOCUMENTS (requires
- * WRITE_EXTERNAL_STORAGE permission declared in the manifest).
+ * API < 29: writes directly to Environment.DIRECTORY_DOCUMENTS/Nav/.
*/
class TrackStorage(private val context: Context) {
@@ -44,33 +55,107 @@ class TrackStorage(private val context: Context) {
context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
}
- /** Write a completed track to Documents/Nav/. Returns true on success. */
+ // ── SAF state ─────────────────────────────────────────────────────────────
+
+ fun safState(): SafState {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return SafState.CONFIGURED
+ val uriStr = prefs.getString(PREF_SAF_TREE_URI, null) ?: return SafState.NOT_CONFIGURED
+ val uri = Uri.parse(uriStr)
+ val hasPermission = context.contentResolver.persistedUriPermissions.any {
+ it.uri == uri && it.isReadPermission && it.isWritePermission
+ }
+ return if (hasPermission) SafState.CONFIGURED else SafState.PERMISSION_LOST
+ }
+
+ fun initSafDirectory(treeUri: Uri) {
+ context.contentResolver.takePersistableUriPermission(
+ treeUri,
+ Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
+ )
+ prefs.edit().putString(PREF_SAF_TREE_URI, treeUri.toString()).apply()
+ NavLogger.i("track", "SAF directory configured: $treeUri")
+ }
+
+ private fun safTreeDoc(): DocumentFile? {
+ val uriStr = prefs.getString(PREF_SAF_TREE_URI, null) ?: return null
+ return DocumentFile.fromTreeUri(context, Uri.parse(uriStr))
+ }
+
+ // ── Public API ────────────────────────────────────────────────────────────
+
+ /** Write a completed track to the configured storage location. Returns true on success. */
fun saveTrack(points: List<TrackPoint>, startMs: Long): Boolean {
if (points.isEmpty()) return false
val name = trackName.format(Instant.ofEpochMilli(startMs))
val fileName = "nav_${fileTimestamp.format(Instant.ofEpochMilli(startMs))}.gpx"
val gpx = GpxSerializer.serialize(points, name)
- return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- saveViaMediaStore(fileName, gpx)
- } else {
- saveViaFile(fileName, gpx)
+ return when {
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && safState() == SafState.CONFIGURED
+ -> saveViaSaf(fileName, gpx)
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
+ -> saveViaMediaStore(fileName, gpx)
+ else -> saveViaFile(fileName, gpx)
}
}
- /** Load all tracks previously saved to Documents/Nav/. */
+ /** Load all tracks from the configured storage location. */
fun loadAllTracks(): List<List<TrackPoint>> {
- return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- loadViaMediaStore()
- } else {
- loadViaFile()
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return loadViaFile()
+ return if (safState() == SafState.CONFIGURED) loadViaSaf()
+ else loadViaMediaStore()
+ }
+
+ // ── SAF ───────────────────────────────────────────────────────────────────
+
+ private fun saveViaSaf(fileName: String, gpx: String): Boolean {
+ val dir = safTreeDoc() ?: run {
+ NavLogger.e("track", "saveViaSaf: SAF tree doc is null")
+ return false
+ }
+ // Avoid duplicates — delete if a file with this name already exists
+ dir.findFile(fileName)?.delete()
+
+ val file = dir.createFile("application/gpx+xml", fileName) ?: run {
+ NavLogger.e("track", "saveViaSaf: createFile failed for $fileName")
+ return false
+ }
+ return runCatching {
+ context.contentResolver.openOutputStream(file.uri)?.use {
+ it.write(gpx.toByteArray())
+ } ?: run {
+ file.delete()
+ NavLogger.e("track", "saveViaSaf: openOutputStream null for $fileName")
+ return@runCatching false
+ }
+ persistUri(file.uri)
+ NavLogger.i("track", "saveViaSaf: saved $fileName → ${file.uri}")
+ true
+ }.getOrElse { e ->
+ NavLogger.e("track", "saveViaSaf: write failed for $fileName: ${e.javaClass.simpleName}: ${e.message}")
+ runCatching { file.delete() }
+ false
+ }
+ }
+
+ private fun loadViaSaf(): List<List<TrackPoint>> {
+ val dir = safTreeDoc() ?: return emptyList()
+ val files = dir.listFiles().filter { it.name?.endsWith(".gpx") == true }
+ NavLogger.i("track", "loadViaSaf: ${files.size} gpx files in SAF dir")
+ return files.mapNotNull { doc ->
+ runCatching {
+ context.contentResolver.openInputStream(doc.uri)?.use { GpxParser.parse(it) }
+ ?.takeIf { it.isNotEmpty() }
+ }.getOrElse { e ->
+ NavLogger.e("track", "loadViaSaf: parse error ${doc.name}: ${e.javaClass.simpleName}: ${e.message}")
+ null
+ }
}
}
- // ── API 29+ ──────────────────────────────────────────────────────────────
+ // ── MediaStore (API 29+ fallback) ─────────────────────────────────────────
private fun saveViaMediaStore(fileName: String, gpx: String): Boolean {
- // Guard: external storage must be mounted before touching MediaStore
val storageState = Environment.getExternalStorageState()
if (storageState != Environment.MEDIA_MOUNTED) {
val msg = "storage not mounted (state=$storageState) — cannot save $fileName"
@@ -78,9 +163,6 @@ class TrackStorage(private val context: Context) {
return false
}
- // IS_PENDING marks the entry as in-progress, preventing a race condition on
- // Android 10-11 where insert() succeeds but openOutputStream() returns null
- // because the file hasn't been physically created on disk yet.
val values = ContentValues().apply {
put(MediaStore.Files.FileColumns.DISPLAY_NAME, fileName)
put(MediaStore.Files.FileColumns.MIME_TYPE, "application/gpx+xml")
@@ -106,16 +188,10 @@ class TrackStorage(private val context: Context) {
}
stream.use { it.write(gpx.toByteArray()) }
- // Clear IS_PENDING so the file is visible to other apps and file managers
val update = ContentValues().apply { put(MediaStore.MediaColumns.IS_PENDING, 0) }
context.contentResolver.update(uri, update, null, null)
- // Persist the URI so future loads don't need a MediaStore query.
- // Querying MediaStore.Files requires READ_EXTERNAL_STORAGE on Android ≤ 12
- // and has no clean permission path on Android 13+; owning the URI directly
- // lets openInputStream() work on any version without any permission.
persistUri(uri)
-
val msg = "saved $fileName (${gpx.length} bytes) → $uri"
Log.d(TAG, msg); NavLogger.i("track", msg)
true
@@ -130,8 +206,6 @@ class TrackStorage(private val context: Context) {
private fun loadViaMediaStore(): List<List<TrackPoint>> {
val tracks = mutableListOf<List<TrackPoint>>()
- // Primary: load from persisted URIs — no MediaStore query, no permissions needed.
- // Each URI was stored at save time and grants direct read access to the file.
val loaded = mutableSetOf<String>()
for (uriString in storedUris()) {
val fileUri = Uri.parse(uriString)
@@ -156,21 +230,12 @@ class TrackStorage(private val context: Context) {
}
NavLogger.i("track", "loadViaMediaStore: ${tracks.size} tracks from stored URIs")
- // Fallback: MediaStore query for tracks saved before URI persistence was added.
- // May fail with SecurityException on some Android versions (caught below).
- // Any track found here is immediately persisted so future loads skip the query.
val baseUri = MediaStore.Files.getContentUri("external")
val projection = arrayOf(
MediaStore.Files.FileColumns._ID,
MediaStore.Files.FileColumns.RELATIVE_PATH,
MediaStore.MediaColumns.IS_PENDING
)
- // Do NOT filter by RELATIVE_PATH — equality matching is fragile across
- // Android versions and devices (trailing-slash normalisation, volume prefix,
- // etc.). The display-name pattern is specific enough on its own.
- // Filtering by OWNER_PACKAGE_NAME tells the system we only want our own files.
- // Android grants access to a package's own MediaStore entries without any
- // storage permission, including after a reinstall with the same package name.
val selection = "${MediaStore.Files.FileColumns.DISPLAY_NAME} LIKE ? AND " +
"${MediaStore.Files.FileColumns.OWNER_PACKAGE_NAME} = ?"
val args = arrayOf("nav_%.gpx", context.packageName)
@@ -190,7 +255,6 @@ class TrackStorage(private val context: Context) {
val path = if (pathCol >= 0) c.getString(pathCol) else "?"
val pending = if (pendingCol >= 0) c.getInt(pendingCol) else -1
val fileUri = Uri.withAppendedPath(baseUri, id.toString())
- // Skip files already loaded via stored URIs
if (fileUri.toString() in loaded) continue
NavLogger.i("track", "loadViaMediaStore: fallback id=$id path=$path pending=$pending")
runCatching {
@@ -203,7 +267,6 @@ class TrackStorage(private val context: Context) {
NavLogger.i("track", "loadViaMediaStore: fallback id=$id → ${points.size} points")
if (points.isNotEmpty()) {
tracks.add(points)
- // Migrate: persist this URI so future launches skip the query
persistUri(fileUri)
}
}
@@ -259,15 +322,8 @@ class TrackStorage(private val context: Context) {
} ?: emptyList()
}
- // ── Post-reinstall recovery ───────────────────────────────────────────────
+ // ── Post-reinstall recovery (MediaStore path only) ────────────────────────
- /**
- * Asks the MediaScanner to re-index Documents/Nav/. When triggered by our
- * app, the scanner re-registers each file with OWNER_PACKAGE_NAME set to
- * our package, restoring ownership that Android clears on uninstall.
- * After this returns, the MediaStore query in [loadAllTracks] will find
- * the files again.
- */
suspend fun triggerNavDirRescan() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return
val navDir = File(
@@ -283,7 +339,7 @@ class TrackStorage(private val context: Context) {
null
) { path, uri ->
NavLogger.i("track", "triggerNavDirRescan: done path=$path uri=$uri")
- if (cont.isActive) cont.resume(Unit)
+ if (cont.isActive) cont.resume(Unit) {}
}
}
} ?: NavLogger.e("track", "triggerNavDirRescan: timed out")
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportFragment.kt b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportFragment.kt
index e7a425f..632f616 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportFragment.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportFragment.kt
@@ -9,7 +9,6 @@ import android.widget.TextView
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import com.google.android.material.button.MaterialButton
-import com.google.android.material.chip.ChipGroup
import kotlinx.coroutines.launch
import org.terst.nav.NavApplication
import org.terst.nav.R
@@ -18,14 +17,13 @@ class TripReportFragment : Fragment() {
private val viewModel by lazy {
TripReportViewModel(
- trackRepository = NavApplication.trackRepository,
+ trackRepository = NavApplication.trackRepository,
logbookRepository = NavApplication.logbookRepository
)
}
private lateinit var tvNarrativeContent: TextView
private lateinit var btnRefresh: MaterialButton
- private lateinit var chipGroup: ChipGroup
private lateinit var progress: ProgressBar
override fun onCreateView(
@@ -38,27 +36,15 @@ class TripReportFragment : Fragment() {
super.onViewCreated(view, savedInstanceState)
tvNarrativeContent = view.findViewById(R.id.tv_narrative_content)
- btnRefresh = view.findViewById(R.id.btn_refresh_report)
- chipGroup = view.findViewById(R.id.chip_group_styles)
- progress = view.findViewById(R.id.progress_report)
+ btnRefresh = view.findViewById(R.id.btn_refresh_report)
+ progress = view.findViewById(R.id.progress_report)
btnRefresh.setOnClickListener { viewModel.generateReport() }
- chipGroup.setOnCheckedStateChangeListener { _, checkedIds ->
- val style = when (checkedIds.firstOrNull()) {
- R.id.chip_adventurous -> NarrativeStyle.ADVENTUROUS
- R.id.chip_journal -> NarrativeStyle.JOURNAL
- R.id.chip_pirate -> NarrativeStyle.PIRATE
- else -> NarrativeStyle.PROFESSIONAL
- }
- viewModel.setStyle(style)
- }
-
viewLifecycleOwner.lifecycleScope.launch {
viewModel.state.collect { state -> renderState(state) }
}
- // Initial generation
viewModel.generateReport()
}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportGenerator.kt b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportGenerator.kt
index bbf00b1..2c7f77f 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportGenerator.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportGenerator.kt
@@ -2,13 +2,10 @@ package org.terst.nav.tripreport
import org.terst.nav.logbook.LogEntry
import org.terst.nav.track.TrackPoint
-
-enum class NarrativeStyle {
- PROFESSIONAL,
- ADVENTUROUS,
- JOURNAL,
- PIRATE
-}
+import java.time.Instant
+import java.time.ZoneId
+import java.time.format.DateTimeFormatter
+import java.util.Locale
data class TripSummary(
val startTimeMs: Long,
@@ -32,12 +29,12 @@ class TripReportGenerator {
val startTime = points.first().timestampMs
val endTime = points.last().timestampMs
-
+
var totalDist = 0.0
for (i in 0 until points.size - 1) {
totalDist += calculateDistance(points[i].lat, points[i].lon, points[i+1].lat, points[i+1].lon)
}
- val distanceNm = totalDist / 1852.0 // meters to nautical miles
+ val distanceNm = totalDist / 1852.0
val maxSog = points.maxOf { it.sogKnots }
val avgSog = points.map { it.sogKnots }.average()
@@ -48,21 +45,21 @@ class TripReportGenerator {
val maxWave = points.mapNotNull { it.waveHeightM }.maxOrNull()
return TripSummary(
- startTimeMs = startTime,
- endTimeMs = endTime,
- distanceNm = distanceNm,
- maxSogKts = maxSog,
- avgSogKts = avgSog,
- minAirTempC = minTemp,
- maxAirTempC = maxTemp,
- maxWaveHeightM = maxWave,
- logEntries = logEntries.filter { it.timestampMs in startTime..endTime },
- points = points
+ startTimeMs = startTime,
+ endTimeMs = endTime,
+ distanceNm = distanceNm,
+ maxSogKts = maxSog,
+ avgSogKts = avgSog,
+ minAirTempC = minTemp,
+ maxAirTempC = maxTemp,
+ maxWaveHeightM = maxWave,
+ logEntries = logEntries.filter { it.timestampMs in startTime..endTime },
+ points = points
)
}
private fun calculateDistance(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
- val r = 6371e3 // Earth radius in meters
+ val r = 6371e3
val phi1 = Math.toRadians(lat1)
val phi2 = Math.toRadians(lat2)
val deltaPhi = Math.toRadians(lat2 - lat1)
@@ -76,42 +73,51 @@ class TripReportGenerator {
return r * c
}
- fun generateNarrative(summary: TripSummary, style: NarrativeStyle): String {
- val durationHrs = (summary.endTimeMs - summary.startTimeMs) / 3600000.0
- val baseFactualString = "Trip from ${java.util.Date(summary.startTimeMs)} to ${java.util.Date(summary.endTimeMs)}. " +
- "Distance: %.1f nm. Max SOG: %.1f kts. Avg SOG: %.1f kts. ".format(summary.distanceNm, summary.maxSogKts, summary.avgSogKts) +
- (summary.maxWaveHeightM?.let { "Max waves: %.1fm. ".format(it) } ?: "") +
- "Events: ${summary.logEntries.joinToString { it.text }}"
-
- return when (style) {
- NarrativeStyle.PROFESSIONAL -> {
- "VOYAGE SUMMARY\n" +
- "Duration: %.1f hours\n".format(durationHrs) +
- "Total Distance: %.1f NM\n".format(summary.distanceNm) +
- "Vessel Performance: Avg Speed %.1f kts, Max Speed %.1f kts\n".format(summary.avgSogKts, summary.maxSogKts) +
- "Meteorological Data: " + (summary.maxWaveHeightM?.let { "Significant wave height reached %.1fm." .format(it)} ?: "No wave data recorded.") + "\n" +
- "Key Events:\n" + summary.logEntries.joinToString("\n") { "- ${it.text}" }
- }
- NarrativeStyle.ADVENTUROUS -> {
- "WHAT A TRIP! We covered %.1f nautical miles of open water.\n".format(summary.distanceNm) +
- "We hit a top speed of %.1f knots! ".format(summary.maxSogKts) +
- (summary.maxWaveHeightM?.let { "The sea was alive with waves up to %.1fm high! ".format(it) } ?: "") + "\n" +
- "During our journey, we logged some great moments:\n" +
- summary.logEntries.joinToString("\n") { "🔥 ${it.text}" }
- }
- NarrativeStyle.JOURNAL -> {
- "Reflecting on our time at sea. We traveled %.1f miles over %.1f hours.\n".format(summary.distanceNm, durationHrs) +
- "The average pace was steady at %.1f knots. ".format(summary.avgSogKts) +
- "I remember writing down: " + summary.logEntries.firstOrNull()?.text + "... " +
- "It was a meaningful passage."
+ fun generateNarrative(summary: TripSummary): String {
+ val zone = ZoneId.systemDefault()
+ val dateFmt = DateTimeFormatter.ofPattern("dd MMM yyyy", Locale.getDefault()).withZone(zone)
+ val timeFmt = DateTimeFormatter.ofPattern("HHmm", Locale.getDefault()).withZone(zone)
+ val startInst = Instant.ofEpochMilli(summary.startTimeMs)
+ val endInst = Instant.ofEpochMilli(summary.endTimeMs)
+
+ val durationMs = summary.endTimeMs - summary.startTimeMs
+ val durationStr = if (durationMs >= 3_600_000) {
+ val h = durationMs / 3_600_000
+ val m = (durationMs % 3_600_000) / 60_000
+ "${h}h ${m}m"
+ } else {
+ "${durationMs / 60_000}m"
+ }
+
+ val sb = StringBuilder()
+ sb.appendLine("PASSAGE · ${dateFmt.format(startInst).uppercase(Locale.getDefault())}")
+ sb.appendLine("${timeFmt.format(startInst)}–${timeFmt.format(endInst)} · $durationStr")
+ sb.appendLine()
+ sb.appendLine("%.1f nm avg %.1f kt max %.1f kt".format(
+ summary.distanceNm, summary.avgSogKts, summary.maxSogKts))
+
+ val conditions = buildList<String> {
+ summary.maxWaveHeightM?.let { add("seas %.1f m".format(it)) }
+ val minT = summary.minAirTempC
+ val maxT = summary.maxAirTempC
+ if (minT != null && maxT != null) {
+ add(if (minT == maxT) "%.0f°C".format(minT) else "%.0f–%.0f°C".format(minT, maxT))
}
- NarrativeStyle.PIRATE -> {
- "AHOY! We've sailed %.1f leagues (well, nautical miles) across the briney deep!\n".format(summary.distanceNm) +
- "The wind caught our sails and we flew at %.1f knots!\n".format(summary.maxSogKts) +
- "Listen to the tales from the log:\n" +
- summary.logEntries.joinToString("\n") { "🏴‍☠️ ${it.text}" } + "\n" +
- "Arr, it was a fine voyage indeed!"
+ }
+ if (conditions.isNotEmpty()) {
+ sb.appendLine()
+ sb.appendLine(conditions.joinToString(" "))
+ }
+
+ if (summary.logEntries.isNotEmpty()) {
+ sb.appendLine()
+ for (entry in summary.logEntries.sortedBy { it.timestampMs }) {
+ val time = timeFmt.format(Instant.ofEpochMilli(entry.timestampMs))
+ val photoMarker = if (entry.photoPath != null) " [photo]" else ""
+ sb.appendLine("$time ${entry.text}$photoMarker")
}
}
+
+ return sb.toString().trimEnd()
}
}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportViewModel.kt b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportViewModel.kt
index e474cd2..603d769 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportViewModel.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportViewModel.kt
@@ -6,7 +6,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
-import org.terst.nav.logbook.InMemoryLogbookRepository
+import org.terst.nav.logbook.LogbookRepository
import org.terst.nav.track.TrackRepository
sealed class TripReportState {
@@ -18,21 +18,13 @@ sealed class TripReportState {
class TripReportViewModel(
private val trackRepository: TrackRepository,
- private val logbookRepository: InMemoryLogbookRepository,
+ private val logbookRepository: LogbookRepository,
private val generator: TripReportGenerator = TripReportGenerator()
) : ViewModel() {
private val _state = MutableStateFlow<TripReportState>(TripReportState.Idle)
val state: StateFlow<TripReportState> = _state.asStateFlow()
- private val _selectedStyle = MutableStateFlow(NarrativeStyle.PROFESSIONAL)
- val selectedStyle: StateFlow<NarrativeStyle> = _selectedStyle.asStateFlow()
-
- fun setStyle(style: NarrativeStyle) {
- _selectedStyle.value = style
- generateReport()
- }
-
fun generateReport() {
viewModelScope.launch {
_state.value = TripReportState.Loading
@@ -43,8 +35,8 @@ class TripReportViewModel(
return@launch
}
val logEntries = logbookRepository.getAll()
- val summary = generator.generateSummary(points, logEntries)
- val narrative = generator.generateNarrative(summary, _selectedStyle.value)
+ val summary = generator.generateSummary(points, logEntries)
+ val narrative = generator.generateNarrative(summary)
_state.value = TripReportState.Success(summary, narrative)
} catch (e: Exception) {
_state.value = TripReportState.Error(e.message ?: "Unknown error generating report")
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt
index 52344f2..f664aa9 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt
@@ -1,5 +1,6 @@
package org.terst.nav.ui
+import android.net.Uri
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@@ -14,6 +15,7 @@ import org.terst.nav.ais.CpaThresholds
import org.terst.nav.data.api.AisHubApiService
import org.terst.nav.thermal.ThermalReading
import org.terst.nav.thermal.ThermalState
+import org.terst.nav.track.SafState
import org.terst.nav.track.SavedTrack
import org.terst.nav.track.TrackPoint
import org.terst.nav.track.TrackRepository
@@ -90,23 +92,41 @@ class MainViewModel(
private var lastSog: Double = 0.0
private var lastCog: Double = 0.0
+ private val _currentPosition = MutableStateFlow<Pair<Double, Double>?>(null)
+ val currentPosition: StateFlow<Pair<Double, Double>?> = _currentPosition.asStateFlow()
+
private val aisRepository = AisRepository()
private val trackRepository = NavApplication.trackRepository
val vesselRepository get() = NavApplication.vesselRepository
+ private val _isLoadingTracks = MutableStateFlow(true)
+ val isLoadingTracks: StateFlow<Boolean> = _isLoadingTracks.asStateFlow()
+
init {
viewModelScope.launch {
runCatching { trackRepository.getPastTracks() }
.onSuccess {
_pastTracks.value = it
- _savedTracks.value = trackRepository.getSavedTracks()
+ runCatching { trackRepository.getSavedTracks() }
+ .onSuccess { saved -> _savedTracks.value = saved }
+ .onFailure { e -> NavLogger.e("track", "init: getSavedTracks failed: ${e.javaClass.simpleName}: ${e.message}") }
}
.onFailure { e -> NavLogger.e("track", "init: failed to load past tracks: ${e.javaClass.simpleName}: ${e.message}") }
+ _isLoadingTracks.value = false
}
}
+ private val _safState = MutableStateFlow(trackRepository.safState())
+ val safState: StateFlow<SafState> = _safState.asStateFlow()
+
+ fun onSafDirectorySelected(treeUri: Uri) {
+ trackRepository.initSafDirectory(treeUri)
+ _safState.value = trackRepository.safState()
+ viewModelScope.launch { reloadPastTracks() }
+ }
+
private val _thermalState = MutableStateFlow(ThermalState.OK)
val thermalState: StateFlow<ThermalState> = _thermalState.asStateFlow()
@@ -156,7 +176,9 @@ class MainViewModel(
viewModelScope.launch {
val summary = trackRepository.stopTrack()
_pastTracks.value = trackRepository.getPastTracks()
- _savedTracks.value = trackRepository.getSavedTracks()
+ runCatching { trackRepository.getSavedTracks() }
+ .onSuccess { _savedTracks.value = it }
+ .onFailure { e -> NavLogger.e("track", "stopTrack: getSavedTracks failed: ${e.javaClass.simpleName}: ${e.message}") }
_trackPoints.value = emptyList()
_isRecording.value = false
_trackStats.value = null
@@ -173,7 +195,9 @@ class MainViewModel(
points.forEach { trackRepository.addPoint(it) }
val summary = trackRepository.stopTrack()
_pastTracks.value = trackRepository.getPastTracks()
- _savedTracks.value = trackRepository.getSavedTracks()
+ runCatching { trackRepository.getSavedTracks() }
+ .onSuccess { _savedTracks.value = it }
+ .onFailure { e -> NavLogger.e("track", "injectTestTrack: getSavedTracks failed: ${e.javaClass.simpleName}: ${e.message}") }
_trackPoints.value = emptyList()
_isRecording.value = false
_trackStats.value = null
@@ -210,6 +234,7 @@ class MainViewModel(
fun addGpsPoint(lat: Double, lon: Double, sogKnots: Double, cogDeg: Double) {
lastLat = lat; lastLon = lon; lastSog = sogKnots; lastCog = cogDeg
+ _currentPosition.value = lat to lon
val conditions = _marineConditions.value
val forecast = _forecast.value.firstOrNull()
val point = TrackPoint(
@@ -415,7 +440,9 @@ class MainViewModel(
viewModelScope.launch {
trackRepository.importTrack(points)
_pastTracks.value = trackRepository.getPastTracks()
- _savedTracks.value = trackRepository.getSavedTracks()
+ runCatching { trackRepository.getSavedTracks() }
+ .onSuccess { _savedTracks.value = it }
+ .onFailure { e -> NavLogger.e("track", "addImportedTrack: getSavedTracks failed: ${e.javaClass.simpleName}: ${e.message}") }
}
}
@@ -426,15 +453,19 @@ class MainViewModel(
*/
fun reloadPastTracks() {
viewModelScope.launch {
+ _isLoadingTracks.value = true
runCatching { trackRepository.reloadTracks() }
.onSuccess { tracks ->
_pastTracks.value = tracks
- _savedTracks.value = trackRepository.getSavedTracks()
+ runCatching { trackRepository.getSavedTracks() }
+ .onSuccess { saved -> _savedTracks.value = saved }
+ .onFailure { e -> NavLogger.e("track", "reloadPastTracks: getSavedTracks failed: ${e.javaClass.simpleName}: ${e.message}") }
NavLogger.i("track", "reloadPastTracks: ${tracks.size} tracks loaded")
}
.onFailure { e ->
NavLogger.e("track", "reloadPastTracks: failed: ${e.javaClass.simpleName}: ${e.message}")
}
+ _isLoadingTracks.value = false
}
}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt
index 32b3643..a3c1eac 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt
@@ -26,6 +26,8 @@ import org.terst.nav.TidalCurrentState
import org.terst.nav.data.model.WindArrow
import org.terst.nav.track.TackEvent
import org.terst.nav.track.TrackPoint
+import org.terst.nav.track.speedColorExpression
+import org.terst.nav.track.speedSegments
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.sqrt
@@ -72,10 +74,12 @@ class MapHandler(private val maplibreMap: MapLibreMap) {
private val USER_POS_LAYER_ID = "user-pos-layer"
private val USER_ICON_ID = "user-icon"
- private val TRACK_ACTIVE_SOURCE_ID = "track-active-source"
- private val TRACK_ACTIVE_LAYER_ID = "track-line-active"
- private val TRACK_PAST_SOURCE_ID = "track-past-source"
- private val TRACK_PAST_LAYER_ID = "track-line-past"
+ private val TRACK_ACTIVE_SOURCE_ID = "track-active-source"
+ private val TRACK_ACTIVE_LAYER_ID = "track-line-active"
+ private val TRACK_PAST_DIM_SOURCE_ID = "track-past-dim-source"
+ private val TRACK_PAST_DIM_LAYER_ID = "track-line-past-dim"
+ private val TRACK_PAST_FOCUS_SOURCE_ID = "track-past-focus-source"
+ private val TRACK_PAST_FOCUS_LAYER_ID = "track-line-past-focus"
private val WIND_SOURCE_ID = "wind-arrows-source"
private val WIND_LAYER_ID = "wind-arrows"
@@ -91,7 +95,8 @@ class MapHandler(private val maplibreMap: MapLibreMap) {
private var tidalCurrentSource: GeoJsonSource? = null
private var userPosSource: GeoJsonSource? = null
private var trackActiveSource: GeoJsonSource? = null
- private var trackPastSource: GeoJsonSource? = null
+ private var trackPastDimSource: GeoJsonSource? = null
+ private var trackPastFocusSource: GeoJsonSource? = null
private var windSource: GeoJsonSource? = null
private var windGridSource: GeoJsonSource? = null
private var tackSource: GeoJsonSource? = null
@@ -306,6 +311,33 @@ class MapHandler(private val maplibreMap: MapLibreMap) {
* Updates the GPS track polyline on the map. Lazily initialises the layers on first call.
*/
fun updateTrackLayer(style: Style, activePoints: List<TrackPoint>, pastTracks: List<List<TrackPoint>>) {
+ if (trackPastDimSource == null) {
+ trackPastDimSource = GeoJsonSource(TRACK_PAST_DIM_SOURCE_ID)
+ style.addSource(trackPastDimSource!!)
+ style.addLayer(LineLayer(TRACK_PAST_DIM_LAYER_ID, TRACK_PAST_DIM_SOURCE_ID).apply {
+ setProperties(
+ PropertyFactory.lineColor("#0B3050"),
+ PropertyFactory.lineOpacity(0.12f),
+ PropertyFactory.lineWidth(2.5f),
+ PropertyFactory.lineCap("round"),
+ PropertyFactory.lineJoin("round")
+ )
+ })
+ }
+
+ if (trackPastFocusSource == null) {
+ trackPastFocusSource = GeoJsonSource(TRACK_PAST_FOCUS_SOURCE_ID)
+ style.addSource(trackPastFocusSource!!)
+ style.addLayer(LineLayer(TRACK_PAST_FOCUS_LAYER_ID, TRACK_PAST_FOCUS_SOURCE_ID).apply {
+ setProperties(
+ PropertyFactory.lineWidth(4f),
+ PropertyFactory.lineCap("round"),
+ PropertyFactory.lineJoin("round"),
+ PropertyFactory.lineColor(speedColorExpression())
+ )
+ })
+ }
+
if (trackActiveSource == null) {
trackActiveSource = GeoJsonSource(TRACK_ACTIVE_SOURCE_ID)
style.addSource(trackActiveSource!!)
@@ -313,23 +345,16 @@ class MapHandler(private val maplibreMap: MapLibreMap) {
setProperties(
PropertyFactory.lineColor("#E53935"),
PropertyFactory.lineWidth(4f),
- PropertyFactory.lineCap("round")
+ PropertyFactory.lineCap("round"),
+ PropertyFactory.lineJoin("round")
)
})
}
- if (trackPastSource == null) {
- trackPastSource = GeoJsonSource(TRACK_PAST_SOURCE_ID)
- style.addSource(trackPastSource!!)
- style.addLayer(LineLayer(TRACK_PAST_LAYER_ID, TRACK_PAST_SOURCE_ID).apply {
- setProperties(
- PropertyFactory.lineColor("#E53935"),
- PropertyFactory.lineWidth(3f),
- PropertyFactory.lineDasharray(arrayOf(1f, 2f)),
- PropertyFactory.lineCap("round")
- )
- })
+ val dimFeatures = pastTracks.map { track ->
+ Feature.fromGeometry(LineString.fromLngLats(track.map { Point.fromLngLat(it.lon, it.lat) }))
}
+ trackPastDimSource?.setGeoJson(FeatureCollection.fromFeatures(dimFeatures))
if (activePoints.size >= 2) {
val coords = activePoints.map { Point.fromLngLat(it.lon, it.lat) }
@@ -337,14 +362,14 @@ class MapHandler(private val maplibreMap: MapLibreMap) {
} else {
trackActiveSource?.setGeoJson(FeatureCollection.fromFeatures(emptyList()))
}
+ }
- if (pastTracks.isNotEmpty()) {
- val features = pastTracks.map { track ->
- Feature.fromGeometry(LineString.fromLngLats(track.map { Point.fromLngLat(it.lon, it.lat) }))
- }
- trackPastSource?.setGeoJson(FeatureCollection.fromFeatures(features))
+ /** Highlights [points] in the focus layer, colored by boat speed. */
+ fun setFocusedTrackPoints(points: List<TrackPoint>?) {
+ if (points != null && points.size >= 2) {
+ trackPastFocusSource?.setGeoJson(FeatureCollection.fromFeatures(speedSegments(points)))
} else {
- trackPastSource?.setGeoJson(FeatureCollection.fromFeatures(emptyList()))
+ trackPastFocusSource?.setGeoJson(FeatureCollection.fromFeatures(emptyList()))
}
}
@@ -417,3 +442,4 @@ class MapHandler(private val maplibreMap: MapLibreMap) {
return Polygon.fromLngLats(listOf(points))
}
}
+
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/voicelog/VoiceLogFragment.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/voicelog/VoiceLogFragment.kt
index ab30d96..2557eb4 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/ui/voicelog/VoiceLogFragment.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/voicelog/VoiceLogFragment.kt
@@ -1,11 +1,14 @@
package org.terst.nav.ui.voicelog
import android.Manifest
+import android.app.Activity
import android.content.Intent
import android.content.pm.PackageManager
-import android.graphics.Bitmap
+import android.graphics.BitmapFactory
import android.net.Uri
+import android.os.Build
import android.os.Bundle
+import android.provider.MediaStore
import android.speech.RecognitionListener
import android.speech.RecognizerIntent
import android.speech.SpeechRecognizer
@@ -17,25 +20,44 @@ import android.widget.ImageView
import android.widget.TextView
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
+import androidx.core.content.FileProvider
import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
+import androidx.recyclerview.widget.DividerItemDecoration
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.button.MaterialButton
import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.google.android.material.textfield.TextInputEditText
+import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.terst.nav.MainActivity
+import org.terst.nav.NavApplication
import org.terst.nav.R
+import org.terst.nav.logbook.LogEntry
import org.terst.nav.logbook.VoiceLogState
import org.terst.nav.logbook.VoiceLogViewModel
+import org.terst.nav.track.SafState
+import org.terst.nav.track.SavedTrackAdapter
+import org.terst.nav.track.TrackDetailSheet
+import org.terst.nav.ui.MainViewModel
import java.io.File
import java.io.FileOutputStream
+import java.time.Instant
+import java.time.ZoneId
+import java.time.format.DateTimeFormatter
import java.util.Locale
class VoiceLogFragment : Fragment() {
- private val viewModel by lazy {
- VoiceLogViewModel(repository = org.terst.nav.NavApplication.logbookRepository)
+ private val logViewModel by lazy {
+ VoiceLogViewModel(repository = NavApplication.logbookRepository)
}
+ private val mainViewModel: MainViewModel by activityViewModels()
+ private lateinit var layoutLogEntry: View
private lateinit var speechRecognizer: SpeechRecognizer
private lateinit var etNote: TextInputEditText
private lateinit var fabMic: FloatingActionButton
@@ -48,39 +70,51 @@ class VoiceLogFragment : Fragment() {
private lateinit var btnSave: MaterialButton
private lateinit var btnClear: MaterialButton
private lateinit var tvSavedConfirmation: TextView
- private lateinit var btnGenerateReport: MaterialButton
- /** Path or URI string for the currently attached photo, null if none. */
private var pendingPhotoPath: String? = null
-
- // ── Camera (TakePicturePreview returns a thumbnail bitmap — no FileProvider needed) ──
+ private var pendingCameraUri: Uri? = null
private val cameraLauncher = registerForActivityResult(
- ActivityResultContracts.TakePicturePreview()
- ) { bitmap: Bitmap? ->
- if (bitmap != null) {
- val file = File(requireContext().cacheDir, "log_${System.currentTimeMillis()}.jpg")
- try {
- FileOutputStream(file).use { bitmap.compress(Bitmap.CompressFormat.JPEG, 85, it) }
- setPhoto(file.absolutePath) { ivPhoto.setImageBitmap(bitmap) }
- } catch (_: Exception) {
- tvStatus.text = "Couldn't save photo"
+ ActivityResultContracts.StartActivityForResult()
+ ) { result ->
+ if (result.resultCode == Activity.RESULT_OK) {
+ val path = pendingPhotoPath ?: return@registerForActivityResult
+ setPhoto(path) {
+ val bm = BitmapFactory.decodeFile(path)
+ if (bm != null) ivPhoto.setImageBitmap(bm)
+ else ivPhoto.setImageURI(pendingCameraUri)
}
+ } else {
+ pendingPhotoPath?.let { File(it).delete() }
+ pendingPhotoPath = null
+ pendingCameraUri = null
}
}
- // ── Gallery (GetContent — no storage permission needed; URI valid for this session) ──
-
private val galleryLauncher = registerForActivityResult(
ActivityResultContracts.GetContent()
) { uri: Uri? ->
if (uri != null) {
- setPhoto(uri.toString()) { ivPhoto.setImageURI(uri) }
+ lifecycleScope.launch {
+ val path = withContext(Dispatchers.IO) {
+ runCatching {
+ val dir = NavApplication.logbookRepository.photoDir
+ val dest = File(dir, "log_${System.currentTimeMillis()}.jpg")
+ requireContext().contentResolver.openInputStream(uri)?.use { input ->
+ FileOutputStream(dest).use { output -> input.copyTo(output) }
+ }
+ dest.absolutePath
+ }.getOrNull()
+ }
+ if (path != null) {
+ setPhoto(path) { ivPhoto.setImageURI(Uri.fromFile(File(path))) }
+ } else {
+ tvStatus.text = "Couldn't copy photo"
+ }
+ }
}
}
- // ── Lifecycle ─────────────────────────────────────────────────────────────
-
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
@@ -90,42 +124,130 @@ class VoiceLogFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
- etNote = view.findViewById(R.id.et_note)
- fabMic = view.findViewById(R.id.fab_mic)
- btnCamera = view.findViewById(R.id.btn_camera)
- btnGallery = view.findViewById(R.id.btn_gallery)
- tvStatus = view.findViewById(R.id.tv_status)
- framePhoto = view.findViewById(R.id.frame_photo)
- ivPhoto = view.findViewById(R.id.iv_photo)
- btnRemovePhoto = view.findViewById(R.id.btn_remove_photo)
- btnSave = view.findViewById(R.id.btn_save)
- btnClear = view.findViewById(R.id.btn_clear)
+ layoutLogEntry = view.findViewById(R.id.layout_log_entry)
+ etNote = view.findViewById(R.id.et_note)
+ fabMic = view.findViewById(R.id.fab_mic)
+ btnCamera = view.findViewById(R.id.btn_camera)
+ btnGallery = view.findViewById(R.id.btn_gallery)
+ tvStatus = view.findViewById(R.id.tv_status)
+ framePhoto = view.findViewById(R.id.frame_photo)
+ ivPhoto = view.findViewById(R.id.iv_photo)
+ btnRemovePhoto = view.findViewById(R.id.btn_remove_photo)
+ btnSave = view.findViewById(R.id.btn_save)
+ btnClear = view.findViewById(R.id.btn_clear)
tvSavedConfirmation = view.findViewById(R.id.tv_saved_confirmation)
- btnGenerateReport = view.findViewById(R.id.btn_generate_report)
setupSpeechRecognizer()
fabMic.setOnClickListener { startListening() }
- btnCamera.setOnClickListener { cameraLauncher.launch(null) }
+
+ btnCamera.setOnClickListener {
+ val file = File(NavApplication.logbookRepository.photoDir, "log_${System.currentTimeMillis()}.jpg")
+ pendingPhotoPath = file.absolutePath
+ pendingCameraUri = FileProvider.getUriForFile(
+ requireContext(), "${requireContext().packageName}.fileprovider", file
+ )
+ val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE).apply {
+ putExtra(MediaStore.EXTRA_OUTPUT, pendingCameraUri)
+ addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
+ }
+ cameraLauncher.launch(intent)
+ }
+
btnGallery.setOnClickListener { galleryLauncher.launch("image/*") }
btnRemovePhoto.setOnClickListener { clearPhoto() }
btnSave.setOnClickListener {
- val text = etNote.text?.toString()?.trim() ?: ""
- viewModel.save(text, pendingPhotoPath)
+ val pos = mainViewModel.currentPosition.value
+ logViewModel.save(
+ text = etNote.text?.toString()?.trim() ?: "",
+ photoPath = pendingPhotoPath,
+ lat = pos?.first,
+ lon = pos?.second
+ )
}
-
btnClear.setOnClickListener { clearEntry() }
- btnGenerateReport.setOnClickListener {
+ view.findViewById<MaterialButton>(R.id.btn_generate_report).setOnClickListener {
parentFragmentManager.beginTransaction()
.replace(R.id.fragment_container, org.terst.nav.tripreport.TripReportFragment())
.addToBackStack(null)
.commit()
}
+ // Show log-entry section only while recording
+ viewLifecycleOwner.lifecycleScope.launch {
+ mainViewModel.isRecording.collect { recording ->
+ layoutLogEntry.visibility = if (recording) View.VISIBLE else View.GONE
+ }
+ }
+
+ // Log entries list
+ val tvNoEntries = view.findViewById<TextView>(R.id.tv_no_log_entries)
+ val rvLogEntries = view.findViewById<RecyclerView>(R.id.rv_log_entries)
+ rvLogEntries.layoutManager = LinearLayoutManager(requireContext())
+ rvLogEntries.isNestedScrollingEnabled = false
+ rvLogEntries.addItemDecoration(DividerItemDecoration(requireContext(), DividerItemDecoration.VERTICAL))
+ val logAdapter = LogbookEntryAdapter()
+ rvLogEntries.adapter = logAdapter
+
+ viewLifecycleOwner.lifecycleScope.launch {
+ logViewModel.entries.collect { entries ->
+ logAdapter.submitList(entries)
+ tvNoEntries.visibility = if (entries.isEmpty()) View.VISIBLE else View.GONE
+ rvLogEntries.visibility = if (entries.isEmpty()) View.GONE else View.VISIBLE
+ }
+ }
+
+ // Inline track list
+ val rv = view.findViewById<RecyclerView>(R.id.rv_saved_tracks)
+ val layoutEmpty = view.findViewById<View>(R.id.layout_empty)
+ val tvEmpty = view.findViewById<TextView>(R.id.tv_empty)
+ val btnSetup = view.findViewById<MaterialButton>(R.id.btn_setup_storage)
+
+ rv.layoutManager = LinearLayoutManager(requireContext())
+ rv.isNestedScrollingEnabled = false
+ rv.addItemDecoration(DividerItemDecoration(requireContext(), DividerItemDecoration.VERTICAL))
+
+ val tracksAdapter = SavedTrackAdapter { track ->
+ mainViewModel.selectTrack(track)
+ (requireActivity() as MainActivity).showTrackDetail()
+ }
+ rv.adapter = tracksAdapter
+
+ viewLifecycleOwner.lifecycleScope.launch {
+ mainViewModel.savedTracks.collect { tracks ->
+ tracksAdapter.submitList(tracks)
+ layoutEmpty.visibility = if (tracks.isEmpty()) View.VISIBLE else View.GONE
+ rv.visibility = if (tracks.isEmpty()) View.GONE else View.VISIBLE
+ }
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ viewLifecycleOwner.lifecycleScope.launch {
+ mainViewModel.safState.collect { state ->
+ when (state) {
+ SafState.NOT_CONFIGURED -> {
+ tvEmpty.text = "No saved tracks yet.\nRecord a trip to see it here."
+ btnSetup.text = "Set up track storage"
+ btnSetup.visibility = View.VISIBLE
+ }
+ SafState.PERMISSION_LOST -> {
+ tvEmpty.text = "Track storage needs re-authorization."
+ btnSetup.text = "Restore track access"
+ btnSetup.visibility = View.VISIBLE
+ }
+ SafState.CONFIGURED -> btnSetup.visibility = View.GONE
+ }
+ }
+ }
+ btnSetup.setOnClickListener {
+ (requireActivity() as MainActivity).launchSafPicker()
+ }
+ }
+
viewLifecycleOwner.lifecycleScope.launch {
- viewModel.state.collect { state -> renderState(state) }
+ logViewModel.state.collect { state -> renderState(state) }
}
}
@@ -139,16 +261,14 @@ class VoiceLogFragment : Fragment() {
}
speechRecognizer = SpeechRecognizer.createSpeechRecognizer(requireContext())
speechRecognizer.setRecognitionListener(object : RecognitionListener {
- override fun onReadyForSpeech(params: Bundle?) { viewModel.onListeningStarted() }
+ override fun onReadyForSpeech(params: Bundle?) { logViewModel.onListeningStarted() }
override fun onResults(results: Bundle?) {
val text = results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
?.firstOrNull() ?: ""
- if (text.isNotBlank()) viewModel.onSpeechRecognized(text)
- else viewModel.onRecognitionError("Could not understand speech")
- }
- override fun onError(error: Int) {
- viewModel.onRecognitionError("Recognition error: $error")
+ if (text.isNotBlank()) logViewModel.onSpeechRecognized(text)
+ else logViewModel.onRecognitionError("Could not understand speech")
}
+ override fun onError(error: Int) { logViewModel.onRecognitionError("Recognition error: $error") }
override fun onBeginningOfSpeech() {}
override fun onBufferReceived(b: ByteArray?) {}
override fun onEndOfSpeech() {}
@@ -184,6 +304,7 @@ class VoiceLogFragment : Fragment() {
private fun clearPhoto() {
pendingPhotoPath = null
+ pendingCameraUri = null
ivPhoto.setImageDrawable(null)
framePhoto.visibility = View.GONE
}
@@ -191,7 +312,7 @@ class VoiceLogFragment : Fragment() {
private fun clearEntry() {
etNote.setText("")
clearPhoto()
- viewModel.retry()
+ logViewModel.retry()
tvSavedConfirmation.text = ""
tvStatus.text = ""
}
@@ -209,7 +330,6 @@ class VoiceLogFragment : Fragment() {
fabMic.isEnabled = false
}
is VoiceLogState.Result -> {
- // Fill the text field; user can edit before saving
etNote.setText(state.recognized)
etNote.setSelection(state.recognized.length)
tvStatus.text = ""
@@ -252,3 +372,69 @@ class VoiceLogFragment : Fragment() {
private const val RC_AUDIO = 1001
}
}
+
+// ── Helpers ────────────────────────────────────────────────────────────────────
+
+private fun decodeThumbnail(path: String, targetW: Int, targetH: Int): android.graphics.Bitmap? {
+ val opts = BitmapFactory.Options().apply { inJustDecodeBounds = true }
+ BitmapFactory.decodeFile(path, opts)
+ if (opts.outWidth <= 0) return null
+ val scale = maxOf(opts.outWidth / targetW, opts.outHeight / targetH).coerceAtLeast(1)
+ return BitmapFactory.decodeFile(path, BitmapFactory.Options().apply { inSampleSize = scale })
+}
+
+// ── Logbook entries adapter ────────────────────────────────────────────────────
+
+private val TIME_FMT: DateTimeFormatter =
+ DateTimeFormatter.ofPattern("dd MMM HH:mm", Locale.getDefault())
+ .withZone(ZoneId.systemDefault())
+
+private class LogbookEntryAdapter : RecyclerView.Adapter<LogbookEntryAdapter.VH>() {
+
+ private var items: List<LogEntry> = emptyList()
+
+ fun submitList(list: List<LogEntry>) {
+ items = list
+ notifyDataSetChanged()
+ }
+
+ inner class VH(view: View) : RecyclerView.ViewHolder(view) {
+ val ivPhoto = view.findViewById<ImageView>(R.id.iv_logbook_photo)
+ val tvTime = view.findViewById<TextView>(R.id.tv_logbook_timestamp)
+ val tvText = view.findViewById<TextView>(R.id.tv_logbook_text)
+ val tvLocation = view.findViewById<TextView>(R.id.tv_logbook_location)
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH =
+ VH(LayoutInflater.from(parent.context).inflate(R.layout.item_logbook_entry, parent, false))
+
+ override fun getItemCount() = items.size
+
+ override fun onBindViewHolder(holder: VH, position: Int) {
+ val e = items[position]
+ holder.tvTime.text = TIME_FMT.format(Instant.ofEpochMilli(e.timestampMs))
+ holder.tvText.text = e.text
+
+ if (e.lat != null && e.lon != null) {
+ holder.tvLocation.text = "%.4f°, %.4f°".format(e.lat, e.lon)
+ holder.tvLocation.visibility = View.VISIBLE
+ } else {
+ holder.tvLocation.visibility = View.GONE
+ }
+
+ val photoPath = e.photoPath
+ if (photoPath != null && File(photoPath).exists()) {
+ val bm = decodeThumbnail(photoPath, 128, 96)
+ if (bm != null) {
+ holder.ivPhoto.setImageBitmap(bm)
+ holder.ivPhoto.visibility = View.VISIBLE
+ } else {
+ holder.ivPhoto.setImageDrawable(null)
+ holder.ivPhoto.visibility = View.GONE
+ }
+ } else {
+ holder.ivPhoto.setImageDrawable(null)
+ holder.ivPhoto.visibility = View.GONE
+ }
+ }
+}
diff --git a/android-app/app/src/main/res/layout/dialog_add_track_note.xml b/android-app/app/src/main/res/layout/dialog_add_track_note.xml
new file mode 100644
index 0000000..639a27a
--- /dev/null
+++ b/android-app/app/src/main/res/layout/dialog_add_track_note.xml
@@ -0,0 +1,69 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ android:padding="16dp">
+
+ <EditText
+ android:id="@+id/et_note_text"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:hint="Note"
+ android:minLines="3"
+ android:gravity="top|start"
+ android:inputType="textMultiLine"
+ android:padding="8dp" />
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:layout_marginTop="12dp">
+
+ <Button
+ android:id="@+id/btn_note_camera"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:text="Camera"
+ android:layout_marginEnd="4dp" />
+
+ <Button
+ android:id="@+id/btn_note_gallery"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:text="Gallery"
+ android:layout_marginStart="4dp" />
+
+ </LinearLayout>
+
+ <FrameLayout
+ android:id="@+id/frame_note_photo"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="8dp"
+ android:visibility="gone">
+
+ <ImageView
+ android:id="@+id/iv_note_photo"
+ android:layout_width="match_parent"
+ android:layout_height="120dp"
+ android:scaleType="centerCrop"
+ android:contentDescription="Selected photo" />
+
+ <Button
+ android:id="@+id/btn_note_remove_photo"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="top|end"
+ android:layout_margin="4dp"
+ android:text="✕"
+ android:textSize="10sp"
+ android:paddingHorizontal="6dp"
+ android:paddingVertical="2dp" />
+
+ </FrameLayout>
+
+</LinearLayout>
diff --git a/android-app/app/src/main/res/layout/fragment_saved_tracks.xml b/android-app/app/src/main/res/layout/fragment_saved_tracks.xml
index 7485e59..a95b368 100644
--- a/android-app/app/src/main/res/layout/fragment_saved_tracks.xml
+++ b/android-app/app/src/main/res/layout/fragment_saved_tracks.xml
@@ -34,15 +34,32 @@
</LinearLayout>
- <TextView
- android:id="@+id/tv_empty"
+ <LinearLayout
+ android:id="@+id/layout_empty"
android:layout_width="match_parent"
android:layout_height="match_parent"
+ android:orientation="vertical"
android:gravity="center"
- android:text="No saved tracks yet.\nRecord a trip to see it here."
- android:textAlignment="center"
- android:textColor="?attr/colorOnSurfaceVariant"
- android:visibility="gone" />
+ android:padding="32dp"
+ android:visibility="gone">
+
+ <TextView
+ android:id="@+id/tv_empty"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="No saved tracks yet.\nRecord a trip to see it here."
+ android:textAlignment="center"
+ android:textColor="?attr/colorOnSurfaceVariant" />
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/btn_setup_storage"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="24dp"
+ android:text="Set up track storage"
+ android:visibility="gone" />
+
+ </LinearLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_saved_tracks"
diff --git a/android-app/app/src/main/res/layout/fragment_trip_report.xml b/android-app/app/src/main/res/layout/fragment_trip_report.xml
index 1ce0bde..7228fa9 100644
--- a/android-app/app/src/main/res/layout/fragment_trip_report.xml
+++ b/android-app/app/src/main/res/layout/fragment_trip_report.xml
@@ -14,61 +14,10 @@
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:text="Trip Narrative"
+ android:text="Trip Report"
android:textSize="24sp"
android:textStyle="bold"
- android:layout_marginBottom="16dp" />
-
- <TextView
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:text="Choose Narrative Style:"
- android:textSize="14sp"
- android:layout_marginBottom="8dp" />
-
- <HorizontalScrollView
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:layout_marginBottom="24dp">
-
- <com.google.android.material.chip.ChipGroup
- android:id="@+id/chip_group_styles"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- app:singleSelection="true"
- app:selectionRequired="true">
-
- <com.google.android.material.chip.Chip
- android:id="@+id/chip_professional"
- style="@style/Widget.Material3.Chip.Filter"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:text="Professional"
- android:checked="true" />
-
- <com.google.android.material.chip.Chip
- android:id="@+id/chip_adventurous"
- style="@style/Widget.Material3.Chip.Filter"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:text="Adventurous" />
-
- <com.google.android.material.chip.Chip
- android:id="@+id/chip_journal"
- style="@style/Widget.Material3.Chip.Filter"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:text="Journal" />
-
- <com.google.android.material.chip.Chip
- android:id="@+id/chip_pirate"
- style="@style/Widget.Material3.Chip.Filter"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:text="Pirate" />
-
- </com.google.android.material.chip.ChipGroup>
- </HorizontalScrollView>
+ android:layout_marginBottom="24dp" />
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
@@ -88,8 +37,9 @@
android:id="@+id/tv_narrative_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:text="Generate a report to see your story..."
- android:textSize="16sp"
+ android:text="Generate a report to see your passage summary…"
+ android:textSize="15sp"
+ android:fontFamily="monospace"
android:lineSpacingExtra="4dp" />
</LinearLayout>
@@ -100,7 +50,7 @@
android:layout_width="match_parent"
android:layout_height="60dp"
android:layout_marginTop="24dp"
- android:text="REFRESH REPORT" />
+ android:text="GENERATE REPORT" />
<ProgressBar
android:id="@+id/progress_report"
diff --git a/android-app/app/src/main/res/layout/fragment_voice_log.xml b/android-app/app/src/main/res/layout/fragment_voice_log.xml
index c1275a6..4a2ff14 100644
--- a/android-app/app/src/main/res/layout/fragment_voice_log.xml
+++ b/android-app/app/src/main/res/layout/fragment_voice_log.xml
@@ -1,156 +1,249 @@
<?xml version="1.0" encoding="utf-8"?>
-<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+<androidx.core.widget.NestedScrollView
+ xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:orientation="vertical"
- android:padding="20dp">
+ android:layout_height="match_parent">
- <TextView
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:text="Log Entry"
- android:textAppearance="?attr/textAppearanceHeadlineSmall"
- android:layout_marginBottom="16dp" />
-
- <!-- Note text — voice fills this; user can also type directly -->
- <com.google.android.material.textfield.TextInputLayout
+ <LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:layout_marginBottom="12dp"
- style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
+ android:orientation="vertical"
+ android:padding="20dp">
- <com.google.android.material.textfield.TextInputEditText
- android:id="@+id/et_note"
+ <!-- ── Log entry section — visible only while recording ── -->
+ <LinearLayout
+ android:id="@+id/layout_log_entry"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:minLines="3"
- android:maxLines="6"
- android:gravity="top"
- android:hint="Tap mic or type a note…"
- android:inputType="textMultiLine|textCapSentences"
- android:scrollbars="vertical" />
-
- </com.google.android.material.textfield.TextInputLayout>
-
- <!-- Action row: mic, camera, gallery, status -->
- <LinearLayout
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:orientation="horizontal"
- android:gravity="center_vertical"
- android:layout_marginBottom="12dp">
+ android:orientation="vertical"
+ android:visibility="gone">
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Log Entry"
+ android:textAppearance="?attr/textAppearanceHeadlineSmall"
+ android:layout_marginBottom="16dp" />
+
+ <com.google.android.material.textfield.TextInputLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="12dp"
+ style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
+
+ <com.google.android.material.textfield.TextInputEditText
+ android:id="@+id/et_note"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:minLines="3"
+ android:maxLines="6"
+ android:gravity="top"
+ android:hint="Tap mic or type a note…"
+ android:inputType="textMultiLine|textCapSentences"
+ android:scrollbars="vertical" />
+
+ </com.google.android.material.textfield.TextInputLayout>
+
+ <!-- Action row: mic, camera, gallery, status -->
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:gravity="center_vertical"
+ android:layout_marginBottom="12dp">
+
+ <com.google.android.material.floatingactionbutton.FloatingActionButton
+ android:id="@+id/fab_mic"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ app:fabSize="mini"
+ android:src="@android:drawable/ic_btn_speak_now"
+ android:contentDescription="Start voice recognition"
+ android:layout_marginEnd="12dp" />
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/btn_camera"
+ style="@style/Widget.Material3.Button.IconButton.Outlined"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:contentDescription="Take photo"
+ app:icon="@android:drawable/ic_menu_camera"
+ android:layout_marginEnd="8dp" />
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/btn_gallery"
+ style="@style/Widget.Material3.Button.IconButton.Outlined"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:contentDescription="Attach photo from gallery"
+ app:icon="@android:drawable/ic_menu_gallery"
+ android:layout_marginEnd="12dp" />
+
+ <TextView
+ android:id="@+id/tv_status"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:text=""
+ android:textSize="14sp"
+ android:textColor="?attr/colorOnSurfaceVariant" />
+
+ </LinearLayout>
+
+ <!-- Photo thumbnail — hidden until a photo is attached -->
+ <FrameLayout
+ android:id="@+id/frame_photo"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="12dp"
+ android:visibility="gone">
+
+ <ImageView
+ android:id="@+id/iv_photo"
+ android:layout_width="120dp"
+ android:layout_height="90dp"
+ android:scaleType="centerCrop"
+ android:contentDescription="Attached photo" />
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/btn_remove_photo"
+ style="@style/Widget.Material3.Button.IconButton"
+ android:layout_width="32dp"
+ android:layout_height="32dp"
+ android:layout_gravity="top|end"
+ android:contentDescription="Remove photo"
+ app:icon="@drawable/ic_close" />
+
+ </FrameLayout>
+
+ <!-- Save / Clear row -->
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:gravity="center_vertical"
+ android:layout_marginBottom="12dp">
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/btn_save"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:text="Save"
+ android:layout_marginEnd="8dp" />
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/btn_clear"
+ style="@style/Widget.Material3.Button.TextButton"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Clear" />
+
+ </LinearLayout>
+
+ <TextView
+ android:id="@+id/tv_saved_confirmation"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:text=""
+ android:textSize="14sp"
+ android:textColor="?attr/colorPrimary"
+ android:layout_marginBottom="20dp" />
+
+ <View
+ android:layout_width="match_parent"
+ android:layout_height="1dp"
+ android:background="?attr/colorOutline"
+ android:layout_marginBottom="20dp" />
+
+ </LinearLayout>
+ <!-- end layout_log_entry -->
+
+ <!-- ── Log entries section ── -->
+ <TextView
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:text="Log Entries"
+ android:textSize="13sp"
+ android:textAllCaps="true"
+ android:letterSpacing="0.08"
+ android:textColor="?attr/colorOnSurfaceVariant"
+ android:layout_marginBottom="8dp" />
- <com.google.android.material.floatingactionbutton.FloatingActionButton
- android:id="@+id/fab_mic"
- android:layout_width="wrap_content"
+ <TextView
+ android:id="@+id/tv_no_log_entries"
+ android:layout_width="match_parent"
android:layout_height="wrap_content"
- app:fabSize="mini"
- android:src="@android:drawable/ic_btn_speak_now"
- android:contentDescription="Start voice recognition"
- android:layout_marginEnd="12dp" />
+ android:text="No log entries yet."
+ android:textColor="?attr/colorOnSurfaceVariant"
+ android:layout_marginBottom="8dp"
+ android:visibility="visible" />
- <com.google.android.material.button.MaterialButton
- android:id="@+id/btn_camera"
- style="@style/Widget.Material3.Button.IconButton.Outlined"
- android:layout_width="wrap_content"
+ <androidx.recyclerview.widget.RecyclerView
+ android:id="@+id/rv_log_entries"
+ android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:contentDescription="Take photo"
- app:icon="@android:drawable/ic_menu_camera"
- android:layout_marginEnd="8dp" />
+ android:nestedScrollingEnabled="false"
+ android:clipToPadding="false"
+ android:layout_marginBottom="16dp" />
+ <!-- Generate Trip Report — always accessible -->
<com.google.android.material.button.MaterialButton
- android:id="@+id/btn_gallery"
- style="@style/Widget.Material3.Button.IconButton.Outlined"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:contentDescription="Attach photo from gallery"
- app:icon="@android:drawable/ic_menu_gallery"
- android:layout_marginEnd="12dp" />
+ android:id="@+id/btn_generate_report"
+ style="@style/Widget.Material3.Button.TonalButton"
+ android:layout_width="match_parent"
+ android:layout_height="60dp"
+ android:text="GENERATE TRIP REPORT"
+ android:layout_marginBottom="24dp" />
+ <!-- ── Trips section ── -->
<TextView
- android:id="@+id/tv_status"
- android:layout_width="0dp"
+ android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:layout_weight="1"
- android:text=""
- android:textSize="14sp"
- android:textColor="?attr/colorOnSurfaceVariant" />
-
- </LinearLayout>
-
- <!-- Photo thumbnail — hidden until a photo is attached -->
- <FrameLayout
- android:id="@+id/frame_photo"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_marginBottom="12dp"
- android:visibility="gone">
-
- <ImageView
- android:id="@+id/iv_photo"
- android:layout_width="120dp"
- android:layout_height="90dp"
- android:scaleType="centerCrop"
- android:contentDescription="Attached photo" />
-
- <com.google.android.material.button.MaterialButton
- android:id="@+id/btn_remove_photo"
- style="@style/Widget.Material3.Button.IconButton"
- android:layout_width="32dp"
- android:layout_height="32dp"
- android:layout_gravity="top|end"
- android:contentDescription="Remove photo"
- app:icon="@drawable/ic_close" />
-
- </FrameLayout>
-
- <!-- Save / Clear row -->
- <LinearLayout
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:orientation="horizontal"
- android:gravity="center_vertical"
- android:layout_marginBottom="12dp">
-
- <com.google.android.material.button.MaterialButton
- android:id="@+id/btn_save"
- android:layout_width="0dp"
+ android:text="Trips"
+ android:textSize="13sp"
+ android:textAllCaps="true"
+ android:letterSpacing="0.08"
+ android:textColor="?attr/colorOnSurfaceVariant"
+ android:layout_marginBottom="8dp" />
+
+ <!-- Empty state -->
+ <LinearLayout
+ android:id="@+id/layout_empty"
+ android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:layout_weight="1"
- android:text="Save"
- android:layout_marginEnd="8dp" />
-
- <com.google.android.material.button.MaterialButton
- android:id="@+id/btn_clear"
- style="@style/Widget.Material3.Button.TextButton"
- android:layout_width="wrap_content"
+ android:orientation="vertical"
+ android:gravity="center_horizontal"
+ android:paddingVertical="24dp"
+ android:visibility="gone">
+
+ <TextView
+ android:id="@+id/tv_empty"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="No saved tracks yet.\nRecord a trip to see it here."
+ android:textAlignment="center"
+ android:textColor="?attr/colorOnSurfaceVariant" />
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/btn_setup_storage"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="16dp"
+ android:text="Set up track storage"
+ android:visibility="gone" />
+
+ </LinearLayout>
+
+ <androidx.recyclerview.widget.RecyclerView
+ android:id="@+id/rv_saved_tracks"
+ android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:text="Clear" />
+ android:nestedScrollingEnabled="false"
+ android:clipToPadding="false"
+ android:paddingBottom="80dp" />
</LinearLayout>
- <!-- Saved confirmation -->
- <TextView
- android:id="@+id/tv_saved_confirmation"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:text=""
- android:textSize="14sp"
- android:textColor="?attr/colorPrimary"
- android:layout_marginBottom="24dp" />
-
- <View
- android:layout_width="match_parent"
- android:layout_height="1dp"
- android:background="?attr/colorOutline"
- android:layout_marginBottom="24dp" />
-
- <com.google.android.material.button.MaterialButton
- android:id="@+id/btn_generate_report"
- style="@style/Widget.Material3.Button.TonalButton"
- android:layout_width="match_parent"
- android:layout_height="60dp"
- android:text="GENERATE TRIP REPORT" />
-
-</LinearLayout>
+</androidx.core.widget.NestedScrollView>
diff --git a/android-app/app/src/main/res/layout/item_log_entry.xml b/android-app/app/src/main/res/layout/item_log_entry.xml
index fc53ef1..e23c7c2 100644
--- a/android-app/app/src/main/res/layout/item_log_entry.xml
+++ b/android-app/app/src/main/res/layout/item_log_entry.xml
@@ -49,6 +49,18 @@
app:layout_constraintTop_toBottomOf="@id/tv_log_label"
app:layout_constraintStart_toEndOf="@id/tv_log_icon"
app:layout_constraintEnd_toEndOf="parent"
+ android:layout_marginStart="8dp" />
+
+ <ImageView
+ android:id="@+id/iv_log_thumb"
+ android:layout_width="80dp"
+ android:layout_height="60dp"
+ android:scaleType="centerCrop"
+ android:layout_marginTop="6dp"
+ android:contentDescription="Log photo"
+ android:visibility="gone"
+ app:layout_constraintTop_toBottomOf="@id/tv_log_detail"
+ app:layout_constraintStart_toEndOf="@id/tv_log_icon"
app:layout_constraintBottom_toBottomOf="parent"
android:layout_marginStart="8dp" />
diff --git a/android-app/app/src/main/res/layout/item_logbook_entry.xml b/android-app/app/src/main/res/layout/item_logbook_entry.xml
new file mode 100644
index 0000000..10cec4a
--- /dev/null
+++ b/android-app/app/src/main/res/layout/item_logbook_entry.xml
@@ -0,0 +1,50 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:padding="12dp"
+ android:gravity="center_vertical"
+ android:background="?attr/selectableItemBackground">
+
+ <ImageView
+ android:id="@+id/iv_logbook_photo"
+ android:layout_width="64dp"
+ android:layout_height="48dp"
+ android:scaleType="centerCrop"
+ android:layout_marginEnd="12dp"
+ android:contentDescription="Log photo"
+ android:visibility="gone" />
+
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:orientation="vertical">
+
+ <TextView
+ android:id="@+id/tv_logbook_timestamp"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:textSize="12sp"
+ android:textColor="?attr/colorOnSurfaceVariant" />
+
+ <TextView
+ android:id="@+id/tv_logbook_text"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:textSize="14sp"
+ android:layout_marginTop="2dp" />
+
+ <TextView
+ android:id="@+id/tv_logbook_location"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:textSize="12sp"
+ android:textColor="?attr/colorOnSurfaceVariant"
+ android:layout_marginTop="2dp"
+ android:visibility="gone" />
+
+ </LinearLayout>
+
+</LinearLayout>
diff --git a/android-app/app/src/main/res/layout/layout_instruments_sheet.xml b/android-app/app/src/main/res/layout/layout_instruments_sheet.xml
index f8dc365..5e58009 100644
--- a/android-app/app/src/main/res/layout/layout_instruments_sheet.xml
+++ b/android-app/app/src/main/res/layout/layout_instruments_sheet.xml
@@ -183,8 +183,7 @@
android:layout_marginEnd="-16dp"
app:layout_constraintTop_toBottomOf="@id/wave_divider"
app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintBottom_toBottomOf="parent">
+ app:layout_constraintEnd_toEndOf="parent">
<!-- Current -->
<LinearLayout
@@ -339,19 +338,5 @@
</LinearLayout>
- <!-- Saved Tracks button -->
- <com.google.android.material.button.MaterialButton
- android:id="@+id/btn_saved_tracks"
- style="@style/Widget.Material3.Button.OutlinedButton"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:layout_marginHorizontal="16dp"
- android:layout_marginTop="12dp"
- android:layout_marginBottom="8dp"
- android:text="Saved Tracks"
- android:textAllCaps="false"
- app:layout_constraintTop_toBottomOf="@id/forecast_row"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/android-app/app/src/main/res/layout/layout_track_detail_sheet.xml b/android-app/app/src/main/res/layout/layout_track_detail_sheet.xml
index 6a2ba50..4a77a36 100644
--- a/android-app/app/src/main/res/layout/layout_track_detail_sheet.xml
+++ b/android-app/app/src/main/res/layout/layout_track_detail_sheet.xml
@@ -6,100 +6,136 @@
android:orientation="vertical"
android:background="?attr/colorSurface">
- <!-- Header -->
+ <!-- Track map — top half -->
+ <org.maplibre.android.maps.MapView
+ android:id="@+id/track_map_view"
+ android:layout_width="match_parent"
+ android:layout_height="0dp"
+ android:layout_weight="1" />
+
+ <!-- Detail panel — bottom half -->
<LinearLayout
android:layout_width="match_parent"
- android:layout_height="56dp"
- android:orientation="horizontal"
- android:gravity="center_vertical"
- android:paddingStart="8dp"
- android:paddingEnd="16dp"
- android:elevation="4dp"
+ android:layout_height="0dp"
+ android:layout_weight="1"
+ android:orientation="vertical"
android:background="?attr/colorSurface">
- <ImageButton
- android:id="@+id/btn_back"
- android:layout_width="48dp"
- android:layout_height="48dp"
- android:src="@drawable/ic_back"
- android:background="?attr/selectableItemBackgroundBorderless"
- android:contentDescription="Back" />
-
- <TextView
- android:id="@+id/detail_title"
- android:layout_width="0dp"
- android:layout_height="wrap_content"
- android:layout_weight="1"
- android:textSize="17sp"
- android:textStyle="bold"
- android:layout_marginStart="4dp" />
+ <!-- Header -->
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="56dp"
+ android:orientation="horizontal"
+ android:gravity="center_vertical"
+ android:paddingStart="8dp"
+ android:paddingEnd="16dp"
+ android:elevation="4dp"
+ android:background="?attr/colorSurface">
- </LinearLayout>
+ <ImageButton
+ android:id="@+id/btn_back"
+ android:layout_width="48dp"
+ android:layout_height="48dp"
+ android:src="@drawable/ic_back"
+ android:background="?attr/selectableItemBackgroundBorderless"
+ android:contentDescription="Back" />
- <!-- Stats row: distance · duration · max · avg -->
- <LinearLayout
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:orientation="horizontal"
- android:paddingHorizontal="12dp"
- android:paddingTop="12dp"
- android:paddingBottom="12dp">
-
- <LinearLayout android:layout_width="0dp" android:layout_height="wrap_content"
- android:layout_weight="1" android:orientation="vertical" android:gravity="center">
- <TextView android:id="@+id/detail_distance" android:layout_width="wrap_content"
- android:layout_height="wrap_content" android:textSize="22sp" android:textStyle="bold" />
- <TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
- android:text="nm" android:textSize="11sp" android:textColor="?attr/colorOnSurfaceVariant" />
- </LinearLayout>
+ <TextView
+ android:id="@+id/detail_title"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:textSize="17sp"
+ android:textStyle="bold"
+ android:layout_marginStart="4dp" />
- <LinearLayout android:layout_width="0dp" android:layout_height="wrap_content"
- android:layout_weight="1" android:orientation="vertical" android:gravity="center">
- <TextView android:id="@+id/detail_duration" android:layout_width="wrap_content"
- android:layout_height="wrap_content" android:textSize="22sp" android:textStyle="bold" />
- <TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
- android:text="time" android:textSize="11sp" android:textColor="?attr/colorOnSurfaceVariant" />
</LinearLayout>
- <LinearLayout android:layout_width="0dp" android:layout_height="wrap_content"
- android:layout_weight="1" android:orientation="vertical" android:gravity="center">
- <TextView android:id="@+id/detail_max_sog" android:layout_width="wrap_content"
- android:layout_height="wrap_content" android:textSize="22sp" android:textStyle="bold" />
- <TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
- android:text="max kt" android:textSize="11sp" android:textColor="?attr/colorOnSurfaceVariant" />
- </LinearLayout>
+ <!-- Stats row: distance · duration · max · avg -->
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:paddingHorizontal="12dp"
+ android:paddingTop="8dp"
+ android:paddingBottom="8dp">
+
+ <LinearLayout android:layout_width="0dp" android:layout_height="wrap_content"
+ android:layout_weight="1" android:orientation="vertical" android:gravity="center">
+ <TextView android:id="@+id/detail_distance" android:layout_width="wrap_content"
+ android:layout_height="wrap_content" android:textSize="22sp" android:textStyle="bold" />
+ <TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
+ android:text="nm" android:textSize="11sp" android:textColor="?attr/colorOnSurfaceVariant" />
+ </LinearLayout>
+
+ <LinearLayout android:layout_width="0dp" android:layout_height="wrap_content"
+ android:layout_weight="1" android:orientation="vertical" android:gravity="center">
+ <TextView android:id="@+id/detail_duration" android:layout_width="wrap_content"
+ android:layout_height="wrap_content" android:textSize="22sp" android:textStyle="bold" />
+ <TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
+ android:text="time" android:textSize="11sp" android:textColor="?attr/colorOnSurfaceVariant" />
+ </LinearLayout>
+
+ <LinearLayout android:layout_width="0dp" android:layout_height="wrap_content"
+ android:layout_weight="1" android:orientation="vertical" android:gravity="center">
+ <TextView android:id="@+id/detail_max_sog" android:layout_width="wrap_content"
+ android:layout_height="wrap_content" android:textSize="22sp" android:textStyle="bold" />
+ <TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
+ android:text="max kt" android:textSize="11sp" android:textColor="?attr/colorOnSurfaceVariant" />
+ </LinearLayout>
+
+ <LinearLayout android:layout_width="0dp" android:layout_height="wrap_content"
+ android:layout_weight="1" android:orientation="vertical" android:gravity="center">
+ <TextView android:id="@+id/detail_avg_sog" android:layout_width="wrap_content"
+ android:layout_height="wrap_content" android:textSize="22sp" android:textStyle="bold" />
+ <TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
+ android:text="avg kt" android:textSize="11sp" android:textColor="?attr/colorOnSurfaceVariant" />
+ </LinearLayout>
- <LinearLayout android:layout_width="0dp" android:layout_height="wrap_content"
- android:layout_weight="1" android:orientation="vertical" android:gravity="center">
- <TextView android:id="@+id/detail_avg_sog" android:layout_width="wrap_content"
- android:layout_height="wrap_content" android:textSize="22sp" android:textStyle="bold" />
- <TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
- android:text="avg kt" android:textSize="11sp" android:textColor="?attr/colorOnSurfaceVariant" />
</LinearLayout>
- </LinearLayout>
+ <View android:layout_width="match_parent" android:layout_height="1dp"
+ android:background="?attr/colorOutlineVariant" android:layout_marginHorizontal="16dp" />
- <View android:layout_width="match_parent" android:layout_height="1dp"
- android:background="?attr/colorOutlineVariant" android:layout_marginHorizontal="16dp" />
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:gravity="center_vertical"
+ android:paddingStart="20dp"
+ android:paddingEnd="8dp"
+ android:paddingTop="8dp"
+ android:paddingBottom="4dp">
- <TextView
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:paddingHorizontal="20dp"
- android:paddingTop="12dp"
- android:paddingBottom="4dp"
- android:text="Event Log"
- android:textSize="13sp"
- android:textAllCaps="true"
- android:letterSpacing="0.08"
- android:textColor="?attr/colorOnSurfaceVariant" />
-
- <androidx.recyclerview.widget.RecyclerView
- android:id="@+id/rv_log_entries"
- android:layout_width="match_parent"
- android:layout_height="0dp"
- android:layout_weight="1"
- android:clipToPadding="false"
- android:paddingBottom="24dp" />
+ <TextView
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:text="Event Log"
+ android:textSize="13sp"
+ android:textAllCaps="true"
+ android:letterSpacing="0.08"
+ android:textColor="?attr/colorOnSurfaceVariant" />
+
+ <Button
+ android:id="@+id/btn_add_note"
+ style="?attr/borderlessButtonStyle"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="+ Add note"
+ android:textSize="12sp"
+ android:paddingHorizontal="8dp" />
+
+ </LinearLayout>
+
+ <androidx.recyclerview.widget.RecyclerView
+ android:id="@+id/rv_log_entries"
+ android:layout_width="match_parent"
+ android:layout_height="0dp"
+ android:layout_weight="1"
+ android:clipToPadding="false"
+ android:paddingBottom="24dp" />
+
+ </LinearLayout>
</LinearLayout>
diff --git a/android-app/app/src/main/res/values/colors.xml b/android-app/app/src/main/res/values/colors.xml
index 2df4109..c3b115c 100755
--- a/android-app/app/src/main/res/values/colors.xml
+++ b/android-app/app/src/main/res/values/colors.xml
@@ -52,7 +52,7 @@
<color name="wave_sky_top">#2B8FC4</color>
<color name="wave_sky_bottom">#87C8DF</color>
<color name="wave_sea_top">#0D7A9A</color>
- <color name="wave_sea_bottom">#074B68</color>
+ <color name="wave_sea_bottom">#0D2137</color>
<color name="wave_shimmer">#50FFFFFF</color>
<color name="wave_whitecap">#80FFFFFF</color>
diff --git a/android-app/app/src/main/res/xml/file_paths.xml b/android-app/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 0000000..c8f824e
--- /dev/null
+++ b/android-app/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<paths>
+ <external-files-path name="nav_photos" path="Pictures/Nav/" />
+</paths>