summaryrefslogtreecommitdiff
path: root/android-app/app/src
diff options
context:
space:
mode:
Diffstat (limited to 'android-app/app/src')
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt4
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/NavApplication.kt6
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/db/NavDatabase.kt26
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookRepository.kt19
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookStorage.kt187
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/SavedTracksFragment.kt35
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/TackDetector.kt71
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/TrackDao.kt25
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/TrackDetailSheet.kt103
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/TrackEntity.kt35
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt15
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportFragment.kt44
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportGenerator.kt60
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportViewModel.kt16
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/safety/SafetyFragment.kt25
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/voicelog/VoiceLogFragment.kt54
-rw-r--r--android-app/app/src/main/res/layout/fragment_safety.xml76
-rw-r--r--android-app/app/src/main/res/layout/fragment_trip_report.xml4
-rw-r--r--android-app/app/src/main/res/layout/item_saved_track.xml15
-rw-r--r--android-app/app/src/main/res/xml/file_paths.xml1
-rw-r--r--android-app/app/src/test/kotlin/org/terst/nav/tripreport/PreTripReportGeneratorTest.kt173
21 files changed, 866 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>
diff --git a/android-app/app/src/test/kotlin/org/terst/nav/tripreport/PreTripReportGeneratorTest.kt b/android-app/app/src/test/kotlin/org/terst/nav/tripreport/PreTripReportGeneratorTest.kt
new file mode 100644
index 0000000..adf4c96
--- /dev/null
+++ b/android-app/app/src/test/kotlin/org/terst/nav/tripreport/PreTripReportGeneratorTest.kt
@@ -0,0 +1,173 @@
+package org.terst.nav.tripreport
+
+import org.junit.Assert.*
+import org.junit.Test
+import org.terst.nav.data.model.ForecastItem
+import org.terst.nav.data.model.MarineConditions
+import kotlin.math.sqrt
+
+class PreTripReportGeneratorTest {
+
+ private val gen = PreTripReportGenerator()
+
+ private fun forecast(
+ timeIso: String = "2026-01-01T12:00",
+ windKt: Double = 12.0,
+ windDir: Double = 90.0,
+ precipPct: Int = 0,
+ weatherCode: Int = 0
+ ) = ForecastItem(timeIso, windKt, windDir, 22.0, precipPct, weatherCode)
+
+ private val defaultBoat = BoatProfile(
+ id = "test", name = "Delos", lengthFt = 36.0,
+ type = BoatType.MONOHULL, rig = RigType.SLOOP,
+ headsails = listOf(
+ SailConfig("155% Genoa", maxWindKt = 15, isHeadsail = true),
+ SailConfig("100% Jib", maxWindKt = 25, isHeadsail = true)
+ ),
+ mainReefs = 2
+ )
+
+ private val defaultForecasts = (0 until 10).map { i ->
+ forecast(timeIso = "2026-01-01T${String.format("%02d", 12 + i)}:00")
+ }
+
+ // ── estimatedSogKt ─────────────────────────────────────────────────────────
+
+ @Test fun `estimatedSogKt never exceeds hull speed`() {
+ val hull = 1.34 * sqrt(36.0)
+ val sog = gen.estimatedSogKt(36.0, 25.0, 90.0)
+ assertTrue("SOG $sog should not exceed hull speed $hull", sog <= hull + 0.001)
+ }
+
+ @Test fun `estimatedSogKt beam reach faster than dead run`() {
+ val beamReach = gen.estimatedSogKt(36.0, 15.0, 90.0)
+ val deadRun = gen.estimatedSogKt(36.0, 15.0, 175.0)
+ assertTrue("Beam reach $beamReach should be faster than dead run $deadRun", beamReach > deadRun)
+ }
+
+ @Test fun `estimatedSogKt close hauled slower than beam reach`() {
+ val closeHauled = gen.estimatedSogKt(36.0, 15.0, 30.0)
+ val beamReach = gen.estimatedSogKt(36.0, 15.0, 90.0)
+ assertTrue("Close hauled $closeHauled should be slower than beam reach $beamReach",
+ closeHauled < beamReach)
+ }
+
+ @Test fun `estimatedSogKt light air produces low sog`() {
+ val lightAir = gen.estimatedSogKt(36.0, 3.0, 90.0)
+ assertTrue("SOG $lightAir should be low in light air (< 2.0 kt)", lightAir < 2.0)
+ }
+
+ @Test fun `estimatedSogKt longer hull is faster`() {
+ val short = gen.estimatedSogKt(25.0, 15.0, 90.0)
+ val long = gen.estimatedSogKt(50.0, 15.0, 90.0)
+ assertTrue("50 ft boat ($long) should be faster than 25 ft ($short)", long > short)
+ }
+
+ @Test fun `estimatedSogKt overpowered reefed boat is slightly slower than full-sail`() {
+ val strong = gen.estimatedSogKt(36.0, 25.0, 90.0) // reefed, windFactor = 0.88
+ val sweet = gen.estimatedSogKt(36.0, 18.0, 90.0) // full sail, windFactor = 1.00
+ assertTrue("Full-sail sweet-spot ($sweet) should be ≥ overpowered-reefed ($strong)",
+ sweet >= strong)
+ }
+
+ // ── generateReport: structure ──────────────────────────────────────────────
+
+ @Test fun `condition window always has exactly 4 slots`() {
+ val report = gen.generateReport(37.0, -122.0, defaultForecasts, null, defaultBoat)
+ assertEquals(4, report.conditionWindow.size)
+ }
+
+ @Test fun `condition window first slot is Now when departure is current time`() {
+ val report = gen.generateReport(37.0, -122.0, defaultForecasts, null, defaultBoat,
+ departureDateTimeMs = System.currentTimeMillis())
+ assertEquals("Now", report.conditionWindow.first().label)
+ }
+
+ @Test fun `route projection heading is within 0 to 360`() {
+ val report = gen.generateReport(37.0, -122.0, defaultForecasts, null, defaultBoat)
+ val hdg = report.routeProjection.departureHeadingDeg
+ assertTrue("Heading $hdg should be in [0, 360]", hdg in 0.0..360.0)
+ }
+
+ @Test fun `route projection outbound nm is positive`() {
+ val report = gen.generateReport(37.0, -122.0, defaultForecasts, null, defaultBoat)
+ assertTrue("Outbound nm should be > 0", report.routeProjection.outboundLegNm > 0.0)
+ }
+
+ // ── generateReport: sail plan ──────────────────────────────────────────────
+
+ @Test fun `sail plan includes main and headsail at moderate wind`() {
+ val report = gen.generateReport(37.0, -122.0, defaultForecasts, null, defaultBoat)
+ assertTrue("Expected headsail entry", report.sailPlan.any {
+ it.sailName.contains("Genoa") || it.sailName.contains("Jib")
+ })
+ assertTrue("Expected Main entry", report.sailPlan.any { it.sailName == "Main" })
+ }
+
+ @Test fun `sail plan reefs main at 23 kt`() {
+ val strongWind = (0 until 10).map { forecast(windKt = 23.0) }
+ val report = gen.generateReport(37.0, -122.0, strongWind, null, defaultBoat)
+ val main = report.sailPlan.first { it.sailName == "Main" }
+ assertTrue("Expected reef at 23 kt: ${main.action}", main.action.contains("Reef"))
+ }
+
+ @Test fun `sail plan uses small headsail above genoa limit`() {
+ val overLimit = (0 until 10).map { forecast(windKt = 18.0) }
+ val report = gen.generateReport(37.0, -122.0, overLimit, null, defaultBoat)
+ val headsail = report.sailPlan.first {
+ it.sailName.contains("Genoa") || it.sailName.contains("Jib")
+ }
+ // At 18 kt, 155% Genoa (max 15 kt) should be swapped for 100% Jib
+ assertTrue("Expected 100% Jib at 18 kt, got: ${headsail.sailName}",
+ headsail.sailName.contains("100%") || headsail.sailName.contains("Jib"))
+ }
+
+ // ── generateReport: watch items ────────────────────────────────────────────
+
+ @Test fun `no watch items in benign conditions`() {
+ val report = gen.generateReport(37.0, -122.0, defaultForecasts, null, defaultBoat)
+ // At 12 kt with no swell data, no warnings expected
+ assertTrue("Expected no high-wind warnings at 12 kt",
+ report.watchItems.none { it.message.contains("Strong") || it.message.contains("consider staying") })
+ }
+
+ @Test fun `strong wind over 30 kt triggers stay in port advisory`() {
+ val gale = (0 until 10).map { forecast(windKt = 32.0) }
+ val report = gen.generateReport(37.0, -122.0, gale, null, defaultBoat)
+ assertTrue("Expected stay-in-port advisory at 32 kt",
+ report.watchItems.any { it.message.contains("consider staying") })
+ }
+
+ @Test fun `short swell period under 8 s triggers choppy sea warning`() {
+ val conditions = MarineConditions(
+ windSpeedKt = 12.0, windDirDeg = 90.0, tempC = 22.0,
+ waveHeightM = null, waveDirDeg = null,
+ swellHeightM = 2.0, swellDirDeg = null, swellPeriodS = 5.0,
+ currentSpeedKt = null, currentDirDeg = null
+ )
+ val report = gen.generateReport(37.0, -122.0, defaultForecasts, conditions, defaultBoat)
+ assertTrue("Expected short-swell warning",
+ report.watchItems.any { it.message.contains("period") || it.message.contains("choppy") })
+ }
+
+ @Test fun `significant swell over 2 m triggers motion advisory`() {
+ val conditions = MarineConditions(
+ windSpeedKt = 12.0, windDirDeg = 90.0, tempC = 22.0,
+ waveHeightM = null, waveDirDeg = null,
+ swellHeightM = 2.5, swellDirDeg = null, swellPeriodS = 12.0,
+ currentSpeedKt = null, currentDirDeg = null
+ )
+ val report = gen.generateReport(37.0, -122.0, defaultForecasts, conditions, defaultBoat)
+ assertTrue("Expected significant-swell advisory",
+ report.watchItems.any { it.message.contains("swell") || it.message.contains("motion") })
+ }
+
+ // ── generateReport: similar trips ─────────────────────────────────────────
+
+ @Test fun `no similar trips when past tracks is empty`() {
+ val report = gen.generateReport(37.0, -122.0, defaultForecasts, null, defaultBoat,
+ pastTracks = emptyList())
+ assertNull(report.similarTrips)
+ }
+}