diff options
Diffstat (limited to 'android-app/app/src/main')
20 files changed, 693 insertions, 128 deletions
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 e4bc9b5..da887f4 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 @@ -504,10 +504,6 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { } } - override fun onConfigureAnchor() { - showOverlay(org.terst.nav.ui.anchorwatch.AnchorWatchHandler()) - } - override fun onHardwareSourceChanged(source: HardwareSource, enabled: Boolean) { viewModel.onHardwareSourceChanged(source, enabled) if (source == HardwareSource.NMEA_INSTRUMENTS) { 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 0fe5708..3873002 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 @@ -2,6 +2,7 @@ package org.terst.nav import android.app.Application import com.google.firebase.crashlytics.FirebaseCrashlytics +import org.terst.nav.db.NavDatabase import org.terst.nav.settings.FeatureFlags import java.io.File import java.io.PrintWriter @@ -21,6 +22,8 @@ class NavApplication : Application() { lateinit var trackRepository: org.terst.nav.track.TrackRepository lateinit var boatProfileRepository: org.terst.nav.tripreport.BoatProfileRepository lateinit var vesselRepository: org.terst.nav.vessel.VesselRepository + lateinit var database: NavDatabase + private set var isTesting: Boolean = false get() { if (field) return true @@ -36,8 +39,9 @@ class NavApplication : Application() { override fun onCreate() { super.onCreate() featureFlags = FeatureFlags(this) + database = NavDatabase.get(this) logbookRepository = org.terst.nav.logbook.LogbookRepository(org.terst.nav.logbook.LogbookStorage(this)) - trackRepository = org.terst.nav.track.TrackRepository(this) + trackRepository = org.terst.nav.track.TrackRepository(this, database.trackDao()) boatProfileRepository = org.terst.nav.tripreport.BoatProfileRepository(this) vesselRepository = org.terst.nav.vessel.VesselRepository(this) FirebaseCrashlytics.getInstance().sendUnsentReports() diff --git a/android-app/app/src/main/kotlin/org/terst/nav/db/NavDatabase.kt b/android-app/app/src/main/kotlin/org/terst/nav/db/NavDatabase.kt new file mode 100644 index 0000000..ccef58b --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/db/NavDatabase.kt @@ -0,0 +1,26 @@ +package org.terst.nav.db + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import org.terst.nav.track.TrackDao +import org.terst.nav.track.TrackEntity + +@Database(entities = [TrackEntity::class], version = 1, exportSchema = false) +abstract class NavDatabase : RoomDatabase() { + + abstract fun trackDao(): TrackDao + + companion object { + @Volatile private var INSTANCE: NavDatabase? = null + + fun get(context: Context): NavDatabase = INSTANCE ?: synchronized(this) { + INSTANCE ?: Room.databaseBuilder( + context.applicationContext, + NavDatabase::class.java, + "nav.db" + ).build().also { INSTANCE = it } + } + } +} 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 index 0b4693e..fe7b40d 100644 --- 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 @@ -1,11 +1,21 @@ package org.terst.nav.logbook +import android.net.Uri +import java.io.File + 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 + /** Temp dir for FileProvider-backed camera captures. Not persistent across reinstall — use importPhoto() after capture. */ + val cameraDir get() = storage.cameraDir + + /** Copies a gallery URI into persistent storage. Returns content:// URI string or file path. */ + fun copyPhoto(uri: Uri): String? = storage.copyFromUri(uri) + + /** Moves a temp camera file into persistent storage, deleting the temp. Returns content:// URI string or file path. */ + fun importPhoto(file: File): String? = storage.importFile(file) fun save(entry: LogEntry): LogEntry { val saved = entry.copy(id = nextId++) @@ -15,4 +25,11 @@ class LogbookRepository(private val storage: LogbookStorage) { } fun getAll(): List<LogEntry> = entries.toList() + + /** Re-reads entries from storage. Call after SAF is (re-)authorised post-reinstall. */ + fun reload() { + entries.clear() + entries.addAll(storage.loadAll()) + nextId = (entries.maxOfOrNull { it.id } ?: 0L) + 1L + } } 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 index 51f1f09..885748e 100644 --- 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 @@ -3,51 +3,188 @@ package org.terst.nav.logbook import android.content.Context import android.graphics.Bitmap import android.net.Uri -import android.os.Environment +import androidx.documentfile.provider.DocumentFile 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 +private const val PREF_NAME = "nav_track_uris" +private const val PREF_SAF_URI = "saf_tree_uri" + class LogbookStorage(private val context: Context) { - private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build() + private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build() private val adapter = moshi.adapter(LogEntryList::class.java) - private val storageFile: File + private val prefs by lazy { context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) } + + /** Temp dir for camera captures — FileProvider-accessible, not persistent across reinstall (expected). */ + val cameraDir: File + get() = File(context.cacheDir, "nav_camera").also { it.mkdirs() } + + // ── SAF helpers ────────────────────────────────────────────────────────────── + + private fun safTreeDoc(): DocumentFile? { + val uriStr = prefs.getString(PREF_SAF_URI, null) ?: return null + val uri = Uri.parse(uriStr) + val hasPerm = context.contentResolver.persistedUriPermissions.any { + it.uri == uri && it.isReadPermission && it.isWritePermission + } + if (!hasPerm) return null + return DocumentFile.fromTreeUri(context, uri) + } + + private fun safLogbookDir(): DocumentFile? { + val tree = safTreeDoc() ?: return null + return tree.findFile("logbook") ?: tree.createDirectory("logbook") + } + + private fun safPhotosDir(): DocumentFile? { + val lb = safLogbookDir() ?: return null + return lb.findFile("photos") ?: lb.createDirectory("photos") + } + + // ── JSON persistence ───────────────────────────────────────────────────────── + + fun loadAll(): List<LogEntry> { + val safDir = safLogbookDir() + if (safDir != null) { + val jsonFile = safDir.findFile("nav_logbook.json") + if (jsonFile != null) { + return runCatching { + context.contentResolver.openInputStream(jsonFile.uri)?.use { stream -> + adapter.fromJson(stream.bufferedReader().readText())?.entries ?: emptyList() + } ?: emptyList() + }.getOrElse { e -> + NavLogger.e("logbook", "SAF loadAll failed: ${e.message}") + emptyList() + } + } + // SAF configured but no JSON yet — migrate legacy if present + migrateLegacyJson(safDir) + } + return legacyLoadAll() + } + + fun saveAll(entries: List<LogEntry>): Boolean { + val safDir = safLogbookDir() + if (safDir != null) { + return runCatching { + val json = adapter.toJson(LogEntryList(entries)) + // DocumentFile doesn't truncate; delete then recreate. + safDir.findFile("nav_logbook.json")?.delete() + val file = safDir.createFile("application/json", "nav_logbook.json") + ?: return false + context.contentResolver.openOutputStream(file.uri)?.use { it.write(json.toByteArray()) } + true + }.getOrElse { e -> + NavLogger.e("logbook", "SAF saveAll failed: ${e.message}") + false + } + } + return legacySaveAll(entries) + } + + // ── Photo persistence ───────────────────────────────────────────────────────── + + /** Copies a gallery URI into persistent storage. Returns a content:// URI string (SAF) or file path (legacy). */ + fun copyFromUri(uri: Uri): String? { + val photosDir = safPhotosDir() + if (photosDir != null) { + return runCatching { + val doc = photosDir.createFile("image/jpeg", "log_${System.currentTimeMillis()}.jpg") + ?: return null + context.contentResolver.openInputStream(uri)?.use { inp -> + context.contentResolver.openOutputStream(doc.uri)?.use { out -> inp.copyTo(out) } + } + doc.uri.toString() + }.getOrNull() + } + return runCatching { + val dest = File(legacyPhotoDir.also { it.mkdirs() }, "log_${System.currentTimeMillis()}.jpg") + context.contentResolver.openInputStream(uri)?.use { inp -> + FileOutputStream(dest).use { out -> inp.copyTo(out) } + } + dest.absolutePath + }.getOrNull() + } + + /** Moves a temp camera file into persistent storage, deleting the temp. Returns content:// or file path. */ + fun importFile(file: File): String? { + val photosDir = safPhotosDir() + if (photosDir != null) { + return runCatching { + val doc = photosDir.createFile("image/jpeg", "log_${System.currentTimeMillis()}.jpg") + ?: return null + file.inputStream().use { inp -> + context.contentResolver.openOutputStream(doc.uri)?.use { out -> inp.copyTo(out) } + } + file.delete() + doc.uri.toString() + }.getOrNull() + } + return runCatching { + val dest = File(legacyPhotoDir.also { it.mkdirs() }, "log_${System.currentTimeMillis()}.jpg") + file.copyTo(dest, overwrite = true) + file.delete() + dest.absolutePath + }.getOrElse { file.absolutePath } + } + + fun saveBitmap(bitmap: Bitmap): String? { + val photosDir = safPhotosDir() + if (photosDir != null) { + return runCatching { + val doc = photosDir.createFile("image/jpeg", "log_${System.currentTimeMillis()}.jpg") + ?: return null + context.contentResolver.openOutputStream(doc.uri)?.use { bitmap.compress(Bitmap.CompressFormat.JPEG, 90, it) } + doc.uri.toString() + }.getOrNull() + } + return runCatching { + val dest = File(legacyPhotoDir.also { it.mkdirs() }, "log_${System.currentTimeMillis()}.jpg") + FileOutputStream(dest).use { bitmap.compress(Bitmap.CompressFormat.JPEG, 90, it) } + dest.absolutePath + }.getOrNull() + } + + // ── Legacy (fallback when SAF not configured) ───────────────────────────────── + + private val legacyStorageFile: 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() } + private val legacyPhotoDir: File + get() = File( + context.getExternalFilesDir(android.os.Environment.DIRECTORY_PICTURES) ?: context.filesDir, + "Nav" + ) - fun loadAll(): List<LogEntry> = runCatching { - if (!storageFile.exists()) return emptyList() - adapter.fromJson(storageFile.readText())?.entries ?: emptyList() + private fun legacyLoadAll(): List<LogEntry> = runCatching { + if (!legacyStorageFile.exists()) return emptyList() + adapter.fromJson(legacyStorageFile.readText())?.entries ?: emptyList() }.getOrElse { e -> - NavLogger.e("logbook", "loadAll failed: ${e.message}") + NavLogger.e("logbook", "legacyLoadAll failed: ${e.message}") emptyList() } - fun saveAll(entries: List<LogEntry>): Boolean = runCatching { - storageFile.parentFile?.mkdirs() - storageFile.writeText(adapter.toJson(LogEntryList(entries))) + private fun legacySaveAll(entries: List<LogEntry>): Boolean = runCatching { + legacyStorageFile.parentFile?.mkdirs() + legacyStorageFile.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 } + private fun migrateLegacyJson(safDir: DocumentFile) { + if (!legacyStorageFile.exists()) return + runCatching { + val text = legacyStorageFile.readText() + safDir.findFile("nav_logbook.json")?.delete() + val file = safDir.createFile("application/json", "nav_logbook.json") ?: return + context.contentResolver.openOutputStream(file.uri)?.use { it.write(text.toByteArray()) } + NavLogger.i("logbook", "Migrated legacy JSON to SAF") + }.onFailure { e -> NavLogger.e("logbook", "migrateLegacyJson failed: ${e.message}") } + } } data class LogEntryList(val entries: List<LogEntry>) 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 fca83cd..76460f9 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 @@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch import org.terst.nav.MainActivity import org.terst.nav.R +import org.terst.nav.tripreport.TripReportFragment import org.terst.nav.ui.MainViewModel import java.time.Instant import java.time.ZoneId @@ -43,13 +44,22 @@ class SavedTracksFragment : Fragment() { rv.layoutManager = LinearLayoutManager(requireContext()) rv.addItemDecoration(DividerItemDecoration(requireContext(), DividerItemDecoration.VERTICAL)) - val adapter = SavedTrackAdapter { track -> - viewModel.selectTrack(track) - parentFragmentManager.beginTransaction() - .replace(R.id.fragment_container, TrackDetailSheet.newInstance()) - .addToBackStack("track_detail") - .commit() - } + val adapter = SavedTrackAdapter( + onSelect = { track -> + viewModel.selectTrack(track) + parentFragmentManager.beginTransaction() + .replace(R.id.fragment_container, TrackDetailSheet.newInstance()) + .addToBackStack("track_detail") + .commit() + }, + onReport = { track -> + viewModel.selectTrack(track) + parentFragmentManager.beginTransaction() + .replace(R.id.fragment_container, TripReportFragment.newInstanceForSavedTrack()) + .addToBackStack("trip_report") + .commit() + } + ) rv.adapter = adapter viewLifecycleOwner.lifecycleScope.launch { @@ -95,7 +105,8 @@ internal val DATE_FMT: DateTimeFormatter = .withZone(ZoneId.systemDefault()) internal class SavedTrackAdapter( - private val onSelect: (SavedTrack) -> Unit + private val onSelect: (SavedTrack) -> Unit, + private val onReport: (SavedTrack) -> Unit ) : RecyclerView.Adapter<SavedTrackAdapter.VH>() { private var items: List<SavedTrack> = emptyList() @@ -106,9 +117,10 @@ internal class SavedTrackAdapter( } inner class VH(view: View) : RecyclerView.ViewHolder(view) { - val tvDate = view.findViewById<TextView>(R.id.tv_track_date) - val tvStats = view.findViewById<TextView>(R.id.tv_track_stats) - val tvTacks = view.findViewById<TextView>(R.id.tv_track_tacks) + val tvDate = view.findViewById<TextView>(R.id.tv_track_date) + val tvStats = view.findViewById<TextView>(R.id.tv_track_stats) + val tvTacks = view.findViewById<TextView>(R.id.tv_track_tacks) + val btnReport = view.findViewById<MaterialButton>(R.id.btn_track_report) } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH = @@ -134,5 +146,6 @@ internal class SavedTrackAdapter( } holder.itemView.setOnClickListener { onSelect(track) } + holder.btnReport.setOnClickListener { onReport(track) } } } 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 429449c..4aab436 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 @@ -5,11 +5,13 @@ import kotlin.math.atan2 import kotlin.math.cos import kotlin.math.sin +enum class TackMode { CRUISING, RACE } + /** * 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) ──] + * [── settled leg A ──][─ guard ─][apex][─ guard ─][── settled leg B ──] * * 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, @@ -22,17 +24,31 @@ import kotlin.math.sin * 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. + * + * Modes: + * CRUISING (default) — conservative; T_SETTLE=30s, T_MANEUVER=30s, STAB_MAX=20°, MIN_GAP=60s + * RACE — tight windows for rapid mark-rounding; T_SETTLE=12s, STAB_MAX=30°, MIN_GAP=20s */ object TackDetector { - 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 Config( + val tSettle: Long, + val tManeuver: Long, + val stabMax: Double, + val minDelta: Double, + val minGapMs: Long, + val startSkipMs: Long, + val minPts: Int + ) + + private val CRUISING_CFG = Config( + tSettle = 30_000L, tManeuver = 30_000L, stabMax = 20.0, + minDelta = 75.0, minGapMs = 60_000L, startSkipMs = 60_000L, minPts = 3 + ) + private val RACE_CFG = Config( + tSettle = 12_000L, tManeuver = 20_000L, stabMax = 30.0, + minDelta = 75.0, minGapMs = 20_000L, startSkipMs = 20_000L, minPts = 3 + ) private data class Candidate( val index: Int, @@ -42,36 +58,37 @@ object TackDetector { val cogAfter: Double ) - fun detectTacks(points: List<TrackPoint>): List<TackEvent> { - if (points.size < MIN_PTS) return emptyList() + fun detectTacks(points: List<TrackPoint>, mode: TackMode = TackMode.CRUISING): List<TackEvent> { + val cfg = if (mode == TackMode.RACE) RACE_CFG else CRUISING_CFG + if (points.size < cfg.minPts) return emptyList() val t0 = points.first().timestampMs val raw = mutableListOf<Candidate>() for (i in points.indices) { val t = points[i].timestampMs - if (t - t0 < START_SKIP_MS) continue + if (t - t0 < cfg.startSkipMs) continue - val beforeEnd = t - T_MANEUVER / 2 - val beforeStart = beforeEnd - T_SETTLE + val beforeEnd = t - cfg.tManeuver / 2 + val beforeStart = beforeEnd - cfg.tSettle val before = points.subList(lowerBound(points, beforeStart), lowerBound(points, beforeEnd)) - if (before.size < MIN_PTS) continue + if (before.size < cfg.minPts) continue - val afterStart = t + T_MANEUVER / 2 - val afterEnd = afterStart + T_SETTLE + val afterStart = t + cfg.tManeuver / 2 + val afterEnd = afterStart + cfg.tSettle val after = points.subList(upperBound(points, afterStart), upperBound(points, afterEnd)) - if (after.size < MIN_PTS) continue + if (after.size < cfg.minPts) continue val cogBefore = circularMean(before.map { it.cogDeg }) val spreadBefore = circularMAD(before.map { it.cogDeg }, cogBefore) - if (spreadBefore > STAB_MAX) continue + if (spreadBefore > cfg.stabMax) continue val cogAfter = circularMean(after.map { it.cogDeg }) val spreadAfter = circularMAD(after.map { it.cogDeg }, cogAfter) - if (spreadAfter > STAB_MAX) continue + if (spreadAfter > cfg.stabMax) continue val delta = abs(angleDiff(cogBefore, cogAfter)) - if (delta < MIN_DELTA) continue + if (delta < cfg.minDelta) continue raw += Candidate(i, t, delta, cogBefore, cogAfter) } @@ -86,21 +103,21 @@ object TackDetector { var prevMs = Long.MIN_VALUE / 2 for (c in raw) { - if (best != null && c.timestampMs - prevMs >= MIN_GAP_MS) { - results += buildTackEvent(points, best!!) + if (best != null && c.timestampMs - prevMs >= cfg.minGapMs) { + results += buildTackEvent(points, best!!, cfg) best = c } else { if (best == null || c.delta > best!!.delta) best = c } prevMs = c.timestampMs } - best?.let { results += buildTackEvent(points, it) } + best?.let { results += buildTackEvent(points, it, cfg) } return results } - private fun buildTackEvent(points: List<TrackPoint>, c: Candidate): TackEvent { + private fun buildTackEvent(points: List<TrackPoint>, c: Candidate, cfg: Config): TackEvent { // Refine map pin: find max instantaneous heading rate within maneuver zone - val maneuvRange = (c.timestampMs - T_MANEUVER / 2)..(c.timestampMs + T_MANEUVER / 2) + val maneuvRange = (c.timestampMs - cfg.tManeuver / 2)..(c.timestampMs + cfg.tManeuver / 2) var bestIdx = c.index var bestRate = 0.0 for (i in 1 until points.size) { diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackDao.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackDao.kt new file mode 100644 index 0000000..9c628df --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackDao.kt @@ -0,0 +1,25 @@ +package org.terst.nav.track + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query + +@Dao +interface TrackDao { + + @Query("SELECT * FROM tracks ORDER BY startMs DESC") + suspend fun getAll(): List<TrackEntity> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(entity: TrackEntity) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(entities: List<TrackEntity>) + + @Query("DELETE FROM tracks") + suspend fun clear() + + @Query("SELECT COUNT(*) FROM tracks") + suspend fun count(): Int +} 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 7c26a01..7dd0e07 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,5 +1,6 @@ package org.terst.nav.track +import android.media.ExifInterface import android.app.Activity import android.app.AlertDialog import android.content.Intent @@ -26,6 +27,7 @@ import androidx.recyclerview.widget.RecyclerView import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.io.InputStream import org.maplibre.android.camera.CameraUpdateFactory import org.maplibre.android.geometry.LatLng import org.maplibre.android.geometry.LatLngBounds @@ -46,11 +48,12 @@ 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 +import java.text.SimpleDateFormat import java.util.Locale +import java.util.TimeZone import kotlin.math.abs /** A notable event extracted from a track for display in the log step-through. */ @@ -72,6 +75,7 @@ class TrackDetailSheet : Fragment() { // Note-adding state private var pendingNotePhotoPath: String? = null + private var pendingNotePhotoTimestampMs: Long? = null private var noteDialogPhotoFrame: FrameLayout? = null private var noteDialogPhotoView: ImageView? = null private var logEventAdapter: LogEventAdapter? = null @@ -82,10 +86,14 @@ class TrackDetailSheet : Fragment() { ) { 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 + lifecycleScope.launch { + val importedPath = withContext(Dispatchers.IO) { + NavApplication.logbookRepository.importPhoto(File(path)) + } ?: path + pendingNotePhotoPath = importedPath + noteDialogPhotoView?.setImageURI(photoUri(importedPath)) + noteDialogPhotoFrame?.visibility = View.VISIBLE + } } else { pendingNotePhotoPath?.let { File(it).delete() } pendingNotePhotoPath = null @@ -97,19 +105,21 @@ class TrackDetailSheet : Fragment() { ) { uri: Uri? -> if (uri != null) { lifecycleScope.launch { - val path = withContext(Dispatchers.IO) { + val result = 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) } + // Read EXIF from original URI before copying to SAF + val ts = requireContext().contentResolver.openInputStream(uri)?.use { stream -> + readExifTimestamp(stream) } - dest.absolutePath + val pathOrUri = NavApplication.logbookRepository.copyPhoto(uri) ?: return@runCatching null + pathOrUri to ts }.getOrNull() } - if (path != null) { - pendingNotePhotoPath = path - noteDialogPhotoView?.setImageURI(Uri.fromFile(File(path))) + if (result != null) { + val (pathOrUri, ts) = result + pendingNotePhotoPath = pathOrUri + pendingNotePhotoTimestampMs = ts + noteDialogPhotoView?.setImageURI(photoUri(pathOrUri)) noteDialogPhotoFrame?.visibility = View.VISIBLE } } @@ -182,10 +192,11 @@ class TrackDetailSheet : Fragment() { noteDialogPhotoFrame = dialogView.findViewById(R.id.frame_note_photo) noteDialogPhotoView = dialogView.findViewById(R.id.iv_note_photo) pendingNotePhotoPath = null + pendingNotePhotoTimestampMs = null noteDialogPhotoFrame?.visibility = View.GONE dialogView.findViewById<Button>(R.id.btn_note_camera).setOnClickListener { - val file = File(NavApplication.logbookRepository.photoDir, "log_${System.currentTimeMillis()}.jpg") + val file = File(NavApplication.logbookRepository.cameraDir, "log_${System.currentTimeMillis()}.jpg") pendingNotePhotoPath = file.absolutePath val uri = FileProvider.getUriForFile( requireContext(), "${requireContext().packageName}.fileprovider", file @@ -204,6 +215,7 @@ class TrackDetailSheet : Fragment() { dialogView.findViewById<Button>(R.id.btn_note_remove_photo).setOnClickListener { pendingNotePhotoPath?.let { File(it).delete() } pendingNotePhotoPath = null + pendingNotePhotoTimestampMs = null noteDialogPhotoView?.setImageDrawable(null) noteDialogPhotoFrame?.visibility = View.GONE } @@ -215,10 +227,13 @@ class TrackDetailSheet : Fragment() { val text = etNote.text?.toString()?.trim() ?: "" val path = pendingNotePhotoPath if (text.isBlank() && path == null) return@setPositiveButton - val anchor = track.points.lastOrNull() + // Use EXIF timestamp if the photo was taken during this track; otherwise anchor to end. + val photoTs = pendingNotePhotoTimestampMs + val entryTs = if (photoTs != null && photoTs in track.startMs..track.endMs) photoTs else track.endMs + val anchor = if (entryTs < track.endMs) nearestTrackPoint(track.points, entryTs) else track.points.lastOrNull() NavApplication.logbookRepository.save( LogEntry( - timestampMs = track.endMs, + timestampMs = entryTs, text = text.ifBlank { "(photo only)" }, entryType = EntryType.GENERAL, lat = anchor?.lat, @@ -227,11 +242,15 @@ class TrackDetailSheet : Fragment() { ) ) pendingNotePhotoPath = null - logEventAdapter?.update(buildLogEvents(track)) + pendingNotePhotoTimestampMs = null + val newEvents = buildLogEvents(track) + logEventAdapter?.update(newEvents) + refreshNoteLayer(newEvents) } .setNegativeButton("Cancel") { _, _ -> pendingNotePhotoPath?.let { File(it).delete() } pendingNotePhotoPath = null + pendingNotePhotoTimestampMs = null } .show() } @@ -354,6 +373,31 @@ class TrackDetailSheet : Fragment() { private fun nearestTrackPoint(points: List<TrackPoint>, timestampMs: Long): TrackPoint? = points.minByOrNull { abs(it.timestampMs - timestampMs) } + /** Updates the note circle layer after a new note is saved, creating the source if needed. */ + private fun refreshNoteLayer(events: List<LogEvent>) { + val notes = events.filter { it.photoPath != null || it.icon == "📝" } + val fc = FeatureCollection.fromFeatures(notes.map { Feature.fromGeometry(Point.fromLngLat(it.lon, it.lat)) }) + trackMap?.style?.let { style -> + (style.getSource("notes-detail-source") as? GeoJsonSource)?.setGeoJson(fc) + ?: drawNotes(style, notes) + } + } + + /** Reads EXIF DateTimeOriginal from a stream, parsed as device-local time. Returns null on failure. */ + private fun readExifTimestamp(stream: InputStream): Long? = runCatching { + val exif = ExifInterface(stream) + val dt = exif.getAttribute(ExifInterface.TAG_DATETIME_ORIGINAL) + ?: exif.getAttribute(ExifInterface.TAG_DATETIME) + ?: return@runCatching null + SimpleDateFormat("yyyy:MM:dd HH:mm:ss", Locale.US) + .apply { timeZone = TimeZone.getDefault() } + .parse(dt)?.time + }.getOrNull() + + private fun photoUri(pathOrUri: String): Uri = + if (pathOrUri.startsWith("content://")) Uri.parse(pathOrUri) + else Uri.fromFile(File(pathOrUri)) + 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) } @@ -398,8 +442,8 @@ private class LogEventAdapter( holder.itemView.setOnClickListener { onTap(e) } val path = e.photoPath - if (path != null && File(path).exists()) { - val bm = decodeThumbnail(path, 160, 120) + if (path != null) { + val bm = decodeThumbnail(holder.itemView.context, path, 160, 120) if (bm != null) { holder.ivThumb.setImageBitmap(bm) holder.ivThumb.visibility = View.VISIBLE @@ -414,10 +458,23 @@ private class LogEventAdapter( } } -private fun decodeThumbnail(path: String, targetW: Int, targetH: Int): android.graphics.Bitmap? { +private fun decodeThumbnail(context: android.content.Context, pathOrUri: String, targetW: Int, targetH: Int): android.graphics.Bitmap? { + if (pathOrUri.startsWith("content://")) { + return runCatching { + val uri = Uri.parse(pathOrUri) + val opts = BitmapFactory.Options().apply { inJustDecodeBounds = true } + context.contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, opts) } + if (opts.outWidth <= 0) return null + val scale = maxOf(opts.outWidth / targetW, opts.outHeight / targetH).coerceAtLeast(1) + context.contentResolver.openInputStream(uri)?.use { stream -> + BitmapFactory.decodeStream(stream, null, BitmapFactory.Options().apply { inSampleSize = scale }) + } + }.getOrNull() + } + if (!File(pathOrUri).exists()) return null val opts = BitmapFactory.Options().apply { inJustDecodeBounds = true } - BitmapFactory.decodeFile(path, opts) + BitmapFactory.decodeFile(pathOrUri, 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 }) + return BitmapFactory.decodeFile(pathOrUri, BitmapFactory.Options().apply { inSampleSize = scale }) } diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackEntity.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackEntity.kt new file mode 100644 index 0000000..502bfba --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackEntity.kt @@ -0,0 +1,35 @@ +package org.terst.nav.track + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "tracks") +data class TrackEntity( + @PrimaryKey val startMs: Long, + val distanceNm: Double, + val durationMs: Long, + val maxSogKt: Double, + val avgSogKt: Double, + val avgWindKt: Double?, + val avgWaveHeightM: Double?, + val tackCount: Int, + val startLat: Double, + val startLon: Double, + val endLat: Double, + val endLon: Double +) + +fun SavedTrack.toEntity(): TrackEntity = TrackEntity( + startMs = startMs, + distanceNm = summary.distanceNm, + durationMs = summary.durationMs, + maxSogKt = summary.maxSogKt, + avgSogKt = summary.avgSogKt, + avgWindKt = summary.avgWindKt, + avgWaveHeightM = summary.avgWaveHeightM, + tackCount = tacks.size, + startLat = points.first().lat, + startLon = points.first().lon, + endLat = points.last().lat, + endLon = points.last().lon +) 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 c40c9c2..84832f5 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 @@ -11,7 +11,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.terst.nav.ui.NavLogger -class TrackRepository(context: Context) { +class TrackRepository(context: Context, private val trackDao: TrackDao) { private val storage = TrackStorage(context) @@ -58,6 +58,8 @@ class TrackRepository(context: Context) { Log.e("TrackRepository", "GPX save failed — ${points.size} points will be lost on restart") NavLogger.e("track", "saveTrack returned false — ${points.size} pts lost on restart") } + val savedTrack = SavedTrack(points, trackStartMs, summary, TackDetector.detectTacks(points)) + runCatching { trackDao.insert(savedTrack.toEntity()) } summary } @@ -72,6 +74,8 @@ class TrackRepository(context: Context) { if (saved) { _pastTracks.add(0, points) NavLogger.i("track", "importTrack: saved ${points.size} pts startMs=$startMs") + val savedTrack = points.toSavedTrack() + runCatching { trackDao.insert(savedTrack.toEntity()) } } else { NavLogger.e("track", "importTrack: save failed for ${points.size} pts") } @@ -125,6 +129,14 @@ class TrackRepository(context: Context) { _pastTracks.addAll(stored) NavLogger.i("track", "getPastTracks: total in memory=${_pastTracks.size}") + if (stored.isNotEmpty() && trackDao.count() == 0) { + // One-time backfill of Room cache from existing GPX files + val entities = stored.mapNotNull { pts -> + runCatching { pts.toSavedTrack().toEntity() }.getOrNull() + } + if (entities.isNotEmpty()) trackDao.insertAll(entities) + NavLogger.i("track", "getPastTracks: backfilled ${entities.size} tracks into Room") + } } _pastTracks.toList() } @@ -153,6 +165,7 @@ class TrackRepository(context: Context) { .onFailure { e -> NavLogger.e("track", "toSavedTrack failed: ${e.javaClass.simpleName}: ${e.message}") } .getOrNull() } + .sortedBy { it.startMs } } fun safState(): SafState = storage.safState() 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 632f616..0a112a8 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 @@ -7,11 +7,13 @@ import android.view.ViewGroup import android.widget.ProgressBar import android.widget.TextView import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope import com.google.android.material.button.MaterialButton import kotlinx.coroutines.launch import org.terst.nav.NavApplication import org.terst.nav.R +import org.terst.nav.ui.MainViewModel class TripReportFragment : Fragment() { @@ -22,10 +24,26 @@ class TripReportFragment : Fragment() { ) } + private val mainViewModel: MainViewModel by activityViewModels() + + companion object { + private const val ARG_FOR_SAVED_TRACK = "for_saved_track" + + fun newInstance(): TripReportFragment = TripReportFragment() + + fun newInstanceForSavedTrack(): TripReportFragment = TripReportFragment().apply { + arguments = Bundle().also { it.putBoolean(ARG_FOR_SAVED_TRACK, true) } + } + } + + private lateinit var tvReportTitle: TextView private lateinit var tvNarrativeContent: TextView private lateinit var btnRefresh: MaterialButton private lateinit var progress: ProgressBar + private var pirateMode = false + private val generator = TripReportGenerator() + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -35,17 +53,35 @@ class TripReportFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + tvReportTitle = view.findViewById(R.id.tv_report_title) tvNarrativeContent = view.findViewById(R.id.tv_narrative_content) btnRefresh = view.findViewById(R.id.btn_refresh_report) progress = view.findViewById(R.id.progress_report) - btnRefresh.setOnClickListener { viewModel.generateReport() } + val forSavedTrack = arguments?.getBoolean(ARG_FOR_SAVED_TRACK, false) == true + val savedTrack = if (forSavedTrack) mainViewModel.selectedTrack.value else null + + if (savedTrack != null) { + viewModel.generateReportForSavedTrack(savedTrack) + btnRefresh.setOnClickListener { viewModel.generateReportForSavedTrack(savedTrack) } + } else { + viewModel.generateReport() + btnRefresh.setOnClickListener { viewModel.generateReport() } + } + + tvReportTitle.setOnLongClickListener { + pirateMode = !pirateMode + tvReportTitle.text = if (pirateMode) "☠ Trip Report ☠" else "Trip Report" + val s = viewModel.state.value + if (s is TripReportState.Success) { + tvNarrativeContent.text = generator.generateNarrative(s.summary, pirate = pirateMode) + } + true + } viewLifecycleOwner.lifecycleScope.launch { viewModel.state.collect { state -> renderState(state) } } - - viewModel.generateReport() } private fun renderState(state: TripReportState) { @@ -60,7 +96,7 @@ class TripReportFragment : Fragment() { is TripReportState.Success -> { progress.visibility = View.GONE btnRefresh.isEnabled = true - tvNarrativeContent.text = state.narrative + tvNarrativeContent.text = generator.generateNarrative(state.summary, pirate = pirateMode) } is TripReportState.Error -> { progress.visibility = View.GONE 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 2c7f77f..a101152 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 @@ -73,7 +73,10 @@ class TripReportGenerator { return r * c } - fun generateNarrative(summary: TripSummary): String { + fun generateNarrative(summary: TripSummary, pirate: Boolean = false): String = + if (pirate) generatePirateNarrative(summary) else generateStandardNarrative(summary) + + private fun generateStandardNarrative(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) @@ -120,4 +123,59 @@ class TripReportGenerator { return sb.toString().trimEnd() } + + private fun generatePirateNarrative(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("☠ VOYAGE · ${dateFmt.format(startInst).uppercase(Locale.getDefault())}") + sb.appendLine("${timeFmt.format(startInst)}–${timeFmt.format(endInst)} · $durationStr") + sb.appendLine() + sb.appendLine("Made %.1f nm by the log · avg %.1f kt · peaked %.1f kt".format( + summary.distanceNm, summary.avgSogKts, summary.maxSogKts)) + + val conditions = buildList<String> { + summary.maxWaveHeightM?.let { add("seas ran %.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)) + } + } + if (conditions.isNotEmpty()) { + sb.appendLine() + sb.appendLine(conditions.joinToString(" ")) + } + + if (summary.logEntries.isNotEmpty()) { + val exclamations = listOf( + "Arr, ", "Blimey! ", "Shiver me timbers — ", + "Avast! ", "By Neptune's beard, ", "Splice the mainbrace! " + ) + sb.appendLine() + for ((i, entry) in summary.logEntries.sortedBy { it.timestampMs }.withIndex()) { + val time = timeFmt.format(Instant.ofEpochMilli(entry.timestampMs)) + val photoMarker = if (entry.photoPath != null) " [portrait]" else "" + val prefix = exclamations[i % exclamations.size] + sb.appendLine("$time $prefix${entry.text}$photoMarker") + } + } + + sb.appendLine() + sb.append("— Arr. Fair winds and following seas. —") + 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 603d769..fbe1a30 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 @@ -7,6 +7,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import org.terst.nav.logbook.LogbookRepository +import org.terst.nav.track.SavedTrack import org.terst.nav.track.TrackRepository sealed class TripReportState { @@ -43,4 +44,19 @@ class TripReportViewModel( } } } + + fun generateReportForSavedTrack(track: SavedTrack) { + viewModelScope.launch { + _state.value = TripReportState.Loading + try { + val logEntries = logbookRepository.getAll() + .filter { it.timestampMs in track.startMs..track.endMs } + val summary = generator.generateSummary(track.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/safety/SafetyFragment.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/safety/SafetyFragment.kt index d906094..c4b6e1e 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/ui/safety/SafetyFragment.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/safety/SafetyFragment.kt @@ -1,9 +1,12 @@ package org.terst.nav.ui.safety import android.os.Bundle +import android.text.Editable +import android.text.TextWatcher import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.EditText import android.widget.TextView import androidx.core.content.ContextCompat import androidx.fragment.app.Fragment @@ -14,6 +17,7 @@ import org.terst.nav.R import org.terst.nav.ais.CpaAlert import org.terst.nav.ais.CpaSeverity import org.terst.nav.ais.label +import org.terst.nav.safety.AnchorWatchState import org.terst.nav.settings.HardwareSource import org.terst.nav.ui.doc.DocFragment @@ -21,7 +25,6 @@ class SafetyFragment : Fragment() { interface SafetyListener { fun onActivateMob() - fun onConfigureAnchor() fun onHardwareSourceChanged(source: HardwareSource, enabled: Boolean) fun onQuitRequested() } @@ -52,9 +55,25 @@ class SafetyFragment : Fragment() { listener?.onActivateMob() } - view.findViewById<MaterialButton>(R.id.button_anchor_config).setOnClickListener { - listener?.onConfigureAnchor() + val etDepth = view.findViewById<EditText>(R.id.et_anchor_depth) + val etRode = view.findViewById<EditText>(R.id.et_anchor_rode) + val tvRadius = view.findViewById<TextView>(R.id.tv_anchor_suggested_radius) + val watcher = object : TextWatcher { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit + override fun afterTextChanged(s: Editable?) { + val depth = etDepth.text.toString().toDoubleOrNull() + val rode = etRode.text.toString().toDoubleOrNull() + tvRadius.text = if (depth != null && rode != null && depth >= 0.0 && rode > 0.0) { + val r = AnchorWatchState.calculateRecommendedWatchCircleRadius(depth, 2.0, rode) + getString(R.string.anchor_suggested_radius_fmt, r) + } else { + getString(R.string.anchor_suggested_radius_empty) + } + } } + etDepth.addTextChangedListener(watcher) + etRode.addTextChangedListener(watcher) val flags = (requireActivity().application as NavApplication).featureFlags 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 2557eb4..8116c1a 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 @@ -44,7 +44,6 @@ 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 @@ -79,10 +78,12 @@ class VoiceLogFragment : Fragment() { ) { 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) + lifecycleScope.launch { + val importedPath = withContext(Dispatchers.IO) { + NavApplication.logbookRepository.importPhoto(File(path)) + } ?: path + pendingPhotoPath = importedPath + setPhoto(importedPath) { displayPhoto(ivPhoto, importedPath) } } } else { pendingPhotoPath?.let { File(it).delete() } @@ -97,17 +98,10 @@ class VoiceLogFragment : Fragment() { 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 { input -> - FileOutputStream(dest).use { output -> input.copyTo(output) } - } - dest.absolutePath - }.getOrNull() + NavApplication.logbookRepository.copyPhoto(uri) } if (path != null) { - setPhoto(path) { ivPhoto.setImageURI(Uri.fromFile(File(path))) } + setPhoto(path) { displayPhoto(ivPhoto, path) } } else { tvStatus.text = "Couldn't copy photo" } @@ -142,7 +136,7 @@ class VoiceLogFragment : Fragment() { fabMic.setOnClickListener { startListening() } btnCamera.setOnClickListener { - val file = File(NavApplication.logbookRepository.photoDir, "log_${System.currentTimeMillis()}.jpg") + val file = File(NavApplication.logbookRepository.cameraDir, "log_${System.currentTimeMillis()}.jpg") pendingPhotoPath = file.absolutePath pendingCameraUri = FileProvider.getUriForFile( requireContext(), "${requireContext().packageName}.fileprovider", file @@ -302,6 +296,13 @@ class VoiceLogFragment : Fragment() { framePhoto.visibility = View.VISIBLE } + private fun displayPhoto(view: ImageView, pathOrUri: String) { + view.setImageURI( + if (pathOrUri.startsWith("content://")) Uri.parse(pathOrUri) + else Uri.fromFile(File(pathOrUri)) + ) + } + private fun clearPhoto() { pendingPhotoPath = null pendingCameraUri = null @@ -375,12 +376,25 @@ class VoiceLogFragment : Fragment() { // ── Helpers ──────────────────────────────────────────────────────────────────── -private fun decodeThumbnail(path: String, targetW: Int, targetH: Int): android.graphics.Bitmap? { +private fun decodeThumbnail(context: android.content.Context, pathOrUri: String, targetW: Int, targetH: Int): android.graphics.Bitmap? { + if (pathOrUri.startsWith("content://")) { + return runCatching { + val uri = Uri.parse(pathOrUri) + val opts = BitmapFactory.Options().apply { inJustDecodeBounds = true } + context.contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, opts) } + if (opts.outWidth <= 0) return null + val scale = maxOf(opts.outWidth / targetW, opts.outHeight / targetH).coerceAtLeast(1) + context.contentResolver.openInputStream(uri)?.use { stream -> + BitmapFactory.decodeStream(stream, null, BitmapFactory.Options().apply { inSampleSize = scale }) + } + }.getOrNull() + } + if (!File(pathOrUri).exists()) return null val opts = BitmapFactory.Options().apply { inJustDecodeBounds = true } - BitmapFactory.decodeFile(path, opts) + BitmapFactory.decodeFile(pathOrUri, 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 }) + return BitmapFactory.decodeFile(pathOrUri, BitmapFactory.Options().apply { inSampleSize = scale }) } // ── Logbook entries adapter ──────────────────────────────────────────────────── @@ -423,8 +437,8 @@ private class LogbookEntryAdapter : RecyclerView.Adapter<LogbookEntryAdapter.VH> } val photoPath = e.photoPath - if (photoPath != null && File(photoPath).exists()) { - val bm = decodeThumbnail(photoPath, 128, 96) + if (photoPath != null) { + val bm = decodeThumbnail(holder.itemView.context, photoPath, 128, 96) if (bm != null) { holder.ivPhoto.setImageBitmap(bm) holder.ivPhoto.visibility = View.VISIBLE diff --git a/android-app/app/src/main/res/layout/fragment_safety.xml b/android-app/app/src/main/res/layout/fragment_safety.xml index 1072abe..1d499ab 100644 --- a/android-app/app/src/main/res/layout/fragment_safety.xml +++ b/android-app/app/src/main/res/layout/fragment_safety.xml @@ -127,13 +127,79 @@ android:text="Status: Inactive" android:textColor="?attr/colorOnSurfaceVariant" /> - <com.google.android.material.button.MaterialButton - android:id="@+id/button_anchor_config" - style="@style/Widget.Material3.Button.TonalButton" + <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" - android:layout_marginTop="16dp" - android:text="CONFIGURE ANCHOR WATCH" /> + android:orientation="horizontal" + android:layout_marginTop="16dp"> + + <LinearLayout + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="1" + android:orientation="vertical" + android:layout_marginEnd="8dp"> + + <TextView + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="@string/anchor_depth_label" + android:textSize="12sp" + android:textColor="?attr/colorOnSurfaceVariant" /> + + <EditText + android:id="@+id/et_anchor_depth" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:hint="@string/anchor_depth_hint" + android:inputType="numberDecimal" + android:importantForAutofill="no" /> + + </LinearLayout> + + <LinearLayout + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="1" + android:orientation="vertical" + android:layout_marginStart="8dp"> + + <TextView + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="@string/anchor_rode_label" + android:textSize="12sp" + android:textColor="?attr/colorOnSurfaceVariant" /> + + <EditText + android:id="@+id/et_anchor_rode" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:hint="@string/anchor_rode_hint" + android:inputType="numberDecimal" + android:importantForAutofill="no" /> + + </LinearLayout> + + </LinearLayout> + + <TextView + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="12dp" + android:text="@string/anchor_suggested_radius_label" + android:textSize="12sp" + android:textColor="?attr/colorOnSurfaceVariant" /> + + <TextView + android:id="@+id/tv_anchor_suggested_radius" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="2dp" + android:text="@string/anchor_suggested_radius_empty" + android:textSize="16sp" + android:textStyle="bold" + android:textColor="?attr/colorOnSurface" /> </LinearLayout> 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 7228fa9..c8092ba 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 @@ -12,12 +12,14 @@ android:padding="24dp"> <TextView + android:id="@+id/tv_report_title" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Trip Report" android:textSize="24sp" android:textStyle="bold" - android:layout_marginBottom="24dp" /> + android:layout_marginBottom="24dp" + android:longClickable="true" /> <com.google.android.material.card.MaterialCardView android:layout_width="match_parent" diff --git a/android-app/app/src/main/res/layout/item_saved_track.xml b/android-app/app/src/main/res/layout/item_saved_track.xml index b0a7e24..681220e 100644 --- a/android-app/app/src/main/res/layout/item_saved_track.xml +++ b/android-app/app/src/main/res/layout/item_saved_track.xml @@ -37,7 +37,20 @@ android:layout_marginTop="4dp" app:layout_constraintTop_toBottomOf="@id/tv_track_date" app:layout_constraintStart_toStartOf="parent" - app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintEnd_toEndOf="parent" /> + + <com.google.android.material.button.MaterialButton + android:id="@+id/btn_track_report" + style="@style/Widget.Material3.Button.TextButton" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="REPORT" + android:textSize="11sp" + android:insetTop="0dp" + android:insetBottom="0dp" + android:paddingStart="0dp" + app:layout_constraintTop_toBottomOf="@id/tv_track_stats" + app:layout_constraintStart_toStartOf="parent" app:layout_constraintBottom_toBottomOf="parent" /> </androidx.constraintlayout.widget.ConstraintLayout> diff --git a/android-app/app/src/main/res/xml/file_paths.xml b/android-app/app/src/main/res/xml/file_paths.xml index c8f824e..b60abc8 100644 --- a/android-app/app/src/main/res/xml/file_paths.xml +++ b/android-app/app/src/main/res/xml/file_paths.xml @@ -1,4 +1,5 @@ <?xml version="1.0" encoding="utf-8"?> <paths> <external-files-path name="nav_photos" path="Pictures/Nav/" /> + <cache-path name="nav_camera" path="nav_camera/" /> </paths> |
