summaryrefslogtreecommitdiff
path: root/android-app
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-05-04 08:37:48 -1000
committerPeter Stone <thepeterstone@gmail.com>2026-05-04 08:37:48 -1000
commit1d3619c241059c3b7405498869d435835a1ed4fc (patch)
treeabe6a15ee1cec1ddd92e5732d0e270e4975a3974 /android-app
parentafc7906c1a47fa9cf9dff5a247e6abae7e8aacbb (diff)
feat: past tracks layer toggle + persist vessel/crew registry
Layers menu: - Add "Past tracks" switch to layer picker sheet — hides/shows previous voyage polylines on the map, persisted across sessions via SharedPrefs Vessel/crew registry: - VesselRepository now takes a Context and persists all vessels and crew to SharedPreferences as JSON (Moshi, same pattern as BoatProfileRepository) - Wire VesselRepository as a singleton in NavApplication so the Fragment and ViewModel share one instance — previously each created its own empty repo Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'android-app')
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt11
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/NavApplication.kt2
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/LayerPickerSheet.kt6
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt3
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/MapLayerManager.kt15
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/vessel/VesselRegistryFragment.kt4
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/vessel/VesselRepository.kt144
-rw-r--r--android-app/app/src/main/res/layout/layout_layer_picker_sheet.xml35
8 files changed, 208 insertions, 12 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 da96e98..5e7ce82 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
@@ -282,6 +282,12 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
layerManager.setParticlesEnabled(enabled)
particleWindView?.visibility = if (enabled) View.VISIBLE else View.GONE
},
+ onPastTracksChanged = { enabled ->
+ layerManager.setPastTracksEnabled(enabled)
+ val style = loadedStyleFlow.value ?: return@LayerPickerSheet
+ val past = if (enabled) viewModel.pastTracks.value else emptyList()
+ mapHandler?.updateTrackLayer(style, viewModel.trackPoints.value, past)
+ },
unitPrefs = unitPrefs,
onUnitsChanged = { refreshUnits() }
)
@@ -656,7 +662,10 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
loadedStyleFlow.filterNotNull()
.combine(viewModel.trackPoints) { style, points -> style to points }
.combine(viewModel.pastTracks) { (style, active), past -> Triple(style, active, past) }
- .collect { (style, active, past) -> mapHandler?.updateTrackLayer(style, active, past) }
+ .collect { (style, active, past) ->
+ val visiblePast = if (layerManager.pastTracksEnabled) past else emptyList()
+ mapHandler?.updateTrackLayer(style, active, visiblePast)
+ }
}
}
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 cf3103d..0bec116 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
@@ -19,6 +19,7 @@ class NavApplication : Application() {
val logbookRepository = org.terst.nav.logbook.InMemoryLogbookRepository()
lateinit var trackRepository: org.terst.nav.track.TrackRepository
lateinit var boatProfileRepository: org.terst.nav.tripreport.BoatProfileRepository
+ lateinit var vesselRepository: org.terst.nav.vessel.VesselRepository
var isTesting: Boolean = false
get() {
if (field) return true
@@ -36,6 +37,7 @@ class NavApplication : Application() {
featureFlags = FeatureFlags(this)
trackRepository = org.terst.nav.track.TrackRepository(this)
boatProfileRepository = org.terst.nav.tripreport.BoatProfileRepository(this)
+ vesselRepository = org.terst.nav.vessel.VesselRepository(this)
FirebaseCrashlytics.getInstance().sendUnsentReports()
installCrashLogger()
}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/LayerPickerSheet.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/LayerPickerSheet.kt
index 78fd70f..bd84961 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/ui/LayerPickerSheet.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/LayerPickerSheet.kt
@@ -19,6 +19,7 @@ class LayerPickerSheet(
private val onBaseChanged: (MapBasePreset) -> Unit,
private val onWindChanged: (Boolean) -> Unit,
private val onParticlesChanged: (Boolean) -> Unit,
+ private val onPastTracksChanged: (Boolean) -> Unit,
private val unitPrefs: UnitPrefs,
private val onUnitsChanged: () -> Unit
) : BottomSheetDialogFragment() {
@@ -54,6 +55,11 @@ class LayerPickerSheet(
particlesSwitch.isChecked = manager.particlesEnabled
particlesSwitch.setOnCheckedChangeListener { _, checked -> onParticlesChanged(checked) }
+ // ── Past tracks switch ────────────────────────────────────────────────
+ val pastTracksSwitch = view.findViewById<SwitchMaterial>(R.id.switch_past_tracks)
+ pastTracksSwitch.isChecked = manager.pastTracksEnabled
+ pastTracksSwitch.setOnCheckedChangeListener { _, checked -> onPastTracksChanged(checked) }
+
// ── Temperature chips ─────────────────────────────────────────────────
val chipTemp = view.findViewById<ChipGroup>(R.id.chip_group_temp)
chipTemp.check(when (unitPrefs.tempUnit) {
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt
index f05da21..88689e0 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt
@@ -19,7 +19,6 @@ import org.terst.nav.settings.FeatureFlags
import org.terst.nav.settings.HardwareSource
import org.terst.nav.vessel.CrewMember
import org.terst.nav.vessel.Vessel
-import org.terst.nav.vessel.VesselRepository
import org.terst.nav.data.model.ForecastItem
import org.terst.nav.data.model.MarineConditions
import org.terst.nav.data.model.WindArrow
@@ -90,7 +89,7 @@ class MainViewModel(
private val trackRepository = org.terst.nav.NavApplication.trackRepository
- val vesselRepository = VesselRepository()
+ val vesselRepository get() = org.terst.nav.NavApplication.vesselRepository
private val _thermalState = MutableStateFlow(ThermalState.OK)
val thermalState: StateFlow<ThermalState> = _thermalState.asStateFlow()
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/MapLayerManager.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/MapLayerManager.kt
index 855233f..1c46248 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/ui/MapLayerManager.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/MapLayerManager.kt
@@ -29,6 +29,9 @@ class MapLayerManager(context: Context) {
var particlesEnabled: Boolean = prefs.getBoolean(KEY_PARTICLES, true)
private set
+ var pastTracksEnabled: Boolean = prefs.getBoolean(KEY_PAST_TRACKS, true)
+ private set
+
// ── Source / Layer IDs ────────────────────────────────────────────────────
companion object {
@@ -42,9 +45,10 @@ class MapLayerManager(context: Context) {
const val LAYER_WIND = "wind-layer"
const val LAYER_SEAMARKS = "openseamap-layer"
- private const val KEY_BASE = "base_preset"
- private const val KEY_WIND = "wind_enabled"
- private const val KEY_PARTICLES = "particles_enabled"
+ private const val KEY_BASE = "base_preset"
+ private const val KEY_WIND = "wind_enabled"
+ private const val KEY_PARTICLES = "particles_enabled"
+ private const val KEY_PAST_TRACKS = "past_tracks_enabled"
// Tile URLs
private const val URL_SATELLITE =
@@ -116,6 +120,11 @@ class MapLayerManager(context: Context) {
prefs.edit().putBoolean(KEY_PARTICLES, enabled).apply()
}
+ fun setPastTracksEnabled(enabled: Boolean) {
+ pastTracksEnabled = enabled
+ prefs.edit().putBoolean(KEY_PAST_TRACKS, enabled).apply()
+ }
+
/** Toggle wind overlay on a live style. */
fun setWindEnabled(style: Style, enabled: Boolean) {
windEnabled = enabled
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/vessel/VesselRegistryFragment.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/vessel/VesselRegistryFragment.kt
index d0d8515..f359962 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/ui/vessel/VesselRegistryFragment.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/vessel/VesselRegistryFragment.kt
@@ -12,15 +12,15 @@ import androidx.fragment.app.Fragment
import com.google.android.material.button.MaterialButton
import com.google.android.material.textfield.TextInputEditText
import com.google.android.material.textfield.TextInputLayout
+import org.terst.nav.NavApplication
import org.terst.nav.R
import org.terst.nav.vessel.CrewMember
import org.terst.nav.vessel.CrewRole
import org.terst.nav.vessel.Vessel
-import org.terst.nav.vessel.VesselRepository
class VesselRegistryFragment : Fragment() {
- private val repo = VesselRepository()
+ private val repo get() = NavApplication.vesselRepository
private lateinit var tvOwnVesselName: TextView
private lateinit var tvOwnVesselDetails: TextView
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/vessel/VesselRepository.kt b/android-app/app/src/main/kotlin/org/terst/nav/vessel/VesselRepository.kt
index b65a816..7ebd2f6 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/vessel/VesselRepository.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/vessel/VesselRepository.kt
@@ -1,11 +1,33 @@
package org.terst.nav.vessel
+import android.content.Context
+import com.squareup.moshi.JsonClass
+import com.squareup.moshi.Moshi
+import com.squareup.moshi.Types
+import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import java.util.UUID
-class VesselRepository {
+class VesselRepository(context: Context) {
+
+ private val prefs = context.getSharedPreferences("vessel_registry", Context.MODE_PRIVATE)
+ private val moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build()
+
+ private val vesselListType = Types.newParameterizedType(List::class.java, VesselJson::class.java)
+ private val crewListType = Types.newParameterizedType(List::class.java, CrewJson::class.java)
+ private val vesselAdapter = moshi.adapter<List<VesselJson>>(vesselListType)
+ private val crewAdapter = moshi.adapter<List<CrewJson>>(crewListType)
private val vessels = mutableListOf<Vessel>()
- private val crew = mutableListOf<CrewMember>()
+ private val crew = mutableListOf<CrewMember>()
+
+ init {
+ prefs.getString(KEY_VESSELS, null)
+ ?.let { runCatching { vesselAdapter.fromJson(it) }.getOrNull() }
+ ?.forEach { vessels.add(it.toDomain()) }
+ prefs.getString(KEY_CREW, null)
+ ?.let { runCatching { crewAdapter.fromJson(it) }.getOrNull() }
+ ?.forEach { crew.add(it.toDomain()) }
+ }
// --- Vessel CRUD ---
@@ -13,6 +35,7 @@ class VesselRepository {
val v = if (vessel.id.isBlank()) vessel.copy(id = UUID.randomUUID().toString()) else vessel
vessels.removeAll { it.id == v.id }
vessels.add(v)
+ saveVessels()
return v
}
@@ -20,10 +43,15 @@ class VesselRepository {
val idx = vessels.indexOfFirst { it.id == vessel.id }
if (idx < 0) return false
vessels[idx] = vessel
+ saveVessels()
return true
}
- fun removeVessel(id: String): Boolean = vessels.removeAll { it.id == id }
+ fun removeVessel(id: String): Boolean {
+ val removed = vessels.removeAll { it.id == id }
+ if (removed) saveVessels()
+ return removed
+ }
fun getVessel(id: String): Vessel? = vessels.find { it.id == id }
@@ -37,6 +65,7 @@ class VesselRepository {
val m = if (member.id.isBlank()) member.copy(id = UUID.randomUUID().toString()) else member
crew.removeAll { it.id == m.id }
crew.add(m)
+ saveCrew()
return m
}
@@ -44,10 +73,15 @@ class VesselRepository {
val idx = crew.indexOfFirst { it.id == member.id }
if (idx < 0) return false
crew[idx] = member
+ saveCrew()
return true
}
- fun removeCrewMember(id: String): Boolean = crew.removeAll { it.id == id }
+ fun removeCrewMember(id: String): Boolean {
+ val removed = crew.removeAll { it.id == id }
+ if (removed) saveCrew()
+ return removed
+ }
fun getCrewMember(id: String): CrewMember? = crew.find { it.id == id }
@@ -56,4 +90,106 @@ class VesselRepository {
fun getCrewByRole(role: CrewRole): List<CrewMember> = crew.filter { it.role == role }
fun getSkipper(): CrewMember? = crew.find { it.role == CrewRole.SKIPPER }
+
+ // --- Persistence ---
+
+ private fun saveVessels() {
+ prefs.edit().putString(KEY_VESSELS, vesselAdapter.toJson(vessels.map { VesselJson.from(it) })).apply()
+ }
+
+ private fun saveCrew() {
+ prefs.edit().putString(KEY_CREW, crewAdapter.toJson(crew.map { CrewJson.from(it) })).apply()
+ }
+
+ // --- JSON transfer objects ---
+
+ @JsonClass(generateAdapter = true)
+ data class VesselJson(
+ val id: String,
+ val name: String,
+ val mmsi: String,
+ val callsign: String,
+ val flag: String,
+ val homePort: String,
+ val hullType: String,
+ val loaMeters: Double,
+ val beamMeters: Double,
+ val draftMeters: Double,
+ val displacementKg: Double,
+ val engineHp: Int,
+ val notes: String,
+ val isOwnVessel: Boolean
+ ) {
+ fun toDomain() = Vessel(
+ id = id,
+ name = name,
+ mmsi = mmsi,
+ callsign = callsign,
+ flag = flag,
+ homePort = homePort,
+ hullType = runCatching { HullType.valueOf(hullType) }.getOrDefault(HullType.MONOHULL),
+ loaMeters = loaMeters,
+ beamMeters = beamMeters,
+ draftMeters = draftMeters,
+ displacementKg = displacementKg,
+ engineHp = engineHp,
+ notes = notes,
+ isOwnVessel = isOwnVessel
+ )
+ companion object {
+ fun from(v: Vessel) = VesselJson(
+ id = v.id,
+ name = v.name,
+ mmsi = v.mmsi,
+ callsign = v.callsign,
+ flag = v.flag,
+ homePort = v.homePort,
+ hullType = v.hullType.name,
+ loaMeters = v.loaMeters,
+ beamMeters = v.beamMeters,
+ draftMeters = v.draftMeters,
+ displacementKg = v.displacementKg,
+ engineHp = v.engineHp,
+ notes = v.notes,
+ isOwnVessel = v.isOwnVessel
+ )
+ }
+ }
+
+ @JsonClass(generateAdapter = true)
+ data class CrewJson(
+ val id: String,
+ val name: String,
+ val role: String,
+ val emergencyContact: String,
+ val emergencyPhone: String,
+ val certifications: List<String>,
+ val notes: String
+ ) {
+ fun toDomain() = CrewMember(
+ id = id,
+ name = name,
+ role = runCatching { CrewRole.valueOf(role) }.getOrDefault(CrewRole.CREW),
+ emergencyContact = emergencyContact,
+ emergencyPhone = emergencyPhone,
+ certifications = certifications,
+ notes = notes
+ )
+ companion object {
+ fun from(m: CrewMember) = CrewJson(
+ id = m.id,
+ name = m.name,
+ role = m.role.name,
+ emergencyContact = m.emergencyContact,
+ emergencyPhone = m.emergencyPhone,
+ certifications = m.certifications,
+ notes = m.notes
+ )
+ }
+ }
+
+ companion object {
+ private const val KEY_VESSELS = "vessels"
+ private const val KEY_CREW = "crew"
+ }
}
diff --git a/android-app/app/src/main/res/layout/layout_layer_picker_sheet.xml b/android-app/app/src/main/res/layout/layout_layer_picker_sheet.xml
index fdfbb7a..2e6f982 100644
--- a/android-app/app/src/main/res/layout/layout_layer_picker_sheet.xml
+++ b/android-app/app/src/main/res/layout/layout_layer_picker_sheet.xml
@@ -141,6 +141,41 @@
</LinearLayout>
+ <!-- Past tracks toggle -->
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:gravity="center_vertical"
+ android:layout_marginTop="4dp"
+ android:layout_marginBottom="8dp">
+
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:orientation="vertical">
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Past tracks"
+ android:textSize="15sp"
+ android:textColor="@color/instrument_text_normal" />
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Show previous voyages on map"
+ android:textSize="12sp"
+ android:textColor="@color/instrument_text_secondary" />
+ </LinearLayout>
+
+ <com.google.android.material.switchmaterial.SwitchMaterial
+ android:id="@+id/switch_past_tracks"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content" />
+
+ </LinearLayout>
+
<!-- ── UNITS ───────────────────────────────────────────────── -->
<View