diff options
Diffstat (limited to 'android-app/app/src/main')
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> |
