diff options
Diffstat (limited to 'android-app/app/src/main')
5 files changed, 228 insertions, 22 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 7a00748..35a0e91 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 @@ -146,10 +146,16 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { hudDepth.text = "—" applyUnitLabels() + NavLogger.i("app", "started") setupBottomSheet() setupBottomNavigation() setupHandlers() setupMap() + // Hidden dev log — long-press the drag handle on the instrument sheet + findViewById<View>(R.id.drag_handle).setOnLongClickListener { + DevLogSheet().show(supportFragmentManager, "dev_log") + true + } fabRecordTrack.setOnClickListener { if (!viewModel.isRecording.value) viewModel.startTrack() @@ -482,6 +488,8 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { hudCog.text = "%.0f°".format(Locale.getDefault(), gpsData.cog) if (!conditionsLoaded) { conditionsLoaded = true + NavLogger.i("gps", "first fix lat=%.4f lon=%.4f sog=%.1fkt cog=%.0f°" + .format(gpsData.latitude, gpsData.longitude, gpsData.sog, gpsData.cog)) viewModel.loadConditions(gpsData.latitude, gpsData.longitude) } if (!weatherLoaded) { diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/repository/WeatherRepository.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/repository/WeatherRepository.kt index 26c5da7..a716051 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/data/repository/WeatherRepository.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/data/repository/WeatherRepository.kt @@ -7,6 +7,7 @@ import org.terst.nav.data.api.WeatherApiService import org.terst.nav.data.model.ForecastItem import org.terst.nav.data.model.MarineConditions import org.terst.nav.data.model.WindArrow +import org.terst.nav.ui.NavLogger class WeatherRepository( private val weatherApi: WeatherApiService, @@ -16,8 +17,9 @@ class WeatherRepository( /** * Fetch 7-day hourly forecast items for the given position. */ - suspend fun fetchForecastItems(lat: Double, lon: Double): Result<List<ForecastItem>> = - runCatching { + suspend fun fetchForecastItems(lat: Double, lon: Double): Result<List<ForecastItem>> { + NavLogger.i("forecast", "lat=%.4f lon=%.4f".format(lat, lon)) + return runCatching { val weather = weatherApi.getWeatherForecast(lat, lon) val h = weather.hourly h.time.indices.map { i -> @@ -30,7 +32,15 @@ class WeatherRepository( weatherCode = h.weathercode[i] ) } + }.also { result -> + result.onSuccess { items -> + val first = items.firstOrNull() + NavLogger.i("forecast", "OK ${items.size} items wind[0]=${first?.windKt?.let { "%.1f".format(it) } ?: "—"}kt@${first?.windDirDeg?.toInt() ?: "—"}°") + }.onFailure { e -> + NavLogger.e("forecast", "${e.javaClass.simpleName}: ${e.message}") + } } + } /** * Fetch a single WindArrow representing the current conditions at [lat]/[lon]. @@ -52,29 +62,43 @@ class WeatherRepository( * Fetches current marine conditions (first forecast hour) for [lat]/[lon]. * Ocean current velocity is converted from m/s to knots. */ - suspend fun fetchCurrentConditions(lat: Double, lon: Double): Result<MarineConditions> = - runCatching { + suspend fun fetchCurrentConditions(lat: Double, lon: Double): Result<MarineConditions> { + NavLogger.i("conditions", "lat=%.4f lon=%.4f".format(lat, lon)) + return runCatching { coroutineScope { - val weatherDeferred = async { weatherApi.getWeatherForecast(lat, lon, forecastDays = 1) } - val marineDeferred = async { marineApi.getMarineForecast(lat, lon) } - val weather = weatherDeferred.await() - val marine = marineDeferred.await() - val w = weather.hourly - val m = marine.hourly - MarineConditions( - windSpeedKt = w.windspeed10m.firstOrNull(), - windDirDeg = w.winddirection10m.firstOrNull(), - tempC = w.temperature2m.firstOrNull(), - waveHeightM = m.waveHeight.firstOrNull(), - waveDirDeg = m.waveDirection.firstOrNull(), - swellHeightM = m.swellWaveHeight.firstOrNull(), - swellDirDeg = m.swellWaveDirection.firstOrNull(), - swellPeriodS = m.swellWavePeriod.firstOrNull(), - currentSpeedKt = m.oceanCurrentVelocity.firstOrNull()?.let { it * 1.94384 }, - currentDirDeg = m.oceanCurrentDirection.firstOrNull() - ) + val weatherDeferred = async { weatherApi.getWeatherForecast(lat, lon, forecastDays = 1) } + val marineDeferred = async { marineApi.getMarineForecast(lat, lon) } + val weather = weatherDeferred.await() + val marine = marineDeferred.await() + val w = weather.hourly + val m = marine.hourly + MarineConditions( + windSpeedKt = w.windspeed10m.firstOrNull(), + windDirDeg = w.winddirection10m.firstOrNull(), + tempC = w.temperature2m.firstOrNull(), + waveHeightM = m.waveHeight.firstOrNull(), + waveDirDeg = m.waveDirection.firstOrNull(), + swellHeightM = m.swellWaveHeight.firstOrNull(), + swellDirDeg = m.swellWaveDirection.firstOrNull(), + swellPeriodS = m.swellWavePeriod.firstOrNull(), + currentSpeedKt = m.oceanCurrentVelocity.firstOrNull()?.let { it * 1.94384 }, + currentDirDeg = m.oceanCurrentDirection.firstOrNull() + ) + } + }.also { result -> + result.onSuccess { c -> + NavLogger.i("conditions", "OK" + + " wind=${c.windSpeedKt?.let { "%.1f".format(it) } ?: "—"}kt@${c.windDirDeg?.toInt() ?: "—"}°" + + " temp=${c.tempC?.let { "%.1f".format(it) } ?: "—"}°C" + + " waves=${c.waveHeightM?.let { "%.2f".format(it) } ?: "—"}m" + + " swell=${c.swellHeightM?.let { "%.2f".format(it) } ?: "—"}m@${c.swellDirDeg?.toInt() ?: "—"}°/${c.swellPeriodS?.toInt() ?: "—"}s" + + " curr=${c.currentSpeedKt?.let { "%.2f".format(it) } ?: "—"}kt@${c.currentDirDeg?.toInt() ?: "—"}°" + ) + }.onFailure { e -> + NavLogger.e("conditions", "${e.javaClass.simpleName}: ${e.message}") } } + } /** * Fetch a current wind arrow for each point in [points] using Open-Meteo's batch endpoint. diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/DevLogSheet.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/DevLogSheet.kt new file mode 100644 index 0000000..99e6d5f --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/DevLogSheet.kt @@ -0,0 +1,58 @@ +package org.terst.nav.ui + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ScrollView +import android.widget.TextView +import android.widget.Toast +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.google.android.material.button.MaterialButton +import org.terst.nav.R + +class DevLogSheet : BottomSheetDialogFragment() { + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View = inflater.inflate(R.layout.sheet_dev_log, container, false) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + // Expand to full height + (dialog as? BottomSheetDialog)?.behavior?.apply { + state = BottomSheetBehavior.STATE_EXPANDED + skipCollapsed = true + } + + val logText = view.findViewById<TextView>(R.id.log_text) + val scrollView = view.findViewById<ScrollView>(R.id.scroll_view) + + fun refresh() { + val text = NavLogger.getLog() + logText.text = text.ifEmpty { "(no log entries yet)" } + scrollView.post { scrollView.fullScroll(View.FOCUS_DOWN) } + } + + refresh() + + view.findViewById<MaterialButton>(R.id.btn_copy).setOnClickListener { + val cm = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + cm.setPrimaryClip(ClipData.newPlainText("nav_dev_log", NavLogger.getLog())) + Toast.makeText(requireContext(), "Copied to clipboard", Toast.LENGTH_SHORT).show() + } + + view.findViewById<MaterialButton>(R.id.btn_clear).setOnClickListener { + NavLogger.clear() + refresh() + } + } +} diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/NavLogger.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/NavLogger.kt new file mode 100644 index 0000000..c638113 --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/NavLogger.kt @@ -0,0 +1,42 @@ +package org.terst.nav.ui + +import java.util.Calendar + +/** + * Lightweight in-memory log ring-buffer. + * + * Format: `HH:mm:ss.SSS LEVEL tag: message` + * + * Designed so both humans and Claude can read the output at a glance. + * Entries are captured from API calls, GPS fixes, and key state changes. + * Retrieve with [getLog] and copy to clipboard via DevLogSheet (long-press + * the drag handle on the instrument sheet). + */ +object NavLogger { + + private const val MAX_ENTRIES = 200 + private val entries = ArrayDeque<String>() + + fun i(tag: String, msg: String) = append("INFO ", tag, msg) + fun w(tag: String, msg: String) = append("WARN ", tag, msg) + fun e(tag: String, msg: String) = append("ERROR", tag, msg) + + private fun append(level: String, tag: String, msg: String) { + val cal = Calendar.getInstance() + val time = "%02d:%02d:%02d.%03d".format( + cal.get(Calendar.HOUR_OF_DAY), + cal.get(Calendar.MINUTE), + cal.get(Calendar.SECOND), + cal.get(Calendar.MILLISECOND) + ) + val entry = "$time $level $tag: $msg" + synchronized(entries) { + entries.addLast(entry) + while (entries.size > MAX_ENTRIES) entries.removeFirst() + } + } + + fun getLog(): String = synchronized(entries) { entries.joinToString("\n") } + + fun clear() = synchronized(entries) { entries.clear() } +} diff --git a/android-app/app/src/main/res/layout/sheet_dev_log.xml b/android-app/app/src/main/res/layout/sheet_dev_log.xml new file mode 100644 index 0000000..5464242 --- /dev/null +++ b/android-app/app/src/main/res/layout/sheet_dev_log.xml @@ -0,0 +1,74 @@ +<?xml version="1.0" encoding="utf-8"?> +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical" + android:background="?attr/colorSurface"> + + <!-- Header: title + action buttons --> + <LinearLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="horizontal" + android:paddingStart="16dp" + android:paddingEnd="8dp" + android:paddingTop="16dp" + android:paddingBottom="8dp" + android:gravity="center_vertical"> + + <TextView + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="1" + android:text="Dev Log" + android:textSize="16sp" + android:textStyle="bold" + android:textColor="?attr/colorOnSurface" /> + + <com.google.android.material.button.MaterialButton + android:id="@+id/btn_clear" + style="@style/Widget.Material3.Button.TextButton" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="Clear" + android:textColor="?attr/colorOnSurfaceVariant" /> + + <com.google.android.material.button.MaterialButton + android:id="@+id/btn_copy" + style="@style/Widget.Material3.Button.TextButton" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="Copy" /> + + </LinearLayout> + + <View + android:layout_width="match_parent" + android:layout_height="1dp" + android:background="?attr/colorOutline" + android:alpha="0.3" /> + + <ScrollView + android:id="@+id/scroll_view" + android:layout_width="match_parent" + android:layout_height="0dp" + android:layout_weight="1" + android:paddingStart="12dp" + android:paddingEnd="12dp" + android:paddingTop="10dp" + android:paddingBottom="24dp" + android:clipToPadding="false"> + + <TextView + android:id="@+id/log_text" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:fontFamily="monospace" + android:textSize="11sp" + android:textColor="?attr/colorOnSurface" + android:lineSpacingMultiplier="1.35" + android:textIsSelectable="true" /> + + </ScrollView> + +</LinearLayout> |
