diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-05-04 08:37:48 -1000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-05-04 08:37:48 -1000 |
| commit | 1d3619c241059c3b7405498869d435835a1ed4fc (patch) | |
| tree | abe6a15ee1cec1ddd92e5732d0e270e4975a3974 /android-app/app/src | |
| parent | afc7906c1a47fa9cf9dff5a247e6abae7e8aacbb (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/app/src')
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 |
