From ca57e40adc0b89e7dc5409475f7510c0c188d715 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Wed, 25 Mar 2026 18:09:53 +0000 Subject: feat(track): implement GPS track recording with map overlay - TrackRepository + TrackPoint wired into MainViewModel: isRecording/trackPoints StateFlows, startTrack/stopTrack/addGpsPoint - MapHandler.updateTrackLayer(): lazily initialises a red LineLayer and updates GeoJSON polyline from List - fab_record_track FAB in activity_main.xml (top|end of bottom nav); icon toggles between ic_track_record and ic_close while recording - MainActivity feeds every GPS fix into viewModel.addGpsPoint() and observes trackPoints to redraw the polyline in real time - ic_track_record.xml vector drawable (red record dot) - 8 TrackRepositoryTest tests all GREEN Co-Authored-By: Claude Sonnet 4.6 --- .../main/kotlin/org/terst/nav/track/TrackPoint.kt | 12 ++++ .../kotlin/org/terst/nav/track/TrackRepository.kt | 24 +++++++ .../org/terst/nav/track/TrackRepositoryTest.kt | 79 ++++++++++++++++++++++ 3 files changed, 115 insertions(+) create mode 100644 test-runner/src/main/kotlin/org/terst/nav/track/TrackPoint.kt create mode 100644 test-runner/src/main/kotlin/org/terst/nav/track/TrackRepository.kt create mode 100644 test-runner/src/test/kotlin/org/terst/nav/track/TrackRepositoryTest.kt (limited to 'test-runner') diff --git a/test-runner/src/main/kotlin/org/terst/nav/track/TrackPoint.kt b/test-runner/src/main/kotlin/org/terst/nav/track/TrackPoint.kt new file mode 100644 index 0000000..d803c8c --- /dev/null +++ b/test-runner/src/main/kotlin/org/terst/nav/track/TrackPoint.kt @@ -0,0 +1,12 @@ +package org.terst.nav.track + +data class TrackPoint( + val lat: Double, + val lon: Double, + val sogKnots: Double, + val cogDeg: Double, + val windSpeedKnots: Double, + val windAngleDeg: Double, + val isTrueWind: Boolean, + val timestampMs: Long +) diff --git a/test-runner/src/main/kotlin/org/terst/nav/track/TrackRepository.kt b/test-runner/src/main/kotlin/org/terst/nav/track/TrackRepository.kt new file mode 100644 index 0000000..c90adb9 --- /dev/null +++ b/test-runner/src/main/kotlin/org/terst/nav/track/TrackRepository.kt @@ -0,0 +1,24 @@ +package org.terst.nav.track + +class TrackRepository { + + var isRecording: Boolean = false + private set + + private val points = mutableListOf() + + fun startTrack() { + points.clear() + isRecording = true + } + + fun stopTrack() { + isRecording = false + } + + fun addPoint(point: TrackPoint) { + if (isRecording) points.add(point) + } + + fun getPoints(): List = points.toList() +} diff --git a/test-runner/src/test/kotlin/org/terst/nav/track/TrackRepositoryTest.kt b/test-runner/src/test/kotlin/org/terst/nav/track/TrackRepositoryTest.kt new file mode 100644 index 0000000..dea19c6 --- /dev/null +++ b/test-runner/src/test/kotlin/org/terst/nav/track/TrackRepositoryTest.kt @@ -0,0 +1,79 @@ +package org.terst.nav.track + +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test + +private fun makePoint( + lat: Double = 37.0, + lon: Double = -122.0, + sog: Double = 5.0, + cog: Double = 90.0, + windSpeed: Double = 10.0, + windAngle: Double = 45.0, + isTrueWind: Boolean = false, + ts: Long = 1_000L +) = TrackPoint(lat, lon, sog, cog, windSpeed, windAngle, isTrueWind, ts) + +class TrackRepositoryTest { + + private lateinit var repo: TrackRepository + + @Before fun setUp() { + repo = TrackRepository() + } + + @Test fun `initial state is not recording`() { + assertFalse(repo.isRecording) + } + + @Test fun `startTrack sets isRecording to true`() { + repo.startTrack() + assertTrue(repo.isRecording) + } + + @Test fun `stopTrack sets isRecording to false`() { + repo.startTrack() + repo.stopTrack() + assertFalse(repo.isRecording) + } + + @Test fun `addPoint appends point when recording`() { + repo.startTrack() + repo.addPoint(makePoint(lat = 37.1)) + assertEquals(1, repo.getPoints().size) + assertEquals(37.1, repo.getPoints()[0].lat, 0.0001) + } + + @Test fun `addPoint is ignored when not recording`() { + repo.addPoint(makePoint()) + assertEquals(0, repo.getPoints().size) + } + + @Test fun `startTrack clears previous points`() { + repo.startTrack() + repo.addPoint(makePoint()) + repo.addPoint(makePoint()) + repo.stopTrack() + repo.startTrack() + assertEquals(0, repo.getPoints().size) + } + + @Test fun `multiple points accumulate in order`() { + repo.startTrack() + repo.addPoint(makePoint(lat = 37.0, ts = 1000L)) + repo.addPoint(makePoint(lat = 37.1, ts = 2000L)) + repo.addPoint(makePoint(lat = 37.2, ts = 3000L)) + val pts = repo.getPoints() + assertEquals(3, pts.size) + assertEquals(37.0, pts[0].lat, 0.0001) + assertEquals(37.2, pts[2].lat, 0.0001) + } + + @Test fun `getPoints returns snapshot not live list`() { + repo.startTrack() + val snapshot = repo.getPoints() + repo.addPoint(makePoint()) + assertEquals(0, snapshot.size) // snapshot taken before addPoint + } +} -- cgit v1.2.3 From e68212991935d33a4baca77d88cd20a82fbcf6a6 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Wed, 25 Mar 2026 18:23:54 +0000 Subject: refactor: address simplify review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TrackRepository.addPoint() now returns Boolean (true if point was added). MainViewModel.addGpsPoint() only updates _trackPoints StateFlow when a point is actually appended — eliminates ~3,600 no-op flow emissions per hour when not recording. MainActivity: loadedStyle promoted from nullable field to MutableStateFlow; trackPoints observer uses filterNotNull + combine so no track points are silently dropped if the style loads after the first GPS fix. Smoke tests: replaced 11× ActivityScenario.launch().use{} with a single @get:Rule ActivityScenarioRule — same isolation, less boilerplate. CI: removed redundant app-debug artifact upload (app-debug.apk is already included inside the test-apks artifact). Removed stale/placeholder comments from MainActivity. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/android.yml | 6 -- .../kotlin/org/terst/nav/MainActivitySmokeTest.kt | 86 +++++++++------------- .../src/main/kotlin/org/terst/nav/MainActivity.kt | 19 ++--- .../kotlin/org/terst/nav/track/TrackRepository.kt | 6 +- .../main/kotlin/org/terst/nav/ui/MainViewModel.kt | 5 +- .../kotlin/org/terst/nav/track/TrackRepository.kt | 6 +- 6 files changed, 53 insertions(+), 75 deletions(-) (limited to 'test-runner') diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 150da6f..2c28410 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -27,12 +27,6 @@ jobs: run: ./gradlew assembleDebug assembleDebugAndroidTest working-directory: android-app - - name: Upload app APK (Firebase / manual download) - uses: actions/upload-artifact@v4 - with: - name: app-debug - path: android-app/app/build/outputs/apk/debug/app-debug.apk - - name: Upload test APKs (shared with smoke-test job) uses: actions/upload-artifact@v4 with: diff --git a/android-app/app/src/androidTest/kotlin/org/terst/nav/MainActivitySmokeTest.kt b/android-app/app/src/androidTest/kotlin/org/terst/nav/MainActivitySmokeTest.kt index a13ef7f..fecd9cc 100644 --- a/android-app/app/src/androidTest/kotlin/org/terst/nav/MainActivitySmokeTest.kt +++ b/android-app/app/src/androidTest/kotlin/org/terst/nav/MainActivitySmokeTest.kt @@ -1,6 +1,5 @@ package org.terst.nav -import androidx.test.core.app.ActivityScenario import androidx.test.espresso.Espresso.onView import androidx.test.espresso.action.ViewActions.click import androidx.test.espresso.assertion.ViewAssertions.matches @@ -8,21 +7,26 @@ import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.withContentDescription import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.ext.junit.runners.AndroidJUnit4 import org.junit.Before +import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith /** * Smoke tests: verify the main UI surfaces launch and respond correctly. - * These run on an emulator without GPS permission, so no LocationService. + * Run without GPS permission — LocationService is not started. * - * Run locally: ./gradlew connectedDebugAndroidTest - * In CI: smoke-test job via android-emulator-runner + * Locally: ./gradlew connectedDebugAndroidTest + * CI: smoke-test job via android-emulator-runner */ @RunWith(AndroidJUnit4::class) class MainActivitySmokeTest { + @get:Rule + val activityRule = ActivityScenarioRule(MainActivity::class.java) + @Before fun setup() { NavApplication.isTesting = true @@ -32,10 +36,8 @@ class MainActivitySmokeTest { @Test fun mainActivity_launches_withoutCrash() { - ActivityScenario.launch(MainActivity::class.java).use { scenario -> - scenario.onActivity { activity -> - assert(!activity.isFinishing) { "MainActivity finished immediately after launch" } - } + activityRule.scenario.onActivity { activity -> + assert(!activity.isFinishing) { "MainActivity finished immediately after launch" } } } @@ -43,89 +45,69 @@ class MainActivitySmokeTest { @Test fun bottomNav_allFourTabs_areDisplayed() { - ActivityScenario.launch(MainActivity::class.java).use { - onView(withText("Map")).check(matches(isDisplayed())) - onView(withText("Instruments")).check(matches(isDisplayed())) - onView(withText("Log")).check(matches(isDisplayed())) - onView(withText("Safety")).check(matches(isDisplayed())) - } + onView(withText("Map")).check(matches(isDisplayed())) + onView(withText("Instruments")).check(matches(isDisplayed())) + onView(withText("Log")).check(matches(isDisplayed())) + onView(withText("Safety")).check(matches(isDisplayed())) } @Test fun bottomNav_safetyTab_showsSafetyDashboard() { - ActivityScenario.launch(MainActivity::class.java).use { - onView(withText("Safety")).perform(click()) - onView(withText("Safety Dashboard")).check(matches(isDisplayed())) - onView(withText("ACTIVATE MOB")).check(matches(isDisplayed())) - onView(withText("ANCHOR WATCH")).check(matches(isDisplayed())) - } + onView(withText("Safety")).perform(click()) + onView(withText("Safety Dashboard")).check(matches(isDisplayed())) + onView(withText("ACTIVATE MOB")).check(matches(isDisplayed())) + onView(withText("ANCHOR WATCH")).check(matches(isDisplayed())) } @Test fun bottomNav_logTab_showsVoiceLogUi() { - ActivityScenario.launch(MainActivity::class.java).use { - onView(withText("Log")).perform(click()) - onView(withContentDescription("Start voice recognition")).check(matches(isDisplayed())) - } + onView(withText("Log")).perform(click()) + onView(withContentDescription("Start voice recognition")).check(matches(isDisplayed())) } @Test fun bottomNav_instrumentsTab_isSelectable() { - ActivityScenario.launch(MainActivity::class.java).use { - onView(withText("Instruments")).perform(click()) - onView(withId(R.id.instrument_bottom_sheet)).check(matches(isDisplayed())) - } + onView(withText("Instruments")).perform(click()) + onView(withId(R.id.instrument_bottom_sheet)).check(matches(isDisplayed())) } @Test fun bottomNav_mapTab_returnsFromOverlay() { - ActivityScenario.launch(MainActivity::class.java).use { - onView(withText("Safety")).perform(click()) - onView(withText("Map")).perform(click()) - onView(withId(R.id.mapView)).check(matches(isDisplayed())) - } + onView(withText("Safety")).perform(click()) + onView(withText("Map")).perform(click()) + onView(withId(R.id.mapView)).check(matches(isDisplayed())) } // ── Persistent FABs ──────────────────────────────────────────────────── @Test fun fabMob_isAlwaysVisible() { - ActivityScenario.launch(MainActivity::class.java).use { - onView(withContentDescription("Man Overboard")).check(matches(isDisplayed())) - } + onView(withContentDescription("Man Overboard")).check(matches(isDisplayed())) } @Test fun fabMob_remainsVisibleOnSafetyTab() { - ActivityScenario.launch(MainActivity::class.java).use { - onView(withText("Safety")).perform(click()) - onView(withContentDescription("Man Overboard")).check(matches(isDisplayed())) - } + onView(withText("Safety")).perform(click()) + onView(withContentDescription("Man Overboard")).check(matches(isDisplayed())) } // ── Track recording ──────────────────────────────────────────────────── @Test fun fabRecordTrack_isDisplayedWithRecordDescription() { - ActivityScenario.launch(MainActivity::class.java).use { - onView(withContentDescription("Record Track")).check(matches(isDisplayed())) - } + onView(withContentDescription("Record Track")).check(matches(isDisplayed())) } @Test fun fabRecordTrack_togglesToStopRecording_onFirstClick() { - ActivityScenario.launch(MainActivity::class.java).use { - onView(withContentDescription("Record Track")).perform(click()) - onView(withContentDescription("Stop Recording")).check(matches(isDisplayed())) - } + onView(withContentDescription("Record Track")).perform(click()) + onView(withContentDescription("Stop Recording")).check(matches(isDisplayed())) } @Test fun fabRecordTrack_togglesBackToRecord_onSecondClick() { - ActivityScenario.launch(MainActivity::class.java).use { - onView(withContentDescription("Record Track")).perform(click()) - onView(withContentDescription("Stop Recording")).perform(click()) - onView(withContentDescription("Record Track")).check(matches(isDisplayed())) - } + onView(withContentDescription("Record Track")).perform(click()) + onView(withContentDescription("Stop Recording")).perform(click()) + onView(withContentDescription("Record Track")).check(matches(isDisplayed())) } } 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 f887a43..f9d4dbd 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 @@ -22,7 +22,10 @@ import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.floatingactionbutton.FloatingActionButton import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -45,7 +48,7 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { private var instrumentHandler: InstrumentHandler? = null private var mapHandler: MapHandler? = null private var anchorWatchHandler: AnchorWatchHandler? = null - private var loadedStyle: Style? = null + private val loadedStyleFlow = MutableStateFlow(null) private lateinit var bottomSheetBehavior: BottomSheetBehavior private lateinit var fragmentContainer: FrameLayout @@ -147,17 +150,13 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { private fun hideOverlays() { fragmentContainer.visibility = View.GONE - // Clear backstack if needed } override fun onActivateMob() { lifecycleScope.launch { LocationService.locationFlow.firstOrNull()?.let { gpsData -> val mediaPlayer = MediaPlayer.create(this@MainActivity, R.raw.mob_alarm) - // In a real redesign, we'd show a specialized MOB fragment - // For now, keep existing handler logic but maybe toggle visibility mobHandler?.activateMob(gpsData.latitude, gpsData.longitude, mediaPlayer) - // Ensure MOB UI is visible - we might need to add it back to activity_main if removed } } } @@ -167,7 +166,6 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { } private fun setupHandlers() { - // ... (Keep existing handler initialization, just update view IDs as needed) instrumentHandler = InstrumentHandler( valueAws = findViewById(R.id.value_aws), valueTws = findViewById(R.id.value_tws), @@ -243,7 +241,7 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { .withLayer(RasterLayer("openseamap-layer", "openseamap-source")) maplibreMap.setStyle(style) { style -> - loadedStyle = style + loadedStyleFlow.value = style val anchorBitmap = rasterizeDrawable(R.drawable.ic_anchor) val arrowBitmap = rasterizeDrawable(R.drawable.ic_tidal_arrow) mapHandler?.setupLayers(style, anchorBitmap, arrowBitmap) @@ -265,10 +263,9 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { } } lifecycleScope.launch { - viewModel.trackPoints.collect { points -> - val style = loadedStyle ?: return@collect - mapHandler?.updateTrackLayer(style, points) - } + loadedStyleFlow.filterNotNull() + .combine(viewModel.trackPoints) { style, points -> style to points } + .collect { (style, points) -> mapHandler?.updateTrackLayer(style, points) } } } 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 c90adb9..7953822 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 @@ -16,8 +16,10 @@ class TrackRepository { isRecording = false } - fun addPoint(point: TrackPoint) { - if (isRecording) points.add(point) + fun addPoint(point: TrackPoint): Boolean { + if (!isRecording) return false + points.add(point) + return true } fun getPoints(): List = points.toList() 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 33decbe..0efff52 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 @@ -71,8 +71,9 @@ class MainViewModel( windSpeedKnots = 0.0, windAngleDeg = 0.0, isTrueWind = false, timestampMs = System.currentTimeMillis() ) - trackRepository.addPoint(point) - _trackPoints.value = trackRepository.getPoints() + if (trackRepository.addPoint(point)) { + _trackPoints.value = trackRepository.getPoints() + } } private val aisHubApi: AisHubApiService by lazy { diff --git a/test-runner/src/main/kotlin/org/terst/nav/track/TrackRepository.kt b/test-runner/src/main/kotlin/org/terst/nav/track/TrackRepository.kt index c90adb9..7953822 100644 --- a/test-runner/src/main/kotlin/org/terst/nav/track/TrackRepository.kt +++ b/test-runner/src/main/kotlin/org/terst/nav/track/TrackRepository.kt @@ -16,8 +16,10 @@ class TrackRepository { isRecording = false } - fun addPoint(point: TrackPoint) { - if (isRecording) points.add(point) + fun addPoint(point: TrackPoint): Boolean { + if (!isRecording) return false + points.add(point) + return true } fun getPoints(): List = points.toList() -- cgit v1.2.3 From 9f01ddfba17dda7fb386e83f007c671fec6d5b8e Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Sat, 4 Apr 2026 07:10:41 +0000 Subject: feat(ui): surface trip planning and reports in instrument sheet --- .agent/worklog.md | 174 +++++---------------- .../src/main/kotlin/org/terst/nav/MainActivity.kt | 14 ++ .../main/res/layout/layout_instruments_sheet.xml | 54 ++++++- .../kotlin/org/terst/nav/track/TrackRepository.kt | 14 +- 4 files changed, 118 insertions(+), 138 deletions(-) (limited to 'test-runner') diff --git a/.agent/worklog.md b/.agent/worklog.md index 7a4467f..95bd266 100644 --- a/.agent/worklog.md +++ b/.agent/worklog.md @@ -1,154 +1,64 @@ # SESSION_STATE.md ## Current Task Goal -Section 7.3 AIS display — COMPLETE (2026-03-15): AIS integrated into ViewModel, MapFragment, and MainActivity +Section 7.4 Trip Reporting & Enhanced Tracks — COMPLETE (2026-04-04) -## Verified (2026-03-15) -- All 41 tests GREEN: 22 GPS/NMEA + 16 AIS + 3 ViewModel AIS (via /tmp/ais-vm-test-runner/) -- NmeaParser extended with MWV (wind), DBT (depth), HDG/HDM (heading) parsers -- Sensor data classes added: WindData, DepthData, HeadingData -- NmeaStreamManager added for TCP stream management +## Verified (2026-04-04) +- Build Successful: `android-app/gradlew assembleDebug` passed. +- UI Layout: `activity_main.xml` refactored to LinearLayout to prevent BottomNav overlap. +- Track Differentiation: Active (solid) vs Past (dotted) tracks verified in `MapHandler.kt`. +- Pre-Trip Logic: Sail suggestions reefing logic verified in `PreTripReportGenerator.kt`. ## Completed Items -### [APPROVED] GpsPosition data class +### [APPROVED] Trip Narrative Generator + Multi-Style AI (2026-04-04) +- `android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportGenerator.kt`: Logic for distance, speed, and stylized narratives (Professional, Adventurous, Journal, Pirate). +- `android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportViewModel.kt`: State management for narrative generation. +- `android-app/app/src/main/kotlin/org/terst/nav/tripreport/TripReportFragment.kt`: UI for viewing and selecting narrative styles. + +### [APPROVED] Pre-Trip Planning & Sail Suggestions (2026-04-04) +- `android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportGenerator.kt`: Routing and reefing suggestions based on wind/waves. +- `android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripModels.kt`: Domain models for boat profiles and suggestions. +- `android-app/app/src/main/kotlin/org/terst/nav/tripreport/PreTripReportFragment.kt`: UI for generating and viewing pre-trip plans. + +### [APPROVED] Enhanced Map & Track Visualization (2026-04-04) +- `android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt`: `updateTrackLayer` now supports active and past tracks with distinct styles. +- `android-app/app/src/main/res/layout/activity_main.xml`: Refactored to vertical LinearLayout root to stabilize BottomNav and FAB placement. +- `android-app/app/src/main/res/layout/layout_instruments_sheet.xml`: Added "TRIP REPORTS & PLANNING" section for direct access from main view. +- `android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt`: Wired Pre-Trip and Trip Report buttons from instrument sheet; added `showReport` navigation helper. +- `android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt`: Added `pastTracks` storage. +- `test-runner/src/main/kotlin/org/terst/nav/track/TrackRepository.kt`: Synchronized with main app implementation. + +### [APPROVED] GpsPosition data class (2026-03-15) - File: `app/src/main/kotlin/org/terst/nav/gps/GpsPosition.kt` -- Package: `org.terst.nav.gps` - Fields: latitude, longitude, sog (knots), cog (degrees true), timestampMs -### [APPROVED] GpsProvider / GpsListener interfaces -- File: `app/src/main/kotlin/org/terst/nav/gps/GpsProvider.kt` -- `GpsProvider`: start/stop, position property, addListener/removeListener -- `GpsListener`: onPositionUpdate(GpsPosition), onFixLost() - -### [APPROVED] DeviceGpsProvider -- File: `app/src/main/kotlin/org/terst/nav/gps/DeviceGpsProvider.kt` +### [APPROVED] DeviceGpsProvider (2026-03-15) - Wraps `LocationManager` with `GPS_PROVIDER` -- Default interval: 1000ms (1 Hz); configurable via constructor -- SOG: Location.speed (m/s) × 1.94384 → knots -- COG: Location.bearing (degrees true, no conversion) - Fix-lost timer: fires `onFixLost()` after 10s with no update -- Thread-safe listener list (synchronized) -- Android dependency: Context, LocationManager, Handler — device only - -### [APPROVED] FakeGpsProvider + GpsProviderTest (9 tests — all GREEN) -- File: `app/src/test/kotlin/org/terst/nav/gps/GpsProviderTest.kt` -- No Android dependencies — pure JVM -- Tests: - - `start sets started to true` - - `stop sets started to false` - - `listener receives position update` - - `listener notified of fix lost` - - `multiple listeners all receive position update` - - `multiple listeners all notified of fix lost` - - `removing listener stops notifications` - - `position property reflects last simulated position` - - `SOG conversion sanity check - 1 mps is approximately 1_94384 knots` -## Build Notes -- `app/build` and `app/.kotlin/sessions` are root-owned from a prior run. - Tests were verified via direct `kotlinc` + `JUnitCore` invocation (all 9 pass). - Full Gradle build requires fixing directory permissions: `chown -R www-data:www-data app/build app/.kotlin` -- Pre-existing XML layout error in `activity_main.xml:300` (unrelated to GPS work) - -### [APPROVED] NmeaParser — RMC parser -- File: `app/src/main/kotlin/org/terst/nav/nmea/NmeaParser.kt` +### [APPROVED] NmeaParser — RMC parser (2026-03-15) - Parses any `*RMC` sentence (GP, GN, etc.) -- Returns `null` for void status (V), malformed input, wrong sentence type -- SOG/COG default to 0.0 when fields are empty -- Latitude: positive = North, negative = South -- Longitude: positive = East, negative = West -- Timestamp: HHMMSS + DDMMYY → Unix epoch millis UTC (YY < 70 → 2000+YY) -- No Android dependencies — pure JVM - -### [APPROVED] GpsPositionTest + NmeaParserTest (22 tests — all GREEN) -- `app/src/test/kotlin/org/terst/nav/gps/GpsPositionTest.kt` (2 tests) -- `app/src/test/kotlin/org/terst/nav/nmea/NmeaParserTest.kt` (11 tests) -- `app/src/test/kotlin/org/terst/nav/gps/GpsProviderTest.kt` (9 tests, pre-existing) -- All verified via direct `kotlinc` (1.9.22) + `JUnitCore` invocation - -### [APPROVED] AisVessel data class -- File: `app/src/main/kotlin/org/terst/nav/ais/AisVessel.kt` -- Package: `org.terst.nav.ais` -- Fields: mmsi, name, callsign, lat, lon, sog, cog, heading, vesselType, timestampMs -- Note: `com/example/androidapp` tree is root-owned; files placed under `org/terst/nav/` (actual project package) - -### [APPROVED] CpaCalculator object -- File: `app/src/main/kotlin/org/terst/nav/ais/CpaCalculator.kt` -- Flat-earth CPA/TCPA algorithm; returns (cpa_nm, tcpa_min) -- Zero-relative-velocity guard: returns (currentDist, 0.0) - -### [APPROVED] AisVdmParser class -- File: `app/src/main/kotlin/org/terst/nav/nmea/AisVdmParser.kt` -- Parses !AIVDM/!AIVDO sentences; multi-part reassembly by seqId -- Type 1/2/3: MMSI, SOG, lon, lat, COG, heading decoded -- Type 5: MMSI, callsign (7 chars), name (20 chars), vesselType decoded; trailing '@'/' ' trimmed -- Not-available sentinel handling: SOG=1023→0.0, COG=3600→0.0, lon=0x6791AC0→0.0, lat=0x3412140→0.0 - -### [APPROVED] AIS Tests (16 tests — all GREEN) -- `test-runner/src/test/kotlin/org/terst/nav/ais/AisVesselTest.kt` (4 tests) -- `test-runner/src/test/kotlin/org/terst/nav/ais/CpaCalculatorTest.kt` (4 tests) -- `test-runner/src/test/kotlin/org/terst/nav/nmea/AisVdmParserTest.kt` (8 tests) -- Verification harness: `/tmp/ais-test-runner/` (JUnit5, com.example.androidapp package) -- Production test files also in `android-app/app/src/test/kotlin/org/terst/nav/ais/` - -### [APPROVED] AisRepository class -- File: `app/src/main/kotlin/org/terst/nav/ais/AisRepository.kt` -- Upserts position targets by MMSI; merges type-5 static data (name/callsign/vesselType) -- Pending static map: holds type-5 data until position arrives for same MMSI -- `evictStale(nowMs)`: removes vessels older than `staleTimeoutMs` (default 10 min) -- `AisDataSource` interface (with Flow) defined in same file - -### [APPROVED] AisHubApiService + AisHubVessel -- File: `app/src/main/kotlin/org/terst/nav/ais/AisHubApiService.kt` -- Note: placed in `ais/` directory (writable) with package `org.terst.nav.data.api` -- `AisHubVessel` data class with Moshi `@Json` and `@JsonClass(generateAdapter=true)` annotations -- `AisHubApiService` Retrofit interface: GET /0/down with lat/lon bounding box params - -### [APPROVED] AisHubSource object -- File: `app/src/main/kotlin/org/terst/nav/ais/AisHubSource.kt` -- Converts `AisHubVessel` REST response to `AisVessel` domain objects -- Returns null for non-numeric MMSI, lat, or lon; defaults sog/cog=0.0, heading=511, vesselType=0 - -### [APPROVED] AIS Repository Tests (11 tests — all GREEN) -- `app/src/test/kotlin/org/terst/nav/ais/AisRepositoryTest.kt` (7 tests) -- `app/src/test/kotlin/org/terst/nav/ais/AisHubSourceTest.kt` (4 tests) -- Harness: `/tmp/ais-repo-test-runner/` (JUnit5, org.terst.nav package, stub annotations for offline build) - -### [APPROVED] Section 7.3 AIS display — ViewModel + MapFragment integration (2026-03-15) -- `MainViewModel.processAisSentence(sentence)` — delegates to AisRepository, updates `_aisTargets` StateFlow -- `MainViewModel.refreshAisFromInternet(latMin, latMax, lonMin, lonMax, username, password)` — AISHub polling via inline Retrofit; skips if username empty -- `MainViewModel.aisTargets: StateFlow>` — exposed to observers -- `AisRepository.ingestVessel(vessel)` — direct insert for internet-sourced vessels -- `MapFragment.updateAisLayer(style, vessels)` — GeoJSON FeatureCollection, SymbolLayer "ais-vessels"; heading-based iconRotate; zoom-step text (visible at zoom ≥ 12) -- `MapFragment.addShipArrowImage(style)` — rasterises ic_ship_arrow.xml into style -- `ic_ship_arrow.xml` — pink arrow vector drawable for AIS icons -- `MainActivity.startAisHardwareFeed(host, port)` — TCP stub reads !AIVDM lines, forwards to viewModel -- `MainViewModelTest` — 3 new tests: valid type-1 adds vessel, same MMSI deduped, non-AIS stays empty -- JVM test harness: `/tmp/ais-vm-test-runner/` (3 tests — all GREEN) +- HHMMSS + DDMMYY → Unix epoch millis UTC -### [APPROVED] TrackRepository (2026-03-25) -- `android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt` -- `test-runner/src/main/kotlin/org/terst/nav/track/TrackRepository.kt` -- `isRecording` flag; `startTrack()` clears + starts; `stopTrack()`; `addPoint()` guards on isRecording; `getPoints()` returns snapshot -- 8 tests — all GREEN (`TrackRepositoryTest`) +### [APPROVED] AIS Integration (2026-03-15) +- `AisVdmParser.kt`: Parses !AIVDM/!AIVDO (Types 1, 2, 3, 5). +- `AisRepository.kt`: Upserts vessels and merges static data. +- `AisHubSource.kt`: Integration with AISHub REST API. +- `MapFragment`: GeoJSON layer for AIS vessels with rotation and labels. -### [APPROVED] Track ViewModel + Map overlay + Record FAB (2026-03-25) -- `MainViewModel`: TrackRepository wired in; exposes `isRecording: StateFlow`, `trackPoints: StateFlow>`; `startTrack()`, `stopTrack()`, `addGpsPoint(lat, lon, sogKnots, cogDeg)` -- `MapHandler.updateTrackLayer(style, points)`: lazy LineLayer init; red (#E53935) 3dp polyline from List -- `MainActivity`: stores `loadedStyle`; GPS flow feeds `viewModel.addGpsPoint()` (m/s→knots); observes `trackPoints` → `mapHandler.updateTrackLayer()`; observes `isRecording` → FAB icon toggle (ic_track_record / ic_close) -- `activity_main.xml`: `fab_record_track` FAB anchored top|end of bottom nav -- `drawable/ic_track_record.xml`: red dot record icon +### [APPROVED] Track Recording (2026-03-25) +- `MainViewModel`: TrackRepository wired; `addGpsPoint` enriches with environmental data. +- `MainActivity`: FAB for toggling recording; MapHandler for rendering. ## Next 3 Specific Steps -1. **Persist track to GPX/Room** — export recorded track as GPX file or store in Room DB -2. **Track stats in Log tab** — show elapsed time, distance, avg SOG while recording -3. **AnchorWatchHandler UI** — wire `AnchorWatchHandler` fully into SafetyFragment (currently stub) +1. **Unit Tests for Trip Generators** — Add JVM tests in `test-runner` for `TripReportGenerator` and `PreTripReportGenerator`. +2. **Persist track to Room** — Replace in-memory `pastTracks` with a Room database for persistence across sessions. +3. **AnchorWatchHandler UI** — Re-integrate `AnchorWatchHandler` UI into `activity_main.xml` and wire it to the Safety Dashboard. ## Scripts Added -- `test-runner/` — standalone Kotlin/JVM Gradle project; runs all 22 GPS/NMEA tests without Android SDK - - Command: `cd /workspace/nav/test-runner && GRADLE_USER_HOME=/tmp/gradle-home ./gradlew test` +- `test-runner/` — standalone Kotlin/JVM Gradle project for fast test cycles. ## Process Improvements -- Gradle builds blocked by Android SDK requirement; added `test-runner/` JVM-only subproject as reliable test runner -- All 22 tests verified GREEN via `test-runner/` JVM project (2026-03-14) \ No newline at end of file +- Stabilized UI layout by moving navigation out of the `CoordinatorLayout`. +- Duplicated core logic into `test-runner` to bypass Android SDK requirements for logic testing. 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 0f2eb91..3f09309 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 @@ -93,6 +93,14 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { setupBottomNavigation() setupHandlers() setupMap() + + findViewById(R.id.btn_nav_pretrip).setOnClickListener { + showReport(org.terst.nav.tripreport.PreTripReportFragment()) + } + + findViewById(R.id.btn_nav_tripreport).setOnClickListener { + showReport(org.terst.nav.tripreport.TripReportFragment()) + } fabRecordTrack.setOnClickListener { if (viewModel.isRecording.value) viewModel.stopTrack() else viewModel.startTrack() @@ -162,6 +170,12 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { fragmentContainer.visibility = View.GONE } + private fun showReport(fragment: androidx.fragment.app.Fragment) { + bottomSheetBehavior.isHideable = true + bottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN + showOverlay(fragment) + } + override fun onActivateMob() { lifecycleScope.launch { LocationService.locationFlow.firstOrNull()?.let { gpsData -> diff --git a/android-app/app/src/main/res/layout/layout_instruments_sheet.xml b/android-app/app/src/main/res/layout/layout_instruments_sheet.xml index a6b74b0..16410c0 100644 --- a/android-app/app/src/main/res/layout/layout_instruments_sheet.xml +++ b/android-app/app/src/main/res/layout/layout_instruments_sheet.xml @@ -159,8 +159,7 @@ android:layout_marginTop="8dp" app:layout_constraintTop_toBottomOf="@id/label_conditions" app:layout_constraintStart_toStartOf="parent" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintBottom_toBottomOf="parent"> + app:layout_constraintEnd_toEndOf="parent"> + + + + + + + + + + + + + diff --git a/test-runner/src/main/kotlin/org/terst/nav/track/TrackRepository.kt b/test-runner/src/main/kotlin/org/terst/nav/track/TrackRepository.kt index 7953822..85dd2dd 100644 --- a/test-runner/src/main/kotlin/org/terst/nav/track/TrackRepository.kt +++ b/test-runner/src/main/kotlin/org/terst/nav/track/TrackRepository.kt @@ -5,22 +5,28 @@ class TrackRepository { var isRecording: Boolean = false private set - private val points = mutableListOf() + private val activePoints = mutableListOf() + private val pastTracks = mutableListOf>() fun startTrack() { - points.clear() + activePoints.clear() isRecording = true } fun stopTrack() { + if (isRecording && activePoints.isNotEmpty()) { + pastTracks.add(activePoints.toList()) + } isRecording = false } fun addPoint(point: TrackPoint): Boolean { if (!isRecording) return false - points.add(point) + activePoints.add(point) return true } - fun getPoints(): List = points.toList() + fun getPoints(): List = activePoints.toList() + + fun getPastTracks(): List> = pastTracks.toList() } -- cgit v1.2.3