diff --git a/README.md b/README.md index 35eaf67..dc8ab9d 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ | **연습 시작과 마무리** | 시작과 종료 시각, 당시 코스와 과제를 저장합니다. 주차 중 홈에서 진행 중 과제와 다음 연습 과제를 구분해 보고, 최근에 마친 연습의 복습을 다시 열 수 있습니다. | | **활성 연습의 차량 상태 관찰** | SDK가 전달한 기어, 방향지시등과 브레이크 상태를 활성 연습에 기록합니다. 기본은 앱 화면이 켜진 동안만 수집하며, 주차와 UX 허용을 확인하고 사용자가 내비 중 기록을 켠 경우에만 화면 밖에서도 이어갑니다. 미확인, 미지원, 거부와 오류를 보존합니다. | | **명시적으로 켠 전면 위치 표본 기록** | 진행 중인 연습에서 주차, UX 허용과 위치 권한을 확인한 뒤 직접 켭니다. 한길 화면이 켜진 동안 SDK 업데이트 표본을 기기에 저장하고, 화면 밖으로 나가거나 연습 변경/종료, 권한 상실, 오류 또는 저장 상한에서 멈춥니다. 같은 앱 실행에서 주차 후 복귀하면 같은 진행 중 연습의 화면 이탈 중단을 안내하며, 주차 중 위치 기록 화면에서 새 기록을 직접 켤 수 있습니다. | -| **주차 후 위치 표본 확인** | 선택한 종료 연습의 위치 기록 실행과 표본, 표본 없는 콜백과 수집 상태를 저장 순서대로 확인하고, 직접 펼친 지도에서 같은 실행에 저장한 위치 표본들과 선택한 복습 도로를 함께 보고 선택 표본을 구분합니다. 수신이 이어진 표본 사이는 점선으로 구분하고, 좌표 없는 콜백과 수신 공백은 이어 표시하지 않습니다. 복습 도로를 아직 고르지 않았다면 같은 코스의 도로선까지 거리를 비교하고, 배경 타일 없는 미리보기에서 선택 표본과 도로선 위 최근접 지점을 확인합니다. 선택 표본 주변의 연속 기록이 한 도로선에 가까우면 후보로 안내하고, 사용자가 고른 도로와 선택 표본을 함께 저장합니다. 이미 고른 도로까지의 거리도 확인할 수 있습니다. 연결한 표본 주변의 연속 기록이 한 도로선을 일관되게 가리키면 그 부분을 강조합니다. 지도 배경을 불러오지 못해도 저장 표본과 고른 도로를 확인할 수 있습니다. 연결한 표본을 교체하거나 해제하고 도로 메모와 연습 전체 메모를 읽습니다. 비교 지도에서 연결한 표본의 상세를 바로 열 수 있습니다. 직접 연결은 실제 이동이나 자동 위치 매칭을 뜻하지 않습니다. SDK 시각과 신뢰도 원본값의 미확인 의미를 유지하며 현재 위치, 정확도나 실제 이동 경로로 표시하지 않습니다. 같은 앱 실행 안에서 화면을 다시 만들면 선택한 연습을 유지합니다. | +| **주차 후 위치 표본 확인** | 선택한 종료 연습의 위치 기록 실행과 표본, 표본 없는 콜백과 수집 상태를 저장 순서대로 확인하고, 직접 펼친 지도에서 같은 실행에 저장한 위치 표본들과 선택한 복습 도로를 함께 보고 선택 표본을 구분합니다. 수신이 이어진 표본 사이는 점선으로 구분하고, 좌표 없는 콜백과 수신 공백은 이어 표시하지 않습니다. 복습 도로를 아직 고르지 않았다면 같은 코스의 도로선까지 거리를 비교하고, 배경 타일 없는 미리보기에서 선택 표본과 도로선 위 최근접 지점을 확인합니다. 선택 표본 주변의 연속 기록이 한 도로선에 가까우면 후보로 안내하고, 사용자가 고른 도로와 선택 표본을 함께 저장합니다. 이미 고른 도로까지의 거리도 확인할 수 있습니다. 연결한 표본 주변의 연속 기록이 한 도로선을 일관되게 가리키면 그 부분을 강조합니다. 해당 구간을 직접 고르면 공개 도로 범위를 다음 연습 목표로 가져올 수 있습니다. 지도 배경을 불러오지 못해도 저장 표본과 고른 도로를 확인할 수 있습니다. 연결한 표본을 교체하거나 해제하고 도로 메모와 연습 전체 메모를 읽습니다. 비교 지도에서 연결한 표본의 상세를 바로 열 수 있습니다. 직접 연결은 실제 이동이나 자동 위치 매칭을 뜻하지 않습니다. SDK 시각과 신뢰도 원본값의 미확인 의미를 유지하며 현재 위치, 정확도나 실제 이동 경로로 표시하지 않습니다. 같은 앱 실행 안에서 화면을 다시 만들면 선택한 연습을 유지합니다. | | **주차 후 차량 기록 확인** | 최근 종료 세션과 지난 연습에서 선택한 종료 기록의 저장된 SDK 관찰을 한 번에 하나씩 확인합니다. 당시 기어, 방향지시등과 브레이크, 연결과 앱 관찰 시각을 보고 이전/다음으로 저장 순서를 따라갑니다. | | **지난 연습 열람** | 종료 이력을 저장 순서대로 한 번에 하나씩 확인합니다. 당시 목표, 코스, 과제와 시작/마무리 버튼 시각을 보여 주며, 이탈과 주행 잠금 후에도 같은 기록으로 돌아옵니다. | | **저장된 배경 기록 실행 확인** | 선택한 종료 이력의 배경 기록 실행을 펼쳐 저장 순서와 시작/종료 기록을 읽습니다. 종료를 확인하지 못한 이전 실행을 계속 표시하며, 저장된 상태를 현재 수집 중이라는 의미나 실제 SDK 수집 구간으로 해석하지 않습니다. | diff --git a/app/src/androidTest/kotlin/io/github/hangillab/hangil/FinishSourceReviewBoundaryTest.kt b/app/src/androidTest/kotlin/io/github/hangillab/hangil/FinishSourceReviewBoundaryTest.kt index 1aad9ad..9e48fa7 100644 --- a/app/src/androidTest/kotlin/io/github/hangillab/hangil/FinishSourceReviewBoundaryTest.kt +++ b/app/src/androidTest/kotlin/io/github/hangillab/hangil/FinishSourceReviewBoundaryTest.kt @@ -617,8 +617,15 @@ class FinishSourceReviewBoundaryTest { SessionReviewRoadRoute( source, allowed, - roadMap = { snapshot, roads, selected, canPresent, changes -> - SessionReviewRoadMap(snapshot, roads, selected, canPresent, changes) + roadMap = { snapshot, roads, selected, section, canPresent, changes -> + SessionReviewRoadMap( + snapshot, + roads, + selected, + canPresent, + changes, + section = section, + ) }, viewModel = checkNotNull(roadModel), ) diff --git a/app/src/androidTest/kotlin/io/github/hangillab/hangil/HistoryNextPracticeFlowTest.kt b/app/src/androidTest/kotlin/io/github/hangillab/hangil/HistoryNextPracticeFlowTest.kt index 172442e..9370d0b 100644 --- a/app/src/androidTest/kotlin/io/github/hangillab/hangil/HistoryNextPracticeFlowTest.kt +++ b/app/src/androidTest/kotlin/io/github/hangillab/hangil/HistoryNextPracticeFlowTest.kt @@ -46,10 +46,18 @@ import androidx.test.platform.app.InstrumentationRegistry import io.github.hangillab.hangil.core.database.OwnedPracticeTestStore import io.github.hangillab.hangil.core.designsystem.HanGilTheme import io.github.hangillab.hangil.core.domain.ConnectionState +import io.github.hangillab.hangil.core.domain.CourseRoadSection import io.github.hangillab.hangil.core.domain.CourseRoadSnapshot import io.github.hangillab.hangil.core.domain.DrivingRestrictions import io.github.hangillab.hangil.core.domain.DrivingRestrictionsProvider import io.github.hangillab.hangil.core.domain.GearPosition +import io.github.hangillab.hangil.core.domain.LocationRecordingAppendResult +import io.github.hangillab.hangil.core.domain.LocationRecordingBeginResult +import io.github.hangillab.hangil.core.domain.LocationRecordingEndResult +import io.github.hangillab.hangil.core.domain.LocationRecordingRunStatus +import io.github.hangillab.hangil.core.domain.LocationRecordingStopReason +import io.github.hangillab.hangil.core.domain.LocationUpdateReading +import io.github.hangillab.hangil.core.domain.LocationUpdateSample import io.github.hangillab.hangil.core.domain.NextPracticeRepository import io.github.hangillab.hangil.core.domain.NextPracticeSaveResult import io.github.hangillab.hangil.core.domain.NextPracticeSeed @@ -132,6 +140,7 @@ class HistoryNextPracticeFlowTest { @Test fun selectedOlderSourceManualTaskCancelLocksAndSavePreserveStoredSessions() { val args = InstrumentationRegistry.getArguments() assumeTrue(args.getString("manualHistoryNextControlled") == "true") + val testSection = args.getString("manualHistoryNextSection") == "true" val expectedScale = checkNotNull(args.getString("expectedHistoryNextFontScale")).toFloat() val expectedDark = checkNotNull(args.getString("expectedHistoryNextDark")).toBooleanStrict() check(expectedScale == 1f || expectedScale == 1.3f) @@ -276,22 +285,44 @@ class HistoryNextPracticeFlowTest { val targetTask = course.tasks.last() fun selection(taskId: String) = PracticeSelection(goal.id, course.id, course.version, course.briefingVersion, taskId) - val older = runBlocking { seed(store, previousPractice, 1000) } + val section = CourseRoadSection(0, 1).takeIf { testSection } + val expectedRoad = + if (section == null) { + course.roadSnapshots.first() + } else { + course.roadSnapshots.first { it.nodes.size > 2 && section.isValidFor(it) } + } + val older = runBlocking { seed(store, previousPractice, 1000, expectedRoad.takeIf { testSection }) } runBlocking { seed(store, previousPractice.copy(task = targetTask), 3000) } var savedRoad = runBlocking { + val anchor = + if (section == null) { + null + } else { + val records = store.locationRecordings.observe(older.sessionId).first() + SessionReviewSample.from(records.runs.single(), records.events.single()) + } val result = store.reviewRoads.save( older, - course.roadSnapshots.first(), + expectedRoad, null, memoText = " 지도에서 고른 구간 🧭\n표지를 다시 살펴보기 ", + initialSample = anchor, ) { true } check(result is SessionReviewRoadWriteResult.Saved) - result.review + if (anchor == null) { + result.review + } else { + val selected = store.reviewRoads.setSample(older, result.review, anchor.withRoadSection(section)) { true } + check(selected is SessionReviewRoadWriteResult.Saved) + selected.review + } } assertEquals(older, savedRoad.source) - assertEquals(course.roadSnapshots.first(), savedRoad.road) + assertEquals(expectedRoad, savedRoad.road) + assertEquals(section, savedRoad.sampleAnchor?.roadSection) val originalPreparation = selection(previousPractice.task.id) runBlocking { store.preparation.saveSelection(originalPreparation) { true } } val sessionsBefore = runBlocking { store.sessions.observeSessions().first() } @@ -361,8 +392,15 @@ class HistoryNextPracticeFlowTest { canKeepOpen = canKeepOpen, readOnly = true, viewModel = reference, - roadMap = { snapshot, roads, selected, canPresent, changes -> - SessionReviewRoadMap(snapshot, roads, selected, canPresent, changes) + roadMap = { snapshot, roads, selected, section, canPresent, changes -> + SessionReviewRoadMap( + snapshot, + roads, + selected, + canPresent, + changes, + section = section, + ) }, ) } @@ -426,7 +464,7 @@ class HistoryNextPracticeFlowTest { assertMissing("review_road_content") click("next_practice_review_road") assertReferenceRoad(savedRoad) - var firstRoadMap = awaitReferenceMap(savedRoad.road) + var firstRoadMap = awaitReferenceMap(savedRoad) captureReferenceMap() for (source in listOf(MapR.string.map_sources, MapR.string.map_road_attribution)) { compose @@ -464,7 +502,7 @@ class HistoryNextPracticeFlowTest { compose.onNodeWithTag("next_practice_save").performScrollTo().assertIsNotEnabled() click("next_practice_review_road") assertReferenceRoad(savedRoad) - val resumedRoadMap = awaitReferenceMap(savedRoad.road) + val resumedRoadMap = awaitReferenceMap(savedRoad) assertTrue(resumedRoadMap !== firstRoadMap) firstRoadMap = resumedRoadMap for (task in course.tasks) { @@ -485,7 +523,7 @@ class HistoryNextPracticeFlowTest { instrument.runOnMainSync { assertFalse(firstRoadMap.isAttachedToWindow) } click("next_practice_review_road") assertReferenceRoad(savedRoad) - assertTrue(awaitReferenceMap(savedRoad.road) !== firstRoadMap) + assertTrue(awaitReferenceMap(savedRoad) !== firstRoadMap) compose.onNodeWithTag("next_task_${targetTask.id}").assertIsSelected() waitTag("next_practice_target_road_use") val staleUseTarget = clickAction("next_practice_target_road_use") @@ -636,6 +674,13 @@ class HistoryNextPracticeFlowTest { assertEquals(savedRoad.source.sessionId, planned.sourceSessionId) assertEquals(savedRoad.version, planned.reviewVersion) assertEquals(savedRoad.road, planned.road) + assertEquals(section, planned.section) + if (section != null) assertTextFits("next_practice_target_road_section") + val planSummary = + instrument.targetContext.getString( + if (section == null) HomeR.string.planned_road_summary else HomeR.string.planned_road_section_summary, + planned.road.title, + ) assertTextFits("next_practice_save") click("next_practice_save") waitTag("history_prepare_next") @@ -684,14 +729,14 @@ class HistoryNextPracticeFlowTest { homeUi.waitTag("saved_planned_road") compose .onNodeWithTag("saved_planned_road", useUnmergedTree = true) - .assertTextEquals(instrument.targetContext.getString(HomeR.string.planned_road_summary, planned.road.title)) + .assertTextEquals(planSummary) assertTextFits("saved_planned_road") homeUi.touch("session_start") homeUi.waitTag("session_active") homeUi.waitTag("session_planned_road") compose .onNodeWithTag("session_planned_road", useUnmergedTree = true) - .assertTextEquals(instrument.targetContext.getString(HomeR.string.planned_road_summary, planned.road.title)) + .assertTextEquals(planSummary) assertTextFits("session_planned_road") val started = runBlocking { @@ -707,7 +752,7 @@ class HistoryNextPracticeFlowTest { homeUi.waitTag("session_latest_planned_road") compose .onNodeWithTag("session_latest_planned_road", useUnmergedTree = true) - .assertTextEquals(instrument.targetContext.getString(HomeR.string.planned_road_summary, planned.road.title)) + .assertTextEquals(planSummary) assertTextFits("session_latest_planned_road") val finished = runBlocking { @@ -749,7 +794,7 @@ class HistoryNextPracticeFlowTest { homeUi.waitTag("history_planned_road") compose .onNodeWithTag("history_planned_road", useUnmergedTree = true) - .assertTextEquals(instrument.targetContext.getString(HomeR.string.planned_road_summary, planned.road.title)) + .assertTextEquals(planSummary) assertTextFits("history_planned_road") assertHost() } catch (failure: Throwable) { @@ -846,8 +891,10 @@ class HistoryNextPracticeFlowTest { assertTextFits("review_road_reference_scope") } - private fun awaitReferenceMap(road: CourseRoadSnapshot): MapView = + private fun awaitReferenceMap(review: SessionReviewRoad): MapView = runBlocking { + val road = review.road + val publicNodes = review.sampleAnchor?.roadSection?.nodesOf(road) waitTag("session_review_road_map_view") compose.onNodeWithTag("session_review_road_map_view", useUnmergedTree = true).performScrollTo().assertIsDisplayed() withTimeout(90_000) { @@ -879,6 +926,23 @@ class HistoryNextPracticeFlowTest { // Native tiles may split or simplify the line. Check observed vertices // against the stored shape in screen pixels, not a node count or GPS accuracy. + fun onShape( + actual: android.graphics.PointF, + shape: List, + ): Boolean = + shape.zipWithNext().any { (start, end) -> + val dx = (end.x - start.x).toDouble() + val dy = (end.y - start.y).toDouble() + val length = dx * dx + dy * dy + val along = + if (length == 0.0) { + 0.0 + } else { + (((actual.x - start.x) * dx + (actual.y - start.y) * dy) / length).coerceIn(0.0, 1.0) + } + hypot(actual.x - start.x - along * dx, actual.y - start.y - along * dy) <= 1.0 + } + fun matches(feature: Feature): Boolean { val line = feature.geometry() as? LineString ?: return false if (feature.getStringProperty("road_id") != road.id || @@ -890,22 +954,10 @@ class HistoryNextPracticeFlowTest { } return line.coordinates().size >= 2 && line.coordinates().all { point -> - val actual = map.projection.toScreenLocation(LatLng(point.latitude(), point.longitude())) - expectedShape.zipWithNext().any { (start, end) -> - val dx = (end.x - start.x).toDouble() - val dy = (end.y - start.y).toDouble() - val length = dx * dx + dy * dy - val along = - if (length == 0.0) { - 0.0 - } else { - (((actual.x - start.x) * dx + (actual.y - start.y) * dy) / length).coerceIn( - 0.0, - 1.0, - ) - } - hypot(actual.x - start.x - along * dx, actual.y - start.y - along * dy) <= 1.0 - } + onShape( + map.projection.toScreenLocation(LatLng(point.latitude(), point.longitude())), + expectedShape, + ) } } val rendered = @@ -913,8 +965,46 @@ class HistoryNextPracticeFlowTest { RectF(0f, 0f, view.width.toFloat(), view.height.toFloat()), "hangil-course-preview-road-selection", ) + val publicSource = style?.getSourceAs("hangil-planned-road-section") + val publicLayer = style?.getLayerAs("hangil-planned-road-section-line") + val rangeMatches = + if (publicNodes == null) { + publicSource == null && publicLayer == null + } else { + val shape = + publicSource?.querySourceFeatures(null).orEmpty().flatMap { + (it.geometry() as? LineString)?.coordinates().orEmpty() + } + val publicRendered = + map.queryRenderedFeatures( + RectF(0f, 0f, view.width.toFloat(), view.height.toFloat()), + "hangil-planned-road-section-line", + ) + val expectedRange = + publicNodes.map { node -> + map.projection.toScreenLocation(LatLng(node.latitude, node.longitude)) + } + publicLayer?.sourceId == "hangil-planned-road-section" && publicRendered.isNotEmpty() && + shape.size >= 2 && + shape.all { point -> + onShape( + map.projection.toScreenLocation(LatLng(point.latitude(), point.longitude())), + expectedRange, + ) + } && + publicNodes.all { node -> + val expected = map.projection.toScreenLocation(LatLng(node.latitude, node.longitude)) + shape.any { point -> + val actual = + map.projection.toScreenLocation( + LatLng(point.latitude(), point.longitude()), + ) + hypot((actual.x - expected.x).toDouble(), (actual.y - expected.y).toDouble()) <= 1.0 + } + } + } val valid = - view.isAttachedToWindow && style?.isFullyLoaded == true && features.isNotEmpty() && + rangeMatches && view.isAttachedToWindow && style?.isFullyLoaded == true && features.isNotEmpty() && features.all(::matches) && rendered.isNotEmpty() && rendered.all(::matches) && selected?.sourceId == "hangil-course-preview-roads" && selected?.filter?.toArray()?.contentDeepEquals(expectedFilter.toArray()) == true && @@ -963,6 +1053,7 @@ class HistoryNextPracticeFlowTest { store: OwnedPracticeTestStore, practice: PreparedPractice, started: Long, + recordedRoad: CourseRoadSnapshot? = null, ): PracticeSession { val selection = PracticeSelection( @@ -974,6 +1065,27 @@ class HistoryNextPracticeFlowTest { ) store.preparation.saveSelection(selection) { true } val source = (store.sessions.start(practice, started) { true } as SessionStartResult.Started).session + if (recordedRoad != null) { + val begun = store.locationRecordings.begin(source, "owned-next-section", "controlled-next-section", started + 20, 10) { true } + check(begun is LocationRecordingBeginResult.Begun) + val node = recordedRoad.nodes.first() + val sample = LocationUpdateSample(node.latitude, node.longitude, 0.0, 0f, 0f, 30, 20) + check( + store.locationRecordings.append(source, begun.run, LocationUpdateReading.Sample(sample), started + 40) { + true + } is LocationRecordingAppendResult.Appended, + ) + check( + store.locationRecordings.end( + begun.run, + LocationRecordingRunStatus.STOPPED, + LocationRecordingStopReason.USER_STOP, + started + 50, + 30, + ) is + LocationRecordingEndResult.Ended, + ) + } return (store.sessions.finish(source.sessionId, started + 500) { true } as SessionFinishResult.Finished).session } diff --git a/app/src/androidTest/kotlin/io/github/hangillab/hangil/LocationRecordingFlowTest.kt b/app/src/androidTest/kotlin/io/github/hangillab/hangil/LocationRecordingFlowTest.kt index 2ce2b49..bc35249 100644 --- a/app/src/androidTest/kotlin/io/github/hangillab/hangil/LocationRecordingFlowTest.kt +++ b/app/src/androidTest/kotlin/io/github/hangillab/hangil/LocationRecordingFlowTest.kt @@ -802,22 +802,67 @@ class LocationRecordingFlowTest { } assertEquals(road, saved.road) assertEquals(SessionReviewSample.from(run, selected), saved.sampleAnchor) + awaitSection() + waitTag("stored_location_map_public_road_section_preview") + click("stored_location_map_use_road_section") + val withSection = + runBlocking { + withTimeout(20_000) { + checkNotNull( + f.store.reviewRoads + .observe(ended.sessionId) + .first { it?.sampleAnchor?.roadSection != null }, + ) + } + } + val chosen = checkNotNull(withSection.sampleAnchor?.roadSection) + assertEquals(road, withSection.road) + assertEquals(saved.sampleAnchor?.withRoadSection(chosen), withSection.sampleAnchor) + assertTrue(chosen.isValidFor(road)) + assertTrue(chosen.nodesOf(road).size < road.nodes.size) val firstMap = awaitSection() + waitTag("stored_location_map_public_road_section_saved") click("location_history_back") assertNoStoredMap(firstMap) openLinkedSample() + awaitSection() + waitTag("stored_location_map_public_road_section_saved") + click("stored_location_map_use_whole_road") + waitTag("stored_location_map_use_road_section") + val wholeRoad = + runBlocking { + checkNotNull( + f.store.reviewRoads + .observe(ended.sessionId) + .first(), + ) + } + assertEquals(saved.sampleAnchor, wholeRoad.sampleAnchor) + click("stored_location_map_use_road_section") val reopened = awaitSection() + waitTag("stored_location_map_public_road_section_saved") + val savedAgain = + runBlocking { + checkNotNull( + f.store.reviewRoads + .observe(ended.sessionId) + .first(), + ) + } + assertEquals(chosen, savedAgain.sampleAnchor?.roadSection) click("location_history_event_previous") assertNoStoredMap(reopened) click("location_history_point_map") assertTrue(nodes("stored_location_map_road_section").isEmpty()) + assertTrue(nodes("stored_location_map_use_road_section").isEmpty()) + assertTrue(nodes("stored_location_map_use_whole_road").isEmpty()) val otherSample = awaitStoredMap(run, recorded.events, recorded.events[1], road) f.drive(true) waitTag("location_history_locked") assertNoStoredMap(otherSample) assertEquals( - saved, + savedAgain, runBlocking { f.store.reviewRoads .observe(ended.sessionId) @@ -3140,7 +3185,7 @@ class LocationRecordingFlowTest { canRead, viewModel = reviewRoadModel, readOnly = true, - comparisonMap = { saved, courseRoads, saveCourseRoad, allowed, changes -> + comparisonMap = { saved, courseRoads, saveCourseRoad, setRoadSection, allowed, changes -> StoredLocationMap( run, events, @@ -3150,6 +3195,7 @@ class LocationRecordingFlowTest { inputChanges = changes, courseRoads = courseRoads, onSaveCourseRoad = saveCourseRoad, + onSetRoadSection = setRoadSection, ) }, sampleCandidate = candidate, diff --git a/app/src/main/kotlin/io/github/hangillab/hangil/MainActivity.kt b/app/src/main/kotlin/io/github/hangillab/hangil/MainActivity.kt index 303a29b..9219f00 100644 --- a/app/src/main/kotlin/io/github/hangillab/hangil/MainActivity.kt +++ b/app/src/main/kotlin/io/github/hangillab/hangil/MainActivity.kt @@ -140,8 +140,15 @@ class MainActivity : ComponentActivity() { composable("history") { historyEntry -> SessionHistoryRoute( reviewRoad = { source, allowed -> - SessionReviewRoadRoute(source, allowed, roadMap = { snapshot, roads, selected, canPresent, changes -> - SessionReviewRoadMap(snapshot, roads, selected, canPresent, changes) + SessionReviewRoadRoute(source, allowed, roadMap = { + snapshot, + roads, + selected, + section, + canPresent, + changes, + -> + SessionReviewRoadMap(snapshot, roads, selected, canPresent, changes, section = section) }) }, onClose = { navigation.popBackStack() }, @@ -176,8 +183,15 @@ class MainActivity : ComponentActivity() { val expected = finishedReviewEntry?.source(token) FinishedSessionReviewRoute( reviewRoad = { source, allowed -> - SessionReviewRoadRoute(source, allowed, roadMap = { snapshot, roads, selected, canPresent, changes -> - SessionReviewRoadMap(snapshot, roads, selected, canPresent, changes) + SessionReviewRoadRoute(source, allowed, roadMap = { + snapshot, + roads, + selected, + section, + canPresent, + changes, + -> + SessionReviewRoadMap(snapshot, roads, selected, canPresent, changes, section = section) }) }, expectedSource = expected, @@ -259,7 +273,7 @@ class MainActivity : ComponentActivity() { readOnly = true, sampleCandidate = candidate, onOpenLinkedSample = openLinkedSample, - comparisonMap = { saved, courseRoads, saveCourseRoad, allowed, changes -> + comparisonMap = { saved, courseRoads, saveCourseRoad, setRoadSection, allowed, changes -> StoredLocationMap( run, events, @@ -269,6 +283,7 @@ class MainActivity : ComponentActivity() { inputChanges = changes, courseRoads = courseRoads, onSaveCourseRoad = saveCourseRoad, + onSetRoadSection = setRoadSection, ) }, ) @@ -287,10 +302,11 @@ class MainActivity : ComponentActivity() { snapshot, roads, selected, + section, canPresent, changes, -> - SessionReviewRoadMap(snapshot, roads, selected, canPresent, changes) + SessionReviewRoadMap(snapshot, roads, selected, canPresent, changes, section = section) }) }, onClose = { navigation.popBackStack() }, @@ -345,10 +361,11 @@ class MainActivity : ComponentActivity() { snapshot, roads, selected, + section, canPresent, changes, -> - SessionReviewRoadMap(snapshot, roads, selected, canPresent, changes) + SessionReviewRoadMap(snapshot, roads, selected, canPresent, changes, section = section) }) }, expectedHistorySource = expected, diff --git a/core/database/src/main/kotlin/io/github/hangillab/hangil/core/database/CourseRoadSectionJson.kt b/core/database/src/main/kotlin/io/github/hangillab/hangil/core/database/CourseRoadSectionJson.kt new file mode 100644 index 0000000..1cc6115 --- /dev/null +++ b/core/database/src/main/kotlin/io/github/hangillab/hangil/core/database/CourseRoadSectionJson.kt @@ -0,0 +1,32 @@ +package io.github.hangillab.hangil.core.database + +import io.github.hangillab.hangil.core.domain.CourseRoadSection +import org.json.JSONObject + +/** Only indices into an independently stored public road are encoded here. */ +internal object CourseRoadSectionJson { + fun fields(section: CourseRoadSection?): Array> = + if (section == null) { + emptyArray() + } else { + arrayOf("section" to "{\"firstNodeIndex\":${section.firstNodeIndex},\"lastNodeIndex\":${section.lastNodeIndex}}") + } + + fun decode( + root: JSONObject, + format: Long, + ): CourseRoadSection? = + when (format) { + 1L -> null + 2L -> { + val section = root.get("section") as? JSONObject ?: error("Invalid road section object") + CourseRoadSection(index(section, "firstNodeIndex"), index(section, "lastNodeIndex")) + } + else -> error("Unknown road section storage format") + } + + private fun index( + section: JSONObject, + key: String, + ): Int = section.get(key) as? Int ?: error("Invalid road section index") +} diff --git a/core/database/src/main/kotlin/io/github/hangillab/hangil/core/database/PlannedPracticeRoadJson.kt b/core/database/src/main/kotlin/io/github/hangillab/hangil/core/database/PlannedPracticeRoadJson.kt index 97bcfa4..1ccfa0f 100644 --- a/core/database/src/main/kotlin/io/github/hangillab/hangil/core/database/PlannedPracticeRoadJson.kt +++ b/core/database/src/main/kotlin/io/github/hangillab/hangil/core/database/PlannedPracticeRoadJson.kt @@ -20,7 +20,7 @@ internal object PlannedPracticeRoadJson { ) } return objectValue( - "format" to "1", + "format" to if (value.section == null) "1" else "2", "origin" to JSONObject.quote(value.origin), "sourceSessionId" to JSONObject.quote(value.sourceSessionId), "reviewVersion" to JSONObject.quote(value.reviewVersion), @@ -34,6 +34,7 @@ internal object PlannedPracticeRoadJson { "capturedAt" to road.capturedAtEpochMillis.toString(), "nodes" to nodes, ), + *CourseRoadSectionJson.fields(value.section), ) } @@ -44,7 +45,8 @@ internal object PlannedPracticeRoadJson { if (raw == null) return null try { val root = JSONObject(raw) - check(integer(root, "format") == 1L && text(root, "origin") == "USER_REVIEW_SELECTION") + check(text(root, "origin") == "USER_REVIEW_SELECTION") + val section = CourseRoadSectionJson.decode(root, integer(root, "format")) val road = root.get("road") as? JSONObject ?: error("Invalid road object") val nodes = road.get("nodes") as? JSONArray ?: error("Invalid road nodes") val decoded = @@ -68,6 +70,7 @@ internal object PlannedPracticeRoadJson { ) }, ), + section, ) check(encode(decoded) == raw) return decoded diff --git a/core/database/src/main/kotlin/io/github/hangillab/hangil/core/database/RoomSessionReviewRoadRepository.kt b/core/database/src/main/kotlin/io/github/hangillab/hangil/core/database/RoomSessionReviewRoadRepository.kt index a8983fb..406ae2a 100644 --- a/core/database/src/main/kotlin/io/github/hangillab/hangil/core/database/RoomSessionReviewRoadRepository.kt +++ b/core/database/src/main/kotlin/io/github/hangillab/hangil/core/database/RoomSessionReviewRoadRepository.kt @@ -58,7 +58,9 @@ internal class RoomSessionReviewRoadRepository } if (initialSample != null) { if (current != null) return@mutate SessionReviewRoadWriteResult.Conflict - if (!sampleMatches(actual, initialSample)) return@mutate SessionReviewRoadWriteResult.SampleChanged + if (initialSample.roadSection != null || !sampleMatches(actual, initialSample)) { + return@mutate SessionReviewRoadWriteResult.SampleChanged + } } val sampleAnchor = current?.takeIf { it.road == road }?.sampleAnchor ?: initialSample val candidate = SessionReviewRoad(actual, road, nextVersion(current?.version), memoText, sampleAnchor) @@ -123,7 +125,7 @@ internal class RoomSessionReviewRoadRepository mutate(expectedSource, expectedCurrent, canCommit) { actual, current -> if (current == null) { SessionReviewRoadWriteResult.Conflict - } else if (sample != null && !sampleMatches(actual, sample)) { + } else if (sample != null && !validSampleChange(actual, current, sample)) { SessionReviewRoadWriteResult.SampleChanged } else if (current.sampleAnchor == sample) { SessionReviewRoadWriteResult.Saved(current) @@ -249,6 +251,16 @@ internal class RoomSessionReviewRoadRepository sample: SessionReviewSample, ): Boolean = SessionReviewRoadRow.sampleMatches(database, source, sample) + private suspend fun validSampleChange( + source: PracticeSession, + current: SessionReviewRoad, + sample: SessionReviewSample, + ): Boolean { + val section = sample.roadSection + if (section != null && (current.sampleAnchor?.sameCallback(sample) != true || !section.isValidFor(current.road))) return false + return sampleMatches(source, sample) + } + private suspend fun readSource(sessionId: String): PracticeSession? { val sessions = database.practiceSessionDao() val entity = sessions.find(sessionId) ?: return null diff --git a/core/database/src/main/kotlin/io/github/hangillab/hangil/core/database/SessionReviewRoadRow.kt b/core/database/src/main/kotlin/io/github/hangillab/hangil/core/database/SessionReviewRoadRow.kt index 5c6951c..8fab75a 100644 --- a/core/database/src/main/kotlin/io/github/hangillab/hangil/core/database/SessionReviewRoadRow.kt +++ b/core/database/src/main/kotlin/io/github/hangillab/hangil/core/database/SessionReviewRoadRow.kt @@ -48,7 +48,7 @@ internal data class SessionReviewRoadRow( ) { return false } - return SessionReviewSample.from(run, event) == sample + return SessionReviewSample.from(run, event).sameCallback(sample) } fun review(rows: List): SessionReviewRoad? { diff --git a/core/database/src/main/kotlin/io/github/hangillab/hangil/core/database/SessionReviewSampleJson.kt b/core/database/src/main/kotlin/io/github/hangillab/hangil/core/database/SessionReviewSampleJson.kt index 50b62bc..dbc5ff7 100644 --- a/core/database/src/main/kotlin/io/github/hangillab/hangil/core/database/SessionReviewSampleJson.kt +++ b/core/database/src/main/kotlin/io/github/hangillab/hangil/core/database/SessionReviewSampleJson.kt @@ -13,7 +13,7 @@ internal object SessionReviewSampleJson { val event = value.event val sample = value.sample return objectValue( - "format" to "1", + "format" to if (value.roadSection == null) "1" else "2", "origin" to JSONObject.quote(value.origin), "processId" to JSONObject.quote(value.processId), "startedAtEpochMillis" to value.startedAtEpochMillis.toString(), @@ -37,6 +37,7 @@ internal object SessionReviewSampleJson { "receivedAtElapsedRealtimeMillis" to sample.receivedAtElapsedRealtimeMillis.toString(), ), ), + *CourseRoadSectionJson.fields(value.roadSection), ) } @@ -44,7 +45,8 @@ internal object SessionReviewSampleJson { if (raw == null) return null try { val root = JSONObject(raw) - check(integer(root, "format") == 1L && text(root, "origin") == "USER_LINKED_STORED_SAMPLE") + check(text(root, "origin") == "USER_LINKED_STORED_SAMPLE") + val section = CourseRoadSectionJson.decode(root, integer(root, "format")) val event = root.get("event") as? JSONObject ?: error("Invalid linked event object") val sample = event.get("sample") as? JSONObject ?: error("Invalid linked sample object") check(text(sample, "source") == "SDK_UPDATE") @@ -73,6 +75,7 @@ internal object SessionReviewSampleJson { text(root, "processId"), integer(root, "startedAtEpochMillis"), integer(root, "startedAtElapsedRealtimeMillis"), + section, ) check(encode(value) == raw) return value diff --git a/core/database/src/test/kotlin/io/github/hangillab/hangil/core/database/PlannedPracticeRoadJsonTest.kt b/core/database/src/test/kotlin/io/github/hangillab/hangil/core/database/PlannedPracticeRoadJsonTest.kt index b457950..a369623 100644 --- a/core/database/src/test/kotlin/io/github/hangillab/hangil/core/database/PlannedPracticeRoadJsonTest.kt +++ b/core/database/src/test/kotlin/io/github/hangillab/hangil/core/database/PlannedPracticeRoadJsonTest.kt @@ -1,11 +1,13 @@ package io.github.hangillab.hangil.core.database +import io.github.hangillab.hangil.core.domain.CourseRoadSection import io.github.hangillab.hangil.core.domain.CourseRoadSnapshot import io.github.hangillab.hangil.core.domain.PlannedPracticeRoad import org.json.JSONObject import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -34,12 +36,30 @@ class PlannedPracticeRoadJsonTest { @Test fun `canonical snapshot preserves every integer bit coordinate precision signed zero and node order`() { val encoded = checkNotNull(PlannedPracticeRoadJson.encode(target)) + assertTrue(encoded.startsWith("{\"format\":1,")) assertEquals(target, PlannedPracticeRoadJson.decode(encoded)) assertEquals(encoded, PlannedPracticeRoadJson.encode(PlannedPracticeRoadJson.decode(encoded))) assertNull(PlannedPracticeRoadJson.decode(null)) assertNull(PlannedPracticeRoadJson.encode(null)) } + @Test fun `section targets round trip with integer indices and still preserve the complete public road`() { + val road = target.road.copy(nodes = target.road.nodes + CourseRoadSnapshot.Node(3, 1, 0.01, 127.13)) + val withSection = PlannedPracticeRoad(target.sourceSessionId, target.reviewVersion, road, CourseRoadSection(0, 1)) + val encoded = checkNotNull(PlannedPracticeRoadJson.encode(withSection)) + assertTrue(encoded.startsWith("{\"format\":2,")) + assertEquals(withSection, PlannedPracticeRoadJson.decode(encoded)) + assertEquals(road, PlannedPracticeRoadJson.decode(encoded)?.road) + val invalid = + listOf( + encoded.replace("\"firstNodeIndex\":0", "\"firstNodeIndex\":\"0\""), + encoded.replace("\"lastNodeIndex\":1", "\"lastNodeIndex\":3"), + encoded.replace("\"format\":2", "\"format\":1"), + JSONObject(encoded).apply { remove("section") }.toString(), + ) + invalid.forEach { raw -> assertThrows(IllegalStateException::class.java) { PlannedPracticeRoadJson.decode(raw) } } + } + @Test fun `malformed noncanonical duplicate unknown partial and coerced fields never become absence`() { val encoded = checkNotNull(PlannedPracticeRoadJson.encode(target)) val invalid = diff --git a/core/database/src/test/kotlin/io/github/hangillab/hangil/core/database/RoomSessionReviewSampleTest.kt b/core/database/src/test/kotlin/io/github/hangillab/hangil/core/database/RoomSessionReviewSampleTest.kt index 055a7ac..c5de66c 100644 --- a/core/database/src/test/kotlin/io/github/hangillab/hangil/core/database/RoomSessionReviewSampleTest.kt +++ b/core/database/src/test/kotlin/io/github/hangillab/hangil/core/database/RoomSessionReviewSampleTest.kt @@ -2,6 +2,7 @@ package io.github.hangillab.hangil.core.database import androidx.room.Room import androidx.room.withTransaction +import io.github.hangillab.hangil.core.domain.CourseRoadSection import io.github.hangillab.hangil.core.domain.LocationRecordingAppendResult import io.github.hangillab.hangil.core.domain.LocationRecordingBeginResult import io.github.hangillab.hangil.core.domain.LocationRecordingEvent @@ -75,6 +76,10 @@ class RoomSessionReviewSampleTest { SessionReviewRoadWriteResult.SampleChanged, repo.save(f.source, road, null, initialSample = wrongOwner) { true }, ) + assertEquals( + SessionReviewRoadWriteResult.SampleChanged, + repo.save(f.source, road, null, initialSample = f.anchor.withSection(CourseRoadSection(1, 2))) { true }, + ) assertNull(repo.observe(f.source.sessionId).first()) var gates = 0 assertEquals( @@ -197,6 +202,35 @@ class RoomSessionReviewSampleTest { } } + @Test fun `a section can only be written on the current linked callback and road under the final gate`() = + runBlocking { + withDatabase { db -> + val f = seed(db) + val repo = RoomSessionReviewRoadRepository(db, catalog) + val section = CourseRoadSection(1, 2) + val anchorWithSection = f.anchor.withSection(section) + assertEquals(SessionReviewRoadWriteResult.SampleChanged, repo.setSample(f.source, f.review, anchorWithSection) { true }) + val linked = saved(repo.setSample(f.source, f.review, f.anchor) { true }) + val second = SessionReviewSample.from(f.run, f.events[1]).withSection(section) + assertEquals(SessionReviewRoadWriteResult.SampleChanged, repo.setSample(f.source, linked, second) { true }) + val outside = f.anchor.withSection(CourseRoadSection(1, linked.road.nodes.size)) + assertEquals(SessionReviewRoadWriteResult.SampleChanged, repo.setSample(f.source, linked, outside) { true }) + var gates = 0 + assertEquals(SessionReviewRoadWriteResult.Blocked, repo.setSample(f.source, linked, anchorWithSection) { ++gates < 3 }) + assertEquals(linked, repo.observe(f.source.sessionId).first()) + + val withSection = saved(repo.setSample(f.source, linked, anchorWithSection) { true }) + assertNotEquals(linked.version, withSection.version) + assertEquals(anchorWithSection, withSection.sampleAnchor) + assertEquals(SessionReviewRoadWriteResult.Conflict, repo.setSample(f.source, linked, f.anchor) { true }) + val withMemo = saved(repo.save(f.source, withSection.road, withSection, memoText = "section memo") { true }) + assertEquals(anchorWithSection, withMemo.sampleAnchor) + val replaced = saved(repo.setSample(f.source, withMemo, SessionReviewSample.from(f.run, f.events[1])) { true }) + assertNull(replaced.sampleAnchor?.roadSection) + assertEquals(f.events, RoomLocationRecordingRepository(db).observe(f.source.sessionId).first().events) + } + } + @Test fun `changed or deleted events fail observers and reject target and deletion writes`() = runBlocking { for (delete in listOf(false, true)) { @@ -261,11 +295,12 @@ class RoomSessionReviewSampleTest { } } - @Test fun `reopen preserves links while next targets omit samples and source deletion cascades`() = + @Test fun `reopen preserves links while next targets copy only the public section and source deletion cascades`() = runBlocking { val context = RuntimeEnvironment.getApplication() val name = "review-sample-${UUID.randomUUID()}.db" check(!context.getDatabasePath(name).exists()) + val section = CourseRoadSection(1, 2) fun open() = Room.databaseBuilder(context, PreparationDatabase::class.java, name).build() try { @@ -274,7 +309,9 @@ class RoomSessionReviewSampleTest { val first = open() try { f = seed(first) - linked = saved(RoomSessionReviewRoadRepository(first, catalog).setSample(f.source, f.review, f.anchor) { true }) + val repo = RoomSessionReviewRoadRepository(first, catalog) + val withoutSection = saved(repo.setSample(f.source, f.review, f.anchor) { true }) + linked = saved(repo.setSample(f.source, withoutSection, f.anchor.withSection(section)) { true }) } finally { first.close() } @@ -291,13 +328,14 @@ class RoomSessionReviewSampleTest { ) { true }, ) val publicTarget = checkNotNull(reopened.preparationDao().current()?.plannedRoadJson) + assertEquals(section, PlannedPracticeRoadJson.decode(publicTarget)?.section) assertFalse( publicTarget.contains("\"event\"") || publicTarget.contains("sdkTimestampMillis") || publicTarget.contains(f.anchor.processId), ) val deletions = RoomSessionDeletionRepository(reopened) val original = (deletions.prepare(f.source) { true } as SessionDeletionPreparation.Ready).target - assertEquals(f.anchor, original.reviewRoad?.sampleAnchor) + assertEquals(f.anchor.withSection(section), original.reviewRoad?.sampleAnchor) assertEquals(SessionDeletionResult.Deleted, deletions.delete(original) { true }) assertNull(RoomSessionReviewRoadRepository(reopened, catalog).observe(f.source.sessionId).first()) assertTrue( @@ -348,6 +386,9 @@ class RoomSessionReviewSampleTest { private fun saved(result: SessionReviewRoadWriteResult): SessionReviewRoad = (result as SessionReviewRoadWriteResult.Saved).review + private fun SessionReviewSample.withSection(section: CourseRoadSection): SessionReviewSample = + SessionReviewSample(event, processId, startedAtEpochMillis, startedAtElapsedRealtimeMillis, section) + private suspend fun withDatabase(block: suspend (PreparationDatabase) -> Unit) { val db = Room.inMemoryDatabaseBuilder(RuntimeEnvironment.getApplication(), PreparationDatabase::class.java).build() try { diff --git a/core/database/src/test/kotlin/io/github/hangillab/hangil/core/database/SessionReviewSampleJsonTest.kt b/core/database/src/test/kotlin/io/github/hangillab/hangil/core/database/SessionReviewSampleJsonTest.kt index 85dffbd..0849d43 100644 --- a/core/database/src/test/kotlin/io/github/hangillab/hangil/core/database/SessionReviewSampleJsonTest.kt +++ b/core/database/src/test/kotlin/io/github/hangillab/hangil/core/database/SessionReviewSampleJsonTest.kt @@ -1,5 +1,6 @@ package io.github.hangillab.hangil.core.database +import io.github.hangillab.hangil.core.domain.CourseRoadSection import io.github.hangillab.hangil.core.domain.LocationRecordingEvent import io.github.hangillab.hangil.core.domain.LocationUpdateReading import io.github.hangillab.hangil.core.domain.LocationUpdateSample @@ -8,6 +9,7 @@ import org.json.JSONObject import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -43,12 +45,37 @@ class SessionReviewSampleJsonTest { @Test fun `all raw fields and full event owner survive canonical encoding without interpreting SDK units`() { val raw = checkNotNull(SessionReviewSampleJson.encode(value)) + assertTrue(raw.startsWith("{\"format\":1,")) assertEquals(value, SessionReviewSampleJson.decode(raw)) assertEquals(raw, SessionReviewSampleJson.encode(SessionReviewSampleJson.decode(raw))) assertNull(SessionReviewSampleJson.decode(null)) assertNull(SessionReviewSampleJson.encode(null)) } + @Test fun `section links preserve the exact callback and reject noncanonical section fields`() { + val linked = + SessionReviewSample( + value.event, + value.processId, + value.startedAtEpochMillis, + value.startedAtElapsedRealtimeMillis, + CourseRoadSection(1, 3), + ) + val raw = checkNotNull(SessionReviewSampleJson.encode(linked)) + assertTrue(raw.startsWith("{\"format\":2,")) + val decoded = checkNotNull(SessionReviewSampleJson.decode(raw)) + assertEquals(linked, decoded) + assertTrue(value.sameCallback(decoded)) + val invalid = + listOf( + raw.replace("\"firstNodeIndex\":1", "\"firstNodeIndex\":-1"), + raw.replace("\"lastNodeIndex\":3", "\"lastNodeIndex\":4294967299"), + raw.replace("\"lastNodeIndex\":3", "\"lastNodeIndex\":1"), + JSONObject(raw).apply { remove("section") }.toString(), + ) + invalid.forEach { assertThrows(IllegalStateException::class.java) { SessionReviewSampleJson.decode(it) } } + } + @Test fun `partial coerced duplicate extra and malformed anchors are errors rather than absence`() { val raw = checkNotNull(SessionReviewSampleJson.encode(value)) val invalid = diff --git a/core/domain/src/main/kotlin/io/github/hangillab/hangil/core/domain/CourseRoadSection.kt b/core/domain/src/main/kotlin/io/github/hangillab/hangil/core/domain/CourseRoadSection.kt new file mode 100644 index 0000000..7b656e1 --- /dev/null +++ b/core/domain/src/main/kotlin/io/github/hangillab/hangil/core/domain/CourseRoadSection.kt @@ -0,0 +1,24 @@ +package io.github.hangillab.hangil.core.domain + +/** Inclusive positions in the exact, versioned public way used for this review. */ +data class CourseRoadSection( + val firstNodeIndex: Int, + val lastNodeIndex: Int, +) { + init { + require(firstNodeIndex >= 0 && lastNodeIndex > firstNodeIndex) { "Invalid public road section order" } + } + + fun isValidFor(road: CourseRoadSnapshot): Boolean { + if (lastNodeIndex >= road.nodes.size) return false + val first = road.nodes[firstNodeIndex] + return road.nodes.subList(firstNodeIndex + 1, lastNodeIndex + 1).any { + it.latitude != first.latitude || it.longitude != first.longitude + } + } + + fun nodesOf(road: CourseRoadSnapshot): List { + require(isValidFor(road)) { "Invalid section for the public road snapshot" } + return road.nodes.subList(firstNodeIndex, lastNodeIndex + 1) + } +} diff --git a/core/domain/src/main/kotlin/io/github/hangillab/hangil/core/domain/PlannedPracticeRoad.kt b/core/domain/src/main/kotlin/io/github/hangillab/hangil/core/domain/PlannedPracticeRoad.kt index b5cd639..4a5482c 100644 --- a/core/domain/src/main/kotlin/io/github/hangillab/hangil/core/domain/PlannedPracticeRoad.kt +++ b/core/domain/src/main/kotlin/io/github/hangillab/hangil/core/domain/PlannedPracticeRoad.kt @@ -7,23 +7,27 @@ class PlannedPracticeRoad( val sourceSessionId: String, val reviewVersion: String, road: CourseRoadSnapshot, + val section: CourseRoadSection? = null, ) { val road: CourseRoadSnapshot = road.copy(nodes = Collections.unmodifiableList(ArrayList(road.nodes))) val origin: String get() = "USER_REVIEW_SELECTION" init { require(sourceSessionId.isNotBlank() && reviewVersion.isNotBlank()) { "Invalid planned road source" } + require(section?.isValidFor(this.road) != false) { "Invalid planned public road section" } } override fun equals(other: Any?): Boolean = other is PlannedPracticeRoad && sourceSessionId == other.sourceSessionId && - reviewVersion == other.reviewVersion && road == other.road + reviewVersion == other.reviewVersion && road == other.road && section == other.section - override fun hashCode(): Int = 31 * (31 * sourceSessionId.hashCode() + reviewVersion.hashCode()) + road.hashCode() + override fun hashCode(): Int = + 31 * (31 * (31 * sourceSessionId.hashCode() + reviewVersion.hashCode()) + road.hashCode()) + (section?.hashCode() ?: 0) override fun toString(): String = "PlannedPracticeRoad(origin=$origin, snapshot=redacted)" companion object { - fun from(review: SessionReviewRoad): PlannedPracticeRoad = PlannedPracticeRoad(review.source.sessionId, review.version, review.road) + fun from(review: SessionReviewRoad): PlannedPracticeRoad = + PlannedPracticeRoad(review.source.sessionId, review.version, review.road, review.sampleAnchor?.roadSection) } } diff --git a/core/domain/src/main/kotlin/io/github/hangillab/hangil/core/domain/SessionReviewRoad.kt b/core/domain/src/main/kotlin/io/github/hangillab/hangil/core/domain/SessionReviewRoad.kt index 9ec3515..a389e8d 100644 --- a/core/domain/src/main/kotlin/io/github/hangillab/hangil/core/domain/SessionReviewRoad.kt +++ b/core/domain/src/main/kotlin/io/github/hangillab/hangil/core/domain/SessionReviewRoad.kt @@ -25,6 +25,7 @@ class SessionReviewRoad( require(version.isNotBlank()) { "Invalid review road version" } require(memoText.isEmpty() || SessionMemoPolicy.isValidText(memoText)) { "Invalid review road memo" } require(sampleAnchor == null || sampleAnchor.event.sessionId == source.sessionId) { "Foreign linked review sample" } + require(sampleAnchor?.roadSection?.isValidFor(this.road) != false) { "Invalid linked public road section" } } override fun equals(other: Any?): Boolean = diff --git a/core/domain/src/main/kotlin/io/github/hangillab/hangil/core/domain/SessionReviewSample.kt b/core/domain/src/main/kotlin/io/github/hangillab/hangil/core/domain/SessionReviewSample.kt index 16b6cf6..aa83c6b 100644 --- a/core/domain/src/main/kotlin/io/github/hangillab/hangil/core/domain/SessionReviewSample.kt +++ b/core/domain/src/main/kotlin/io/github/hangillab/hangil/core/domain/SessionReviewSample.kt @@ -6,6 +6,7 @@ class SessionReviewSample( val processId: String, val startedAtEpochMillis: Long, val startedAtElapsedRealtimeMillis: Long, + val roadSection: CourseRoadSection? = null, ) { val origin: String get() = "USER_LINKED_STORED_SAMPLE" val sample: LocationUpdateSample get() = (event.reading as LocationUpdateReading.Sample).sample @@ -20,13 +21,21 @@ class SessionReviewSample( } } - override fun equals(other: Any?): Boolean = - other is SessionReviewSample && event == other.event && processId == other.processId && + /** Compares the immutable callback and run owner, independent of the public road choice. */ + fun sameCallback(other: SessionReviewSample): Boolean = + event == other.event && processId == other.processId && startedAtEpochMillis == other.startedAtEpochMillis && startedAtElapsedRealtimeMillis == other.startedAtElapsedRealtimeMillis + fun withRoadSection(section: CourseRoadSection?): SessionReviewSample = + SessionReviewSample(event, processId, startedAtEpochMillis, startedAtElapsedRealtimeMillis, section) + + override fun equals(other: Any?): Boolean = other is SessionReviewSample && sameCallback(other) && roadSection == other.roadSection + override fun hashCode(): Int = - 31 * (31 * (31 * event.hashCode() + processId.hashCode()) + startedAtEpochMillis.hashCode()) + - startedAtElapsedRealtimeMillis.hashCode() + 31 * ( + 31 * (31 * (31 * event.hashCode() + processId.hashCode()) + startedAtEpochMillis.hashCode()) + + startedAtElapsedRealtimeMillis.hashCode() + ) + (roadSection?.hashCode() ?: 0) override fun toString(): String = "SessionReviewSample(origin=$origin, snapshot=redacted)" diff --git a/core/domain/src/test/kotlin/io/github/hangillab/hangil/core/domain/PlannedPracticeRoadTest.kt b/core/domain/src/test/kotlin/io/github/hangillab/hangil/core/domain/PlannedPracticeRoadTest.kt index 929c052..81449ca 100644 --- a/core/domain/src/test/kotlin/io/github/hangillab/hangil/core/domain/PlannedPracticeRoadTest.kt +++ b/core/domain/src/test/kotlin/io/github/hangillab/hangil/core/domain/PlannedPracticeRoadTest.kt @@ -22,4 +22,22 @@ class PlannedPracticeRoadTest { assertFalse(target.toString().contains("private")) assertFalse(target.toString().contains("127")) } + + @Test fun `target distinguishes the saved public section from a whole road`() { + val nodes = + listOf( + CourseRoadSnapshot.Node(1, 1, 37.5, 127.0), + CourseRoadSnapshot.Node(2, 1, 37.5, 127.001), + CourseRoadSnapshot.Node(3, 1, 37.5, 127.002), + ) + val road = CourseRoadSnapshot("road", "Road", "residential", 9, 2, 100, nodes) + val section = CourseRoadSection(1, 2) + val target = PlannedPracticeRoad("source", "version", road, section) + assertEquals(nodes, target.road.nodes) + assertEquals(nodes.drop(1), section.nodesOf(target.road)) + assertNotEquals(target, PlannedPracticeRoad("source", "version", road)) + assertThrows(IllegalArgumentException::class.java) { + PlannedPracticeRoad("source", "version", road, CourseRoadSection(1, 3)) + } + } } diff --git a/feature/course/src/main/kotlin/io/github/hangillab/hangil/feature/course/CourseScreen.kt b/feature/course/src/main/kotlin/io/github/hangillab/hangil/feature/course/CourseScreen.kt index ca9fdea..5d7d6f8 100644 --- a/feature/course/src/main/kotlin/io/github/hangillab/hangil/feature/course/CourseScreen.kt +++ b/feature/course/src/main/kotlin/io/github/hangillab/hangil/feature/course/CourseScreen.kt @@ -128,7 +128,16 @@ internal fun CourseScreen( } state.plannedRoad?.let { plan -> Text( - stringResource(R.string.preparation_planned_road_title, plan.road.title), + stringResource( + if (plan.section == + null + ) { + R.string.preparation_planned_road_title + } else { + R.string.preparation_planned_road_section_title + }, + plan.road.title, + ), Modifier.testTag("preparation_planned_road"), style = MaterialTheme.typography.titleLarge, ) diff --git a/feature/course/src/main/kotlin/io/github/hangillab/hangil/feature/course/NextPracticeScreen.kt b/feature/course/src/main/kotlin/io/github/hangillab/hangil/feature/course/NextPracticeScreen.kt index 6d39526..bad0069 100644 --- a/feature/course/src/main/kotlin/io/github/hangillab/hangil/feature/course/NextPracticeScreen.kt +++ b/feature/course/src/main/kotlin/io/github/hangillab/hangil/feature/course/NextPracticeScreen.kt @@ -217,7 +217,15 @@ private fun NextPracticeChoices( val target = state.targetReview if (target != null) { Text(target.road.title, Modifier.testTag("next_practice_target_road"), style = MaterialTheme.typography.titleLarge) - Text(stringResource(R.string.next_practice_target_road_scope), style = MaterialTheme.typography.bodyLarge) + if (target.sampleAnchor?.roadSection != null) { + Text( + stringResource(R.string.next_practice_target_road_section_scope), + Modifier.testTag("next_practice_target_road_section"), + style = MaterialTheme.typography.bodyLarge, + ) + } else { + Text(stringResource(R.string.next_practice_target_road_scope), style = MaterialTheme.typography.bodyLarge) + } } if (state.targetChoiceRequired) { Text( @@ -277,7 +285,16 @@ private fun NextPracticeChoices( "next_practice_target_road_use", ).frozenNextClick(useRoad, !state.saving), ) { - Text(stringResource(R.string.next_practice_target_road_use), style = MaterialTheme.typography.titleLarge) + Text( + stringResource( + if (road.review.sampleAnchor?.roadSection == null) { + R.string.next_practice_target_road_use + } else { + R.string.next_practice_target_road_use_section + }, + ), + style = MaterialTheme.typography.titleLarge, + ) } } } diff --git a/feature/course/src/main/kotlin/io/github/hangillab/hangil/feature/course/PreparationDraft.kt b/feature/course/src/main/kotlin/io/github/hangillab/hangil/feature/course/PreparationDraft.kt index 1857594..1db6c4a 100644 --- a/feature/course/src/main/kotlin/io/github/hangillab/hangil/feature/course/PreparationDraft.kt +++ b/feature/course/src/main/kotlin/io/github/hangillab/hangil/feature/course/PreparationDraft.kt @@ -46,5 +46,8 @@ internal fun PracticeSelection?.savedFields(): ArrayList { road.nodes.forEach { fields.addAll(listOf(it.osmNodeId.toString(), it.osmNodeVersion.toString(), it.latitude.toString(), it.longitude.toString())) } + plan.section?.let { section -> + fields.addAll(listOf("road-section", section.firstNodeIndex.toString(), section.lastNodeIndex.toString())) + } return fields } diff --git a/feature/course/src/main/res/values/strings.xml b/feature/course/src/main/res/values/strings.xml index ee85373..a3c6d1b 100644 --- a/feature/course/src/main/res/values/strings.xml +++ b/feature/course/src/main/res/values/strings.xml @@ -42,15 +42,18 @@ 이전 연습의 참고 구간 다음 연습의 목표 도로 내가 고른 연습 전 목표예요. 실제 주행이나 내비 경로를 뜻하지 않으며 메모와 표본 연결은 복사하지 않아요. + 복습에서 고른 공개도로 구간을 다음 연습 목표로 저장해요. 고른 도로를 다시 확인해야 해요. 새 도로를 선택하거나 연결 안 함을 눌러 주세요. 이전 연습의 복습 도로를 확인하고 있어요. 이 연습에 저장한 복습 도로가 없어요. 복습 도로를 확인하지 못했어요. 도로 다시 읽기 이 도로를 목표로 + 이 구간을 목표로 연결 안 함 도로를 고르지 않으면 코스와 과제만 저장해요. 연습 전 목표 도로: %1$s + 연습 전 목표 구간 %1$s 실제 주행이나 내비 경로가 아닌 연습 목표예요. 다른 목표나 코스를 저장하면 기존 목표 도로 연결은 해제돼요. 이전 연습의 복습 도로는 남아요. 저장된 준비 다시 읽기 diff --git a/feature/home/src/main/kotlin/io/github/hangillab/hangil/feature/home/HomeScreen.kt b/feature/home/src/main/kotlin/io/github/hangillab/hangil/feature/home/HomeScreen.kt index 93485fe..57a6a4e 100644 --- a/feature/home/src/main/kotlin/io/github/hangillab/hangil/feature/home/HomeScreen.kt +++ b/feature/home/src/main/kotlin/io/github/hangillab/hangil/feature/home/HomeScreen.kt @@ -246,12 +246,7 @@ internal fun HomeScreen( ) Text(ready.task.title, Modifier.testTag("saved_task"), style = MaterialTheme.typography.titleLarge) ready.plannedRoad?.let { plan -> - Text( - stringResource(R.string.planned_road_summary, plan.road.title), - Modifier.testTag("saved_planned_road"), - style = MaterialTheme.typography.titleLarge, - ) - Text(stringResource(R.string.planned_road_scope), style = MaterialTheme.typography.bodyLarge) + PlannedRoadSummary(plan, "saved_planned_road") } key(ready) { ParkedDisclosure( diff --git a/feature/home/src/main/kotlin/io/github/hangillab/hangil/feature/home/LocationHistoryScreen.kt b/feature/home/src/main/kotlin/io/github/hangillab/hangil/feature/home/LocationHistoryScreen.kt index 9558588..81d7c96 100644 --- a/feature/home/src/main/kotlin/io/github/hangillab/hangil/feature/home/LocationHistoryScreen.kt +++ b/feature/home/src/main/kotlin/io/github/hangillab/hangil/feature/home/LocationHistoryScreen.kt @@ -249,7 +249,9 @@ private fun LocationHistoryReady( val anchor = linkedSampleToPresent ?: return@LaunchedEffect // Consume before suspending or validating so a rejected focus request never waits for a later selection. onLinkedSamplePresented() - if (event == null || event.reading !is LocationUpdateReading.Sample || SessionReviewSample.from(run, event) != anchor) { + if (event == null || event.reading !is LocationUpdateReading.Sample || + !SessionReviewSample.from(run, event).sameCallback(anchor) + ) { return@LaunchedEffect } if (!viewModel.canRead(state.token)) return@LaunchedEffect diff --git a/feature/home/src/main/kotlin/io/github/hangillab/hangil/feature/home/LocationHistoryViewModel.kt b/feature/home/src/main/kotlin/io/github/hangillab/hangil/feature/home/LocationHistoryViewModel.kt index 9538371..72b6a0b 100644 --- a/feature/home/src/main/kotlin/io/github/hangillab/hangil/feature/home/LocationHistoryViewModel.kt +++ b/feature/home/src/main/kotlin/io/github/hangillab/hangil/feature/home/LocationHistoryViewModel.kt @@ -214,7 +214,7 @@ class LocationHistoryViewModel } catch (_: IllegalArgumentException) { return@synchronized false } - if (actual != anchor || this.owner(token) !== owner || owner.queryJob !== query || + if (!actual.sameCallback(anchor) || this.owner(token) !== owner || owner.queryJob !== query || owner.states.value !== ready ) { return@synchronized false diff --git a/feature/home/src/main/kotlin/io/github/hangillab/hangil/feature/home/PlannedRoadSummary.kt b/feature/home/src/main/kotlin/io/github/hangillab/hangil/feature/home/PlannedRoadSummary.kt new file mode 100644 index 0000000..d346cdd --- /dev/null +++ b/feature/home/src/main/kotlin/io/github/hangillab/hangil/feature/home/PlannedRoadSummary.kt @@ -0,0 +1,25 @@ +package io.github.hangillab.hangil.feature.home + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import io.github.hangillab.hangil.core.domain.PlannedPracticeRoad + +@Composable +internal fun PlannedRoadSummary( + plan: PlannedPracticeRoad, + tag: String, +) { + Text( + stringResource( + if (plan.section == null) R.string.planned_road_summary else R.string.planned_road_section_summary, + plan.road.title, + ), + Modifier.testTag(tag), + style = MaterialTheme.typography.titleLarge, + ) + Text(stringResource(R.string.planned_road_scope), style = MaterialTheme.typography.bodyLarge) +} diff --git a/feature/home/src/main/kotlin/io/github/hangillab/hangil/feature/home/SessionHistoryScreen.kt b/feature/home/src/main/kotlin/io/github/hangillab/hangil/feature/home/SessionHistoryScreen.kt index 37205ae..5d4c725 100644 --- a/feature/home/src/main/kotlin/io/github/hangillab/hangil/feature/home/SessionHistoryScreen.kt +++ b/feature/home/src/main/kotlin/io/github/hangillab/hangil/feature/home/SessionHistoryScreen.kt @@ -518,12 +518,7 @@ private fun HistoryRecord( ) Text(session.taskTitle, Modifier.testTag("history_task"), style = MaterialTheme.typography.titleLarge) session.selection.plannedRoad?.let { plan -> - Text( - stringResource(R.string.planned_road_summary, plan.road.title), - Modifier.testTag("history_planned_road"), - style = MaterialTheme.typography.titleLarge, - ) - Text(stringResource(R.string.planned_road_scope), style = MaterialTheme.typography.bodyLarge) + PlannedRoadSummary(plan, "history_planned_road") } } if (!allowBrowsing) nextPractice() diff --git a/feature/home/src/main/kotlin/io/github/hangillab/hangil/feature/home/SessionPanel.kt b/feature/home/src/main/kotlin/io/github/hangillab/hangil/feature/home/SessionPanel.kt index af692b3..b716129 100644 --- a/feature/home/src/main/kotlin/io/github/hangillab/hangil/feature/home/SessionPanel.kt +++ b/feature/home/src/main/kotlin/io/github/hangillab/hangil/feature/home/SessionPanel.kt @@ -251,12 +251,7 @@ internal fun SessionPanel( ) Text(active.taskTitle, Modifier.testTag("session_active_task"), style = MaterialTheme.typography.titleLarge) active.selection.plannedRoad?.let { plan -> - Text( - stringResource(R.string.planned_road_summary, plan.road.title), - Modifier.testTag("session_planned_road"), - style = MaterialTheme.typography.titleLarge, - ) - Text(stringResource(R.string.planned_road_scope), style = MaterialTheme.typography.bodyLarge) + PlannedRoadSummary(plan, "session_planned_road") } if (active.courseReviewStatus == CourseReviewStatus.DEMO) ParkedBadge(stringResource(R.string.demo_course)) VehicleRecordingStatus(recordingState, onRetryRecording, allowRecordingRetry) @@ -414,12 +409,7 @@ private fun LatestSessionPanel( Text(stringResource(R.string.session_latest), style = MaterialTheme.typography.titleLarge) Text(latest.courseTitle, Modifier.testTag("session_latest_course"), style = MaterialTheme.typography.headlineSmall) latest.selection.plannedRoad?.let { plan -> - Text( - stringResource(R.string.planned_road_summary, plan.road.title), - Modifier.testTag("session_latest_planned_road"), - style = MaterialTheme.typography.titleLarge, - ) - Text(stringResource(R.string.planned_road_scope), style = MaterialTheme.typography.bodyLarge) + PlannedRoadSummary(plan, "session_latest_planned_road") } if (latest.courseReviewStatus == CourseReviewStatus.DEMO) ParkedBadge(stringResource(R.string.demo_course)) latest.endedAtEpochMillis?.let { diff --git a/feature/home/src/main/kotlin/io/github/hangillab/hangil/feature/home/SessionReviewRoadRoute.kt b/feature/home/src/main/kotlin/io/github/hangillab/hangil/feature/home/SessionReviewRoadRoute.kt index e110c4f..b2c48fd 100644 --- a/feature/home/src/main/kotlin/io/github/hangillab/hangil/feature/home/SessionReviewRoadRoute.kt +++ b/feature/home/src/main/kotlin/io/github/hangillab/hangil/feature/home/SessionReviewRoadRoute.kt @@ -26,6 +26,7 @@ import io.github.hangillab.hangil.core.designsystem.HanGilSpacing import io.github.hangillab.hangil.core.designsystem.ParkedButton import io.github.hangillab.hangil.core.designsystem.ParkedChoice import io.github.hangillab.hangil.core.designsystem.ParkedDisclosure +import io.github.hangillab.hangil.core.domain.CourseRoadSection import io.github.hangillab.hangil.core.domain.CourseRoadSnapshot import io.github.hangillab.hangil.core.domain.PracticeSession import io.github.hangillab.hangil.core.domain.SessionReviewRoad @@ -39,11 +40,20 @@ import io.github.hangillab.hangil.feature.home.SessionReviewRoadUiState as RoadS fun SessionReviewRoadRoute( expectedSource: PracticeSession, canKeepOpen: () -> Boolean, - roadMap: (@Composable (PracticeSession, List, String?, () -> Boolean, Flow) -> Unit)? = null, + roadMap: (@Composable (PracticeSession, List, String?, CourseRoadSection?, () -> Boolean, Flow) -> Unit)? = + null, viewModel: SessionReviewRoadViewModel = hiltViewModel(), readOnly: Boolean = false, - comparisonMap: (@Composable (SessionReviewRoad?, List, ((String) -> Unit)?, () -> Boolean, Flow) -> Unit)? = - null, + comparisonMap: ( + @Composable ( + SessionReviewRoad?, + List, + ((String) -> Unit)?, + ((CourseRoadSection?) -> Unit)?, + () -> Boolean, + Flow, + ) -> Unit + )? = null, sampleCandidate: SessionReviewSample? = null, onOpenLinkedSample: ((SessionReviewSample) -> Boolean)? = null, ) { @@ -186,7 +196,9 @@ fun SessionReviewRoadRoute( val anchor = displayed.stored?.sampleAnchor if (!comparing && anchor != null) { Text( - stringResource(R.string.review_road_sample_present), + stringResource( + if (anchor.roadSection == null) R.string.review_road_sample_present else R.string.review_road_section_present, + ), Modifier.fillMaxWidth().testTag("review_road_sample_present"), style = MaterialTheme.typography.bodyLarge, ) @@ -228,11 +240,21 @@ fun SessionReviewRoadRoute( } if (comparisonMap != null) { val saveCourseRoad: ((String) -> Unit)? = - if (saved == null && candidate != null && displayed.canWrite && !displayed.busy) { + if (saved == null && candidate != null && displayed.canChangeSample) { { roadId -> viewModel.saveFirstRoadAndSample(displayed.token, roadId, candidate) } } else { null } + val onSetRoadSection: ((CourseRoadSection?) -> Unit)? = + if (candidate != null && anchor?.sameCallback(candidate) == true && displayed.canChangeSample) { + { section -> + if (allowed() && viewModel.canRead(displayed.token)) { + viewModel.setSample(displayed.token, candidate.withRoadSection(section)) + } + } + } else { + null + } if (saved == null && candidate != null && displayed.candidates.isNotEmpty() && !displayed.canWrite) { Text( stringResource(if (displayed.busy) R.string.review_road_busy else R.string.review_road_sample_active), @@ -245,6 +267,7 @@ fun SessionReviewRoadRoute( saved, displayed.candidates, saveCourseRoad, + onSetRoadSection, { allowed() && viewModel.canRead(displayed.token) }, viewModel.inputChanges, ) @@ -255,6 +278,7 @@ fun SessionReviewRoadRoute( expectedSource, listOf(saved.road), saved.road.id, + saved.sampleAnchor?.roadSection, { allowed() && viewModel.canRead(displayed.token) }, viewModel.inputChanges, ) @@ -403,6 +427,10 @@ fun SessionReviewRoadRoute( expectedSource, roads, displayed.draftRoadId, + displayed.stored + ?.takeIf { it.road == displayed.draft } + ?.sampleAnchor + ?.roadSection, { allowed() && viewModel.canRead(displayed.token) }, viewModel.inputChanges, ) @@ -574,10 +602,11 @@ private fun ReviewSampleControls( ) { val stored = checkNotNull(state.stored) val anchor = stored.sampleAnchor + val isCurrentSample = candidate != null && anchor?.sameCallback(candidate) == true val status = when { anchor == null -> R.string.review_road_sample_absent to "review_road_sample_absent" - candidate != null && anchor == candidate -> R.string.review_road_sample_current to "review_road_sample_current" + isCurrentSample -> R.string.review_road_sample_current to "review_road_sample_current" else -> R.string.review_road_sample_other to "review_road_sample_other" } Text(stringResource(status.first), Modifier.fillMaxWidth().testTag(status.second), style = MaterialTheme.typography.titleLarge) @@ -603,7 +632,7 @@ private fun ReviewSampleControls( ) else -> Unit } - if (anchor != null && anchor != candidate && onOpenLinkedSample != null) { + if (anchor != null && !isCurrentSample && onOpenLinkedSample != null) { var openFailed by remember(state.token, anchor) { mutableStateOf(false) } val open = { openFailed = !viewModel.canRead(state.token) || !onOpenLinkedSample(anchor) @@ -630,7 +659,7 @@ private fun ReviewSampleControls( ) } } - val canChange = state.canWrite && !state.busy && state.confirmation == null && state.edit == null && state.retainedMemo == null + val canChange = state.canChangeSample if (state.busy) { Text( stringResource(R.string.review_road_busy), @@ -640,7 +669,7 @@ private fun ReviewSampleControls( } else if (!state.canWrite) { Text(stringResource(R.string.review_road_sample_active), Modifier.fillMaxWidth(), style = MaterialTheme.typography.bodyLarge) } - if (candidate != null && anchor != candidate) { + if (candidate != null && !isCurrentSample) { val link = { viewModel.setSample(state.token, candidate) Unit diff --git a/feature/home/src/main/kotlin/io/github/hangillab/hangil/feature/home/SessionReviewRoadUiState.kt b/feature/home/src/main/kotlin/io/github/hangillab/hangil/feature/home/SessionReviewRoadUiState.kt index 40457fc..77abbae 100644 --- a/feature/home/src/main/kotlin/io/github/hangillab/hangil/feature/home/SessionReviewRoadUiState.kt +++ b/feature/home/src/main/kotlin/io/github/hangillab/hangil/feature/home/SessionReviewRoadUiState.kt @@ -50,6 +50,8 @@ sealed interface SessionReviewRoadUiState { val retainedMemo: Edit? = null, ) : SessionReviewRoadUiState { val draftRoadId: String? get() = draft?.id + val canChangeSample: Boolean + get() = canWrite && !busy && confirmation == null && edit == null && retainedMemo == null } data class Edit( diff --git a/feature/home/src/main/kotlin/io/github/hangillab/hangil/feature/home/SessionReviewRoadViewModel.kt b/feature/home/src/main/kotlin/io/github/hangillab/hangil/feature/home/SessionReviewRoadViewModel.kt index 4ce0284..d233779 100644 --- a/feature/home/src/main/kotlin/io/github/hangillab/hangil/feature/home/SessionReviewRoadViewModel.kt +++ b/feature/home/src/main/kotlin/io/github/hangillab/hangil/feature/home/SessionReviewRoadViewModel.kt @@ -229,6 +229,12 @@ class SessionReviewRoadViewModel ) { return@synchronized null } + if (sample != null) { + val section = sample.roadSection + if (section != null && (!section.isValidFor(stored.road) || stored.sampleAnchor?.sameCallback(sample) != true)) { + return@synchronized null + } + } schedule(owner, stored.road, stored.memoText, discardMemo = false, memoEdit = false, sampleEdit = true, sample = sample) } ?: return false job.start() @@ -244,7 +250,7 @@ class SessionReviewRoadViewModel synchronized(lock) { val owner = owner(token) ?: return@synchronized null if (!editable(owner) || owner.stored != null || owner.edit != null || owner.confirmation != null || - owner.retainedMemo != null || sample.event.sessionId != owner.source.sessionId + owner.retainedMemo != null || sample.event.sessionId != owner.source.sessionId || sample.roadSection != null ) { return@synchronized null } diff --git a/feature/home/src/main/res/values/strings.xml b/feature/home/src/main/res/values/strings.xml index dc65d09..080c7ad 100644 --- a/feature/home/src/main/res/values/strings.xml +++ b/feature/home/src/main/res/values/strings.xml @@ -309,6 +309,7 @@ 그대로 유지 저장한 복습 구간: %1$s (구간 메모 포함) 연습 전 목표 도로: %1$s + 연습 전 목표 구간 %1$s 실제 주행이나 내비 경로가 아닌 연습 목표예요. 이 도로에 연결한 표본이 없어요. 지금 보는 표본을 이 도로에 연결했어요. @@ -321,6 +322,7 @@ 표본 연결을 해제했어요. 저장한 표본 원본은 남아 있어요. 연습 중에는 표본 연결을 바꿀 수 없어요. 이 도로에 직접 연결한 저장 표본이 있어요. 실제 이동 매칭은 미확인이에요. + 이 도로에서 복습 구간과 연결 표본을 직접 골랐어요. 실제 이동은 미확인이에요. 이 도로의 연결 내용을 지울까요? 직접 연결한 표본도 해제돼요. 표본 원본은 지워지지 않으며 새 도로에 자동으로 옮기지 않아요. 연결한 표본 원본 diff --git a/feature/map/src/main/kotlin/io/github/hangillab/hangil/feature/map/DemoMap.kt b/feature/map/src/main/kotlin/io/github/hangillab/hangil/feature/map/DemoMap.kt index 10af240..4a5e317 100644 --- a/feature/map/src/main/kotlin/io/github/hangillab/hangil/feature/map/DemoMap.kt +++ b/feature/map/src/main/kotlin/io/github/hangillab/hangil/feature/map/DemoMap.kt @@ -29,6 +29,7 @@ import org.maplibre.android.geometry.LatLngBounds import org.maplibre.android.maps.MapLibreMapOptions import org.maplibre.android.maps.MapView import org.maplibre.android.tile.TileOperation +import org.maplibre.geojson.LineString import java.util.concurrent.atomic.AtomicBoolean import kotlin.math.roundToInt @@ -53,6 +54,8 @@ internal fun DemoMap( onNativeSelect: (String, () -> Boolean) -> Unit, modifier: Modifier = Modifier, canRender: () -> Boolean = { true }, + publicRoadSection: LineString? = null, + publicRoadSectionColor: Int = pointFill, ) { val context = LocalContext.current val owner = LocalLifecycleOwner.current @@ -71,6 +74,8 @@ internal fun DemoMap( pointFill, pointText, pointBorder, + publicRoadSection, + publicRoadSectionColor, canInteract = { active.get() && owner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) && canInteract(attempt, token) @@ -155,6 +160,8 @@ private fun createMap( pointFill: Int, pointText: Int, pointBorder: Int, + publicRoadSection: LineString?, + publicRoadSectionColor: Int, canInteract: () -> Boolean, canRender: () -> Boolean, onRendered: () -> Unit, @@ -196,6 +203,14 @@ private fun createMap( if (active.get()) { try { RoadPreviewLayers.add(style, preview.roads, pointFill) + publicRoadSection?.let { + StoredLocationRoadSection.addPublic( + style, + it, + publicRoadSectionColor, + candidate = false, + ) + } CoursePreviewLayers.add(style, preview, pointFill, pointText, pointBorder, fontScale) styleLoaded.set(true) val coordinates = diff --git a/feature/map/src/main/kotlin/io/github/hangillab/hangil/feature/map/SessionReviewRoadMap.kt b/feature/map/src/main/kotlin/io/github/hangillab/hangil/feature/map/SessionReviewRoadMap.kt index 8a81e97..42d736d 100644 --- a/feature/map/src/main/kotlin/io/github/hangillab/hangil/feature/map/SessionReviewRoadMap.kt +++ b/feature/map/src/main/kotlin/io/github/hangillab/hangil/feature/map/SessionReviewRoadMap.kt @@ -19,6 +19,7 @@ import androidx.compose.runtime.key import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -31,6 +32,7 @@ import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import io.github.hangillab.hangil.core.designsystem.HanGilDimensions import io.github.hangillab.hangil.core.designsystem.HanGilSpacing +import io.github.hangillab.hangil.core.domain.CourseRoadSection import io.github.hangillab.hangil.core.domain.CourseRoadSnapshot import io.github.hangillab.hangil.core.domain.PracticeSession import kotlinx.coroutines.delay @@ -51,9 +53,17 @@ fun SessionReviewRoadMap( canPresent: () -> Boolean, inputChanges: Flow, modifier: Modifier = Modifier, + section: CourseRoadSection? = null, ) { // Snapshot on each composition so mutations of an external list cannot mutate remembered keys. val preview = MapPreview.from(source, roads) + val sectionRoad = preview.roads.singleOrNull { it.id == selectedRoadId } + val publicRoadSection = + remember(sectionRoad, section) { + if (sectionRoad == null || section == null) null else StoredLocationRoadSection.publicLine(sectionRoad, section) + } + val sectionOwner = section?.let { selectedRoadId to it } + val suppliedSectionOwner by rememberUpdatedState(sectionOwner) val owner = LocalLifecycleOwner.current val receipt = remember(preview, owner) { AtomicBoolean(true) } val withdrawn = remember(preview, owner) { mutableStateOf(false) } @@ -94,13 +104,13 @@ fun SessionReviewRoadMap( val fontScale = LocalDensity.current.fontScale var attempt by remember(preview, owner) { mutableIntStateOf(0) } val capturedAttempt = attempt - val load = remember(preview, owner, dark, fontScale, capturedAttempt) { mutableStateOf(MapLoadState.LOADING) } + val load = remember(preview, owner, dark, fontScale, capturedAttempt, sectionOwner) { mutableStateOf(MapLoadState.LOADING) } val renderer = remember(load) { AtomicBoolean(true) } val tokens = remember(preview, owner) { AtomicLong() } val token = remember(load) { tokens.incrementAndGet() } val current: () -> Boolean = remember(load) { - { renderer.get() && attempt == capturedAttempt && permitted() } + { renderer.get() && attempt == capturedAttempt && sectionOwner == suppliedSectionOwner && permitted() } } DisposableEffect(renderer) { onDispose { renderer.set(false) } } val nonces = remember(preview, owner) { AtomicLong() } @@ -177,7 +187,7 @@ fun SessionReviewRoadMap( ) { Text(stringResource(R.string.map_retry), style = MaterialTheme.typography.titleMedium) } } } else { - key(preview, owner, dark, fontScale, capturedAttempt) { + key(preview, owner, dark, fontScale, capturedAttempt, sectionOwner) { DemoMap( attempt = capturedAttempt, preview = preview, @@ -226,6 +236,8 @@ fun SessionReviewRoadMap( }, onNativeSelect = { _, _ -> }, canRender = { current() && load.value != MapLoadState.FAILED }, + publicRoadSection = publicRoadSection, + publicRoadSectionColor = MaterialTheme.colorScheme.secondary.toArgb(), modifier = Modifier.matchParentSize().testTag("session_review_road_map_view"), ) } @@ -237,6 +249,13 @@ fun SessionReviewRoadMap( } } } + if (publicRoadSection != null && load.value == MapLoadState.READY) { + Text( + stringResource(R.string.session_review_road_map_public_road_section), + Modifier.testTag("session_review_road_map_public_road_section"), + style = MaterialTheme.typography.bodyLarge, + ) + } if (choice != null) { Text( if (acknowledged.value) { diff --git a/feature/map/src/main/kotlin/io/github/hangillab/hangil/feature/map/StoredLocationMap.kt b/feature/map/src/main/kotlin/io/github/hangillab/hangil/feature/map/StoredLocationMap.kt index 6e9304b..cb8f340 100644 --- a/feature/map/src/main/kotlin/io/github/hangillab/hangil/feature/map/StoredLocationMap.kt +++ b/feature/map/src/main/kotlin/io/github/hangillab/hangil/feature/map/StoredLocationMap.kt @@ -44,12 +44,14 @@ import androidx.lifecycle.repeatOnLifecycle import io.github.hangillab.hangil.core.designsystem.HanGilDimensions import io.github.hangillab.hangil.core.designsystem.HanGilSpacing import io.github.hangillab.hangil.core.designsystem.ParkedButton +import io.github.hangillab.hangil.core.domain.CourseRoadSection import io.github.hangillab.hangil.core.domain.CourseRoadSnapshot import io.github.hangillab.hangil.core.domain.LocationRecordingEvent import io.github.hangillab.hangil.core.domain.LocationRecordingRun import io.github.hangillab.hangil.core.domain.LocationUpdateReading import io.github.hangillab.hangil.core.domain.LocationUpdateSample import io.github.hangillab.hangil.core.domain.SessionReviewRoad +import io.github.hangillab.hangil.core.domain.SessionReviewSample import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow @@ -82,6 +84,7 @@ fun StoredLocationMap( inputChanges: Flow = emptyFlow(), courseRoads: List = emptyList(), onSaveCourseRoad: ((String) -> Unit)? = null, + onSetRoadSection: ((CourseRoadSection?) -> Unit)? = null, ) { val input = remember(run, events, selectedEvent, courseRoads) { @@ -93,6 +96,7 @@ fun StoredLocationMap( val suppliedSelection by rememberUpdatedState(selectedEvent) val suppliedReview by rememberUpdatedState(reviewRoad) val suppliedCourseRoads by rememberUpdatedState(courseRoads) + val suppliedSetRoadSection by rememberUpdatedState(onSetRoadSection) val owner = LocalLifecycleOwner.current val receipt = remember(input, reviewRoad, owner) { AtomicBoolean(true) } val withdrawn = remember(input, reviewRoad, owner) { mutableStateOf(false) } @@ -152,6 +156,7 @@ fun StoredLocationMap( permitted() && previewChoice == preview && withoutBackground == fallback && attempt == capturedAttempt } val load = remember(input, reviewRoad, preview, owner, dark, fontScale, fallback, attempt) { mutableStateOf(MapLoadState.LOADING) } + val suppliedLoad by rememberUpdatedState(load) val onFailed: () -> Unit = { if (mapPermitted()) { if (preview == null && !fallback) { @@ -168,6 +173,7 @@ fun StoredLocationMap( val pointBorder = MaterialTheme.colorScheme.surface.toArgb() val roadColor = MaterialTheme.colorScheme.tertiary.toArgb() val sectionColor = MaterialTheme.colorScheme.onSurface.toArgb() + val publicSectionColor = MaterialTheme.colorScheme.secondary.toArgb() val localBackground = pointBorder.takeIf { preview != null || fallback } val sourceMatches = reviewRoad == null || input == null || reviewRoad.source.copy(endedAtEpochMillis = null) == input.run.source Column(modifier.fillMaxWidth().testTag("stored_location_map"), verticalArrangement = Arrangement.spacedBy(HanGilSpacing.lg)) { @@ -227,6 +233,26 @@ fun StoredLocationMap( ) val selectedSample = (input.selectedEvent?.reading as? LocationUpdateReading.Sample)?.sample val section = remember(input, reviewRoad) { reviewRoad?.let { StoredLocationRoadSuggestion.section(input, it) } } + val anchor = reviewRoad?.sampleAnchor + val savedSection = anchor?.roadSection + val publicSection = + remember(section, reviewRoad, savedSection) { + (savedSection ?: section?.publicRange)?.let { range -> + reviewRoad?.let { StoredLocationRoadSection.publicLine(it.road, range) } + } + } + val publicSectionLegend = + publicSection?.let { + stringResource( + if (savedSection == + null + ) { + R.string.stored_map_public_road_section_preview + } else { + R.string.stored_map_public_road_section_saved + }, + ) + } val sectionLegend = section?.let { stringResource(R.string.stored_map_road_section) } if (sectionLegend != null && load.value == MapLoadState.READY) { Text( @@ -235,6 +261,21 @@ fun StoredLocationMap( style = MaterialTheme.typography.bodyLarge, ) } + if (publicSectionLegend != null && load.value == MapLoadState.READY) { + Text( + publicSectionLegend, + Modifier.fillMaxWidth().testTag( + if (savedSection == + null + ) { + "stored_location_map_public_road_section_preview" + } else { + "stored_location_map_public_road_section_saved" + }, + ), + style = MaterialTheme.typography.bodyLarge, + ) + } if (reviewRoad != null && selectedSample != null) { val meters = remember(selectedSample, reviewRoad.road) { @@ -357,7 +398,9 @@ fun StoredLocationMap( input, mapRoad, preview?.second, - section, + section?.projected, + publicSection, + savedSection == null, localBackground, dark, fontScale, @@ -365,12 +408,13 @@ fun StoredLocationMap( pointBorder, roadColor, sectionColor, + publicSectionColor, mapPermitted, inputChanges = inputChanges, canInteract = { mapPermitted() && load.value == MapLoadState.READY }, onRendered = { if (mapPermitted() && load.value == MapLoadState.LOADING) load.value = MapLoadState.READY }, onFailed = onFailed, - contentDescription = if (sectionLegend == null) description else "$description $sectionLegend", + contentDescription = listOfNotNull(description, sectionLegend, publicSectionLegend).joinToString(" "), modifier = Modifier.matchParentSize().testTag("stored_location_map_view"), ) } @@ -386,6 +430,37 @@ fun StoredLocationMap( } } } + val exactAnchor = + selectedSample != null && anchor?.sameCallback(SessionReviewSample.from(run, checkNotNull(selectedEvent))) == true + if (onSetRoadSection != null && publicSection != null && exactAnchor && load.value == MapLoadState.READY) { + ParkedButton( + onClick = { + if (mapPermitted() && suppliedLoad === load && load.value == MapLoadState.READY && + suppliedSetRoadSection != null + ) { + onSetRoadSection(if (savedSection == null) section?.publicRange else null) + } + }, + modifier = + Modifier.testTag( + if (savedSection == null) "stored_location_map_use_road_section" else "stored_location_map_use_whole_road", + ), + prominent = false, + ) { + Text( + stringResource( + if (savedSection == + null + ) { + R.string.stored_map_use_road_section + } else { + R.string.stored_map_use_whole_road + }, + ), + style = MaterialTheme.typography.titleMedium, + ) + } + } if (reviewRoad != null || (selectedSample != null && input.courseRoads.isNotEmpty())) { Text( stringResource(R.string.map_road_attribution), @@ -510,6 +585,8 @@ private fun NativeStoredLocationMap( road: CourseRoadSnapshot?, nearest: Point?, section: LineString?, + publicSection: LineString?, + publicSectionIsCandidate: Boolean, localBackground: Int?, dark: Boolean, fontScale: Float, @@ -517,6 +594,7 @@ private fun NativeStoredLocationMap( pointBorder: Int, roadColor: Int, sectionColor: Int, + publicSectionColor: Int, canRender: () -> Boolean, inputChanges: Flow, canInteract: () -> Boolean, @@ -537,6 +615,8 @@ private fun NativeStoredLocationMap( road, nearest, section, + publicSection, + publicSectionIsCandidate, localBackground, dark, fontScale, @@ -544,6 +624,7 @@ private fun NativeStoredLocationMap( pointBorder, roadColor, sectionColor, + publicSectionColor, savedState, active, current = { active.get() && canRender() }, @@ -597,6 +678,8 @@ private fun createStoredMap( road: CourseRoadSnapshot?, nearest: Point?, section: LineString?, + publicSection: LineString?, + publicSectionIsCandidate: Boolean, localBackground: Int?, dark: Boolean, fontScale: Float, @@ -604,6 +687,7 @@ private fun createStoredMap( pointBorder: Int, roadColor: Int, sectionColor: Int, + publicSectionColor: Int, savedState: MapViewSavedState, active: AtomicBoolean, current: () -> Boolean, @@ -663,6 +747,9 @@ private fun createStoredMap( RoadPreviewLayers.select(loaded, it.id) } section?.let { StoredLocationRoadSection.add(loaded, it, sectionColor) } + publicSection?.let { + StoredLocationRoadSection.addPublic(loaded, it, publicSectionColor, publicSectionIsCandidate) + } val isPreview = nearest != null if (!isPreview) StoredLocationTrace.add(loaded, input, pointFill) StoredLocationPoint.add(loaded, input, pointFill, pointBorder, fontScale, selectedOnly = isPreview) diff --git a/feature/map/src/main/kotlin/io/github/hangillab/hangil/feature/map/StoredLocationRoadSection.kt b/feature/map/src/main/kotlin/io/github/hangillab/hangil/feature/map/StoredLocationRoadSection.kt index 81c1fe4..f49345e 100644 --- a/feature/map/src/main/kotlin/io/github/hangillab/hangil/feature/map/StoredLocationRoadSection.kt +++ b/feature/map/src/main/kotlin/io/github/hangillab/hangil/feature/map/StoredLocationRoadSection.kt @@ -1,19 +1,51 @@ package io.github.hangillab.hangil.feature.map +import io.github.hangillab.hangil.core.domain.CourseRoadSection +import io.github.hangillab.hangil.core.domain.CourseRoadSnapshot import org.maplibre.android.maps.Style import org.maplibre.android.style.layers.LineLayer import org.maplibre.android.style.layers.Property import org.maplibre.android.style.layers.PropertyFactory.lineCap import org.maplibre.android.style.layers.PropertyFactory.lineColor +import org.maplibre.android.style.layers.PropertyFactory.lineDasharray import org.maplibre.android.style.layers.PropertyFactory.lineJoin import org.maplibre.android.style.layers.PropertyFactory.lineWidth import org.maplibre.android.style.sources.GeoJsonSource import org.maplibre.geojson.Feature import org.maplibre.geojson.LineString +import org.maplibre.geojson.Point internal object StoredLocationRoadSection { const val SOURCE = "hangil-stored-location-road-section" const val LAYER = "hangil-stored-location-road-section-line" + const val PUBLIC_SOURCE = "hangil-planned-road-section" + const val PUBLIC_LAYER = "hangil-planned-road-section-line" + + fun publicLine( + road: CourseRoadSnapshot, + section: CourseRoadSection, + ): LineString? = + section.takeIf { it.isValidFor(road) }?.let { + LineString.fromLngLats(it.nodesOf(road).map { node -> Point.fromLngLat(node.longitude, node.latitude) }) + } + + fun addPublic( + style: Style, + section: LineString, + color: Int, + candidate: Boolean, + ) { + style.addSource(GeoJsonSource(PUBLIC_SOURCE, Feature.fromGeometry(section))) + val line = + LineLayer(PUBLIC_LAYER, PUBLIC_SOURCE).withProperties( + lineColor(color), + lineWidth(7f), + lineCap(Property.LINE_CAP_ROUND), + lineJoin(Property.LINE_JOIN_ROUND), + ) + if (candidate) line.setProperties(lineDasharray(arrayOf(2f, 2f))) + style.addLayer(line) + } fun add( style: Style, diff --git a/feature/map/src/main/kotlin/io/github/hangillab/hangil/feature/map/StoredLocationRoadSuggestion.kt b/feature/map/src/main/kotlin/io/github/hangillab/hangil/feature/map/StoredLocationRoadSuggestion.kt index 7bba9f2..2f54405 100644 --- a/feature/map/src/main/kotlin/io/github/hangillab/hangil/feature/map/StoredLocationRoadSuggestion.kt +++ b/feature/map/src/main/kotlin/io/github/hangillab/hangil/feature/map/StoredLocationRoadSuggestion.kt @@ -1,5 +1,6 @@ package io.github.hangillab.hangil.feature.map +import io.github.hangillab.hangil.core.domain.CourseRoadSection import io.github.hangillab.hangil.core.domain.CourseRoadSnapshot import io.github.hangillab.hangil.core.domain.LocationRecordingEvent import io.github.hangillab.hangil.core.domain.LocationUpdateReading @@ -26,9 +27,10 @@ internal object StoredLocationRoadSuggestion { fun section( input: StoredLocationMapInput, review: SessionReviewRoad, - ): LineString? { + ): Suggestion? { val event = input.selectedEvent ?: return null - if (event.reading !is LocationUpdateReading.Sample || SessionReviewSample.from(input.run, event) != review.sampleAnchor) return null + val anchor = review.sampleAnchor ?: return null + if (event.reading !is LocationUpdateReading.Sample || !anchor.sameCallback(SessionReviewSample.from(input.run, event))) return null val match = match(input, event)?.takeIf { it.road == review.road } ?: return null return try { projectedSection(match) @@ -70,7 +72,7 @@ internal object StoredLocationRoadSuggestion { return if (samples.drop(1).all { nearestRoad(it, input.courseRoads) == nearest }) Match(nearest, samples) else null } - private fun projectedSection(match: Match): LineString? { + private fun projectedSection(match: Match): Suggestion? { val line = match.road.nodes.map { Point.fromLngLat(it.longitude, it.latitude) } val segments = line.zipWithNext() val offsets = segments.map { (start, end) -> distance(start, end) }.runningFold(0.0, Double::plus) @@ -121,7 +123,8 @@ internal object StoredLocationRoadSuggestion { val section = listOf(first.point) + line.subList(first.index + 1, last.index + 1) + last.point val points = section.filterIndexed { index, point -> index == 0 || point != section[index - 1] } - return points.takeIf { it.size >= 2 && it.distinct().size == it.size }?.let(LineString::fromLngLats) + if (points.size < 2 || points.distinct().size != points.size) return null + return Suggestion(LineString.fromLngLats(points), CourseRoadSection(first.index, last.index + 1)) } private fun distance( @@ -152,6 +155,11 @@ internal object StoredLocationRoadSuggestion { Point.fromLngLat(last.longitude, last.latitude), ) + data class Suggestion( + val projected: LineString, + val publicRange: CourseRoadSection, + ) + private data class Match( val road: CourseRoadSnapshot, val samples: List, diff --git a/feature/map/src/main/res/values/strings.xml b/feature/map/src/main/res/values/strings.xml index ccfce0b..74d46f2 100644 --- a/feature/map/src/main/res/values/strings.xml +++ b/feature/map/src/main/res/values/strings.xml @@ -75,6 +75,10 @@ 지도 배경 다시 불러오기 고른 도로가 지도의 위도 범위를 벗어나 함께 표시할 수 없어요. 표본 원본과 복습 도로를 따로 확인해 주세요. 가장 굵은 선은 연속 표본에 가까운 도로 구간이에요. 실제로 지나간 길은 미확인이에요. + 굵은 색 점선은 다음 연습에 사용할 수 있는 공개 도로 구간이에요. + 굵은 색 선으로 표시한 공개 도로 구간을 다음 연습에 사용할 수 있어요. + 다음 연습에 이 구간 사용 + 다음 연습에 전체 도로 사용 저장한 도로선까지 약 %1$d m 저장한 도로선까지 1 m 미만 저장한 도로선까지의 거리를 확인하지 못했어요. @@ -89,6 +93,7 @@ %1$s 미리보기. 큰 점은 선택한 저장 표본이고 작은 테두리 점은 선택 표본에 가장 가까운 도로선 위 지점이에요. 이 미리보기는 배경 타일 없이 그려요. %1$s 저장 고정된 공개 도로 모양이에요. 실제 이동 경로나 위치 표본과 연결한 자료가 아니에요. + 색으로 강조한 공개 도로 구간을 다음 연습에 사용할 수 있어요. %1$s 복습의 공개 도로 모양. %2$s. 실제 이동 경로와 현재 위치를 나타내지 않아요. 주차와 화면 조작 허용을 확인한 뒤 도로 지도를 다시 열어 주세요. 도로 자료나 선택을 지도에 표시할 수 없어요. 복습을 다시 열어 주세요. diff --git a/feature/map/src/test/kotlin/io/github/hangillab/hangil/feature/map/StoredLocationRoadSuggestionTest.kt b/feature/map/src/test/kotlin/io/github/hangillab/hangil/feature/map/StoredLocationRoadSuggestionTest.kt index 85a229b..928d1d4 100644 --- a/feature/map/src/test/kotlin/io/github/hangillab/hangil/feature/map/StoredLocationRoadSuggestionTest.kt +++ b/feature/map/src/test/kotlin/io/github/hangillab/hangil/feature/map/StoredLocationRoadSuggestionTest.kt @@ -1,6 +1,7 @@ package io.github.hangillab.hangil.feature.map import io.github.hangillab.hangil.core.domain.CourseReviewStatus +import io.github.hangillab.hangil.core.domain.CourseRoadSection import io.github.hangillab.hangil.core.domain.CourseRoadSnapshot import io.github.hangillab.hangil.core.domain.LocationRecordingEvent import io.github.hangillab.hangil.core.domain.LocationRecordingRun @@ -122,7 +123,9 @@ class StoredLocationRoadSuggestionTest { roads = listOf(curved, otherRoad), ) val section = checkNotNull(StoredLocationRoadSuggestion.section(forward, linkedReview(forward, curved))) - val points = section.coordinates() + val points = section.projected.coordinates() + assertEquals(CourseRoadSection(0, curved.nodes.lastIndex), section.publicRange) + assertEquals(curved.nodes, section.publicRange.nodesOf(curved)) assertEquals(3, points.size) assertEquals(Point.fromLngLat(bend.longitude, bend.latitude), points[1]) assertEquals(readings.first().sample.longitude, points.first().longitude(), 0.00001) @@ -146,7 +149,14 @@ class StoredLocationRoadSuggestionTest { val readings = listOf(sample(-0.00005, 1_000, latitude), sample(0.00010, 2_000, latitude), sample(0.00025, 3_000, latitude)) val captured = input(readings, roads = listOf(split)) val section = checkNotNull(StoredLocationRoadSuggestion.section(captured, linkedReview(captured, split))) - assertEquals(readings.last().sample.longitude, section.coordinates().last().longitude(), 0.00001) + assertEquals( + readings.last().sample.longitude, + section.projected + .coordinates() + .last() + .longitude(), + 0.00001, + ) } @Test fun `dense nodes on a straight road preserve the same section endpoints`() { @@ -160,12 +170,15 @@ class StoredLocationRoadSuggestionTest { val latitude = BASE_LATITUDE + 0.00009 val readings = listOf(sample(-0.00037, 1_000, latitude), sample(0.0, 2_000, latitude), sample(0.00037, 3_000, latitude)) val sparseInput = input(readings, roads = listOf(nearRoad)) - val sparse = checkNotNull(StoredLocationRoadSuggestion.section(sparseInput, linkedReview(sparseInput))).coordinates() + val sparse = checkNotNull(StoredLocationRoadSuggestion.section(sparseInput, linkedReview(sparseInput))).projected.coordinates() val denseInput = input(readings, roads = listOf(dense)) - val detailed = checkNotNull(StoredLocationRoadSuggestion.section(denseInput, linkedReview(denseInput, dense))).coordinates() - assertEquals(sparse.first().longitude(), detailed.first().longitude(), 0.00001) - assertEquals(sparse.last().longitude(), detailed.last().longitude(), 0.00001) - assertTrue(detailed.size > sparse.size) + val detailed = checkNotNull(StoredLocationRoadSuggestion.section(denseInput, linkedReview(denseInput, dense))) + assertEquals(CourseRoadSection(7, 13), detailed.publicRange) + assertEquals(dense.nodes.subList(7, 14), detailed.publicRange.nodesOf(dense)) + val points = detailed.projected.coordinates() + assertEquals(sparse.first().longitude(), points.first().longitude(), 0.00001) + assertEquals(sparse.last().longitude(), points.last().longitude(), 0.00001) + assertTrue(points.size > sparse.size) } @Test fun `two nearby arms of the same road cannot resolve a section`() {