From 80aa2531144563e8a117ffd81f6dd6d9af8bd574 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Thu, 2 Jul 2026 02:56:25 +0000 Subject: fix+feat: layer cards, ocean floor, notifications, units, log, photos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layer picker: - Fix blank cards: app:layout_columnWeight → android:layout_columnWeight (GridLayout children require android: namespace for weight attributes) - Add Distance unit chips (nm/km/mi) to UNITS section Map layers: - Fix ocean floor: use addLayerAbove(lastFillLayer) instead of addLayerAt(0) so base rasters appear above OFM water/land fills but below roads+labels - CardView elevation 16dp→4dp to eliminate visible shadow border Notifications: - Add POST_NOTIFICATIONS to manifest - Request it alongside location permissions on Android 13+ (TIRAMISU+) Conditions sheet: - showOverlay() now always hides bottom sheet (STATE_HIDDEN) so it can't leak onto non-map pages; bottom padding (80dp) added to Safety+Log panes Log entries: - Log entry form visible always (was only shown while recording) - Photo-only events filtered from track timeline; photos appear only when paired with real text in a log entry Anchor watch: - Inputs and output radius now use configured depth unit (ft/m) with label updates and ft→m conversion before calculation Photos: - Tap thumbnail in track event log → fullscreen dialog viewer Co-Authored-By: Claude Sonnet 4.6 --- android-app/app/src/main/AndroidManifest.xml | 1 + .../app/src/main/assets/docs/getting_started.md | 18 ++++++ .../app/src/main/assets/docs/instruments.md | 19 ++++++ android-app/app/src/main/assets/docs/safety.md | 19 ++++++ .../src/main/kotlin/org/terst/nav/MainActivity.kt | 25 ++++++-- .../app/src/main/kotlin/org/terst/nav/UnitPrefs.kt | 18 ++++++ .../kotlin/org/terst/nav/track/TrackDetailSheet.kt | 28 ++++++++- .../kotlin/org/terst/nav/ui/LayerPickerSheet.kt | 17 ++++++ .../kotlin/org/terst/nav/ui/MapLayerManager.kt | 45 +++++++++----- .../org/terst/nav/ui/safety/SafetyFragment.kt | 19 ++++-- .../org/terst/nav/ui/voicelog/VoiceLogFragment.kt | 7 +-- .../app/src/main/res/layout/activity_main.xml | 2 +- .../app/src/main/res/layout/fragment_safety.xml | 6 +- .../app/src/main/res/layout/fragment_voice_log.xml | 9 +-- .../main/res/layout/layout_layer_picker_sheet.xml | 69 +++++++++++++++++----- 15 files changed, 250 insertions(+), 52 deletions(-) create mode 100644 android-app/app/src/main/assets/docs/getting_started.md create mode 100644 android-app/app/src/main/assets/docs/instruments.md create mode 100644 android-app/app/src/main/assets/docs/safety.md (limited to 'android-app') diff --git a/android-app/app/src/main/AndroidManifest.xml b/android-app/app/src/main/AndroidManifest.xml index 1701e17..6c792f2 100644 --- a/android-app/app/src/main/AndroidManifest.xml +++ b/android-app/app/src/main/AndroidManifest.xml @@ -7,6 +7,7 @@ + () + if (!locationGranted) { + toRequest += Manifest.permission.ACCESS_FINE_LOCATION + toRequest += Manifest.permission.ACCESS_COARSE_LOCATION + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) + != PackageManager.PERMISSION_GRANTED) { + toRequest += Manifest.permission.POST_NOTIFICATIONS + } - if (fineLocation == PackageManager.PERMISSION_GRANTED || coarseLocation == PackageManager.PERMISSION_GRANTED) { + if (locationGranted) { startServices() + if (toRequest.isNotEmpty()) requestPermissionLauncher.launch(toRequest.toTypedArray()) } else { - requestPermissionLauncher.launch(arrayOf( - Manifest.permission.ACCESS_FINE_LOCATION, - Manifest.permission.ACCESS_COARSE_LOCATION - )) + requestPermissionLauncher.launch(toRequest.toTypedArray()) } } diff --git a/android-app/app/src/main/kotlin/org/terst/nav/UnitPrefs.kt b/android-app/app/src/main/kotlin/org/terst/nav/UnitPrefs.kt index c676390..20de3e4 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/UnitPrefs.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/UnitPrefs.kt @@ -7,6 +7,7 @@ enum class TempUnit { CELSIUS, FAHRENHEIT } enum class DepthUnit { FEET, METERS } enum class PressureUnit { HPA, INHG } enum class SpeedUnit { KNOTS, MPH, KPH } +enum class DistanceUnit { NM, KM, MI } /** * Persists and applies the user's preferred display units. @@ -32,6 +33,10 @@ class UnitPrefs(context: Context) { get() = runCatching { SpeedUnit.valueOf(prefs.getString(KEY_SPEED, null) ?: "") }.getOrDefault(SpeedUnit.KNOTS) set(v) { prefs.edit().putString(KEY_SPEED, v.name).apply() } + var distanceUnit: DistanceUnit + get() = runCatching { DistanceUnit.valueOf(prefs.getString(KEY_DISTANCE, null) ?: "") }.getOrDefault(DistanceUnit.NM) + set(v) { prefs.edit().putString(KEY_DISTANCE, v.name).apply() } + fun formatTemp(tempC: Double): String = when (tempUnit) { TempUnit.CELSIUS -> "%.0f".format(Locale.getDefault(), tempC) TempUnit.FAHRENHEIT -> "%.0f".format(Locale.getDefault(), tempC * 9.0 / 5.0 + 32.0) @@ -74,10 +79,23 @@ class UnitPrefs(context: Context) { SpeedUnit.KPH -> "kph" } + fun formatDistance(nm: Double): String = when (distanceUnit) { + DistanceUnit.NM -> "%.1f".format(Locale.getDefault(), nm) + DistanceUnit.KM -> "%.1f".format(Locale.getDefault(), nm * 1.852) + DistanceUnit.MI -> "%.1f".format(Locale.getDefault(), nm * 1.15078) + } + + fun distanceUnitLabel(): String = when (distanceUnit) { + DistanceUnit.NM -> "nm" + DistanceUnit.KM -> "km" + DistanceUnit.MI -> "mi" + } + companion object { private const val KEY_TEMP = "temp_unit" private const val KEY_DEPTH = "depth_unit" private const val KEY_PRESSURE = "pressure_unit" private const val KEY_SPEED = "speed_unit" + private const val KEY_DISTANCE = "distance_unit" } } 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 7dd0e07..e194efc 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 @@ -352,9 +352,11 @@ class TrackDetailSheet : Fragment() { "%.0f° → %.0f° (Δ%.0f°)".format(t.cogBefore, t.cogAfter, abs(delta))) } - // Log entries saved during or associated with this track's time window + // Log entries saved during or associated with this track's time window. + // Photo-only entries (no real text) are excluded — photos belong to log entries, not as standalone events. val logEntries = NavApplication.logbookRepository.getAll() .filter { it.timestampMs in track.startMs..track.endMs } + .filter { it.text.isNotBlank() && it.text != "(photo only)" } for (entry in logEntries) { val lat = entry.lat ?: nearestTrackPoint(points, entry.timestampMs)?.lat ?: continue val lon = entry.lon ?: nearestTrackPoint(points, entry.timestampMs)?.lon ?: continue @@ -447,17 +449,41 @@ private class LogEventAdapter( if (bm != null) { holder.ivThumb.setImageBitmap(bm) holder.ivThumb.visibility = View.VISIBLE + holder.ivThumb.setOnClickListener { showFullscreenPhoto(holder.itemView.context, path) } } else { holder.ivThumb.setImageDrawable(null) holder.ivThumb.visibility = View.GONE + holder.ivThumb.setOnClickListener(null) } } else { holder.ivThumb.setImageDrawable(null) holder.ivThumb.visibility = View.GONE + holder.ivThumb.setOnClickListener(null) } } } +private fun showFullscreenPhoto(context: android.content.Context, pathOrUri: String) { + val uri = if (pathOrUri.startsWith("content://")) Uri.parse(pathOrUri) else Uri.fromFile(File(pathOrUri)) + val imageView = android.widget.ImageView(context).apply { + setImageURI(uri) + scaleType = android.widget.ImageView.ScaleType.FIT_CENTER + setBackgroundColor(android.graphics.Color.BLACK) + layoutParams = android.view.ViewGroup.LayoutParams( + android.view.ViewGroup.LayoutParams.MATCH_PARENT, + android.view.ViewGroup.LayoutParams.MATCH_PARENT + ) + } + android.app.AlertDialog.Builder(context) + .setView(imageView) + .setPositiveButton("Close", null) + .show() + .window?.setLayout( + android.view.ViewGroup.LayoutParams.MATCH_PARENT, + android.view.ViewGroup.LayoutParams.MATCH_PARENT + ) +} + private fun decodeThumbnail(context: android.content.Context, pathOrUri: String, targetW: Int, targetH: Int): android.graphics.Bitmap? { if (pathOrUri.startsWith("content://")) { return runCatching { 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 b60af50..47d29c4 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 @@ -13,6 +13,7 @@ import com.google.android.material.card.MaterialCardView import com.google.android.material.chip.ChipGroup import com.google.android.material.switchmaterial.SwitchMaterial import org.terst.nav.DepthUnit +import org.terst.nav.DistanceUnit import org.terst.nav.PressureUnit import org.terst.nav.R import org.terst.nav.SpeedUnit @@ -178,6 +179,22 @@ class LayerPickerSheet( } onUnitsChanged() } + + val chipDistance = view.findViewById(R.id.chip_group_distance) + chipDistance.check(when (unitPrefs.distanceUnit) { + DistanceUnit.NM -> R.id.chip_nm + DistanceUnit.KM -> R.id.chip_km + DistanceUnit.MI -> R.id.chip_mi + }) + chipDistance.setOnCheckedStateChangeListener { _, ids -> + unitPrefs.distanceUnit = when (ids.firstOrNull()) { + R.id.chip_nm -> DistanceUnit.NM + R.id.chip_km -> DistanceUnit.KM + R.id.chip_mi -> DistanceUnit.MI + else -> return@setOnCheckedStateChangeListener + } + onUnitsChanged() + } } private fun dpToPx(dp: Int): Int = 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 f6ee4fb..8cee462 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 @@ -2,6 +2,8 @@ package org.terst.nav.ui import android.content.Context import org.maplibre.android.maps.Style +import org.maplibre.android.style.layers.BackgroundLayer +import org.maplibre.android.style.layers.FillLayer import org.maplibre.android.style.layers.PropertyFactory import org.maplibre.android.style.layers.RasterLayer import org.maplibre.android.style.sources.RasterSource @@ -129,25 +131,38 @@ class MapLayerManager(context: Context) { } /** - * Inserts base-map rasters at indices 0/1/2 so OFM vector layers sit on top. - * Call this immediately after the style finishes loading. + * Inserts base-map rasters above the last OFM fill/background layer so they appear + * above OFM water/land fills but below OFM road lines and label symbols. + * addLayerAt(0) would put rasters below everything, where OFM fills cover them. + * addLayer() (appending) would put rasters on top of labels, hiding them. + * addLayerAbove(lastFill) is the sweet spot: imagery shows through, labels overlay. */ fun addBaseLayersToStyle(style: Style) { - android.util.Log.d("MapLayers", "addBaseLayersToStyle preset=$basePreset ofmLayers=${style.layers.size}") - // Insert in ascending index order so final order from bottom is: ocean(0) → satellite(1) → charts(2) → OFM… - style.addLayerAt(RasterLayer(LAYER_OCEAN, SOURCE_OCEAN).apply { - setProperties(PropertyFactory.rasterOpacity(1f)) - setProperties(PropertyFactory.visibility(visibilityFor(basePreset, LAYER_OCEAN))) - }, 0) - style.addLayerAt(RasterLayer(LAYER_SATELLITE, SOURCE_SATELLITE).apply { - setProperties(PropertyFactory.rasterOpacity(1f)) - setProperties(PropertyFactory.visibility(visibilityFor(basePreset, LAYER_SATELLITE))) - }, 1) - style.addLayerAt(RasterLayer(LAYER_CHARTS, SOURCE_CHARTS).apply { + val lastFillId = style.layers + .lastOrNull { it is BackgroundLayer || it is FillLayer }?.id + android.util.Log.d("MapLayers", "addBaseLayersToStyle preset=$basePreset ofmLayers=${style.layers.size} insertAbove=$lastFillId") + + fun addBase(layer: RasterLayer) { + if (lastFillId != null) style.addLayerAbove(layer, lastFillId) + else style.addLayerAt(layer, 0) + } + + // Add in reverse visual order (charts first = lowest in our stack) + // because each addLayerAbove(x, lastFillId) inserts x just above lastFillId, + // pushing previous insertions higher. Final order: ocean → satellite → charts → OFM lines/labels. + addBase(RasterLayer(LAYER_CHARTS, SOURCE_CHARTS).apply { setProperties(PropertyFactory.rasterOpacity(opacityFor(basePreset))) setProperties(PropertyFactory.visibility(visibilityFor(basePreset, LAYER_CHARTS))) - }, 2) - android.util.Log.d("MapLayers", "base layers inserted, ocean=${visibilityFor(basePreset, LAYER_OCEAN)} satellite=${visibilityFor(basePreset, LAYER_SATELLITE)}") + }) + addBase(RasterLayer(LAYER_SATELLITE, SOURCE_SATELLITE).apply { + setProperties(PropertyFactory.rasterOpacity(1f)) + setProperties(PropertyFactory.visibility(visibilityFor(basePreset, LAYER_SATELLITE))) + }) + addBase(RasterLayer(LAYER_OCEAN, SOURCE_OCEAN).apply { + setProperties(PropertyFactory.rasterOpacity(1f)) + setProperties(PropertyFactory.visibility(visibilityFor(basePreset, LAYER_OCEAN))) + }) + android.util.Log.d("MapLayers", "base layers inserted above $lastFillId, ocean=${visibilityFor(basePreset, LAYER_OCEAN)}") } /** 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 c3bef8f..91c3489 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 @@ -13,8 +13,10 @@ import androidx.core.content.ContextCompat import androidx.fragment.app.Fragment import com.google.android.material.button.MaterialButton import com.google.android.material.materialswitch.MaterialSwitch +import org.terst.nav.DepthUnit import org.terst.nav.NavApplication import org.terst.nav.R +import org.terst.nav.UnitPrefs import org.terst.nav.ais.CpaAlert import org.terst.nav.ais.CpaSeverity import org.terst.nav.ais.label @@ -63,6 +65,11 @@ class SafetyFragment : Fragment() { chevronAnchor.text = if (expanding) "▼" else "▶" } + val unitPrefs = UnitPrefs(requireContext()) + val unitLabel = unitPrefs.depthUnitLabel() + view.findViewById(R.id.tv_anchor_depth_label).text = "Depth ($unitLabel)" + view.findViewById(R.id.tv_anchor_rode_label).text = "Rode Out ($unitLabel)" + val etDepth = view.findViewById(R.id.et_anchor_depth) val etRode = view.findViewById(R.id.et_anchor_rode) val tvRadius = view.findViewById(R.id.tv_anchor_suggested_radius) @@ -70,11 +77,13 @@ class SafetyFragment : Fragment() { 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) + val inputDepth = etDepth.text.toString().toDoubleOrNull() + val inputRode = etRode.text.toString().toDoubleOrNull() + tvRadius.text = if (inputDepth != null && inputRode != null && inputDepth >= 0.0 && inputRode > 0.0) { + val depthM = if (unitPrefs.depthUnit == DepthUnit.FEET) inputDepth * 0.3048 else inputDepth + val rodeM = if (unitPrefs.depthUnit == DepthUnit.FEET) inputRode * 0.3048 else inputRode + val r = AnchorWatchState.calculateRecommendedWatchCircleRadius(depthM, 2.0, rodeM) + "${unitPrefs.formatDepth(r)} $unitLabel" } else { getString(R.string.anchor_suggested_radius_empty) } 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 51a0685..83e8c07 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 @@ -164,12 +164,7 @@ class VoiceLogFragment : Fragment() { } btnClear.setOnClickListener { clearEntry() } - // Show log-entry section only while recording - viewLifecycleOwner.lifecycleScope.launch { - mainViewModel.isRecording.collect { recording -> - layoutLogEntry.visibility = if (recording) View.VISIBLE else View.GONE - } - } + layoutLogEntry.visibility = View.VISIBLE // Log entries list val tvNoEntries = view.findViewById(R.id.tv_no_log_entries) diff --git a/android-app/app/src/main/res/layout/activity_main.xml b/android-app/app/src/main/res/layout/activity_main.xml index ed1b7ce..99aa192 100644 --- a/android-app/app/src/main/res/layout/activity_main.xml +++ b/android-app/app/src/main/res/layout/activity_main.xml @@ -126,7 +126,7 @@ android:layout_height="wrap_content" app:behavior_hideable="false" app:behavior_peekHeight="120dp" - app:cardElevation="16dp" + app:cardElevation="4dp" app:strokeWidth="0dp" app:shapeAppearance="@style/ShapeAppearance.Nav.BottomSheet" app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior"> 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 78f8754..4a8efa9 100644 --- a/android-app/app/src/main/res/layout/fragment_safety.xml +++ b/android-app/app/src/main/res/layout/fragment_safety.xml @@ -4,7 +4,9 @@ xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" - android:background="?attr/colorSurface"> + android:background="?attr/colorSurface" + android:clipToPadding="false" + android:paddingBottom="80dp"> + android:layout_height="match_parent" + android:clipToPadding="false" + android:paddingBottom="80dp"> - + + android:orientation="vertical"> + android:gravity="center_vertical" + android:layout_marginBottom="10dp"> + + + + + + + + + + -- cgit v1.2.3