diff --git a/.gitignore b/.gitignore
index 09c3b8a0..606d836c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,6 +13,9 @@
.gradle/
build/
+# .omx
+.omx
+
# Local configuration file (sdk path, etc)
local.properties
diff --git a/nureongi/.gitignore b/nureongi/.gitignore
new file mode 100644
index 00000000..adfa9bff
--- /dev/null
+++ b/nureongi/.gitignore
@@ -0,0 +1,19 @@
+*.iml
+.kotlin
+.gradle
+**/build/
+xcuserdata
+!src/**/build/
+local.properties
+.idea
+.DS_Store
+captures
+.externalNativeBuild
+.cxx
+*.xcodeproj/*
+!*.xcodeproj/project.pbxproj
+!*.xcodeproj/xcshareddata/
+!*.xcodeproj/project.xcworkspace/
+!*.xcworkspace/contents.xcworkspacedata
+**/xcshareddata/WorkspaceSettings.xcsettings
+node_modules/
diff --git a/nureongi/AGENTS.md b/nureongi/AGENTS.md
new file mode 100644
index 00000000..6aca4125
--- /dev/null
+++ b/nureongi/AGENTS.md
@@ -0,0 +1,5 @@
+# AGENTS.md
+
+## UI 작업
+
+UI 화면을 새로 만들거나 수정할 때는 먼저 [`docs/ui/RULE.md`](nureongi/docs/ui/RULE.md) 를 읽고 그 규칙을 따릅니다.
diff --git a/nureongi/README.md b/nureongi/README.md
new file mode 100644
index 00000000..ca0d5756
--- /dev/null
+++ b/nureongi/README.md
@@ -0,0 +1,31 @@
+This is a Kotlin Multiplatform project targeting Android, iOS.
+
+* [/iosApp](./iosApp/iosApp) contains an iOS application. Even if you’re sharing your UI with Compose Multiplatform,
+ you need this entry point for your iOS app. This is also where you should add SwiftUI code for your project.
+
+* [/shared](./shared/src) is for code that will be shared across your Compose Multiplatform applications.
+ It contains several subfolders:
+ - [commonMain](./shared/src/commonMain/kotlin) is for code that’s common for all targets.
+ - Other folders are for Kotlin code that will be compiled for only the platform indicated in the folder name.
+ For example, if you want to use Apple’s CoreCrypto for the iOS part of your Kotlin app,
+ the [iosMain](./shared/src/iosMain/kotlin) folder would be the right place for such calls.
+ Similarly, if you want to edit the Desktop (JVM) specific part, the [jvmMain](./shared/src/jvmMain/kotlin)
+ folder is the appropriate location.
+
+### Running the apps
+
+Use the run configurations provided by the run widget in your IDE's toolbar. You can also use these commands and options:
+
+- Android app: `./gradlew :androidApp:assembleDebug`
+- iOS app: open the [/iosApp](./iosApp) directory in Xcode and run it from there.
+
+### Running tests
+
+Use the run button in your IDE's editor gutter, or run tests using Gradle tasks:
+
+- Android tests: `./gradlew :shared:testAndroidHostTest`
+- iOS tests: `./gradlew :shared:iosSimulatorArm64Test`
+
+---
+
+Learn more about [Kotlin Multiplatform](https://www.jetbrains.com/help/kotlin-multiplatform-dev/get-started.html)…
\ No newline at end of file
diff --git a/nureongi/androidApp/build.gradle.kts b/nureongi/androidApp/build.gradle.kts
new file mode 100644
index 00000000..6d03bb5b
--- /dev/null
+++ b/nureongi/androidApp/build.gradle.kts
@@ -0,0 +1,48 @@
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
+
+plugins {
+ alias(libs.plugins.androidApplication)
+ alias(libs.plugins.composeMultiplatform)
+ alias(libs.plugins.composeCompiler)
+}
+
+kotlin {
+ compilerOptions {
+ jvmTarget = JvmTarget.JVM_11
+ }
+}
+dependencies {
+ implementation(projects.shared)
+
+ implementation(libs.androidx.activity.compose)
+
+ implementation(libs.compose.uiToolingPreview)
+ debugImplementation(libs.compose.uiTooling)
+}
+
+android {
+ namespace = "com.woowa.nureongi"
+ compileSdk = libs.versions.android.compileSdk.get().toInt()
+
+ defaultConfig {
+ applicationId = "com.woowa.nureongi"
+ minSdk = libs.versions.android.minSdk.get().toInt()
+ targetSdk = libs.versions.android.targetSdk.get().toInt()
+ versionCode = 1
+ versionName = "1.0"
+ }
+ packaging {
+ resources {
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ }
+ }
+ buildTypes {
+ getByName("release") {
+ isMinifyEnabled = false
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ }
+}
\ No newline at end of file
diff --git a/nureongi/androidApp/src/main/AndroidManifest.xml b/nureongi/androidApp/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..257d813c
--- /dev/null
+++ b/nureongi/androidApp/src/main/AndroidManifest.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/nureongi/androidApp/src/main/kotlin/com/woowa/nureongi/MainActivity.kt b/nureongi/androidApp/src/main/kotlin/com/woowa/nureongi/MainActivity.kt
new file mode 100644
index 00000000..7032bf27
--- /dev/null
+++ b/nureongi/androidApp/src/main/kotlin/com/woowa/nureongi/MainActivity.kt
@@ -0,0 +1,25 @@
+package com.woowa.nureongi
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.tooling.preview.Preview
+
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ enableEdgeToEdge()
+ super.onCreate(savedInstanceState)
+
+ setContent {
+ App()
+ }
+ }
+}
+
+@Preview
+@Composable
+fun AppAndroidPreview() {
+ App()
+}
\ No newline at end of file
diff --git a/nureongi/androidApp/src/main/res/drawable-v24/ic_launcher_foreground.xml b/nureongi/androidApp/src/main/res/drawable-v24/ic_launcher_foreground.xml
new file mode 100644
index 00000000..2b068d11
--- /dev/null
+++ b/nureongi/androidApp/src/main/res/drawable-v24/ic_launcher_foreground.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/nureongi/androidApp/src/main/res/drawable/ic_launcher_background.xml b/nureongi/androidApp/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 00000000..e93e11ad
--- /dev/null
+++ b/nureongi/androidApp/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,170 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/nureongi/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/nureongi/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 00000000..eca70cfe
--- /dev/null
+++ b/nureongi/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/nureongi/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/nureongi/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 00000000..eca70cfe
--- /dev/null
+++ b/nureongi/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/nureongi/androidApp/src/main/res/mipmap-hdpi/ic_launcher.png b/nureongi/androidApp/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 00000000..a571e600
Binary files /dev/null and b/nureongi/androidApp/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/nureongi/androidApp/src/main/res/mipmap-hdpi/ic_launcher_round.png b/nureongi/androidApp/src/main/res/mipmap-hdpi/ic_launcher_round.png
new file mode 100644
index 00000000..61da551c
Binary files /dev/null and b/nureongi/androidApp/src/main/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/nureongi/androidApp/src/main/res/mipmap-mdpi/ic_launcher.png b/nureongi/androidApp/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 00000000..c41dd285
Binary files /dev/null and b/nureongi/androidApp/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/nureongi/androidApp/src/main/res/mipmap-mdpi/ic_launcher_round.png b/nureongi/androidApp/src/main/res/mipmap-mdpi/ic_launcher_round.png
new file mode 100644
index 00000000..db5080a7
Binary files /dev/null and b/nureongi/androidApp/src/main/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/nureongi/androidApp/src/main/res/mipmap-xhdpi/ic_launcher.png b/nureongi/androidApp/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 00000000..6dba46da
Binary files /dev/null and b/nureongi/androidApp/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/nureongi/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/nureongi/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_round.png
new file mode 100644
index 00000000..da31a871
Binary files /dev/null and b/nureongi/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/nureongi/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher.png b/nureongi/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 00000000..15ac6817
Binary files /dev/null and b/nureongi/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/nureongi/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/nureongi/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
new file mode 100644
index 00000000..b216f2d3
Binary files /dev/null and b/nureongi/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/nureongi/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/nureongi/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 00000000..f25a4197
Binary files /dev/null and b/nureongi/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/nureongi/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/nureongi/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
new file mode 100644
index 00000000..e96783cc
Binary files /dev/null and b/nureongi/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/nureongi/androidApp/src/main/res/values/strings.xml b/nureongi/androidApp/src/main/res/values/strings.xml
new file mode 100644
index 00000000..ef27a069
--- /dev/null
+++ b/nureongi/androidApp/src/main/res/values/strings.xml
@@ -0,0 +1,3 @@
+
+ Nureongi
+
\ No newline at end of file
diff --git a/nureongi/build.gradle.kts b/nureongi/build.gradle.kts
new file mode 100644
index 00000000..14086257
--- /dev/null
+++ b/nureongi/build.gradle.kts
@@ -0,0 +1,8 @@
+plugins {
+ alias(libs.plugins.androidApplication) apply false
+ alias(libs.plugins.androidMultiplatformLibrary) apply false
+ alias(libs.plugins.composeMultiplatform) apply false
+ alias(libs.plugins.composeCompiler) apply false
+ alias(libs.plugins.kotlinMultiplatform) apply false
+ alias(libs.plugins.kotlinSerialization) apply false
+}
diff --git "a/nureongi/docs/log/\352\263\265\355\206\265_UI_\354\273\264\355\217\254\353\204\214\355\212\270_\354\204\244\352\263\204_\352\270\260\353\241\235.md" "b/nureongi/docs/log/\352\263\265\355\206\265_UI_\354\273\264\355\217\254\353\204\214\355\212\270_\354\204\244\352\263\204_\352\270\260\353\241\235.md"
new file mode 100644
index 00000000..33ba0bc0
--- /dev/null
+++ "b/nureongi/docs/log/\352\263\265\355\206\265_UI_\354\273\264\355\217\254\353\204\214\355\212\270_\354\204\244\352\263\204_\352\270\260\353\241\235.md"
@@ -0,0 +1,375 @@
+# 누렁이 공통 UI 컴포넌트 설계 기록
+
+`docs/ui/RULE.md`, `AGENTS.md`, `docs/ui` 스크린샷을 기준으로
+`nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/` 아래에
+공통 컴포넌트를 새로 작성하면서 내린 판단과 그 근거, 장단점을 정리한다.
+
+## 1. 패키지 구조: theme / model / component 분리
+
+**결정**: `ui/theme`(디자인 토큰), `ui/model`(UI 전용 DTO), `ui/component`(공통 컴포넌트)
+세 패키지로 나누었다.
+
+- **근거**: RULE.md 1번 규칙("공통 컴포넌트는 비즈니스 로직을 포함하지 않는다")을 지키려면
+ "표시할 값의 형태(모델)"와 "그리는 방법(컴포넌트)"을 물리적으로도 분리해 두는 편이
+ 서로 섞이는 것을 막기 쉽다고 판단했다. 컬러·타이포 같은 디자인 토큰도 컴포넌트
+ 내부에 흩어지면 다크 테마/접근성 대비 기준을 일괄로 바꾸기 어려워지므로 별도 패키지로
+ 뺐다.
+- **장점**: 새로운 화면을 만들 때 "이 값을 UI 모델로 변환했는가", "이 색은 토큰에서
+ 가져왔는가"를 패키지 단위로 점검할 수 있다. RULE.md에 적은 grep 검증
+ (`domain.` 패키지 import 검색 등)도 패키지 경계가 분명해야 의미가 있다.
+- **단점/트레이드오프**: 아직 화면(스크린)이 하나도 없는 시점에 패키지부터 나누는 것은
+ 과설계로 보일 수 있다. 다만 "공통 컴포넌트를 먼저 만든다"는 이번 작업의 목적 자체가
+ 구조를 먼저 잡고 화면에서 재사용하는 흐름이라, 지금 단계에서 분리해 두는 것이
+ 나중에 한꺼번에 옮기는 비용보다 싸다고 판단했다.
+
+## 2. 디자인 토큰: 색상 16진수 값을 직접 명시
+
+**결정**: `NureongiColors`에 스크린샷에서 추정한 16진수 컬러 값을
+(`Background = 0xFF121212`, `Accent = 0xFFFFC400` 등) 직접 정의했다.
+
+- **근거**: 디자이너가 정한 정확한 컬러 값(피그마 등)을 받지 못한 상태였다.
+ 스크린샷을 보고 추정한 값을 쓰되, 한 곳(`NureongiColors`)에만 정의해 두면
+ 나중에 정확한 값으로 교체할 때 컴포넌트 코드를 건드릴 필요가 없다.
+- **장점**: 색이 한 곳에 모여 있어 "명도 대비가 WCAG AA를 만족하는가"를 한 파일만
+ 보고 검토할 수 있다. RULE.md 2번 접근성 규칙과 직접 연결된다.
+- **단점**: 지금 정의한 값은 추정치이므로, 실제 디자인 산출물이 나오면 반드시
+ 교체가 필요하다. (의도적으로 "추정값"이라는 사실을 숨기지 않기 위해, 이 문서와
+ `RULE.md`에도 스크린샷 기준이라는 점을 명시해 두었다.)
+
+## 3. UI 모델(DTO)을 기본 타입 조합으로 최소화
+
+**결정**: `PlaceUiModel(name, location)`, `RouteNodeUiModel(row, column, label, state)`,
+`StatItemUiModel(value, label)` 처럼 화면에 "그대로 표시할 문자열/열거형"만 담는
+작은 DTO를 만들었다. 도메인의 장소·경로·거리 계산 모델은 참조하지 않는다.
+
+- **근거**: RULE.md 1번 규칙에서 사용자가 별도로 강조한 "도메인 모델보다 기본값/DTO
+ 위주로 공통 컴포넌트를 만들 것"을 그대로 반영했다. 변환(도메인 → UI 모델)은
+ 화면/매퍼 계층의 책임으로 미뤄, 공통 컴포넌트는 도메인이 바뀌어도 영향을 받지 않게
+ 했다.
+- **장점**: 컴포넌트의 `@Preview`를 ViewModel 없이 더미 DTO만으로 그릴 수 있다
+ (실제로 모든 컴포넌트의 프리뷰가 이렇게 동작한다 — "Preview가 추가 설정 없이
+ 렌더링되는가"가 곧 로직 분리 검증 수단이 된다는 RULE.md의 테스트 방법을 그대로
+ 실천한 것).
+- **단점/트레이드오프**: 화면에서 도메인 모델 → UI 모델 매핑 코드를 별도로 작성해야
+ 하는 비용이 생긴다. 다만 그 비용은 "공통 컴포넌트가 도메인 변화에 흔들리지 않는다"는
+ 이득과 맞바꾸는 것이라 판단했다.
+
+## 4. 상태 호이스팅: 선택·활성화 상태를 모두 파라미터로 받음
+
+**결정**: `PlaceListItem`의 `selected`, `CtaButton`의 `enabled`,
+`NavigationTopBar`의 `currentStep` 등 모든 "현재 상태"를 컴포넌트 내부
+`remember`로 갖지 않고 파라미터로 받았다. 내부에 상태를 둔 곳은 프리뷰 코드뿐이다.
+
+- **근거**: RULE.md 1번 규칙의 "로직을 위로 끌어올리기(state hoisting)"를 위함이다.
+ 공통 컴포넌트가 상태를 가지면, 화면이 그 상태를 알아야 할 때 다시 끌어올려야 하는
+ 이중 작업이 생긴다.
+- **장점**: 화면(ViewModel)이 단일 진실 공급원이 되고, 컴포넌트는 순수 함수에 가깝게
+ 유지된다. Compose UI 테스트에서 `selected = true/false`를 직접 주입해 모든 상태를
+ 손쉽게 검증할 수 있다.
+- **단점**: 호출하는 쪽(화면)에서 상태 관리 코드가 늘어난다. 그러나 이는 Compose의
+ 권장 패턴(Now in Android 등 공식 샘플)과 일치하므로 RULE.md 3번 규칙과도
+ 부합한다고 판단했다.
+
+## 5. 접근성: 장식 vs 정보 아이콘을 구분해서 시맨틱 처리
+
+**결정**: `BrailleIcon`처럼 순수 장식인 요소는 `clearAndSetSemantics {}`로 스크린
+리더에서 제외하고, `PlaceListItem`의 선택 상태는 `selected`, `CtaButton`의
+비활성 상태는 `disabled()`, `SegmentedProgressIndicator`는 `progressBarRangeInfo`,
+`DirectionGuideCard`/`StatInfoCard`는 `mergeDescendants` + `contentDescription`으로
+정보를 하나의 의미 단위로 묶어 전달하도록 했다.
+
+- **근거**: RULE.md 2번 규칙("시각장애인을 위한 접근성을 최우선으로 고려")을
+ 구체적인 코드로 옮긴 것이다. 정보가 없는 장식 요소까지 스크린 리더가 읽으면
+ 오히려 사용자의 탐색을 방해하므로, "읽어야 할 것"과 "읽지 않아도 될 것"을
+ 명확히 나누는 것이 중요하다고 판단했다.
+- **장점**: TalkBack 등으로 테스트했을 때 "2번 출구, 지상 광장 방면, 선택됨"처럼
+ 자연스러운 문장으로 읽히고, 불필요한 "이미지" 같은 잡음이 줄어든다.
+- **단점/한계**: 실제 기기에서 TalkBack으로 들어보지 않으면 의미 단위가 자연스러운지
+ 확신하기 어렵다. 이번 작업은 컴파일 검증까지만 했고, 실기기 음성 검증은
+ RULE.md에 적은 "테스트 방법"으로 남겨 두었다 — 화면이 만들어진 뒤 반드시
+ 진행해야 한다.
+
+## 6. CTA 버튼의 "강조 스타일"을 enum으로 분리
+
+**결정**: 옐로우 배경(기본 CTA)과 화이트 배경("새 목적지 안내" 같은 보조 CTA)을
+`CtaButtonEmphasis { ACCENT, NEUTRAL }` enum 파라미터로 표현했다.
+
+- **근거**: 스크린샷상 두 버튼은 모양(둥근 모서리, 전체 너비, 텍스트 중앙 정렬)은
+ 같고 색상 의미만 다르다. 별도 컴포넌트로 쪼개면 모양이 바뀔 때 두 곳을 고쳐야
+ 하므로, "같은 모양 + 다른 의미"를 enum으로 표현하는 쪽을 택했다.
+- **장점**: 새로운 강조 단계가 필요해지면 enum 값만 추가하면 되고, 레이아웃
+ 코드는 한 곳에만 있다.
+- **단점**: enum 분기 로직(`when`)이 컴포넌트 내부에 생긴다. 강조 단계가
+ 지금처럼 2~3가지를 넘어 다양해지면, enum보다 색상을 직접 파라미터로 받는
+ 방식으로 리팩터링하는 편이 나을 수 있다 — 지금은 과설계를 피하기 위해
+ 최소 단위(enum)로 시작했다.
+
+> **후기(16번 항목 참고)**: 실제로 `emphasis` 값은 끝까지 `ACCENT`/`NEUTRAL`
+> 두 가지를 넘지 않았고, 병렬 리뷰에서 "실사용처가 1곳뿐인 enum"이라는
+> 지적을 받아 `CtaButtonEmphasis` enum을 제거하고 `containerColor`/
+> `contentColor`를 직접 받는 방식으로 리팩터링했다. 이 절의 "단점"에서
+> 예상한 방향대로 바뀐 셈이라, 결정 자체보다는 "언제 다음 단계로 넘어갈
+> 신호를 볼 것인가"를 미리 적어 둔 기록으로 남긴다.
+
+## 7. RouteMapCard: 좌표 계산 없이 "이미 계산된 노드"만 그림
+
+**결정**: `RouteMapCard`는 경로 탐색이나 좌표 변환을 하지 않고,
+`RouteNodeUiModel(row, column, label, state)` 목록을 받아 `Canvas`로 점-선
+다이어그램을 그리기만 한다. 배경 격자(점선)는 `rows x columns` 크기로 항상
+그려지는 장식이고, 실제 경로는 `path` 목록 순서대로 선으로 이어 그린다.
+
+- **근거**: 이 카드가 가장 "로직처럼 보이기 쉬운" 컴포넌트였다 — 실제 역사 구조,
+ 최단 경로 계산 등은 명백히 도메인 책임이다. RULE.md 1번 규칙을 지키려면
+ "이미 계산된 결과를 어떻게 배치해서 그릴지"만 컴포넌트가 맡고, "어떤 경로가
+ 최적인지"는 절대 들어오지 않도록 경계를 분명히 그어야 한다고 판단했다.
+- **장점**: 경로 탐색 알고리즘이 바뀌어도 이 컴포넌트는 전혀 영향을 받지 않는다.
+ `Canvas` 기반이라 노드 수·격자 크기가 달라져도 같은 코드로 그릴 수 있다.
+- **단점/한계**: `Canvas`로 직접 그리다 보니 다른 컴포넌트보다 코드가 길고,
+ 접근성 정보를 시각 요소 각각에 줄 수 없어 카드 전체를 하나의
+ `contentDescription`(예: "한빛역 점자 블럭 지도 경로: 개찰구 → 갈림길 → 2번 출구")
+ 으로 뭉뚱그렸다. 더 정교한 음성 안내가 필요하면, 지도 자체보다는
+ `DirectionGuideCard`의 문장형 안내에 정보를 싣는 쪽이 맞다고 본다.
+
+## 8. Preview를 컴포넌트 파일에 private으로 동봉 (별도 파일 → 인라인으로 변경)
+
+**결정**: 처음에는 `ComponentPreviews.kt` 한 파일에 모든 프리뷰를 모았다가,
+사용자 요청에 따라 각 컴포넌트 파일 하단에 `private @Preview` 함수로 옮겼다.
+
+- **근거**: "공통 컴포넌트가 ViewModel 없이 더미 상태만으로 그려지는가"를 검증하는
+ 수단이 프리뷰이므로, 검증 코드는 검증 대상 옆에 있는 것이 더 직관적이다
+ (다른 사람이 컴포넌트를 고칠 때 프리뷰도 함께 보면서 고치게 된다).
+- **장점**: 컴포넌트와 프리뷰가 같은 파일에 있어 변경 시 누락하기 어렵고,
+ `private`이라 다른 곳에서 참조되지 않는다는 것이 코드만 봐도 드러난다.
+- **단점**: 프리뷰끼리 공유하던 작은 헬퍼(공통 배경 Modifier 등)를 각 파일에서
+ 살짝씩 중복 작성하게 됐다. 중복은 각 파일이 3~5줄 수준으로 작아 추상화를
+ 도입할 정도는 아니라고 판단해 그대로 두었다("세 줄의 비슷한 코드가 섣부른
+ 추상화보다 낫다").
+
+## 9. 빌드 검증 범위: commonMain 메타데이터 + Android 컴파일까지만
+
+**결정**: `./gradlew :shared:compileCommonMainKotlinMetadata` 와
+`:shared:compileAndroidHostTestSources` 두 태스크로 컴파일 성공만 확인했다.
+실제 기기·에뮬레이터 실행이나 TalkBack 검증은 하지 않았다.
+
+- **근거**: 이번 작업은 "화면이 아직 없는 상태에서 공통 컴포넌트를 먼저 만드는 것"이라
+ 실행 가능한 화면이 없다. 컴파일 검증으로 타입·시그니처 오류를 우선 잡고,
+ RULE.md에 적어 둔 실기기 검증(Accessibility Scanner, TalkBack)은 화면이 붙는
+ 다음 단계에서 진행하는 것이 합리적이라고 판단했다.
+- **장점**: 빠르게 반복하며 컴파일 오류(예: `Arrangement.spacedBy` 임포트 실수,
+ `weight`가 `RowScope`/`ColumnScope` 확장 함수라 잘못 임포트하면 충돌하는 문제 등)를
+ 먼저 걷어낼 수 있었다.
+- **한계**: 실제 다크 테마 명도 대비, 터치 타겟 크기, TalkBack 발화 순서는
+ 코드만으로는 확인할 수 없다. **이 부분은 화면을 조립한 뒤 RULE.md의 테스트
+ 방법대로 반드시 추가 검증이 필요하다.**
+
+## 10. Preview 렌더링 오류 수정: 벡터 드로어블의 프레임워크 색상 참조 제거
+
+**결정**: `ic_volume.xml`의 두 `path`에 쓰인 `android:fillColor="@android:color/transparent"`를
+리터럴 ARGB 값 `#00000000`으로 교체했다.
+
+- **근거**: `VoiceGuideButton`의 `@Preview`를 렌더링할 때
+ `IllegalArgumentException: Invalid color value @android:color/transparent`가 발생했다.
+ Compose Multiplatform 리소스 시스템(`org.jetbrains.compose.resources`)은 벡터
+ 드로어블을 자체 파서로 해석하기 때문에 `@android:color/...` 같은 안드로이드
+ 프레임워크 리소스 참조를 해석하지 못한다. 프레임워크에 의존하지 않는 리터럴
+ hex 값으로 바꾸면 동일한 "완전 투명" 의미를 유지하면서 파싱 문제를 없앨 수 있다.
+- **장점**: 추가 설정 없이 `painterResource`로 그대로 그릴 수 있고, 다른 플랫폼
+ (iOS 등)에서도 동일하게 동작한다 — 애초에 멀티플랫폼 리소스에는 플랫폼 종속
+ 참조를 쓰지 않는 편이 안전하다.
+- **단점/주의**: 새 벡터 드로어블을 추가할 때도 `@android:color/...`,
+ `@color/...`처럼 프레임워크·앱 리소스를 참조하는 속성이 없는지 확인해야 한다는
+ 규칙이 하나 더 생긴 셈이다.
+
+## 11. 네이밍 정비: `title`/`description` 같은 범용 이름을 역할이 드러나는 이름으로 교체
+
+**결정**: 사용자가 지적한 대로, "표시 위치(제목/설명)"만 가리키는 범용 이름을
+"무엇을 담는 값인지" 드러내는 이름으로 바꿨다.
+
+- `PlaceUiModel`: `title → name`(장소 이름, 예: "1번 출구"),
+ `description → location`(위치 정보, 예: "지상 · 버스정류장 방면")
+- `DirectionGuideCard`: `title → instruction`(핵심 안내 문구, 예: "8m 직진"),
+ `subtitle → landmark`(보충 설명·주변 지형지물, 예: "다음 점형 블럭 · 출구 갈림길"),
+ `description → guideMessage`(전체 안내 문장)
+- `PrimaryActionButton` → `CtaButton` (파일명 포함), `PrimaryActionButtonEmphasis` →
+ `CtaButtonEmphasis`: "Primary"라는 이름이 `NEUTRAL`(보조 CTA) 변형과 모순되고,
+ 실제 역할은 "화면 하단에 고정되는 전체 너비 CTA 버튼"이므로 그 역할을 그대로
+ 담은 이름으로 바꿨다.
+- `CurrentLocationBadge` → `CurrentLocationBar` (파일명 포함): "Badge"는 보통
+ 작은 상태 표시 칩을 가리키는데, 이 컴포넌트는 `fillMaxWidth` + `Row`로 가로
+ 전체를 차지하며 "현재 위치" 라벨 칩 + 위치 이름 + "변경 ›" 액션을 함께 담은
+ 정보 바에 가깝다(정작 "배지"라 부를 만한 건 내부의 작은 "현재 위치" 텍스트
+ 칩이다). 실제 크기·구성과 어긋나는 이름을 "전체 너비 정보 바"라는 역할에
+ 맞춰 바꿨다.
+
+- **근거**: `title`/`subtitle`/`description`은 Compose 컴포넌트에서 흔히 쓰는
+ 슬롯 이름이라, 한 컴포넌트 안에 여러 개가 함께 있으면(`DirectionGuideCard`처럼)
+ 각각이 어떤 역할인지 이름만 보고는 구분할 수 없었다. 또한 `PrimaryActionButton`은
+ 이름이 "주(主) 액션"을 약속하지만 실제로는 보조 CTA(`NEUTRAL`)도 그리므로
+ 이름과 동작이 어긋났다.
+- **장점**: 코드를 처음 보는 사람도 `instruction`/`landmark`/`guideMessage`처럼
+ 이름만으로 "이 값이 화면에서 어떤 역할을 하는지" 추론할 수 있다. `CtaButton`은
+ 강조 단계(emphasis)와 무관하게 "이 버튼은 CTA다"라는 사실만 약속하므로
+ 이름과 enum 옵션(`ACCENT`/`NEUTRAL`) 사이에 모순이 사라진다.
+- **단점/주의**: `RouteNodeUiModel.label`처럼 "값이 가리키는 대상이 하나뿐이고
+ 이름이 그 역할을 그대로 드러내는" 경우는 바꾸지 않았다 — 모든 `title`/`label`을
+ 기계적으로 바꾸기보다는, "한 컴포넌트 안에서 여러 개의 범용 이름이 충돌하는가",
+ "이름이 실제 동작과 모순되는가"를 기준으로 선별했다.
+
+## 12. RouteMapCard: Canvas 그리기 로직을 의미 단위 private 함수로 분리
+
+**결정**: `RouteMapCard`의 `Canvas` 람다 안에 한 덩어리로 있던 그리기 코드를
+"배경 격자"와 "강조 경로"라는 두 책임으로 나눠 `DrawScope` 확장 함수
+`drawBackgroundGrid`/`drawHighlightedRoute`로 추출했다. 두 함수가 공통으로
+쓰는 좌표 변환은 최상위 `cellOffset` 함수로 뺐다.
+
+- **근거**: 사용자가 "RouteMapCard가 너무 어렵다"고 지적한 부분으로, 60줄 가까운
+ 그리기 코드가 한 람다에 들어 있어 "지금 격자를 그리는 중인지 경로를 그리는
+ 중인지"를 코드만 보고 구분하기 어려웠다. 이름이 있는 함수로 쪼개면 컴포저블
+ 본문이 "격자를 그리고 → 경로를 그린다"는 두 줄의 흐름으로 요약된다.
+- **장점**: 각 함수가 자기 책임(배경/경로)에만 집중해 더 짧고 읽기 쉬워졌고,
+ 필요하면 단위 테스트나 별도 프리뷰로 각 그리기 단계를 독립적으로 검증할 수
+ 있는 여지가 생겼다.
+- **단점**: `DrawScope` 확장 함수로 빼면서 `cellWidth`/`cellHeight`/`textMeasurer`
+ 등을 매개변수로 명시적으로 넘겨야 해 호출부 코드가 살짝 길어졌다. 그러나
+ "어떤 정보로 무엇을 그리는지"가 시그니처에 그대로 드러나는 이점이 더 크다고
+ 판단했다.
+
+## 13. StatInfoCard/StatItemUiModel 제거, StatTile로 대체
+
+**결정**: "여러 항목을 가로로 나눠 배치하는 책임"과 "값+라벨 한 쌍을 어떻게
+그릴지(스타일·시맨틱)"가 `StatInfoCard` 한 함수에 묶여 있던 것을, 사용자
+요청대로 후자만 `StatTile(value, label)`로 분리하고 전자(`StatInfoCard`)와
+이를 위한 DTO(`StatItemUiModel`)는 통째로 삭제했다. 여러 타일을 가로로
+늘어놓는 배치는 호출 측 화면이 `Row` + `Modifier.weight(1f)`로 직접 조립하도록
+남겨 둔다.
+
+- **근거**: `StatInfoCard`는 "남은 거리/남은 점형 블럭"이라는 특정 화면의
+ 레이아웃 형태(가로 2분할)를 공통 컴포넌트 안에 고정해 버렸다. 그런데 이
+ 레이아웃은 이미 Compose 표준 도구(`Row` + `weight`)만으로 화면에서 직접
+ 조립할 수 있는 단순한 조합이라, 그 위에 한 겹 더 감싸는 것이 "강제로 묶은
+ 느낌"(사용자 표현)을 줬다. 공통 컴포넌트는 "재사용 가치가 있는 가장 작은
+ 단위"(타일 한 장)까지만 책임지고, 배치는 화면의 자유에 맡기는 편이 RULE.md
+ 1번 규칙(불필요한 추상화를 끌어올리지 않는다)과도 맞는다고 판단했다.
+- **장점**: `StatTile` 하나만 있으면 2분할이든 3분할이든, 가로든 세로든 화면이
+ 원하는 대로 배치할 수 있다. DTO(`StatItemUiModel`)도 사라져 "문자열 두 개를
+ 굳이 데이터 클래스로 감싸야 하는가"라는 간접 비용이 없어졌다.
+- **단점/주의**: 화면마다 `Row`/`weight` 배치 코드를 반복해서 작성해야 한다.
+ 다만 그 코드는 2~3줄 수준이라("세 줄의 비슷한 코드가 섣부른 추상화보다
+ 낫다") 지금 단계에서는 감수할 만하다고 봤다 — 만약 나중에 동일한 배치가
+ 여러 화면에서 반복되면 그때 다시 레이아웃 컴포넌트로 끌어올리면 된다.
+
+## 14. 네이밍 검토: `SegmentedProgressIndicator`는 그대로 유지
+
+**결정**: "그냥 `ProgressIndicator`로 줄이는 게 어떤가"라는 검토 의견에 대해,
+이름을 바꾸지 않고 `Segmented`를 유지하기로 했다.
+
+- **근거**: Compose Material3에는 이미 `LinearProgressIndicator`/
+ `CircularProgressIndicator`처럼 **연속적인 퍼센트 진행률**을 나타내는 표준
+ 컴포넌트가 있다. 반면 이 컴포넌트는 "3단계 중 1단계"처럼 **이산적인 단계를
+ 분할된 막대로** 보여주는 단계형 표시줄이다(`SegmentedProgressIndicator.kt`
+ 문서 주석 참고). `Segmented`는 바로 이 차이 — 연속 진행률이 아니라 단계형
+ 진행 표시라는 점 — 를 정확히 짚어주는 핵심 수식어이므로, 빼면 오히려
+ Material3의 표준 진행률 표시와 혼동을 줄 위험이 있다고 판단했다.
+- **장점**: 11번 항목에서 정한 "범용 이름이 충돌하거나 실제 동작과 모순될 때만
+ 바꾼다"는 기준을 그대로 적용한 사례다 — 모든 이름을 짧게 줄이는 것이 목표가
+ 아니라, 이름이 실제 역할을 정확히 드러내는지가 기준임을 재확인했다.
+- **단점/주의**: 없음 — 이번 검토는 "바꾸지 않기로 한 결정"이므로 코드 변경은
+ 없었다.
+
+
+## 15. 유지보수성 리뷰 반영: 컬러 토큰 정리 + Canvas 매직 넘버 상수화
+
+**결정**: 15번 리뷰의 ④(유지보수성) 항목 중 "이름과 실제 용도가 어긋나거나
+미사용인 토큰", "Canvas 기반 컴포넌트의 매직 넘버 클러스터" 두 가지를 바로 반영했다.
+
+- **`NureongiColors.OnAccentSurface` → `NeutralSurface` 로 이름 변경**
+ (`NureongiColors.kt`, `CtaButton.kt`): "On*" 접두사는 이 파일에서
+ "그 위에 올라가는 전경색"을 뜻하는데(`OnAccent`, `OnDisabled`),
+ `OnAccentSurface`는 실제로는 보조 CTA의 *컨테이너(배경)* 색으로 쓰여
+ 네이밍 컨벤션과 어긋났다. 역할이 드러나는 `NeutralSurface`로 바꾸고,
+ KDoc에 "보조 CTA에서 강조색 대신 쓰는 화이트 컨테이너 색상"이라고 명시했다.
+- **`NureongiColors.SurfaceSelected`, `NureongiTypography.ScreenTitle` 제거**:
+ 둘 다 어떤 컴포넌트·`MaterialTheme` 매핑에서도 실제로 읽히지 않는 죽은
+ 토큰이었다(`ScreenTitle`은 `titleLarge`에 매핑만 되어 있을 뿐, `titleLarge`를
+ 읽는 곳이 없었다). "나중에 쓸 건지 버려진 건지 판단할 수 없다"는 지적을
+ 반영해, 필요해지면 그때 다시 추가하기로 하고 제거했다.
+- **`RouteMapCard`/`BrailleIcon`의 매직 넘버를 `private const val` 로 추출**:
+ `RouteMapCard.kt`에는 격자 점 반지름·투명도·강조 노드 반지름·내부 구멍
+ 비율·라벨 오프셋 등 `GRID_DOT_RADIUS`/`HIGHLIGHTED_NODE_RADIUS`/
+ `HIGHLIGHTED_NODE_HOLE_RADIUS_RATIO` 같은 이름의 상수 10개를 파일 상단에
+ 모았다. `BrailleIcon.kt`에는 `PADDING_RATIO`/`DOT_RADIUS_RATIO`(둘 다
+ 0.22f)를 분리해, "같은 값이지만 우연일 뿐 서로 무관하다"는 점을 KDoc으로
+ 명시했다.
+- **근거**: 이름이 컨벤션과 어긋나거나 죽어 있는 토큰, 그리고 "하나를 바꾸면
+ 다른 곳도 따라 바뀌는지 알 수 없는" 매직 넘버 클러스터는 다음에 코드를
+ 만지는 사람이 가장 먼저 걸려 넘어지는 지점이라고 판단했다. 이 세 가지는
+ 동작 변경 없이 이름/구조만 정리하는 작업이라 위험이 작고, 따로 검증할
+ 화면도 필요 없어 바로 반영하기로 했다.
+- **장점**: `OnAccentSurface`/`NeutralSurface`처럼 이름만 보고 용도를
+ 오해할 가능성이 줄었고, 죽은 토큰이 사라져 디자인 시스템을 살펴볼 때
+ "이게 실제로 쓰이는 값인가"를 다시 확인할 필요가 없어졌다. `RouteMapCard`의
+ 노드 반지름·내부 구멍 비율처럼 서로 곱셈으로 얽혀 있던 값들도 이름이
+ 생기면서 관계가 코드에 드러난다(예: `radius * HIGHLIGHTED_NODE_HOLE_RADIUS_RATIO`).
+- **단점/주의**: `NeutralSurface`는 여전히 `TextPrimary`와 같은 흰색
+ 값(`0xFFFFFFFF`)을 갖는다. 이름을 분리한 것은 "지금 같은 색이라도 보조
+ CTA 배경과 본문 글자색은 서로 다른 의미로 바뀔 수 있다"는 의도를 코드에
+ 남기기 위함이며, 두 토큰을 하나로 합치는 것은 오히려 우연한 값의 일치를
+ 의미적 결합으로 착각하게 만들 수 있어 피했다.
+
+
+## 5가지 관점 병렬 리뷰 결과 및 후속 조치
+
+**결정**: `ui/component`, `ui/theme`, `ui/model` 전체를 다섯 가지 관점
+— ① state ownership & flow, ② composition & reusability,
+③ recomposition cost & stability, ④ 유지보수성, ⑤ 과설계 —
+으로 나눠 병렬로 리뷰받고, 그중 일부를 실제 코드에 반영했다.
+
+### 리뷰 결과 요약
+
+- **① State ownership & flow**: 모든 production composable이 stateless하며
+ 단방향 데이터 흐름을 지킴. 다만 `RouteMapCard`의 `routeDescription`이
+ 매 recomposition마다 재계산되는 점, `NavigationTopBar`가 `currentStep`을
+ 그대로 `completedSteps`로 전달하는 암묵적 변환을 지적받았다.
+- **② Composition & reusability**: 개별 컴포넌트는 단일 책임을 잘 지키지만,
+ 카드 컨테이너 스타일(`clip + background(Surface) + padding`)과
+ "클릭 가능한 강조 텍스트 + 제목" 패턴이 `DirectionGuideCard`/`RouteMapCard`/
+ `PlaceListItem`/`StatTile`/`CurrentLocationBar`/`NavigationTopBar` 등
+ 여러 파일에서 구조적으로 중복된다는 지적을 받았다. `BrailleIcon`의
+ `rows`/`columns`, `CtaButton`의 `emphasis` when 매트릭스도 같이 언급되었다.
+- **③ Recomposition cost & stability**: 대부분 컴포넌트가 primitive/소형
+ data class만 받아 안정적이다. 유일하게 실질적인 지점은 `RouteMapCard`로,
+ `List`이 컴파일러에 unstable 타입으로 인식되고
+ `routeDescription`/`labelStyle`이 매번 재생성된다는 지적을 받았다.
+- **④ 유지보수성**: `NureongiColors.OnAccentSurface`가 "On*" 네이밍
+ 컨벤션과 달리 컨테이너색으로 쓰이는 점, `SurfaceSelected`/`ScreenTitle`
+ 같은 미사용 토큰, `RouteMapCard`/`BrailleIcon`의 매직 넘버 클러스터,
+ `NavigationTopBar`↔`SegmentedProgressIndicator` 간 `currentStep`/
+ `completedSteps` 암묵적 변환이 지적되었다.
+- **⑤ 과설계**: 화면이 아직 하나도 없는 시점에서, 실사용처가 없는
+ `RouteNodeUiModel.State.NEUTRAL`, `NureongiColors.SurfaceSelected`,
+ `BrailleIcon`의 `size`/`rows`/`columns`, `CtaButtonEmphasis` enum,
+ `BackNavigationTopBar`, `DirectionGuideCard`의 `leadingIcon` 슬롯 등이
+ "나중에 필요할 것 같아서" 미리 추가된 추측성 일반화로 지적되었다.
+
+### 반영한 것 / 반영하지 않은 것
+
+- **반영**: `CtaButton`의 `emphasis: CtaButtonEmphasis` 파라미터를 제거하고
+ `containerColor`/`contentColor`를 직접 받도록 바꿨다. `enabled`에 따른
+ 색 보정만 `if` 식으로 남기고 `when` 분기를 없앴다. 이는 ②(emphasis
+ when 매트릭스)와 ⑤(실사용 1곳뿐인 enum) 지적을 함께 해소한다.
+- **반영**: `NavigationTopBar`의 `currentStep` KDoc과 `SegmentedProgressIndicator`
+ 호출부에 "현재 단계까지 채워진 것으로 표시한다(`currentStep == completedSteps`)"
+ 라는 설명을 추가해, ①·④에서 지적된 암묵적 변환을 문서화했다.
+- **시도했다가 되돌림**: "클릭 가능한 강조 텍스트 + 제목" 중복(②)을 없애려고
+ 공유 `TappableLabel` 컴포저블을 추출했다가, 사용자 판단으로 다시 각 파일의
+ 인라인 구현으로 되돌렸다. 작은 보일러플레이트 3곳을 위해 새 공개 API를
+ 만드는 비용이 더 크다고 본 것으로, ⑤(과설계) 리뷰의 결론과도 같은 방향이다.
+- **보류**: `RouteMapCard`의 `ImmutableList` 전환·`remember` 적용(③),
+ 카드 컨테이너 공통화(②), `RouteNodeUiModel.State.NEUTRAL`/`BrailleIcon`의
+ `size`/`rows`/`columns`/`BackNavigationTopBar`/`leadingIcon` 슬롯 같은
+ 추측성 일반화 정리(⑤)는 이번에는 손대지 않았다. 화면이 만들어지면서 실제
+ 재사용·가변성 패턴이 드러난 뒤에 처리하는 편이, 지금 추측만으로 정리하는
+ 것보다 안전하다고 판단했다 (이는 ⑤ 리뷰가 짚은 "화면이 생기기 전에
+ 정리하라"는 제안과 다소 배치되지만, 최소한 신규 추가는 멈추고 기존 코드
+ 정리는 화면이 생긴 뒤로 미루는 절충을 택했다). 다만 이름이 실제 용도와
+ 어긋나거나 완전히 미사용인 토큰(④)은 16번 항목에서 바로 정리했다.
diff --git "a/nureongi/docs/log/\353\252\251\354\240\201\354\247\200_\354\204\240\355\203\235_\355\231\224\353\251\264_\353\246\254\353\267\260_\353\260\217_\354\204\244\352\263\204_\352\270\260\353\241\235.md" "b/nureongi/docs/log/\353\252\251\354\240\201\354\247\200_\354\204\240\355\203\235_\355\231\224\353\251\264_\353\246\254\353\267\260_\353\260\217_\354\204\244\352\263\204_\352\270\260\353\241\235.md"
new file mode 100644
index 00000000..c2210d54
--- /dev/null
+++ "b/nureongi/docs/log/\353\252\251\354\240\201\354\247\200_\354\204\240\355\203\235_\355\231\224\353\251\264_\353\246\254\353\267\260_\353\260\217_\354\204\244\352\263\204_\352\270\260\353\241\235.md"
@@ -0,0 +1,151 @@
+# 목적지 선택 화면 리뷰 및 설계 기록
+
+`DestinationSelectionScreen`, `CurrentLocationBar`, `PlaceListItem`, `NureongiColors`를
+작업하면서 나온 리뷰와 최종 설계 판단, 반영/보류 이유, 트레이드오프를 한 문서로
+정리한다.
+
+## 전제
+
+- 아직 `DestinationSelectionScreen`을 소유하는 ViewModel이 없다.
+- 현재는 `Screen`이 최상위 상태 소유자다.
+- 목적지 목록은 현재 정렬/필터링하지 않는다.
+- 목적지를 식별할 안정적인 id가 아직 `PlaceUiModel`에 없다.
+
+## 1. Screen이 선택 상태를 소유한다
+
+**최종 판단**: 유지.
+
+- **리뷰 내용**: `DestinationSelectionScreen`이 `remember { mutableStateOf(null) }`로
+ 선택 인덱스를 내부에서 가진다. 화면 재생성 시 선택이 사라질 수 있고, ViewModel이
+ 생기면 상태를 위로 올리는 편이 더 자연스럽다.
+- **판단 이유**: 현재 선택 상태는 목적지 리스트의 선택 표시와 CTA 문구/활성화에 쓰이는
+ UI 상태다. ViewModel이 없는 단계에서 별도 상태 계층을 미리 만들면 실제 도메인 모델이
+ 생겼을 때 다시 맞춰야 할 가능성이 크다.
+- **장점**: 화면 하나 안에서 상태 흐름을 바로 읽을 수 있고, 불필요한 임시 구조를 만들지
+ 않는다.
+- **트레이드오프**: 화면 재생성 시 선택이 보존되지 않는다. ViewModel이 생기면
+ `DestinationSelectionUiState`로 옮기는 것이 맞다.
+- **후속 기준**: ViewModel이 도입되면 `selectedDestinationId`를 포함한 UiState로 옮긴다.
+
+## 2. 선택 상태는 일단 인덱스로 관리한다
+
+**최종 판단**: 보류.
+
+- **리뷰 내용**: `selectedIndex`는 `destinations` 목록의 순서에 의존한다. 목록이 정렬,
+ 필터링, 갱신되면 선택 상태가 다른 목적지를 가리킬 수 있다.
+- **판단 이유**: 지적 자체는 맞지만 현재 화면에서는 목적지 목록을 정렬하거나 필터링하지
+ 않는다. 즉 지금 당장 발생하는 문제가 아니며, 아직 목적지를 식별할 안정적인 id도 없다.
+- **장점**: 현재 모델을 억지로 키우지 않고 단순하게 유지한다.
+- **트레이드오프**: 나중에 정렬/필터링/갱신이 들어오면 인덱스 기반 선택은 바로 다시
+ 검토해야 한다.
+- **후속 기준**: 목적지 id가 생기면 `selectedDestinationId`로 바꾸고, Screen 또는
+ ViewModel 중 당시 최상위 상태 소유자에서 관리한다.
+
+## 3. 같은 목적지를 다시 눌러도 선택 해제하지 않는다
+
+**최종 판단**: 반영.
+
+- **리뷰 내용**: 기존 로직은 이미 선택된 항목을 다시 누르면 `selectedIndex`를 `null`로
+ 바꿔 선택을 해제했다. 하지만 `onSelectDestination`이라는 이름만으로는 "토글" 동작이
+ 드러나지 않았다.
+- **반영 내용**: `onSelectDestination`은 항상 `selectedIndex = index`만 수행하도록
+ 단순화했다.
+- **판단 이유**: 목적지 선택 화면의 핵심 흐름은 "목적지를 고르고 안내를 시작한다"이다.
+ 선택 해제는 핵심 흐름이 아니고, 재탭으로 CTA가 비활성화되는 예외 흐름은 오히려
+ 의도를 흐린다.
+- **장점**: 함수 이름과 실제 동작이 일치한다. 선택 상태 전이가 단순해진다.
+- **트레이드오프**: 사용자가 선택을 완전히 비우는 직접 동작은 없다. 현재 UX에서는 다른
+ 목적지를 선택하거나 화면을 벗어나면 충분하다고 판단했다.
+
+## 4. 현재 위치 변경 액션은 터치 영역을 확보하고 텍스트를 중앙 정렬한다
+
+**최종 판단**: 반영.
+
+- **리뷰 내용**: `CurrentLocationBar`의 "변경 ›" 액션은 텍스트와 작은 padding만으로
+ 클릭 영역을 만들고 있었다. 터치 영역이 작고, 최소 영역을 추가하면 텍스트 정렬이
+ 어색해질 수 있었다.
+- **반영 내용**: "변경 ›" 액션을 `Box`로 감싸고
+ `sizeIn(minWidth = 48.dp, minHeight = 48.dp)`,
+ `contentAlignment = Alignment.Center`를 적용했다.
+- **판단 이유**: 클릭 가능한 영역과 텍스트 배치를 분리하면 "터치하기 쉬운 영역"과
+ "가운데 정렬된 텍스트"를 동시에 만족할 수 있다.
+- **장점**: 최소 터치 영역을 확보하면서 텍스트가 가로/세로 중앙에 배치된다.
+- **트레이드오프**: `Text` 하나였던 구조가 `Box + Text`로 한 단계 늘어난다. 클릭 영역과
+ 시각 정렬을 동시에 만족하려면 이 정도 구조 증가는 합리적이라고 판단했다.
+
+## 5. 긴 현재 위치 이름은 한 줄 말줄임으로 처리한다
+
+**최종 판단**: 반영.
+
+- **리뷰 내용**: `CurrentLocationBar`는 왼쪽 현재 위치 영역과 오른쪽 "변경 ›" 액션을
+ 나란히 배치한다. `locationName`이 길어지면 오른쪽 액션과 공간 경쟁이 생길 수 있다.
+- **반영 내용**: 왼쪽 정보 영역에 `weight(1f)`를 주고, `locationName` 텍스트에는
+ `maxLines = 1`, `TextOverflow.Ellipsis`, `weight(1f)`를 적용했다. 오른쪽 액션은
+ 최소 너비를 유지한다.
+- **판단 이유**: 현재 위치 이름은 외부 입력값이라 길이를 통제하기 어렵다. 긴 문자열이
+ 들어와도 변경 액션은 항상 보여야 한다.
+- **장점**: 긴 위치명에도 레이아웃이 깨지지 않고, 사용자가 눌러야 하는 변경 액션이
+ 밀려나지 않는다.
+- **트레이드오프**: 긴 위치명의 전체 텍스트는 화면에 모두 보이지 않는다. 전체 위치명이
+ 중요한 요구가 생기면 별도 상세 표시나 접근성 설명 보강을 검토한다.
+
+## 6. 목적지 항목은 단일 선택 항목으로 의미를 고정한다
+
+**최종 판단**: 반영.
+
+- **리뷰 내용**: `PlaceListItem`은 목적지 목록에서 하나를 고르는 항목인데
+ `Role.Button`을 사용하고 있었다. 단순 버튼보다 선택 가능한 항목의 의미가 더 정확하다.
+- **반영 내용**: `selectable`의 role을 `Role.Button`에서 `Role.RadioButton`으로 변경했다.
+- **판단 이유**: 현재 사용처에서 `PlaceListItem`은 단순 이동 버튼이 아니라 목적지 목록
+ 중 하나를 고르는 항목이다. 단일 선택 목록이라는 의미를 컴포넌트 내부에 반영하는 것이
+ 현재 화면의 의도와 맞다.
+- **장점**: 선택됨/선택 안 됨 상태가 컴포넌트의 의미와 자연스럽게 연결된다.
+- **트레이드오프**: 같은 UI를 나중에 단순 버튼 목록으로 재사용하려면 role 고정이 제약이
+ 될 수 있다. 그때는 role을 파라미터로 열기보다, 현재 컴포넌트 이름을 더 구체화하거나
+ 별도 컴포넌트를 만드는 쪽을 우선 검토한다.
+
+## 7. 선택되지 않은 카드와 아이콘 색을 별도 토큰으로 분리한다
+
+**최종 판단**: 반영.
+
+- **반영 내용**: `SurfaceBorder`, `IconSurface` 색상 토큰을 추가해 선택되지 않은
+ `PlaceListItem`의 테두리와 아이콘 배경에 사용했다.
+- **판단 이유**: 기존에는 선택되지 않은 카드의 테두리와 아이콘 배경이 모두 `Surface`와
+ 같아 계층감이 약했다. 카드 배경, 테두리, 아이콘 배경은 같은 어두운 계열이어도 서로
+ 다른 의미를 갖는다.
+- **장점**: 색의 의미가 토큰 이름에 드러나고, 이후 시각 조정 시 컴포넌트 내부 값을 직접
+ 바꾸지 않아도 된다.
+- **트레이드오프**: 색상 토큰 수가 늘어난다. 현재는 `PlaceListItem`에서만 쓰이므로
+ 과해 보일 수 있지만, 카드 테두리와 아이콘 표면은 재사용 가능한 의미 단위라고 판단했다.
+
+## 8. 목적지 항목의 크기를 키운다
+
+**최종 판단**: 반영.
+
+- **반영 내용**: `PlaceListItem`의 padding과 내부 간격을 16dp에서 20dp로 키우고,
+ `BrailleIcon` 크기를 40dp에서 56dp로 키웠다.
+- **판단 이유**: 목적지 선택 항목은 화면의 핵심 선택 대상이다. 더 큰 여백과 아이콘은
+ 각 항목을 더 명확한 선택 단위로 보이게 한다.
+- **장점**: 리스트 항목의 시각적 존재감과 터치 여유가 커진다.
+- **트레이드오프**: 한 화면에 동시에 보이는 목적지 개수는 줄어든다. 현재 목적지 목록은
+ 스크롤 가능한 리스트이고, 선택 정확성이 더 중요하다고 판단했다.
+
+## 9. Preview 전용 데이터는 Preview 근처에 둔다
+
+**최종 판단**: 반영.
+
+- **리뷰 내용**: `sampleDestinations`는 Preview 전용 데이터인데 파일 상단에 있어 실제
+ 화면 기본 데이터처럼 보일 수 있었다.
+- **반영 내용**: 이름을 `previewDestinations`로 바꾸고
+ `DestinationSelectionContentPreview` 바로 위로 옮겼다.
+- **판단 이유**: 데이터의 사용처가 Preview 하나뿐이면 사용처 가까이에 두는 편이 코드
+ 탐색에 더 명확하다.
+- **장점**: production 화면 API와 Preview fixture의 경계가 분명해진다.
+- **트레이드오프**: 파일을 위에서부터 읽을 때 샘플 목록을 먼저 볼 수는 없다. 하지만
+ Preview를 볼 때만 필요한 데이터이므로 Preview 근처 배치가 더 적절하다고 판단했다.
+
+## 남은 과제
+
+- ViewModel이 도입되면 선택 상태를 `DestinationSelectionUiState`로 옮긴다.
+- 목적지에 안정적인 id가 생기면 인덱스 기반 선택 상태를 `selectedDestinationId`로 바꾼다.
+- 위치명의 전체 텍스트 제공이 필요해지면 상세 표시 또는 접근성 설명 보강을 검토한다.
diff --git a/nureongi/docs/ui/RULE.md b/nureongi/docs/ui/RULE.md
new file mode 100644
index 00000000..986b7493
--- /dev/null
+++ b/nureongi/docs/ui/RULE.md
@@ -0,0 +1,80 @@
+# UI 규칙
+
+## 0. 주석은 최대한 작성하지 않는다
+
+- UI 코드는 컴포저블 이름, 파라미터 이름, 상태 이름만으로 의도가 드러나게 작성하고,
+ 설명용 주석은 최대한 달지 않는다.
+- 주석은 복잡한 접근성 처리, 플랫폼 제약, 비직관적인 우회 구현처럼 코드만으로 이유를
+ 알기 어려운 경우에만 짧게 작성한다.
+- 단순한 레이아웃 구조, 상태 호이스팅, 콜백 전달, Preview 설명을 반복하는 KDoc이나
+ 인라인 주석은 작성하지 않는다.
+
+## 1. 공통 컴포넌트는 비즈니스 로직을 포함하지 않는다
+
+- 공통(재사용) 컴포저블은 상태를 직접 들고 있거나 ViewModel·Repository·UseCase 등을
+ 참조하지 않는다. 필요한 값과 콜백은 모두 파라미터로 받는다(상태 호이스팅).
+- 컴포저블 선언부는 다음을 지킨다.
+ - 상태는 `value: T`, 이벤트는 `onXxx: () -> Unit` 형태로 분리해서 받는다.
+ - `Modifier`는 기본값 `Modifier`로 받아 가장 먼저 선언하고, 호출 측에서 레이아웃을
+ 제어할 수 있게 한다.
+ - 화면(스크린) 단위 컴포저블에서만 ViewModel을 참조하고, 그 아래 컴포넌트들은
+ 화면에서 내려준 상태/콜백만 사용한다(로직을 위로 끌어올리기, state hoisting).
+ - 파라미터 타입은 도메인 모델을 직접 받지 않고, 화면에 필요한 값만 모은
+ 기본 타입(`String`, `Int`, `Boolean` 등)이나 UI 전용 DTO(`XxxUiModel`,
+ `XxxUiState` 등)로 받는다. 도메인 모델 → UI 모델 변환은 화면/매퍼 계층의
+ 책임으로 두고, 공통 컴포넌트는 도메인 모델의 존재 자체를 몰라야 한다.
+- 테스트 방법
+ - 공통 컴포넌트가 정의된 패키지(`*.ui.component`, `*.designsystem` 등)에서
+ `ViewModel`, `Repository`, `UseCase`, `inject`, `koinViewModel`, `hiltViewModel` 등의
+ 참조가 없는지 검색해 확인한다.
+ 예: `grep -rn "ViewModel\|Repository\|UseCase" shared/src/commonMain/kotlin/.../component`
+ - 공통 컴포넌트의 함수 시그니처에 `domain` 패키지의 타입(예: `import ...domain.model...`)이
+ 파라미터로 들어오는지 검색해 확인한다.
+ 예: `grep -rn "fun .*(.*: .*domain\." shared/src/commonMain/kotlin/.../component`
+ 검색 결과가 있다면 해당 파라미터를 기본 타입 또는 UI 전용 DTO로 바꾼다.
+ - 공통 컴포넌트에 대한 `@Preview`(또는 Compose Multiplatform Preview)를 작성해,
+ ViewModel 의존성 없이 더미 상태값만으로 모든 상태(기본/선택/비활성/에러 등)를
+ 렌더링할 수 있는지 확인한다. Preview가 추가 설정 없이 바로 그려지면 로직 분리가
+ 잘 된 것이다.
+ - Compose UI 테스트에서 실제 ViewModel 대신 가짜(fake) 상태와 람다만 주입해
+ 컴포넌트를 단독으로 테스트할 수 있는지 확인한다(테스트 작성이 어렵다면 컴포넌트가
+ 여전히 외부 상태에 의존하고 있다는 신호다).
+
+## 2. 시각장애인을 위한 접근성을 최우선으로 고려한다
+
+- 모든 상호작용 요소(`Button`, `IconButton`, `Card`, 리스트 아이템 등)에는
+ `Modifier.semantics`/`contentDescription`/`role`을 통해 스크린 리더가 읽을 수 있는
+ 한글 설명을 제공한다. 의미 없는 장식 요소는 `clearAndSetSemantics {}` 또는
+ `contentDescription = null` 로 명시적으로 제외한다.
+- 안내 문구, 상태 변화(예: 도착, 다음 점형 블럭 안내)는 화면 표시와 동시에 TalkBack 등
+ 접근성 서비스로도 전달되도록 `liveRegion`(`LiveRegionMode.Polite`/`Assertive`) 등을
+ 활용한다.
+- 색상만으로 정보를 구분하지 않는다(예: 선택 상태를 옐로우 보더만으로 표현하지 않고
+ 텍스트나 아이콘 변화도 함께 제공). 명도 대비는 WCAG AA 기준(최소 4.5:1, 큰 텍스트는
+ 3:1)을 만족해야 한다.
+- 터치 타겟은 최소 48dp x 48dp를 확보한다.
+- 테스트 방법
+ - Android Studio의 **Accessibility Scanner**(또는 Layout Inspector의 접근성 패널)로
+ 각 화면을 검사해 `contentDescription` 누락, 터치 타겟 크기, 명도 대비 경고가
+ 없는지 확인한다.
+ - 실제 기기/에뮬레이터에서 **TalkBack**을 켜고 화면을 끝까지 탐색하며, 모든 정보가
+ 한글 음성으로 정확하게 안내되는지, 포커스 이동 순서가 자연스러운지 확인한다.
+ - Compose UI 테스트에서 `onNodeWithContentDescription(...)`,
+ `assertIsDisplayed()`, `SemanticsMatcher` 등을 사용해 접근성 속성이 의도대로
+ 설정되어 있는지 검증하는 테스트를 작성한다.
+
+## 3. 구글이 권장하는 샘플 앱을 참고해서 작성한다
+
+- 새로운 화면/컴포넌트를 작성하기 전에 Google의 공식 아키텍처 가이드와 샘플 앱
+ (예: [Now in Android](https://github.com/android/nowinandroid),
+ [Compose 공식 샘플](https://github.com/android/compose-samples),
+ [Android Architecture Samples](https://github.com/android/architecture-samples))의
+ 화면 구조, 상태 관리(UiState), 폴더 구조, 네이밍 컨벤션을 참고한다.
+- 새로운 패턴을 도입할 때는 "이 구조가 공식 샘플의 어떤 패턴과 비슷한가"를 PR
+ 설명이나 커밋 메시지에 한 줄이라도 남긴다.
+- 테스트 방법
+ - PR 리뷰 체크리스트에 "참고한 공식 샘플/문서 링크"를 명시하는 항목을 추가하고,
+ 리뷰어가 해당 샘플의 구조와 비교해서 리뷰할 수 있도록 한다.
+ - 화면의 상태 클래스(`UiState`), 이벤트 처리 방식(`onEvent`/`Action` sealed
+ interface 등)이 공식 샘플과 동일한 패턴을 따르는지 코드 리뷰에서 직접 비교해
+ 확인한다.
diff --git a/nureongi/docs/ui/current_location.png b/nureongi/docs/ui/current_location.png
new file mode 100644
index 00000000..6fd974c7
Binary files /dev/null and b/nureongi/docs/ui/current_location.png differ
diff --git a/nureongi/docs/ui/main_screen.png b/nureongi/docs/ui/main_screen.png
new file mode 100644
index 00000000..c0657ddf
Binary files /dev/null and b/nureongi/docs/ui/main_screen.png differ
diff --git a/nureongi/docs/ui/main_screen_select.png b/nureongi/docs/ui/main_screen_select.png
new file mode 100644
index 00000000..d8848bd4
Binary files /dev/null and b/nureongi/docs/ui/main_screen_select.png differ
diff --git a/nureongi/docs/ui/navigation.png b/nureongi/docs/ui/navigation.png
new file mode 100644
index 00000000..2a1cf8a8
Binary files /dev/null and b/nureongi/docs/ui/navigation.png differ
diff --git a/nureongi/docs/ui/navigation_goal.png b/nureongi/docs/ui/navigation_goal.png
new file mode 100644
index 00000000..7436ca46
Binary files /dev/null and b/nureongi/docs/ui/navigation_goal.png differ
diff --git a/nureongi/gradle.properties b/nureongi/gradle.properties
new file mode 100644
index 00000000..6f8e6ea6
--- /dev/null
+++ b/nureongi/gradle.properties
@@ -0,0 +1,12 @@
+#Kotlin
+kotlin.code.style=official
+kotlin.daemon.jvmargs=-Xmx3072M
+
+#Gradle
+org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8
+org.gradle.configuration-cache=true
+org.gradle.caching=true
+
+#Android
+android.nonTransitiveRClass=true
+android.useAndroidX=true
\ No newline at end of file
diff --git a/nureongi/gradle/gradle-daemon-jvm.properties b/nureongi/gradle/gradle-daemon-jvm.properties
new file mode 100644
index 00000000..050d1428
--- /dev/null
+++ b/nureongi/gradle/gradle-daemon-jvm.properties
@@ -0,0 +1,12 @@
+#This file is generated by updateDaemonJvm
+toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/b62178ff26b34365c61e54dea2180e32/redirect
+toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/f2dede3f3c566068b401dc14a9646d39/redirect
+toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/b62178ff26b34365c61e54dea2180e32/redirect
+toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/f2dede3f3c566068b401dc14a9646d39/redirect
+toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/9aafe8bc391c4bbca3e440130e15608b/redirect
+toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/109553caae279a667336ea8850b50c92/redirect
+toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/b62178ff26b34365c61e54dea2180e32/redirect
+toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/f2dede3f3c566068b401dc14a9646d39/redirect
+toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/9eb5d45802b65696ed3ce0f14bb1e4ff/redirect
+toolchainVendor=AMAZON
+toolchainVersion=21
\ No newline at end of file
diff --git a/nureongi/gradle/libs.versions.toml b/nureongi/gradle/libs.versions.toml
new file mode 100644
index 00000000..9f65fc1b
--- /dev/null
+++ b/nureongi/gradle/libs.versions.toml
@@ -0,0 +1,46 @@
+[versions]
+agp = "9.0.1"
+android-compileSdk = "36"
+android-minSdk = "29"
+android-targetSdk = "36"
+androidx-activity = "1.13.0"
+androidx-appcompat = "1.7.1"
+androidx-core = "1.19.0"
+androidx-espresso = "3.7.0"
+androidx-lifecycle = "2.11.0-beta01"
+androidx-navigation = "2.9.2"
+androidx-testExt = "1.3.0"
+composeMultiplatform = "1.11.1"
+junit = "4.13.2"
+kotlin = "2.4.0"
+kotlinx-serialization = "1.9.0"
+material3 = "1.11.0-alpha07"
+
+[libraries]
+kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
+kotlin-testJunit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
+junit = { module = "junit:junit", version.ref = "junit" }
+androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core" }
+androidx-testExt-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-testExt" }
+androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-espresso" }
+androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
+androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
+compose-uiTooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" }
+androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
+androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
+androidx-navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "androidx-navigation" }
+compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "composeMultiplatform" }
+compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "composeMultiplatform" }
+compose-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "material3" }
+compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "composeMultiplatform" }
+compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "composeMultiplatform" }
+compose-uiToolingPreview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "composeMultiplatform" }
+kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
+
+[plugins]
+androidApplication = { id = "com.android.application", version.ref = "agp" }
+androidMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" }
+composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" }
+composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
+kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
+kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
diff --git a/nureongi/gradle/wrapper/gradle-wrapper.jar b/nureongi/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 00000000..8bdaf60c
Binary files /dev/null and b/nureongi/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/nureongi/gradle/wrapper/gradle-wrapper.properties b/nureongi/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 00000000..75927bd2
--- /dev/null
+++ b/nureongi/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,8 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionSha256Sum=a17ddd85a26b6a7f5ddb71ff8b05fc5104c0202c6e64782429790c933686c806
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
\ No newline at end of file
diff --git a/nureongi/gradlew b/nureongi/gradlew
new file mode 100644
index 00000000..adff685a
--- /dev/null
+++ b/nureongi/gradlew
@@ -0,0 +1,248 @@
+#!/bin/sh
+
+#
+# Copyright © 2015 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/nureongi/gradlew.bat b/nureongi/gradlew.bat
new file mode 100644
index 00000000..c4bdd3ab
--- /dev/null
+++ b/nureongi/gradlew.bat
@@ -0,0 +1,93 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/nureongi/iosApp/Configuration/Config.xcconfig b/nureongi/iosApp/Configuration/Config.xcconfig
new file mode 100644
index 00000000..4b01d398
--- /dev/null
+++ b/nureongi/iosApp/Configuration/Config.xcconfig
@@ -0,0 +1,7 @@
+TEAM_ID=
+
+PRODUCT_NAME=Nureongi
+PRODUCT_BUNDLE_IDENTIFIER=com.woowa.nureongi.Nureongi$(TEAM_ID)
+
+CURRENT_PROJECT_VERSION=1
+MARKETING_VERSION=1.0
\ No newline at end of file
diff --git a/nureongi/iosApp/iosApp.xcodeproj/project.pbxproj b/nureongi/iosApp/iosApp.xcodeproj/project.pbxproj
new file mode 100644
index 00000000..5f82cf14
--- /dev/null
+++ b/nureongi/iosApp/iosApp.xcodeproj/project.pbxproj
@@ -0,0 +1,373 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 77;
+ objects = {
+
+/* Begin PBXFileReference section */
+ D8584B3BEC419D7743C33F2E /* Nureongi.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Nureongi.app; sourceTree = BUILT_PRODUCTS_DIR; };
+/* End PBXFileReference section */
+
+/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
+ 375D75D643F103AB8510401C /* Exceptions for "iosApp" folder in "iosApp" target */ = {
+ isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
+ membershipExceptions = (
+ Info.plist,
+ );
+ target = D37DD355FDCCF40BC6F2CA66 /* iosApp */;
+ };
+/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
+
+/* Begin PBXFileSystemSynchronizedRootGroup section */
+ CEBBD74A45893A7999360004 /* iosApp */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ exceptions = (
+ 375D75D643F103AB8510401C /* Exceptions for "iosApp" folder in "iosApp" target */,
+ );
+ path = iosApp;
+ sourceTree = "";
+ };
+ D058E0F93DCA56AA49940726 /* Configuration */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ path = Configuration;
+ sourceTree = "";
+ };
+/* End PBXFileSystemSynchronizedRootGroup section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ DCD7B99E0325B525BA8650F9 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ DB18BD1F2B69560078AA6AFA = {
+ isa = PBXGroup;
+ children = (
+ D058E0F93DCA56AA49940726 /* Configuration */,
+ CEBBD74A45893A7999360004 /* iosApp */,
+ 4801F21228DD8C1E5BF41FCB /* Products */,
+ );
+ sourceTree = "";
+ };
+ 4801F21228DD8C1E5BF41FCB /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ D8584B3BEC419D7743C33F2E /* Nureongi.app */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ D37DD355FDCCF40BC6F2CA66 /* iosApp */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = CEA0D05C1502D76F02986EAC /* Build configuration list for PBXNativeTarget "iosApp" */;
+ buildPhases = (
+ 45D5F0855CB021B7E93B79DC /* Compile Kotlin Framework */,
+ 83A4F8EFED5CD4DBB13A0670 /* Sources */,
+ DCD7B99E0325B525BA8650F9 /* Frameworks */,
+ 37C9E9E1592817873AC6F0F2 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ fileSystemSynchronizedGroups = (
+ CEBBD74A45893A7999360004 /* iosApp */,
+ );
+ name = iosApp;
+ packageProductDependencies = (
+ );
+ productName = iosApp;
+ productReference = D8584B3BEC419D7743C33F2E /* Nureongi.app */;
+ productType = "com.apple.product-type.application";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ 97A583238DC6CAB444DF4004 /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = 1;
+ LastSwiftUpdateCheck = 1620;
+ LastUpgradeCheck = 1620;
+ TargetAttributes = {
+ D37DD355FDCCF40BC6F2CA66 = {
+ CreatedOnToolsVersion = 16.2;
+ };
+ };
+ };
+ buildConfigurationList = 4B78F8F0D39C6884E07C7C03 /* Build configuration list for PBXProject "iosApp" */;
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ en,
+ Base,
+ );
+ mainGroup = DB18BD1F2B69560078AA6AFA;
+ minimizedProjectReferenceProxies = 1;
+ preferredProjectObjectVersion = 77;
+ productRefGroup = 4801F21228DD8C1E5BF41FCB /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ D37DD355FDCCF40BC6F2CA66 /* iosApp */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ 37C9E9E1592817873AC6F0F2 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXShellScriptBuildPhase section */
+ 45D5F0855CB021B7E93B79DC /* Compile Kotlin Framework */ = {
+ isa = PBXShellScriptBuildPhase;
+ alwaysOutOfDate = 1;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ );
+ inputPaths = (
+ );
+ name = "Compile Kotlin Framework";
+ outputFileListPaths = (
+ );
+ outputPaths = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "if [ \"YES\" = \"$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED\" ]; then\n echo \"Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \\\"YES\\\"\"\n exit 0\nfi\ncd \"$SRCROOT/..\"\n./gradlew :shared:embedAndSignAppleFrameworkForXcode\n";
+ };
+/* End PBXShellScriptBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ 83A4F8EFED5CD4DBB13A0670 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin XCBuildConfiguration section */
+ ED588284BC296C2BDD06CE36 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReferenceAnchor = D058E0F93DCA56AA49940726 /* Configuration */;
+ baseConfigurationReferenceRelativePath = Config.xcconfig;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "DEBUG=1",
+ "$(inherited)",
+ );
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 18.2;
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
+ MTL_FAST_MATH = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ SDKROOT = iphoneos;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ };
+ name = Debug;
+ };
+ 887162D8E623BE233FBBA7D5 /* Release */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReferenceAnchor = D058E0F93DCA56AA49940726 /* Configuration */;
+ baseConfigurationReferenceRelativePath = Config.xcconfig;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 18.2;
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ MTL_FAST_MATH = YES;
+ SDKROOT = iphoneos;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Release;
+ };
+ 71C0CF17BDAC34B65A32FBB1 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ARCHS = arm64;
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_IDENTITY = "Apple Development";
+ CODE_SIGN_STYLE = Automatic;
+ DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
+ DEVELOPMENT_TEAM = "${TEAM_ID}";
+ ENABLE_PREVIEWS = YES;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = iosApp/Info.plist;
+ INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
+ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
+ INFOPLIST_KEY_UILaunchScreen_Generation = YES;
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ C8540DFBE1910ECC82395956 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ARCHS = arm64;
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_IDENTITY = "Apple Development";
+ CODE_SIGN_STYLE = Automatic;
+ DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
+ DEVELOPMENT_TEAM = "${TEAM_ID}";
+ ENABLE_PREVIEWS = YES;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = iosApp/Info.plist;
+ INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
+ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
+ INFOPLIST_KEY_UILaunchScreen_Generation = YES;
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ 4B78F8F0D39C6884E07C7C03 /* Build configuration list for PBXProject "iosApp" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ ED588284BC296C2BDD06CE36 /* Debug */,
+ 887162D8E623BE233FBBA7D5 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ CEA0D05C1502D76F02986EAC /* Build configuration list for PBXNativeTarget "iosApp" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 71C0CF17BDAC34B65A32FBB1 /* Debug */,
+ C8540DFBE1910ECC82395956 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+ };
+ rootObject = 97A583238DC6CAB444DF4004 /* Project object */;
+}
\ No newline at end of file
diff --git a/nureongi/iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/nureongi/iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 00000000..fe1aa713
--- /dev/null
+++ b/nureongi/iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/nureongi/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json b/nureongi/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json
new file mode 100644
index 00000000..ee7e3ca0
--- /dev/null
+++ b/nureongi/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json
@@ -0,0 +1,11 @@
+{
+ "colors" : [
+ {
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
\ No newline at end of file
diff --git a/nureongi/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/nureongi/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 00000000..4c663a0b
--- /dev/null
+++ b/nureongi/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,36 @@
+{
+ "images" : [
+ {
+ "filename" : "app-icon-1024.png",
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "tinted"
+ }
+ ],
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
\ No newline at end of file
diff --git a/nureongi/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png b/nureongi/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png
new file mode 100644
index 00000000..53fc536f
Binary files /dev/null and b/nureongi/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png differ
diff --git a/nureongi/iosApp/iosApp/Assets.xcassets/Contents.json b/nureongi/iosApp/iosApp/Assets.xcassets/Contents.json
new file mode 100644
index 00000000..4aa7c535
--- /dev/null
+++ b/nureongi/iosApp/iosApp/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
\ No newline at end of file
diff --git a/nureongi/iosApp/iosApp/ContentView.swift b/nureongi/iosApp/iosApp/ContentView.swift
new file mode 100644
index 00000000..46a12f4b
--- /dev/null
+++ b/nureongi/iosApp/iosApp/ContentView.swift
@@ -0,0 +1,18 @@
+import UIKit
+import SwiftUI
+import Shared
+
+struct ComposeView: UIViewControllerRepresentable {
+ func makeUIViewController(context: Self.Context) -> UIViewController {
+ MainViewControllerKt.MainViewController()
+ }
+
+ func updateUIViewController(_ uiViewController: UIViewController, context: Self.Context) {}
+}
+
+struct ContentView: View {
+ var body: some View {
+ ComposeView()
+ .ignoresSafeArea()
+ }
+}
\ No newline at end of file
diff --git a/nureongi/iosApp/iosApp/Info.plist b/nureongi/iosApp/iosApp/Info.plist
new file mode 100644
index 00000000..ed67386c
--- /dev/null
+++ b/nureongi/iosApp/iosApp/Info.plist
@@ -0,0 +1,8 @@
+
+
+
+
+ CADisableMinimumFrameDurationOnPhone
+
+
+
\ No newline at end of file
diff --git a/nureongi/iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json b/nureongi/iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json
new file mode 100644
index 00000000..4aa7c535
--- /dev/null
+++ b/nureongi/iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
\ No newline at end of file
diff --git a/nureongi/iosApp/iosApp/iOSApp.swift b/nureongi/iosApp/iosApp/iOSApp.swift
new file mode 100644
index 00000000..d83dca61
--- /dev/null
+++ b/nureongi/iosApp/iosApp/iOSApp.swift
@@ -0,0 +1,10 @@
+import SwiftUI
+
+@main
+struct iOSApp: App {
+ var body: some Scene {
+ WindowGroup {
+ ContentView()
+ }
+ }
+}
\ No newline at end of file
diff --git a/nureongi/settings.gradle.kts b/nureongi/settings.gradle.kts
new file mode 100644
index 00000000..03fc9cf9
--- /dev/null
+++ b/nureongi/settings.gradle.kts
@@ -0,0 +1,32 @@
+rootProject.name = "Nureongi"
+enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
+
+pluginManagement {
+ repositories {
+ google {
+ mavenContent {
+ includeGroupAndSubgroups("androidx")
+ includeGroupAndSubgroups("com.android")
+ includeGroupAndSubgroups("com.google")
+ }
+ }
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+dependencyResolutionManagement {
+ repositories {
+ google {
+ mavenContent {
+ includeGroupAndSubgroups("androidx")
+ includeGroupAndSubgroups("com.android")
+ includeGroupAndSubgroups("com.google")
+ }
+ }
+ mavenCentral()
+ }
+}
+
+include(":androidApp")
+include(":shared")
\ No newline at end of file
diff --git a/nureongi/shared/build.gradle.kts b/nureongi/shared/build.gradle.kts
new file mode 100644
index 00000000..5c34aed4
--- /dev/null
+++ b/nureongi/shared/build.gradle.kts
@@ -0,0 +1,63 @@
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
+
+plugins {
+ alias(libs.plugins.kotlinMultiplatform)
+ alias(libs.plugins.androidMultiplatformLibrary)
+ alias(libs.plugins.composeMultiplatform)
+ alias(libs.plugins.composeCompiler)
+ alias(libs.plugins.kotlinSerialization)
+}
+
+kotlin {
+ listOf(
+ iosArm64(),
+ iosSimulatorArm64()
+ ).forEach { iosTarget ->
+ iosTarget.binaries.framework {
+ baseName = "Shared"
+ isStatic = true
+ }
+ }
+
+ androidLibrary {
+ namespace = "com.woowa.nureongi.shared"
+ compileSdk = libs.versions.android.compileSdk.get().toInt()
+ minSdk = libs.versions.android.minSdk.get().toInt()
+
+ compilerOptions {
+ jvmTarget = JvmTarget.JVM_11
+ }
+ androidResources {
+ enable = true
+ }
+ withHostTest {
+ isIncludeAndroidResources = true
+ }
+ }
+
+ sourceSets {
+ androidMain.dependencies {
+ implementation(libs.androidx.activity.compose)
+ implementation(libs.compose.uiToolingPreview)
+ }
+ commonMain.dependencies {
+ implementation(libs.compose.runtime)
+ implementation(libs.compose.foundation)
+ implementation(libs.compose.material3)
+ implementation(libs.compose.ui)
+ implementation(libs.compose.components.resources)
+ implementation(libs.compose.uiToolingPreview)
+ implementation(libs.androidx.lifecycle.viewmodelCompose)
+ implementation(libs.androidx.lifecycle.runtimeCompose)
+ implementation(libs.androidx.navigation.compose)
+ implementation(libs.kotlinx.serialization.json)
+ }
+ commonTest.dependencies {
+ implementation(libs.kotlin.test)
+ }
+ }
+}
+
+dependencies {
+ androidRuntimeClasspath(libs.compose.uiTooling)
+}
diff --git a/nureongi/shared/src/androidHostTest/kotlin/com/woowa/nureongi/SharedLogicAndroidHostTest.kt b/nureongi/shared/src/androidHostTest/kotlin/com/woowa/nureongi/SharedLogicAndroidHostTest.kt
new file mode 100644
index 00000000..3668d683
--- /dev/null
+++ b/nureongi/shared/src/androidHostTest/kotlin/com/woowa/nureongi/SharedLogicAndroidHostTest.kt
@@ -0,0 +1,12 @@
+package com.woowa.nureongi
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class SharedLogicAndroidHostTest {
+
+ @Test
+ fun example() {
+ assertEquals(3, 1 + 2)
+ }
+}
\ No newline at end of file
diff --git a/nureongi/shared/src/androidMain/kotlin/com/woowa/nureongi/Platform.android.kt b/nureongi/shared/src/androidMain/kotlin/com/woowa/nureongi/Platform.android.kt
new file mode 100644
index 00000000..7063fc25
--- /dev/null
+++ b/nureongi/shared/src/androidMain/kotlin/com/woowa/nureongi/Platform.android.kt
@@ -0,0 +1,9 @@
+package com.woowa.nureongi
+
+import android.os.Build
+
+class AndroidPlatform : Platform {
+ override val name: String = "Android ${Build.VERSION.SDK_INT}"
+}
+
+actual fun getPlatform(): Platform = AndroidPlatform()
\ No newline at end of file
diff --git a/nureongi/shared/src/androidMain/kotlin/com/woowa/nureongi/ui/accessibility/ScreenReaderState.android.kt b/nureongi/shared/src/androidMain/kotlin/com/woowa/nureongi/ui/accessibility/ScreenReaderState.android.kt
new file mode 100644
index 00000000..23e1476b
--- /dev/null
+++ b/nureongi/shared/src/androidMain/kotlin/com/woowa/nureongi/ui/accessibility/ScreenReaderState.android.kt
@@ -0,0 +1,36 @@
+package com.woowa.nureongi.ui.accessibility
+
+import android.content.Context
+import android.view.accessibility.AccessibilityManager
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.platform.LocalContext
+
+@Composable
+actual fun rememberScreenReaderEnabled(): Boolean {
+ val context = LocalContext.current
+ val accessibilityManager = remember {
+ context.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager
+ }
+
+ var isEnabled by remember {
+ mutableStateOf(accessibilityManager.isTouchExplorationEnabled)
+ }
+
+ DisposableEffect(accessibilityManager) {
+ val listener = AccessibilityManager.TouchExplorationStateChangeListener { enabled ->
+ isEnabled = enabled
+ }
+ accessibilityManager.addTouchExplorationStateChangeListener(listener)
+ isEnabled = accessibilityManager.isTouchExplorationEnabled
+ onDispose {
+ accessibilityManager.removeTouchExplorationStateChangeListener(listener)
+ }
+ }
+
+ return isEnabled
+}
diff --git a/nureongi/shared/src/androidMain/kotlin/com/woowa/nureongi/ui/speech/SpeechToTextRecognizer.android.kt b/nureongi/shared/src/androidMain/kotlin/com/woowa/nureongi/ui/speech/SpeechToTextRecognizer.android.kt
new file mode 100644
index 00000000..00448e82
--- /dev/null
+++ b/nureongi/shared/src/androidMain/kotlin/com/woowa/nureongi/ui/speech/SpeechToTextRecognizer.android.kt
@@ -0,0 +1,183 @@
+package com.woowa.nureongi.ui.speech
+
+import android.Manifest
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.os.Bundle
+import android.speech.RecognitionListener
+import android.speech.RecognizerIntent
+import android.speech.SpeechRecognizer
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.platform.LocalContext
+import androidx.core.content.ContextCompat
+import java.util.Locale
+
+@Composable
+internal actual fun rememberSpeechToTextRecognizer(): SpeechToTextRecognizer {
+ val context = LocalContext.current.applicationContext
+ val recognizerHolder = remember { arrayOfNulls(1) }
+ val permissionLauncher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.RequestPermission(),
+ ) { isGranted ->
+ recognizerHolder[0]?.onPermissionResult(isGranted)
+ }
+
+ val currentRecognizer = remember(context) {
+ AndroidSpeechToTextRecognizer(
+ context = context,
+ requestRecordAudioPermission = {
+ permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
+ },
+ )
+ }
+ recognizerHolder[0] = currentRecognizer
+
+ DisposableEffect(currentRecognizer) {
+ onDispose(currentRecognizer::release)
+ }
+
+ return currentRecognizer
+}
+
+private class AndroidSpeechToTextRecognizer(
+ private val context: Context,
+ private val requestRecordAudioPermission: () -> Unit,
+) : SpeechToTextRecognizer {
+ override val isAvailable: Boolean
+ get() = SpeechRecognizer.isRecognitionAvailable(context)
+
+ private var speechRecognizer: SpeechRecognizer? = null
+ private var pendingResult: ((List) -> Unit)? = null
+ private var pendingError: ((String) -> Unit)? = null
+
+ override fun startListening(
+ onResult: (List) -> Unit,
+ onError: (String) -> Unit,
+ ) {
+ if (!isAvailable) {
+ onError("음성 인식을 사용할 수 없습니다.")
+ return
+ }
+
+ pendingResult = onResult
+ pendingError = onError
+
+ if (!hasRecordAudioPermission()) {
+ requestRecordAudioPermission()
+ return
+ }
+
+ startRecognition()
+ }
+
+ override fun stopListening() {
+ speechRecognizer?.stopListening()
+ }
+
+ fun onPermissionResult(isGranted: Boolean) {
+ if (isGranted) {
+ startRecognition()
+ } else {
+ pendingError?.invoke("마이크 권한이 필요합니다.")
+ clearCallbacks()
+ }
+ }
+
+ fun release() {
+ speechRecognizer?.destroy()
+ speechRecognizer = null
+ clearCallbacks()
+ }
+
+ private fun startRecognition() {
+ val recognizer = speechRecognizer ?: SpeechRecognizer.createSpeechRecognizer(context)
+ .also { speechRecognizer = it }
+ recognizer.setRecognitionListener(createRecognitionListener())
+ recognizer.startListening(createRecognizerIntent())
+ }
+
+ private fun createRecognitionListener(): RecognitionListener {
+ return object : RecognitionListener {
+ override fun onReadyForSpeech(params: Bundle?) = Unit
+
+ override fun onBeginningOfSpeech() = Unit
+
+ override fun onRmsChanged(rmsdB: Float) = Unit
+
+ override fun onBufferReceived(buffer: ByteArray?) = Unit
+
+ override fun onEndOfSpeech() = Unit
+
+ override fun onError(error: Int) {
+ pendingError?.invoke(error.toSpeechRecognitionMessage())
+ clearCallbacks()
+ }
+
+ override fun onResults(results: Bundle?) {
+ val candidates = results
+ ?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
+ .orEmpty()
+ if (candidates.isEmpty()) {
+ pendingError?.invoke("출발지를 듣지 못했습니다. 다시 말씀해주세요.")
+ } else {
+ pendingResult?.invoke(candidates)
+ }
+ clearCallbacks()
+ }
+
+ override fun onPartialResults(partialResults: Bundle?) = Unit
+
+ override fun onEvent(
+ eventType: Int,
+ params: Bundle?,
+ ) = Unit
+ }
+ }
+
+ private fun createRecognizerIntent(): Intent {
+ return Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
+ putExtra(
+ RecognizerIntent.EXTRA_LANGUAGE_MODEL,
+ RecognizerIntent.LANGUAGE_MODEL_FREE_FORM,
+ )
+ putExtra(RecognizerIntent.EXTRA_LANGUAGE, Locale.KOREA.toLanguageTag())
+ putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, false)
+ }
+ }
+
+ private fun hasRecordAudioPermission(): Boolean {
+ return ContextCompat.checkSelfPermission(
+ context,
+ Manifest.permission.RECORD_AUDIO,
+ ) == PackageManager.PERMISSION_GRANTED
+ }
+
+ private fun clearCallbacks() {
+ pendingResult = null
+ pendingError = null
+ }
+}
+
+private fun Int.toSpeechRecognitionMessage(): String {
+ return when (this) {
+ SpeechRecognizer.ERROR_AUDIO -> "마이크 입력 중 오류가 발생했습니다."
+ SpeechRecognizer.ERROR_CLIENT -> "음성 인식을 시작하지 못했습니다."
+ SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS -> "마이크 권한이 필요합니다."
+ SpeechRecognizer.ERROR_NETWORK,
+ SpeechRecognizer.ERROR_NETWORK_TIMEOUT,
+ SpeechRecognizer.ERROR_SERVER,
+ -> "네트워크 상태를 확인한 뒤 다시 시도해주세요."
+
+ SpeechRecognizer.ERROR_NO_MATCH,
+ SpeechRecognizer.ERROR_SPEECH_TIMEOUT,
+ -> "출발지를 듣지 못했습니다. 다시 말씀해주세요."
+
+ SpeechRecognizer.ERROR_RECOGNIZER_BUSY -> "음성 인식이 이미 실행 중입니다."
+ else -> "음성 인식에 실패했습니다. 다시 시도해주세요."
+ }
+}
diff --git a/nureongi/shared/src/androidMain/kotlin/com/woowa/nureongi/ui/voice/VoiceGuide.android.kt b/nureongi/shared/src/androidMain/kotlin/com/woowa/nureongi/ui/voice/VoiceGuide.android.kt
new file mode 100644
index 00000000..8aa3d503
--- /dev/null
+++ b/nureongi/shared/src/androidMain/kotlin/com/woowa/nureongi/ui/voice/VoiceGuide.android.kt
@@ -0,0 +1,87 @@
+package com.woowa.nureongi.ui.voice
+
+import android.content.Context
+import android.speech.tts.TextToSpeech
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.platform.LocalContext
+import java.util.Locale
+
+@Composable
+internal actual fun rememberVoiceGuide(): VoiceGuide {
+ val context = LocalContext.current.applicationContext
+ val voiceGuide = remember {
+ AndroidVoiceGuide(context)
+ }
+
+ DisposableEffect(Unit) {
+ onDispose(voiceGuide::release)
+ }
+
+ return voiceGuide
+}
+
+private class AndroidVoiceGuide(
+ context: Context,
+) : VoiceGuide, TextToSpeech.OnInitListener {
+ private var textToSpeech: TextToSpeech? = null
+ private var isReady = false
+ private var pendingMessage: String? = null
+
+ init {
+ textToSpeech = TextToSpeech(context, this)
+ }
+
+ override fun onInit(status: Int) {
+ val textToSpeech = textToSpeech ?: return
+ if (status != TextToSpeech.SUCCESS) {
+ pendingMessage = null
+ return
+ }
+
+ val languageResult = textToSpeech.setLanguage(Locale.KOREAN)
+ isReady = languageResult != TextToSpeech.LANG_MISSING_DATA &&
+ languageResult != TextToSpeech.LANG_NOT_SUPPORTED
+
+ if (isReady) {
+ pendingMessage?.let(::speakNow)
+ }
+ pendingMessage = null
+ }
+
+ override fun speak(message: String) {
+ if (message.isBlank()) {
+ return
+ }
+
+ if (isReady) {
+ speakNow(message)
+ } else {
+ pendingMessage = message
+ }
+ }
+
+ override fun stop() {
+ pendingMessage = null
+ textToSpeech?.stop()
+ }
+
+ fun release() {
+ stop()
+ textToSpeech?.shutdown()
+ textToSpeech = null
+ isReady = false
+ }
+
+ private fun speakNow(message: String) {
+ textToSpeech?.speak(
+ message,
+ TextToSpeech.QUEUE_FLUSH,
+ null,
+ VOICE_GUIDE_UTTERANCE_ID,
+ )
+ }
+}
+
+private const val VOICE_GUIDE_UTTERANCE_ID = "nureongi_voice_guide"
diff --git a/nureongi/shared/src/androidMain/kotlin/com/woowa/nureongi/ui/voice/VoiceGuideInteractionModifier.android.kt b/nureongi/shared/src/androidMain/kotlin/com/woowa/nureongi/ui/voice/VoiceGuideInteractionModifier.android.kt
new file mode 100644
index 00000000..0f291715
--- /dev/null
+++ b/nureongi/shared/src/androidMain/kotlin/com/woowa/nureongi/ui/voice/VoiceGuideInteractionModifier.android.kt
@@ -0,0 +1,19 @@
+package com.woowa.nureongi.ui.voice
+
+import android.view.MotionEvent
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.pointer.pointerInteropFilter
+
+internal actual fun Modifier.stopVoiceGuideOnInteraction(
+ onInteraction: () -> Unit,
+): Modifier {
+ return pointerInteropFilter { event ->
+ when (event.actionMasked) {
+ MotionEvent.ACTION_DOWN,
+ MotionEvent.ACTION_HOVER_ENTER,
+ MotionEvent.ACTION_HOVER_MOVE,
+ -> onInteraction()
+ }
+ false
+ }
+}
diff --git a/nureongi/shared/src/commonMain/composeResources/drawable/compose-multiplatform.xml b/nureongi/shared/src/commonMain/composeResources/drawable/compose-multiplatform.xml
new file mode 100644
index 00000000..1ffc948c
--- /dev/null
+++ b/nureongi/shared/src/commonMain/composeResources/drawable/compose-multiplatform.xml
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/nureongi/shared/src/commonMain/composeResources/drawable/ic_volume.xml b/nureongi/shared/src/commonMain/composeResources/drawable/ic_volume.xml
new file mode 100644
index 00000000..0cf24f2c
--- /dev/null
+++ b/nureongi/shared/src/commonMain/composeResources/drawable/ic_volume.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
diff --git a/nureongi/shared/src/commonMain/composeResources/font/koddiudongothic-bold.ttf b/nureongi/shared/src/commonMain/composeResources/font/koddiudongothic-bold.ttf
new file mode 100644
index 00000000..a2d167be
Binary files /dev/null and b/nureongi/shared/src/commonMain/composeResources/font/koddiudongothic-bold.ttf differ
diff --git a/nureongi/shared/src/commonMain/composeResources/font/koddiudongothic-extrabold.ttf b/nureongi/shared/src/commonMain/composeResources/font/koddiudongothic-extrabold.ttf
new file mode 100644
index 00000000..95f14d38
Binary files /dev/null and b/nureongi/shared/src/commonMain/composeResources/font/koddiudongothic-extrabold.ttf differ
diff --git a/nureongi/shared/src/commonMain/composeResources/font/koddiudongothic-regular.ttf b/nureongi/shared/src/commonMain/composeResources/font/koddiudongothic-regular.ttf
new file mode 100644
index 00000000..2d81f614
Binary files /dev/null and b/nureongi/shared/src/commonMain/composeResources/font/koddiudongothic-regular.ttf differ
diff --git a/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/App.kt b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/App.kt
new file mode 100644
index 00000000..c3870aab
--- /dev/null
+++ b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/App.kt
@@ -0,0 +1,12 @@
+package com.woowa.nureongi
+
+import androidx.compose.runtime.Composable
+import com.woowa.nureongi.ui.navigation.NureongiAppRoute
+import com.woowa.nureongi.ui.theme.NureongiTheme
+
+@Composable
+fun App() {
+ NureongiTheme {
+ NureongiAppRoute()
+ }
+}
diff --git a/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/Greeting.kt b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/Greeting.kt
new file mode 100644
index 00000000..0df1c22f
--- /dev/null
+++ b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/Greeting.kt
@@ -0,0 +1,9 @@
+package com.woowa.nureongi
+
+class Greeting {
+ private val platform = getPlatform()
+
+ fun greet(): String {
+ return sayHello(platform.name)
+ }
+}
\ No newline at end of file
diff --git a/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/GreetingUtil.kt b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/GreetingUtil.kt
new file mode 100644
index 00000000..a35406ca
--- /dev/null
+++ b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/GreetingUtil.kt
@@ -0,0 +1,4 @@
+package com.woowa.nureongi
+
+fun sayHello(to: String): String =
+ "Hello, $to!"
\ No newline at end of file
diff --git a/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/Platform.kt b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/Platform.kt
new file mode 100644
index 00000000..19395b77
--- /dev/null
+++ b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/Platform.kt
@@ -0,0 +1,7 @@
+package com.woowa.nureongi
+
+interface Platform {
+ val name: String
+}
+
+expect fun getPlatform(): Platform
\ No newline at end of file
diff --git a/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/domain/data/SeohyeonBasementStationMapData.kt b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/domain/data/SeohyeonBasementStationMapData.kt
new file mode 100644
index 00000000..37cb0aed
--- /dev/null
+++ b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/domain/data/SeohyeonBasementStationMapData.kt
@@ -0,0 +1,392 @@
+package com.woowa.nureongi.domain.data
+
+import com.woowa.nureongi.domain.model.Edge
+import com.woowa.nureongi.domain.model.EdgeCategory
+import com.woowa.nureongi.domain.model.NavigationPoint
+import com.woowa.nureongi.domain.model.Node
+import com.woowa.nureongi.domain.model.Station
+
+object SeohyeonBasementStationMapData : StationMapDataSource {
+ private val data = createMapData()
+
+ override fun getMapData(): StationMapData = data
+
+ private fun createMapData(): StationMapData {
+ val nodesById = listOf(
+ node("exit-3", "3번 출구", floor = -1),
+ node("n1", "N1 점형 블록", floor = -1),
+ node("n2", "N2 점형 블록", floor = -1),
+ node("restroom-women", "여자 화장실", floor = -1),
+ node("n3", "N3 점형 블록", floor = -1),
+ node("n4", "N4 점형 블록", floor = -1),
+ node("n5", "N5 점형 블록", floor = -1),
+ node("n6", "N6 점형 블록", floor = -1),
+ node("ticket-office", "매표소", floor = -1),
+ node("n7", "N7 점형 블록", floor = -1),
+ node("n8", "N8 점형 블록", floor = -1),
+ node("gate-upper-outside", "상단 개찰구 바깥쪽", floor = -1),
+ node("gate-upper-inside", "상단 개찰구 안쪽", floor = -1),
+ node("exit-4", "4번 출구", floor = -1),
+ node("n9", "N9 점형 블록", floor = -1),
+ node("n10", "N10 점형 블록", floor = -1),
+ node("exit-5", "5번 출구", floor = -1),
+ node("n11", "N11 점형 블록", floor = -1),
+ node("n12", "N12 점형 블록", floor = -1),
+ node("restroom-men", "남자 화장실", floor = -1),
+ node("exit-1", "1번 출구", floor = -1),
+ node("n13", "N13 점형 블록", floor = -1),
+ node("gate-lower-outside", "하단 개찰구 바깥쪽", floor = -1),
+ node("gate-lower-inside", "하단 개찰구 안쪽", floor = -1),
+ node("n15", "N15 점형 블록", floor = -1),
+ node("n16", "N16 점형 블록", floor = -1),
+ node("stairs-upper-suwon", "수원역 방면 지하철 계단", floor = -1),
+ node("n17", "N17 점형 블록", floor = -1),
+ node("stairs-upper-wangsimni", "왕십리역 방면 지하철 계단", floor = -1),
+ node("n18", "N18 점형 블록", floor = -1),
+ node("n19", "N19 점형 블록", floor = -1),
+ node("n20", "N20 점형 블록", floor = -1),
+ node("n21", "N21 점형 블록", floor = -1),
+ node("elevator-lower-suwon", "수원역 방면 승강장 엘리베이터", floor = -1),
+ node("n22", "N22 점형 블록", floor = -1),
+ node("n23", "N23 점형 블록", floor = -1),
+ node("n24", "N24 점형 블록", floor = -1),
+ node("stairs-lower-suwon-a", "수원역 방면 지하철 계단 A", floor = -1),
+ node("n25", "N25 점형 블록", floor = -1),
+ node("stairs-lower-suwon-b", "수원역 방면 지하철 계단 B", floor = -1),
+ node("elevator-lower-wangsimni", "왕십리역 방면 승강장 엘리베이터", floor = -1),
+ node("n26", "N26 점형 블록", floor = -1),
+ node("n27", "N27 점형 블록", floor = -1),
+ node("n28", "N28 점형 블록", floor = -1),
+ node("stairs-lower-wangsimni-a", "왕십리역 방면 지하철 계단 A", floor = -1),
+ node("n29", "N29 점형 블록", floor = -1),
+ node("n30", "N30 점형 블록", floor = -1),
+ node("stairs-lower-wangsimni-b", "왕십리역 방면 지하철 계단 B", floor = -1),
+ node("b2-upper-suwon-entry", "수원역 방면 상단 계단 하단 점형 블록", floor = -2),
+ node("b2-upper-suwon-n1", "수원역 방면 상단 승강장 연결 점형 블록", floor = -2),
+ node("platform-suwon-2-2", "수원역 방면 2-2 승강장", floor = -2),
+ node("platform-wangsimni-7-4-upper", "왕십리역 방면 7-4 승강장", floor = -2),
+ node("b2-lower-suwon-a-entry", "수원역 방면 계단 A 하단 점형 블록", floor = -2),
+ node("b2-lower-suwon-a-n1", "수원역 방면 5-4 연결 점형 블록", floor = -2),
+ node("platform-suwon-5-4", "수원역 방면 5-4 승강장", floor = -2),
+ node("b2-lower-suwon-b-entry", "수원역 방면 계단 B 하단 점형 블록", floor = -2),
+ node("b2-lower-suwon-b-n1", "수원역 방면 7-4 연결 점형 블록", floor = -2),
+ node("platform-suwon-7-4", "수원역 방면 7-4 승강장", floor = -2),
+ node("b2-lower-suwon-elevator-entry", "수원역 방면 엘리베이터 하차 점형 블록", floor = -2),
+ node("platform-suwon-6-4", "수원역 방면 6-4 승강장", floor = -2),
+ node("b2-lower-wangsimni-a-entry", "왕십리역 방면 계단 A 하단 점형 블록", floor = -2),
+ node("b2-lower-wangsimni-a-n1", "왕십리역 방면 4-1 연결 점형 블록", floor = -2),
+ node("platform-wangsimni-4-1", "왕십리역 방면 4-1 승강장", floor = -2),
+ node("b2-lower-wangsimni-b-entry", "왕십리역 방면 계단 B 하단 점형 블록", floor = -2),
+ node("b2-lower-wangsimni-b-n1", "왕십리역 방면 2-2 연결 점형 블록", floor = -2),
+ node("platform-wangsimni-2-2", "왕십리역 방면 2-2 승강장", floor = -2),
+ node("b2-lower-wangsimni-elevator-entry", "왕십리역 방면 엘리베이터 하차 점형 블록", floor = -2),
+ node("b2-lower-wangsimni-elevator-n1", "왕십리역 방면 3-1 연결 점형 블록", floor = -2),
+ node("b2-lower-wangsimni-elevator-n2", "왕십리역 방면 3-1 앞 점형 블록", floor = -2),
+ node("platform-wangsimni-3-1", "왕십리역 방면 3-1 승강장", floor = -2),
+ ).associateBy(Node::id)
+
+ val edges = buildList {
+ addBidirectionalEdges("exit-3-n1", nodesById, "exit-3", "n1", 12f, 180)
+ addBidirectionalEdges("n1-n2", nodesById, "n1", "n2", 20f, 180)
+ addBidirectionalEdges("n2-restroom-women", nodesById, "n2", "restroom-women", 14f, 270)
+ addBidirectionalEdges("n2-n3", nodesById, "n2", "n3", 10f, 180)
+ addBidirectionalEdges("n3-n4", nodesById, "n3", "n4", 45f, 90)
+ addBidirectionalEdges("n4-n5", nodesById, "n4", "n5", 47f, 180)
+ addBidirectionalEdges("n4-n6", nodesById, "n4", "n6", 31f, 0)
+ addBidirectionalEdges("n4-ticket-office", nodesById, "n4", "ticket-office", 24f, 90)
+ addBidirectionalEdges("n1-n6", nodesById, "n1", "n6", 44f, 90)
+ addBidirectionalEdges("n6-n7", nodesById, "n6", "n7", 38f, 0)
+ addBidirectionalEdges("n6-n8", nodesById, "n6", "n8", 19f, 90)
+ addBidirectionalEdges("n7-gate-upper-outside", nodesById, "n7", "gate-upper-outside", 4f, 90)
+ addBidirectionalEdges("n8-exit-4", nodesById, "n8", "exit-4", 3f, 0)
+ addBidirectionalEdges("n5-n9", nodesById, "n5", "n9", 44f, 270)
+ addBidirectionalEdges("n5-n10", nodesById, "n5", "n10", 18f, 90)
+ addBidirectionalEdges("n10-exit-5", nodesById, "n10", "exit-5", 29f, 180)
+ addBidirectionalEdges("n5-n11", nodesById, "n5", "n11", 59f, 180)
+ addBidirectionalEdges("n9-n12", nodesById, "n9", "n12", 1f, 180)
+ addBidirectionalEdges("n12-restroom-men", nodesById, "n12", "restroom-men", 12f, 270)
+ addBidirectionalEdges("n12-exit-1", nodesById, "n12", "exit-1", 26f, 180)
+ addBidirectionalEdges("n11-n13", nodesById, "n11", "n13", 1f, 90)
+ addBidirectionalEdges("n13-gate-lower-outside", nodesById, "n13", "gate-lower-outside", 3f, 180)
+ addBidirectionalEdges(
+ "gate-upper-pass",
+ nodesById,
+ "gate-upper-outside",
+ "gate-upper-inside",
+ 1f,
+ 0,
+ EdgeCategory.FARE_GATE,
+ )
+ addBidirectionalEdges("gate-upper-inside-n15", nodesById, "gate-upper-inside", "n15", 8f, 0)
+ addBidirectionalEdges("n15-n16", nodesById, "n15", "n16", 51f, 270)
+ addBidirectionalEdges("n16-stairs-upper-suwon", nodesById, "n16", "stairs-upper-suwon", 18f, 0)
+ addBidirectionalEdges("n15-n17", nodesById, "n15", "n17", 12f, 90)
+ addBidirectionalEdges("n17-stairs-upper-wangsimni", nodesById, "n17", "stairs-upper-wangsimni", 18f, 0)
+ addBidirectionalEdges(
+ "gate-lower-pass",
+ nodesById,
+ "gate-lower-outside",
+ "gate-lower-inside",
+ 1f,
+ 180,
+ EdgeCategory.FARE_GATE,
+ )
+ addBidirectionalEdges("gate-lower-inside-n18", nodesById, "gate-lower-inside", "n18", 11f, 180)
+ addBidirectionalEdges("n18-n19", nodesById, "n18", "n19", 21f, 270)
+ addBidirectionalEdges("n18-n20", nodesById, "n18", "n20", 8f, 90)
+ addBidirectionalEdges("n19-n21", nodesById, "n19", "n21", 16f, 270)
+ addBidirectionalEdges("n21-elevator-lower-suwon", nodesById, "n21", "elevator-lower-suwon", 1f, 270)
+ addBidirectionalEdges("n21-n22", nodesById, "n21", "n22", 4f, 0)
+ addBidirectionalEdges("n21-n23", nodesById, "n21", "n23", 7f, 180)
+ addBidirectionalEdges("n22-n24", nodesById, "n22", "n24", 3f, 90)
+ addBidirectionalEdges("n24-stairs-lower-suwon-a", nodesById, "n24", "stairs-lower-suwon-a", 7f, 0)
+ addBidirectionalEdges("n23-n25", nodesById, "n23", "n25", 3f, 270)
+ addBidirectionalEdges("n25-stairs-lower-suwon-b", nodesById, "n25", "stairs-lower-suwon-b", 2f, 180)
+ addBidirectionalEdges("n20-elevator-lower-wangsimni", nodesById, "n20", "elevator-lower-wangsimni", 1f, 90)
+ addBidirectionalEdges("n20-n26", nodesById, "n20", "n26", 3f, 0)
+ addBidirectionalEdges("n20-n27", nodesById, "n20", "n27", 1f, 180)
+ addBidirectionalEdges("n26-n28", nodesById, "n26", "n28", 3f, 90)
+ addBidirectionalEdges("n28-stairs-lower-wangsimni-a", nodesById, "n28", "stairs-lower-wangsimni-a", 7f, 0)
+ addBidirectionalEdges("n27-n29", nodesById, "n27", "n29", 5f, 180)
+ addBidirectionalEdges("n29-n30", nodesById, "n29", "n30", 3f, 90)
+ addBidirectionalEdges("n30-stairs-lower-wangsimni-b", nodesById, "n30", "stairs-lower-wangsimni-b", 3f, 180)
+ addBidirectionalEdges(
+ "stairs-upper-suwon-b2",
+ nodesById,
+ "stairs-upper-suwon",
+ "b2-upper-suwon-entry",
+ 1f,
+ 180,
+ EdgeCategory.STAIRS,
+ )
+ addBidirectionalEdges("b2-upper-suwon-entry-b2-upper-suwon-n1", nodesById, "b2-upper-suwon-entry", "b2-upper-suwon-n1", 3f, 0)
+ addBidirectionalEdges("b2-upper-suwon-n1-platform-suwon-2-2", nodesById, "b2-upper-suwon-n1", "platform-suwon-2-2", 15f, 90)
+ addBidirectionalEdges(
+ "stairs-upper-wangsimni-b2",
+ nodesById,
+ "stairs-upper-wangsimni",
+ "platform-wangsimni-7-4-upper",
+ 1f,
+ 180,
+ EdgeCategory.STAIRS,
+ )
+ addBidirectionalEdges(
+ "stairs-lower-suwon-a-b2",
+ nodesById,
+ "stairs-lower-suwon-a",
+ "b2-lower-suwon-a-entry",
+ 1f,
+ 180,
+ EdgeCategory.STAIRS,
+ )
+ addBidirectionalEdges("b2-lower-suwon-a-entry-b2-lower-suwon-a-n1", nodesById, "b2-lower-suwon-a-entry", "b2-lower-suwon-a-n1", 9f, 0)
+ addBidirectionalEdges("b2-lower-suwon-a-n1-platform-suwon-5-4", nodesById, "b2-lower-suwon-a-n1", "platform-suwon-5-4", 15f, 90)
+ addBidirectionalEdges(
+ "stairs-lower-suwon-b-b2",
+ nodesById,
+ "stairs-lower-suwon-b",
+ "b2-lower-suwon-b-entry",
+ 1f,
+ 180,
+ EdgeCategory.STAIRS,
+ )
+ addBidirectionalEdges("b2-lower-suwon-b-entry-b2-lower-suwon-b-n1", nodesById, "b2-lower-suwon-b-entry", "b2-lower-suwon-b-n1", 5f, 180)
+ addBidirectionalEdges("b2-lower-suwon-b-n1-platform-suwon-7-4", nodesById, "b2-lower-suwon-b-n1", "platform-suwon-7-4", 17f, 90)
+ addBidirectionalEdges(
+ "elevator-lower-suwon-b2",
+ nodesById,
+ "elevator-lower-suwon",
+ "b2-lower-suwon-elevator-entry",
+ 1f,
+ 180,
+ EdgeCategory.ELEVATOR,
+ )
+ addBidirectionalEdges("b2-lower-suwon-elevator-entry-platform-suwon-6-4", nodesById, "b2-lower-suwon-elevator-entry", "platform-suwon-6-4", 10f, 90)
+ addBidirectionalEdges(
+ "stairs-lower-wangsimni-a-b2",
+ nodesById,
+ "stairs-lower-wangsimni-a",
+ "b2-lower-wangsimni-a-entry",
+ 1f,
+ 180,
+ EdgeCategory.STAIRS,
+ )
+ addBidirectionalEdges("b2-lower-wangsimni-a-entry-b2-lower-wangsimni-a-n1", nodesById, "b2-lower-wangsimni-a-entry", "b2-lower-wangsimni-a-n1", 1f, 0)
+ addBidirectionalEdges("b2-lower-wangsimni-a-n1-platform-wangsimni-4-1", nodesById, "b2-lower-wangsimni-a-n1", "platform-wangsimni-4-1", 17f, 270)
+ addBidirectionalEdges(
+ "stairs-lower-wangsimni-b-b2",
+ nodesById,
+ "stairs-lower-wangsimni-b",
+ "b2-lower-wangsimni-b-entry",
+ 1f,
+ 180,
+ EdgeCategory.STAIRS,
+ )
+ addBidirectionalEdges("b2-lower-wangsimni-b-entry-b2-lower-wangsimni-b-n1", nodesById, "b2-lower-wangsimni-b-entry", "b2-lower-wangsimni-b-n1", 5f, 180)
+ addBidirectionalEdges("b2-lower-wangsimni-b-n1-platform-wangsimni-2-2", nodesById, "b2-lower-wangsimni-b-n1", "platform-wangsimni-2-2", 17f, 270)
+ addBidirectionalEdges(
+ "elevator-lower-wangsimni-b2",
+ nodesById,
+ "elevator-lower-wangsimni",
+ "b2-lower-wangsimni-elevator-entry",
+ 1f,
+ 180,
+ EdgeCategory.ELEVATOR,
+ )
+ addBidirectionalEdges("b2-lower-wangsimni-elevator-entry-b2-lower-wangsimni-elevator-n1", nodesById, "b2-lower-wangsimni-elevator-entry", "b2-lower-wangsimni-elevator-n1", 7f, 270)
+ addBidirectionalEdges("b2-lower-wangsimni-elevator-n1-b2-lower-wangsimni-elevator-n2", nodesById, "b2-lower-wangsimni-elevator-n1", "b2-lower-wangsimni-elevator-n2", 4f, 0)
+ addBidirectionalEdges("b2-lower-wangsimni-elevator-n2-platform-wangsimni-3-1", nodesById, "b2-lower-wangsimni-elevator-n2", "platform-wangsimni-3-1", 2f, 270)
+ }
+
+ val station = Station(
+ id = "seohyeon-basement",
+ name = "서현역 지하철역",
+ nodes = nodesById.values.toList(),
+ edges = edges,
+ navigationPoints = listOf(
+ NavigationPoint("exit-3", 180),
+ NavigationPoint("restroom-women", 90),
+ NavigationPoint("ticket-office", 270),
+ NavigationPoint("gate-upper-outside", 270),
+ NavigationPoint("gate-upper-inside", 0),
+ NavigationPoint("exit-4", 180),
+ NavigationPoint("exit-5", 0),
+ NavigationPoint("restroom-men", 90),
+ NavigationPoint("exit-1", 0),
+ NavigationPoint("gate-lower-outside", 0),
+ NavigationPoint("gate-lower-inside", 180),
+ NavigationPoint("platform-suwon-2-2", 270),
+ NavigationPoint("platform-wangsimni-7-4-upper", 90),
+ NavigationPoint("platform-suwon-5-4", 270),
+ NavigationPoint("platform-suwon-7-4", 270),
+ NavigationPoint("platform-suwon-6-4", 270),
+ NavigationPoint("platform-wangsimni-4-1", 90),
+ NavigationPoint("platform-wangsimni-2-2", 90),
+ NavigationPoint("platform-wangsimni-3-1", 90),
+ ),
+ )
+
+ return StationMapData(
+ station = station,
+ rows = 58,
+ columns = 24,
+ nodePositions = mapOf(
+ "exit-3" to MapNodePosition(10, 2),
+ "n1" to MapNodePosition(12, 2),
+ "n2" to MapNodePosition(15, 2),
+ "restroom-women" to MapNodePosition(15, 0),
+ "n3" to MapNodePosition(17, 2),
+ "n4" to MapNodePosition(17, 12),
+ "n5" to MapNodePosition(24, 12),
+ "n6" to MapNodePosition(12, 12),
+ "ticket-office" to MapNodePosition(17, 16),
+ "n7" to MapNodePosition(8, 12),
+ "n8" to MapNodePosition(12, 17),
+ "gate-upper-outside" to MapNodePosition(8, 15),
+ "gate-upper-inside" to MapNodePosition(6, 15),
+ "exit-4" to MapNodePosition(10, 17),
+ "n9" to MapNodePosition(24, 4),
+ "n10" to MapNodePosition(24, 16),
+ "exit-5" to MapNodePosition(29, 16),
+ "n11" to MapNodePosition(32, 12),
+ "n12" to MapNodePosition(25, 4),
+ "restroom-men" to MapNodePosition(25, 0),
+ "exit-1" to MapNodePosition(29, 4),
+ "n13" to MapNodePosition(32, 13),
+ "gate-lower-outside" to MapNodePosition(34, 13),
+ "gate-lower-inside" to MapNodePosition(36, 13),
+ "n15" to MapNodePosition(4, 15),
+ "n16" to MapNodePosition(4, 5),
+ "stairs-upper-suwon" to MapNodePosition(2, 5),
+ "n17" to MapNodePosition(4, 18),
+ "stairs-upper-wangsimni" to MapNodePosition(2, 18),
+ "n18" to MapNodePosition(38, 13),
+ "n19" to MapNodePosition(38, 9),
+ "n20" to MapNodePosition(38, 18),
+ "n21" to MapNodePosition(38, 6),
+ "elevator-lower-suwon" to MapNodePosition(38, 4),
+ "n22" to MapNodePosition(36, 6),
+ "n23" to MapNodePosition(40, 6),
+ "n24" to MapNodePosition(36, 8),
+ "stairs-lower-suwon-a" to MapNodePosition(34, 8),
+ "n25" to MapNodePosition(40, 4),
+ "stairs-lower-suwon-b" to MapNodePosition(42, 4),
+ "elevator-lower-wangsimni" to MapNodePosition(38, 20),
+ "n26" to MapNodePosition(36, 18),
+ "n27" to MapNodePosition(40, 18),
+ "n28" to MapNodePosition(36, 20),
+ "stairs-lower-wangsimni-a" to MapNodePosition(34, 20),
+ "n29" to MapNodePosition(43, 18),
+ "n30" to MapNodePosition(43, 20),
+ "stairs-lower-wangsimni-b" to MapNodePosition(45, 20),
+ "b2-upper-suwon-entry" to MapNodePosition(48, 5),
+ "b2-upper-suwon-n1" to MapNodePosition(46, 5),
+ "platform-suwon-2-2" to MapNodePosition(46, 8),
+ "platform-wangsimni-7-4-upper" to MapNodePosition(48, 18),
+ "b2-lower-suwon-a-entry" to MapNodePosition(50, 8),
+ "b2-lower-suwon-a-n1" to MapNodePosition(48, 8),
+ "platform-suwon-5-4" to MapNodePosition(48, 11),
+ "b2-lower-suwon-b-entry" to MapNodePosition(52, 4),
+ "b2-lower-suwon-b-n1" to MapNodePosition(54, 4),
+ "platform-suwon-7-4" to MapNodePosition(54, 8),
+ "b2-lower-suwon-elevator-entry" to MapNodePosition(50, 6),
+ "platform-suwon-6-4" to MapNodePosition(50, 9),
+ "b2-lower-wangsimni-a-entry" to MapNodePosition(50, 20),
+ "b2-lower-wangsimni-a-n1" to MapNodePosition(48, 20),
+ "platform-wangsimni-4-1" to MapNodePosition(48, 16),
+ "b2-lower-wangsimni-b-entry" to MapNodePosition(52, 20),
+ "b2-lower-wangsimni-b-n1" to MapNodePosition(54, 20),
+ "platform-wangsimni-2-2" to MapNodePosition(54, 16),
+ "b2-lower-wangsimni-elevator-entry" to MapNodePosition(50, 18),
+ "b2-lower-wangsimni-elevator-n1" to MapNodePosition(50, 16),
+ "b2-lower-wangsimni-elevator-n2" to MapNodePosition(48, 16),
+ "platform-wangsimni-3-1" to MapNodePosition(48, 14),
+ ),
+ )
+ }
+}
+
+private fun node(
+ id: String,
+ name: String,
+ floor: Int,
+): Node {
+ return Node(
+ id = id,
+ name = name,
+ floor = floor,
+ )
+}
+
+private fun MutableList.addBidirectionalEdges(
+ id: String,
+ nodesById: Map,
+ firstNodeId: String,
+ secondNodeId: String,
+ distance: Float,
+ angle: Int,
+ category: EdgeCategory? = null,
+) {
+ val first = requireNotNull(nodesById[firstNodeId])
+ val second = requireNotNull(nodesById[secondNodeId])
+ add(
+ Edge(
+ id = "$id-forward",
+ from = first,
+ to = second,
+ distance = distance,
+ angle = angle,
+ category = category,
+ ),
+ )
+ add(
+ Edge(
+ id = "$id-reverse",
+ from = second,
+ to = first,
+ distance = distance,
+ angle = (angle + 180) % 360,
+ category = category,
+ ),
+ )
+}
diff --git a/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/domain/data/StationMapData.kt b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/domain/data/StationMapData.kt
new file mode 100644
index 00000000..fbca1b8e
--- /dev/null
+++ b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/domain/data/StationMapData.kt
@@ -0,0 +1,35 @@
+package com.woowa.nureongi.domain.data
+
+import com.woowa.nureongi.domain.model.Station
+
+data class StationMapData(
+ val station: Station,
+ val rows: Int,
+ val columns: Int,
+ val nodePositions: Map,
+) {
+ init {
+ require(rows > 0 && columns > 0) {
+ "Map rows and columns must be greater than zero."
+ }
+
+ val nodeIds = station.nodes.map { node -> node.id }.toSet()
+ require(nodePositions.keys == nodeIds) {
+ "Every station node must have exactly one map position."
+ }
+ require(nodePositions.values.all { position ->
+ position.row in 0 until rows && position.column in 0 until columns
+ }) {
+ "Every node position must be within the map bounds."
+ }
+ }
+}
+
+data class MapNodePosition(
+ val row: Int,
+ val column: Int,
+)
+
+fun interface StationMapDataSource {
+ fun getMapData(): StationMapData
+}
diff --git a/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/domain/data/WoowaEleventhFloorMapData.kt b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/domain/data/WoowaEleventhFloorMapData.kt
new file mode 100644
index 00000000..701e7d53
--- /dev/null
+++ b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/domain/data/WoowaEleventhFloorMapData.kt
@@ -0,0 +1,169 @@
+package com.woowa.nureongi.domain.data
+
+import com.woowa.nureongi.domain.model.Edge
+import com.woowa.nureongi.domain.model.NavigationPoint
+import com.woowa.nureongi.domain.model.Node
+import com.woowa.nureongi.domain.model.Station
+
+object WoowaEleventhFloorMapData : StationMapDataSource {
+ private val data = createMapData()
+
+ override fun getMapData(): StationMapData = data
+
+ private fun createMapData(): StationMapData {
+ val nodesById = listOf(
+ node("a", "옆 강의실"),
+ node("b"),
+ node("c"),
+ node("d"),
+ node("e", "우물가"),
+ node("f"),
+ node("g"),
+ node("h", "자동문 안"),
+ node("i", "자동문 밖"),
+ node("j", "자동문 밖"),
+ node("k", "자동문 안"),
+ node("l", "코치실"),
+ node("m"),
+ node("n"),
+ node("o", "수성 / 화성"),
+ node("p", "금성 / 지구"),
+ node("q"),
+ node("r", "여자화장실"),
+ node("s", "남자화장실"),
+ node("t"),
+ node("u", "천왕성"),
+ node("v", "토성"),
+ node("w", "목성"),
+ node("x"),
+ node("y", "큰 강의실"),
+ node("z", "비상구"),
+ ).associateBy(Node::id)
+
+ val edges = buildList {
+ addBidirectionalEdges("a-b", nodesById, "a", "b", 1.5f, 90)
+ addBidirectionalEdges("b-c", nodesById, "b", "c", 7.5f, 90)
+ addBidirectionalEdges("c-d", nodesById, "c", "d", 2.5f, 90)
+ addBidirectionalEdges("e-f", nodesById, "e", "f", 1.5f, 90)
+ addBidirectionalEdges("h-i", nodesById, "h", "i", 1f, 90)
+ addBidirectionalEdges("i-j", nodesById, "i", "j", 7f, 90)
+ addBidirectionalEdges("j-k", nodesById, "j", "k", 1f, 90)
+ addBidirectionalEdges("l-m", nodesById, "l", "m", 1f, 90)
+ addBidirectionalEdges("n-o", nodesById, "n", "o", 2f, 90)
+ addBidirectionalEdges("o-p", nodesById, "o", "p", 5f, 90)
+ addBidirectionalEdges("q-r", nodesById, "q", "r", 1.5f, 90)
+ addBidirectionalEdges("r-s", nodesById, "r", "s", 7.5f, 90)
+ addBidirectionalEdges("s-t", nodesById, "s", "t", 1.5f, 90)
+ addBidirectionalEdges("u-v", nodesById, "u", "v", 2f, 90)
+ addBidirectionalEdges("v-w", nodesById, "v", "w", 1.5f, 90)
+ addBidirectionalEdges("w-x", nodesById, "w", "x", 1.5f, 90)
+ addBidirectionalEdges("b-f", nodesById, "b", "f", 5.5f, 180)
+ addBidirectionalEdges("d-g", nodesById, "d", "g", 3f, 180)
+ addBidirectionalEdges("f-h", nodesById, "f", "h", 6f, 180)
+ addBidirectionalEdges("g-k", nodesById, "g", "k", 9f, 180)
+ addBidirectionalEdges("h-m", nodesById, "h", "m", 9f, 180)
+ addBidirectionalEdges("k-n", nodesById, "k", "n", 6.5f, 180)
+ addBidirectionalEdges("m-q", nodesById, "m", "q", 2f, 180)
+ addBidirectionalEdges("n-t", nodesById, "n", "t", 4f, 180)
+ addBidirectionalEdges("q-x", nodesById, "q", "x", 2.5f, 180)
+ addBidirectionalEdges("c-y", nodesById, "c", "y", 1.5f, 0)
+ addBidirectionalEdges("z-g", nodesById, "z", "g", 1f, 90)
+ }
+
+ val station = Station(
+ id = "woowa-eleventh-floor",
+ name = "우아한테크코스 11층",
+ nodes = nodesById.values.toList(),
+ edges = edges,
+ navigationPoints = listOf(
+ NavigationPoint(nodeId = "a", initialAngle = 90),
+ NavigationPoint(nodeId = "e", initialAngle = 90),
+ NavigationPoint(nodeId = "l", initialAngle = 90),
+ NavigationPoint(nodeId = "o", initialAngle = 270),
+ NavigationPoint(nodeId = "p", initialAngle = 270),
+ NavigationPoint(nodeId = "r", initialAngle = 0),
+ NavigationPoint(nodeId = "s", initialAngle = 0),
+ NavigationPoint(nodeId = "u", initialAngle = 90),
+ NavigationPoint(nodeId = "v", initialAngle = 0),
+ NavigationPoint(nodeId = "w", initialAngle = 0),
+ NavigationPoint(nodeId = "y", initialAngle = 0),
+ NavigationPoint(nodeId = "z", initialAngle = 90),
+ ),
+ )
+
+ return StationMapData(
+ station = station,
+ rows = 13,
+ columns = 14,
+ nodePositions = mapOf(
+ "y" to MapNodePosition(0, 8),
+ "a" to MapNodePosition(2, 3),
+ "b" to MapNodePosition(2, 5),
+ "c" to MapNodePosition(2, 8),
+ "d" to MapNodePosition(2, 10),
+ "e" to MapNodePosition(4, 3),
+ "f" to MapNodePosition(4, 5),
+ "z" to MapNodePosition(4, 8),
+ "g" to MapNodePosition(4, 10),
+ "h" to MapNodePosition(6, 5),
+ "i" to MapNodePosition(6, 7),
+ "j" to MapNodePosition(6, 8),
+ "k" to MapNodePosition(6, 10),
+ "l" to MapNodePosition(8, 3),
+ "m" to MapNodePosition(8, 5),
+ "n" to MapNodePosition(8, 10),
+ "o" to MapNodePosition(8, 11),
+ "p" to MapNodePosition(8, 13),
+ "q" to MapNodePosition(10, 5),
+ "r" to MapNodePosition(10, 7),
+ "s" to MapNodePosition(10, 8),
+ "t" to MapNodePosition(10, 10),
+ "u" to MapNodePosition(12, 2),
+ "v" to MapNodePosition(12, 3),
+ "w" to MapNodePosition(12, 4),
+ "x" to MapNodePosition(12, 5),
+ ),
+ )
+ }
+}
+
+private fun node(
+ id: String,
+ name: String = "${id.uppercase()} 점형 블록",
+): Node {
+ return Node(
+ id = id,
+ name = name,
+ floor = 11,
+ )
+}
+
+private fun MutableList.addBidirectionalEdges(
+ id: String,
+ nodesById: Map,
+ firstNodeId: String,
+ secondNodeId: String,
+ distance: Float,
+ angle: Int,
+) {
+ val first = requireNotNull(nodesById[firstNodeId])
+ val second = requireNotNull(nodesById[secondNodeId])
+ add(
+ Edge(
+ id = "$id-forward",
+ from = first,
+ to = second,
+ distance = distance,
+ angle = angle,
+ ),
+ )
+ add(
+ Edge(
+ id = "$id-reverse",
+ from = second,
+ to = first,
+ distance = distance,
+ angle = (angle + 180) % 360,
+ ),
+ )
+}
diff --git a/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/domain/model/Edge.kt b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/domain/model/Edge.kt
new file mode 100644
index 00000000..a7463b5d
--- /dev/null
+++ b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/domain/model/Edge.kt
@@ -0,0 +1,30 @@
+package com.woowa.nureongi.domain.model
+
+data class Edge(
+ val id: String,
+ val from: Node,
+ val to: Node,
+ val distance: Float,
+ val angle: Int,
+ val category: EdgeCategory? = null,
+) {
+ init {
+ require(from.id != to.id) {
+ "Edge.from and Edge.to must be different nodes."
+ }
+ require(distance > 0f) {
+ "Edge.distance must be greater than zero."
+ }
+ require(angle in 0..359) {
+ "Edge.angle must be between 0 and 359."
+ }
+ }
+}
+
+enum class EdgeCategory {
+ FLAT,
+ STAIRS,
+ ESCALATOR,
+ ELEVATOR,
+ FARE_GATE,
+}
diff --git a/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/domain/model/NavigationPoint.kt b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/domain/model/NavigationPoint.kt
new file mode 100644
index 00000000..dc078190
--- /dev/null
+++ b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/domain/model/NavigationPoint.kt
@@ -0,0 +1,12 @@
+package com.woowa.nureongi.domain.model
+
+data class NavigationPoint(
+ val nodeId: String,
+ val initialAngle: Int,
+) {
+ init {
+ require(initialAngle in 0..359) {
+ "NavigationPoint.initialAngle must be between 0 and 359."
+ }
+ }
+}
diff --git a/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/domain/model/Node.kt b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/domain/model/Node.kt
new file mode 100644
index 00000000..34a5ffd8
--- /dev/null
+++ b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/domain/model/Node.kt
@@ -0,0 +1,8 @@
+package com.woowa.nureongi.domain.model
+
+data class Node(
+ val id: String,
+ val name: String,
+ val landmark: String? = null,
+ val floor: Int,
+)
diff --git a/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/domain/model/Route.kt b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/domain/model/Route.kt
new file mode 100644
index 00000000..f0d8a5c0
--- /dev/null
+++ b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/domain/model/Route.kt
@@ -0,0 +1,56 @@
+package com.woowa.nureongi.domain.model
+
+class Route(
+ steps: List,
+ val totalDistance: Float,
+ val startPoint: NavigationPoint,
+ val destinationPoint: NavigationPoint,
+) {
+ val steps: List = steps.toList()
+
+ init {
+ require(this.steps.isNotEmpty()) {
+ "Route.steps cannot be empty."
+ }
+ require(this.steps.first().fromNode.id == startPoint.nodeId) {
+ "The first route step must depart from the starting point."
+ }
+ require(this.steps.zipWithNext().all { (current, next) ->
+ current.toNode == next.fromNode
+ }) {
+ "Route steps must be continuous."
+ }
+ require(this.steps.last().toNode.id == destinationPoint.nodeId) {
+ "The last route step must arrive at the destination."
+ }
+ require(totalDistance == this.steps.sumOf { step -> step.edge.distance.toDouble() }.toFloat()) {
+ "Route.totalDistance must equal the sum of its edge distances."
+ }
+ }
+
+ fun isArrived(stepIndex: Int): Boolean {
+ validateStepIndex(stepIndex)
+ return stepIndex >= steps.size
+ }
+
+ fun currentStep(stepIndex: Int): RouteStep? {
+ validateStepIndex(stepIndex)
+ return steps.getOrNull(stepIndex)
+ }
+
+ fun remainingDistance(stepIndex: Int): Float {
+ validateStepIndex(stepIndex)
+ return steps.getOrNull(stepIndex)?.remainingDistance ?: 0f
+ }
+
+ fun advance(stepIndex: Int): Int {
+ validateStepIndex(stepIndex)
+ return (stepIndex + 1).coerceAtMost(steps.size)
+ }
+
+ private fun validateStepIndex(stepIndex: Int) {
+ require(stepIndex in 0..steps.size) {
+ "stepIndex must be between 0 and steps.size."
+ }
+ }
+}
diff --git a/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/domain/model/RouteStep.kt b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/domain/model/RouteStep.kt
new file mode 100644
index 00000000..1a4472fa
--- /dev/null
+++ b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/domain/model/RouteStep.kt
@@ -0,0 +1,17 @@
+package com.woowa.nureongi.domain.model
+
+data class RouteStep(
+ val fromNode: Node,
+ val toNode: Node,
+ val edge: Edge,
+ val remainingDistance: Float,
+) {
+ init {
+ require(edge.from == fromNode && edge.to == toNode) {
+ "RouteStep nodes must match the edge endpoints."
+ }
+ require(remainingDistance >= 0f) {
+ "RouteStep.remainingDistance cannot be negative."
+ }
+ }
+}
diff --git a/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/domain/model/Station.kt b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/domain/model/Station.kt
new file mode 100644
index 00000000..74135d13
--- /dev/null
+++ b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/domain/model/Station.kt
@@ -0,0 +1,82 @@
+package com.woowa.nureongi.domain.model
+
+class Station(
+ val id: String,
+ val name: String,
+ nodes: List,
+ edges: List,
+ navigationPoints: List,
+) {
+ val nodes: List = nodes.toList()
+ val edges: List = edges.toList()
+ val navigationPoints: List = navigationPoints.toList()
+
+ init {
+ require(this.nodes.map(Node::id).distinct().size == this.nodes.size) {
+ "Node IDs must be unique within a station."
+ }
+ require(this.edges.map(Edge::id).distinct().size == this.edges.size) {
+ "Edge IDs must be unique within a station."
+ }
+ require(this.navigationPoints.isNotEmpty()) {
+ "A station must have at least one navigation point."
+ }
+
+ val nodesById = this.nodes.associateBy(Node::id)
+ this.edges.forEach { edge ->
+ require(nodesById[edge.from.id] == edge.from && nodesById[edge.to.id] == edge.to) {
+ "Every edge endpoint must belong to the station."
+ }
+ }
+ this.navigationPoints.forEach { navigationPoint ->
+ require(navigationPoint.nodeId in nodesById) {
+ "Every navigation point must reference a node in the station."
+ }
+ }
+ require(
+ this.navigationPoints.map(NavigationPoint::nodeId).distinct().size ==
+ this.navigationPoints.size,
+ ) {
+ "Only one navigation point can reference a node."
+ }
+
+ val connectedNodeIds = this.edges
+ .flatMap { edge -> listOf(edge.from.id, edge.to.id) }
+ .toSet()
+ require(this.nodes.all { node -> node.id in connectedNodeIds }) {
+ "A station cannot contain an isolated node."
+ }
+ require(isStronglyConnected(nodesById.keys)) {
+ "Every node in a station must be reachable from every other node."
+ }
+ }
+
+ fun findNode(nodeId: String): Node? = nodes.firstOrNull { node -> node.id == nodeId }
+
+ fun findNavigationPoint(nodeId: String): NavigationPoint? {
+ return navigationPoints.firstOrNull { navigationPoint -> navigationPoint.nodeId == nodeId }
+ }
+
+ private fun isStronglyConnected(nodeIds: Set): Boolean {
+ val outgoingNodeIds = edges.groupBy(
+ keySelector = { edge -> edge.from.id },
+ valueTransform = { edge -> edge.to.id },
+ )
+
+ return nodeIds.all { startNodeId ->
+ val visited = mutableSetOf(startNodeId)
+ val pending = mutableListOf(startNodeId)
+
+ while (pending.isNotEmpty()) {
+ val currentNodeId = pending.removeAt(pending.lastIndex)
+ outgoingNodeIds[currentNodeId].orEmpty().forEach { nextNodeId ->
+ if (visited.add(nextNodeId)) {
+ pending.add(nextNodeId)
+ }
+ }
+ }
+
+ visited.containsAll(nodeIds)
+ }
+ }
+}
diff --git a/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/domain/service/Navigatable.kt b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/domain/service/Navigatable.kt
new file mode 100644
index 00000000..8da7ec64
--- /dev/null
+++ b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/domain/service/Navigatable.kt
@@ -0,0 +1,13 @@
+package com.woowa.nureongi.domain.service
+
+import com.woowa.nureongi.domain.model.NavigationPoint
+import com.woowa.nureongi.domain.model.Route
+import com.woowa.nureongi.domain.model.Station
+
+fun interface Navigatable {
+ fun findRoute(
+ station: Station,
+ from: NavigationPoint,
+ destination: NavigationPoint,
+ ): Route
+}
diff --git a/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/domain/service/Navigator.kt b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/domain/service/Navigator.kt
new file mode 100644
index 00000000..37220ef2
--- /dev/null
+++ b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/domain/service/Navigator.kt
@@ -0,0 +1,86 @@
+package com.woowa.nureongi.domain.service
+
+import com.woowa.nureongi.domain.model.Edge
+import com.woowa.nureongi.domain.model.NavigationPoint
+import com.woowa.nureongi.domain.model.Node
+import com.woowa.nureongi.domain.model.Route
+import com.woowa.nureongi.domain.model.RouteStep
+import com.woowa.nureongi.domain.model.Station
+
+class Navigator : Navigatable {
+ override fun findRoute(
+ station: Station,
+ from: NavigationPoint,
+ destination: NavigationPoint,
+ ): Route {
+ require(from in station.navigationPoints) {
+ "The start point must belong to the station."
+ }
+ val start = requireNotNull(station.findNode(from.nodeId))
+ val destinationNode = requireNotNull(station.findNode(destination.nodeId)) {
+ "The destination must belong to the station."
+ }
+ require(destination in station.navigationPoints) {
+ "The destination point must belong to the station."
+ }
+ require(start != destinationNode) {
+ "The start node and destination node must be different."
+ }
+
+ val distances = station.nodes.associate { node ->
+ node.id to if (node == start) 0f else Float.POSITIVE_INFINITY
+ }.toMutableMap()
+ val previousEdges = mutableMapOf()
+ val unvisitedNodeIds = station.nodes.map(Node::id).toMutableSet()
+
+ while (unvisitedNodeIds.isNotEmpty()) {
+ val currentNodeId = unvisitedNodeIds.minByOrNull { nodeId ->
+ distances.getValue(nodeId)
+ } ?: break
+ if (currentNodeId == destinationNode.id) {
+ break
+ }
+ unvisitedNodeIds.remove(currentNodeId)
+
+ station.edges
+ .filter { edge -> edge.from.id == currentNodeId && edge.to.id in unvisitedNodeIds }
+ .forEach { edge ->
+ val candidateDistance = distances.getValue(currentNodeId) + edge.distance
+ if (candidateDistance < distances.getValue(edge.to.id)) {
+ distances[edge.to.id] = candidateDistance
+ previousEdges[edge.to.id] = edge
+ }
+ }
+ }
+
+ val routeEdges = buildList {
+ var currentNode = destinationNode
+ while (currentNode != start) {
+ val edge = checkNotNull(previousEdges[currentNode.id]) {
+ "The station graph must contain a route to the destination."
+ }
+ add(edge)
+ currentNode = edge.from
+ }
+ }.asReversed()
+
+ var remainingDistance = routeEdges.sumOf { edge -> edge.distance.toDouble() }.toFloat()
+ val steps = routeEdges.map { edge ->
+ RouteStep(
+ fromNode = edge.from,
+ toNode = edge.to,
+ edge = edge,
+ remainingDistance = remainingDistance,
+ ).also {
+ remainingDistance -= edge.distance
+ }
+ }
+
+ return Route(
+ steps = steps,
+ totalDistance = routeEdges.sumOf { edge -> edge.distance.toDouble() }.toFloat(),
+ startPoint = from,
+ destinationPoint = destination,
+ )
+ }
+}
diff --git a/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/accessibility/ScreenReaderState.kt b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/accessibility/ScreenReaderState.kt
new file mode 100644
index 00000000..7cf84ee9
--- /dev/null
+++ b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/accessibility/ScreenReaderState.kt
@@ -0,0 +1,10 @@
+package com.woowa.nureongi.ui.accessibility
+
+import androidx.compose.runtime.Composable
+
+/**
+ * 현재 스크린 리더(Android: TalkBack, iOS: VoiceOver)가 활성화되어 있는지 여부를 반환합니다.
+ * 스크린 리더 상태가 변경되면 자동으로 recomposition이 발생합니다.
+ */
+@Composable
+expect fun rememberScreenReaderEnabled(): Boolean
diff --git a/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/component/BrailleIcon.kt b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/component/BrailleIcon.kt
new file mode 100644
index 00000000..a7a9bfef
--- /dev/null
+++ b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/component/BrailleIcon.kt
@@ -0,0 +1,66 @@
+package com.woowa.nureongi.ui.component
+
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.semantics.clearAndSetSemantics
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.woowa.nureongi.ui.theme.NureongiColors
+import com.woowa.nureongi.ui.theme.NureongiTheme
+
+private const val PADDING_RATIO = 0.22f
+private const val DOT_RADIUS_RATIO = 0.22f
+
+@Composable
+fun BrailleIcon(
+ modifier: Modifier = Modifier,
+ dotColor: Color = NureongiColors.Accent,
+ backgroundColor: Color = NureongiColors.Surface,
+ rows: Int = 3,
+ columns: Int = 3,
+) {
+ Canvas(
+ modifier = modifier
+ .clearAndSetSemantics {}
+ .background(color = backgroundColor, shape = RoundedCornerShape(12.dp)),
+ ) {
+ val horizontalPadding = this.size.width * PADDING_RATIO
+ val verticalPadding = this.size.height * PADDING_RATIO
+ val drawableWidth = this.size.width - horizontalPadding * 2
+ val drawableHeight = this.size.height - verticalPadding * 2
+ val dotRadius = (minOf(drawableWidth / columns, drawableHeight / rows)) * DOT_RADIUS_RATIO
+
+ for (row in 0 until rows) {
+ for (column in 0 until columns) {
+ val x = horizontalPadding + drawableWidth * (column + 0.5f) / columns
+ val y = verticalPadding + drawableHeight * (row + 0.5f) / rows
+ drawCircle(color = dotColor, radius = dotRadius, center = Offset(x, y))
+ }
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun BrailleIconPreview() {
+ NureongiTheme {
+ Row(
+ modifier = Modifier
+ .background(NureongiColors.Background)
+ .padding(20.dp),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ BrailleIcon()
+ BrailleIcon(dotColor = NureongiColors.OnAccent, backgroundColor = NureongiColors.Accent)
+ }
+ }
+}
diff --git a/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/component/CtaButton.kt b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/component/CtaButton.kt
new file mode 100644
index 00000000..16c37281
--- /dev/null
+++ b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/component/CtaButton.kt
@@ -0,0 +1,89 @@
+package com.woowa.nureongi.ui.component
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.disabled
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.woowa.nureongi.ui.theme.NureongiColors
+import com.woowa.nureongi.ui.theme.NureongiTheme
+import com.woowa.nureongi.ui.theme.NureongiTypography
+
+@Composable
+fun CtaButton(
+ text: String,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ containerColor: Color,
+ contentColor: Color,
+) {
+ Box(
+ modifier = modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(16.dp))
+ .background(containerColor)
+ .clickable(
+ enabled = enabled,
+ onClickLabel = text,
+ role = Role.Button,
+ onClick = onClick,
+ )
+ .semantics { if (!enabled) disabled() },
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = text,
+ style = NureongiTypography.ItemTitle,
+ color = contentColor,
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun CtaButtonPreview() {
+ NureongiTheme {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(NureongiColors.Background)
+ .padding(20.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ CtaButton(
+ text = "2번 출구까지 안내 시작",
+ onClick = {},
+ containerColor = NureongiColors.Accent,
+ contentColor = NureongiColors.OnAccent,
+ )
+ CtaButton(
+ text = "목적지를 선택하세요",
+ onClick = {},
+ enabled = false,
+ containerColor = NureongiColors.Disabled,
+ contentColor = NureongiColors.OnDisabled
+ )
+ CtaButton(
+ text = "새 목적지 안내",
+ onClick = {},
+ containerColor = NureongiColors.NeutralSurface,
+ contentColor = NureongiColors.OnAccent,
+ )
+ }
+ }
+}
diff --git a/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/component/CurrentLocationBar.kt b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/component/CurrentLocationBar.kt
new file mode 100644
index 00000000..7a7efb4f
--- /dev/null
+++ b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/component/CurrentLocationBar.kt
@@ -0,0 +1,106 @@
+package com.woowa.nureongi.ui.component
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.sizeIn
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.clearAndSetSemantics
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.woowa.nureongi.ui.theme.NureongiColors
+import com.woowa.nureongi.ui.theme.NureongiTheme
+import com.woowa.nureongi.ui.theme.NureongiTypography
+
+@Composable
+fun CurrentLocationBar(
+ locationName: String,
+ onChangeClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(12.dp))
+ .clickable(
+ onClickLabel = "현재 위치 변경",
+ role = Role.Button,
+ onClick = onChangeClick,
+ )
+ .background(NureongiColors.Surface)
+ .padding(horizontal = 16.dp, vertical = 14.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Row(
+ modifier = Modifier.weight(1f),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = "현재 위치",
+ style = NureongiTypography.ItemDescription,
+ color = NureongiColors.OnAccent,
+ modifier = Modifier
+ .clip(RoundedCornerShape(6.dp))
+ .background(NureongiColors.Accent)
+ .padding(horizontal = 8.dp, vertical = 4.dp),
+ )
+ Text(
+ text = locationName,
+ style = NureongiTypography.ItemTitle,
+ color = NureongiColors.TextPrimary,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f),
+ )
+ }
+ Box(
+ modifier = Modifier
+ .widthIn(min = 48.dp)
+ .clip(RoundedCornerShape(6.dp))
+ .clearAndSetSemantics { contentDescription = "현재 위치 변경" }
+ .sizeIn(minWidth = 48.dp, minHeight = 48.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = "변경 ›",
+ style = NureongiTypography.SectionHeader,
+ color = NureongiColors.Accent,
+ modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
+ )
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun CurrentLocationBarPreview() {
+ NureongiTheme {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(NureongiColors.Background)
+ .padding(20.dp),
+ ) {
+ CurrentLocationBar(locationName = "개찰구", onChangeClick = {})
+ CurrentLocationBar(locationName = "아주아주긴출발지이름입니다", onChangeClick = {})
+ CurrentLocationBar(locationName = "너무 너무 길어서 어떻게 해야할지 모르겠는 출발지 이름인데 이걸 정말 어떻게 해야할지 감이 안오네요.", onChangeClick = {})
+
+ }
+ }
+}
diff --git a/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/component/DirectionGuideCard.kt b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/component/DirectionGuideCard.kt
new file mode 100644
index 00000000..15880fc9
--- /dev/null
+++ b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/component/DirectionGuideCard.kt
@@ -0,0 +1,142 @@
+package com.woowa.nureongi.ui.component
+
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.heading
+import androidx.compose.ui.semantics.clearAndSetSemantics
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.woowa.nureongi.ui.theme.NureongiColors
+import com.woowa.nureongi.ui.theme.NureongiTheme
+import com.woowa.nureongi.ui.theme.NureongiTypography
+import com.woowa.nureongi.ui.theme.NureongiTypography.GuidanceInstructionStyle
+import com.woowa.nureongi.ui.theme.NureongiTypography.GuidanceMessageStyle
+
+
+@Composable
+fun DirectionGuideCard(
+ instruction: String,
+ landmark: String,
+ guideMessage: String,
+ modifier: Modifier = Modifier,
+ leadingIcon: @Composable () -> Unit = {},
+) {
+ Column(
+ modifier = modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(20.dp))
+ .background(NureongiColors.Surface)
+ .padding(28.dp),
+ verticalArrangement = Arrangement.spacedBy(24.dp),
+ ) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(20.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.clearAndSetSemantics {
+ contentDescription = "$instruction. $landmark"
+ heading()
+ },
+ ) {
+ Box(
+ modifier = Modifier
+ .size(88.dp)
+ .clip(RoundedCornerShape(16.dp))
+ .background(NureongiColors.Background),
+ contentAlignment = Alignment.Center,
+ ) {
+ leadingIcon()
+ }
+ Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
+ Text(
+ text = instruction,
+ style = GuidanceInstructionStyle,
+ color = NureongiColors.Accent,
+ )
+ Text(
+ text = landmark,
+ style = NureongiTypography.ItemTitle,
+ color = NureongiColors.TextSecondary,
+ )
+ }
+ }
+ Text(
+ text = guideMessage,
+ style = GuidanceMessageStyle,
+ color = NureongiColors.TextPrimary,
+ )
+ }
+}
+
+@Composable
+fun StraightArrowIcon(
+ modifier: Modifier = Modifier,
+) {
+ Canvas(modifier = modifier) {
+ val strokeWidth = size.minDimension * 0.14f
+ val centerX = size.width / 2f
+ val topY = size.height * 0.12f
+ val bottomY = size.height * 0.86f
+ val arrowSideY = size.height * 0.34f
+ val arrowSideX = size.width * 0.22f
+
+ drawLine(
+ color = NureongiColors.Accent,
+ start = Offset(centerX, bottomY),
+ end = Offset(centerX, topY),
+ strokeWidth = strokeWidth,
+ cap = StrokeCap.Round,
+ )
+ drawLine(
+ color = NureongiColors.Accent,
+ start = Offset(centerX, topY),
+ end = Offset(arrowSideX, arrowSideY),
+ strokeWidth = strokeWidth,
+ cap = StrokeCap.Round,
+ )
+ drawLine(
+ color = NureongiColors.Accent,
+ start = Offset(centerX, topY),
+ end = Offset(size.width - arrowSideX, arrowSideY),
+ strokeWidth = strokeWidth,
+ cap = StrokeCap.Round,
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun DirectionGuideCardPreview() {
+ NureongiTheme {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(NureongiColors.Background)
+ .padding(20.dp),
+ ) {
+ DirectionGuideCard(
+ instruction = "8m 직진",
+ landmark = "다음 점형 블록 · 출구 갈림길",
+ guideMessage = "2번 출구까지 안내를 시작합니다. 앞으로 8미터 직진하세요. 8미터 앞에 갈림길이 있습니다.",
+ leadingIcon = {
+ StraightArrowIcon()
+ },
+ )
+ }
+ }
+}
diff --git a/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/component/NavigationTopBar.kt b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/component/NavigationTopBar.kt
new file mode 100644
index 00000000..cf5990a1
--- /dev/null
+++ b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/component/NavigationTopBar.kt
@@ -0,0 +1,151 @@
+package com.woowa.nureongi.ui.component
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.sizeIn
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.heading
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.woowa.nureongi.ui.theme.NureongiColors
+import com.woowa.nureongi.ui.theme.NureongiTheme
+import com.woowa.nureongi.ui.theme.NureongiTypography
+
+@Composable
+fun NavigationTopBar(
+ destinationName: String,
+ currentStep: Int,
+ totalSteps: Int,
+ onCloseClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(modifier = modifier.fillMaxWidth()) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 8.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = "✕",
+ color = NureongiColors.TextPrimary,
+ modifier = Modifier
+ .clickable(
+ onClickLabel = "안내 종료",
+ role = Role.Button,
+ onClick = onCloseClick,
+ )
+ .padding(8.dp),
+ )
+ Column(horizontalAlignment = Alignment.Start) {
+ Text(
+ text = "목적지",
+ style = NureongiTypography.ItemDescription,
+ color = NureongiColors.TextSecondary,
+ )
+ Text(
+ text = destinationName,
+ style = NureongiTypography.ItemTitle,
+ color = NureongiColors.TextPrimary,
+ modifier = Modifier.semantics { heading() },
+ )
+ }
+ Text(
+ text = "$currentStep / $totalSteps",
+ style = NureongiTypography.ItemDescription,
+ color = NureongiColors.TextSecondary,
+ )
+ }
+ SegmentedProgressIndicator(
+ totalSteps = totalSteps,
+ completedSteps = currentStep,
+ modifier = Modifier.padding(top = 8.dp),
+ )
+ }
+}
+
+@Composable
+fun BackNavigationTopBar(
+ title: String,
+ onBackClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(vertical = 8.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Box(
+ modifier = Modifier
+ .sizeIn(minWidth = 48.dp, minHeight = 48.dp)
+ .clickable(
+ onClickLabel = "뒤로 가기",
+ role = Role.Button,
+ onClick = onBackClick,
+ )
+ .padding(end = 8.dp),
+ contentAlignment = Alignment.CenterStart,
+ ) {
+ Text(
+ text = "‹ 뒤로",
+ style = NureongiTypography.ItemTitle,
+ color = NureongiColors.Accent,
+ )
+ }
+ Text(
+ text = title,
+ style = NureongiTypography.ScreenTitle,
+ color = NureongiColors.TextPrimary,
+ modifier = Modifier.semantics { heading() },
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun NavigationTopBarPreview() {
+ NureongiTheme {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(NureongiColors.Background)
+ .padding(20.dp),
+ ) {
+ NavigationTopBar(
+ destinationName = "2번 출구",
+ currentStep = 1,
+ totalSteps = 3,
+ onCloseClick = {},
+ )
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun BackNavigationTopBarPreview() {
+ NureongiTheme {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(NureongiColors.Background)
+ .padding(20.dp),
+ ) {
+ BackNavigationTopBar(title = "현재 위치", onBackClick = {})
+ }
+ }
+}
diff --git a/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/component/PlaceListItem.kt b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/component/PlaceListItem.kt
new file mode 100644
index 00000000..8c1b36fa
--- /dev/null
+++ b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/component/PlaceListItem.kt
@@ -0,0 +1,107 @@
+package com.woowa.nureongi.ui.component
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.selection.selectable
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.selected
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.woowa.nureongi.ui.model.PlaceUiModel
+import com.woowa.nureongi.ui.theme.NureongiColors
+import com.woowa.nureongi.ui.theme.NureongiTheme
+import com.woowa.nureongi.ui.theme.NureongiTypography
+
+@Composable
+fun PlaceListItem(
+ place: PlaceUiModel,
+ selected: Boolean,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val containerColor = if (selected) NureongiColors.SelectedSurface else NureongiColors.Surface
+ val borderColor = if (selected) NureongiColors.Accent else NureongiColors.SurfaceBorder
+ val iconBackground = if (selected) NureongiColors.Accent else NureongiColors.IconSurface
+ val dotColor = if (selected) NureongiColors.OnAccent else NureongiColors.Accent
+
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(16.dp))
+ .background(containerColor)
+ .border(width = 2.dp, color = borderColor, shape = RoundedCornerShape(16.dp))
+ .selectable(
+ selected = selected,
+ onClick = onClick,
+ role = Role.RadioButton,
+ )
+ .semantics {
+ this.selected = selected
+ }
+ .padding(20.dp),
+ horizontalArrangement = Arrangement.spacedBy(20.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ BrailleIcon(
+ backgroundColor = iconBackground,
+ dotColor = dotColor,
+ modifier = Modifier.size(56.dp)
+ )
+ Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ Text(
+ text = place.name,
+ style = NureongiTypography.ItemTitle,
+ color = NureongiColors.TextPrimary,
+ )
+ Text(
+ text = place.location,
+ style = NureongiTypography.ItemDescription,
+ color = NureongiColors.TextSecondary,
+ )
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun PlaceListItemPreview() {
+ NureongiTheme {
+ Column(
+ modifier = Modifier
+ .background(NureongiColors.Background)
+ .padding(20.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ var selectedIndex by remember { mutableStateOf(1) }
+ val places = listOf(
+ PlaceUiModel("1번 출구", "지상 · 버스정류장 방면"),
+ PlaceUiModel("2번 출구", "지상 · 광장 방면"),
+ PlaceUiModel("화장실", "대합실 왼쪽"),
+ )
+ places.forEachIndexed { index, place ->
+ PlaceListItem(
+ place = place,
+ selected = index == selectedIndex,
+ onClick = { selectedIndex = index },
+ )
+ }
+ }
+ }
+}
diff --git a/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/component/SegmentedProgressIndicator.kt b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/component/SegmentedProgressIndicator.kt
new file mode 100644
index 00000000..80ee6cb9
--- /dev/null
+++ b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/component/SegmentedProgressIndicator.kt
@@ -0,0 +1,72 @@
+package com.woowa.nureongi.ui.component
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.semantics.ProgressBarRangeInfo
+import androidx.compose.ui.semantics.clearAndSetSemantics
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.progressBarRangeInfo
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.woowa.nureongi.ui.theme.NureongiColors
+import com.woowa.nureongi.ui.theme.NureongiTheme
+
+@Composable
+fun SegmentedProgressIndicator(
+ totalSteps: Int,
+ completedSteps: Int,
+ modifier: Modifier = Modifier,
+) {
+ require(totalSteps > 0) { "totalSteps 는 1 이상이어야 합니다." }
+ val completed = completedSteps.coerceIn(0, totalSteps)
+
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .height(4.dp)
+ .clearAndSetSemantics {
+ contentDescription = "전체 ${totalSteps}단계 중 ${completed}단계 진행"
+ progressBarRangeInfo = ProgressBarRangeInfo(
+ current = completed.toFloat(),
+ range = 0f..totalSteps.toFloat(),
+ steps = totalSteps - 1,
+ )
+ },
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ ) {
+ repeat(totalSteps) { index ->
+ val color = if (index < completed) NureongiColors.Accent else NureongiColors.Surface
+ Row(
+ modifier = Modifier
+ .weight(1f)
+ .height(4.dp)
+ .clip(RoundedCornerShape(2.dp))
+ .background(color),
+ ) {}
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun SegmentedProgressIndicatorPreview() {
+ NureongiTheme {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(NureongiColors.Background)
+ .padding(20.dp),
+ ) {
+ SegmentedProgressIndicator(totalSteps = 3, completedSteps = 1)
+ }
+ }
+}
diff --git a/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/component/StatTile.kt b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/component/StatTile.kt
new file mode 100644
index 00000000..2d7c021a
--- /dev/null
+++ b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/component/StatTile.kt
@@ -0,0 +1,67 @@
+package com.woowa.nureongi.ui.component
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.clearAndSetSemantics
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.woowa.nureongi.ui.theme.NureongiColors
+import com.woowa.nureongi.ui.theme.NureongiTheme
+import com.woowa.nureongi.ui.theme.NureongiTypography.StatLabelStyle
+import com.woowa.nureongi.ui.theme.NureongiTypography.StatValueStyle
+
+@Composable
+fun StatTile(
+ value: String,
+ label: String,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier = modifier
+ .clip(RoundedCornerShape(16.dp))
+ .background(NureongiColors.Surface)
+ .padding(horizontal = 20.dp, vertical = 18.dp)
+ .clearAndSetSemantics {
+ contentDescription = "$label $value"
+ },
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ Text(
+ text = value,
+ style = StatValueStyle,
+ color = NureongiColors.Accent,
+ )
+ Text(
+ text = label,
+ style = StatLabelStyle,
+ color = NureongiColors.TextSecondary,
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun StatTilePreview() {
+ NureongiTheme {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(NureongiColors.Background)
+ .padding(20.dp),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ StatTile(value = "20m", label = "남은 거리", modifier = Modifier.weight(1f))
+ StatTile(value = "2개", label = "남은 점형 블럭", modifier = Modifier.weight(1f))
+ }
+ }
+}
diff --git a/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/component/TactileMiniMap.kt b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/component/TactileMiniMap.kt
new file mode 100644
index 00000000..857d79e3
--- /dev/null
+++ b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/component/TactileMiniMap.kt
@@ -0,0 +1,441 @@
+package com.woowa.nureongi.ui.component
+
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.PathEffect
+import androidx.compose.ui.graphics.drawscope.DrawScope
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.TextMeasurer
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.drawText
+import androidx.compose.ui.text.rememberTextMeasurer
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.woowa.nureongi.ui.model.MiniMapUiModel
+import com.woowa.nureongi.ui.model.RouteNodeUiModel
+import com.woowa.nureongi.ui.theme.NureongiColors
+import com.woowa.nureongi.ui.theme.NureongiTheme
+import com.woowa.nureongi.ui.theme.NureongiTypography
+
+private const val GRID_LINE_ALPHA = 0.3f
+private const val GRID_LINE_STROKE_WIDTH = 2f
+private val GRID_LINE_DASH_PATTERN = floatArrayOf(10f, 10f)
+private const val GRID_DOT_ALPHA = 0.5f
+private const val GRID_DOT_RADIUS = 5f
+
+private const val NODE_RADIUS = 8f
+private const val HIGHLIGHTED_NODE_RADIUS = 14f
+private const val HIGHLIGHTED_NODE_HOLE_RADIUS_RATIO = 0.5f
+private const val ROUTE_LINE_STROKE_WIDTH = 6f
+private const val LABEL_OFFSET = 6f
+
+@Composable
+fun TactileMiniMap(
+ uiModel: MiniMapUiModel,
+ modifier: Modifier = Modifier,
+) {
+ val textMeasurer = rememberTextMeasurer()
+ val labelStyle = TextStyle(
+ fontSize = NureongiTypography.ItemDescription.fontSize,
+ color = NureongiColors.Accent,
+ )
+ val routeDescription = uiModel.path.joinToString(separator = " → ") { it.label ?: "점형 블록" }
+
+ val infiniteTransition = rememberInfiniteTransition(label = "RadarState")
+
+ // 1. 현재 위치 노드를 반짝이게 하는 레이더 파동 무한 애니메이션
+ val blinkAlpha by infiniteTransition.animateFloat(
+ initialValue = 0.0f,
+ targetValue = 1.0f,
+ animationSpec = infiniteRepeatable(
+ animation = tween(durationMillis = 1500),
+ repeatMode = RepeatMode.Restart
+ ),
+ label = "RadarAlpha"
+ )
+
+ // 2. 가야 할 경로의 점선 흐름 애니메이션 (25f + 15f = 40f 주기)
+ val dashPhase by infiniteTransition.animateFloat(
+ initialValue = 0f,
+ targetValue = 40f,
+ animationSpec = infiniteRepeatable(
+ animation = tween(durationMillis = 1000, easing = LinearEasing),
+ repeatMode = RepeatMode.Restart
+ ),
+ label = "DashPhase"
+ )
+
+ Column(
+ modifier = modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(20.dp))
+ .background(NureongiColors.Surface)
+ .padding(20.dp),
+ ) {
+ Text(
+ text = uiModel.title,
+ style = NureongiTypography.ItemDescription,
+ color = NureongiColors.TextSecondary,
+ )
+ Box(modifier = Modifier.padding(top = 12.dp)) {
+ Canvas(
+ modifier = Modifier
+ .fillMaxWidth()
+ .aspectRatio(1f)
+ .semantics { contentDescription = "경로: $routeDescription" },
+ ) {
+ if (uiModel.rows < 2 || uiModel.columns < 2) return@Canvas
+
+ val cellWidth = size.width / (uiModel.columns - 1)
+ val cellHeight = size.height / (uiModel.rows - 1)
+
+ drawBackgroundGrid(rows = uiModel.rows, columns = uiModel.columns, cellWidth = cellWidth, cellHeight = cellHeight)
+ drawHighlightedRoute(
+ path = uiModel.path,
+ rows = uiModel.rows,
+ columns = uiModel.columns,
+ cellWidth = cellWidth,
+ cellHeight = cellHeight,
+ textMeasurer = textMeasurer,
+ labelStyle = labelStyle,
+ blinkAlpha = blinkAlpha,
+ dashPhase = dashPhase,
+ )
+ }
+ }
+ }
+}
+
+private fun cellOffset(row: Int, column: Int, cellWidth: Float, cellHeight: Float): Offset =
+ Offset(x = column * cellWidth, y = row * cellHeight)
+
+private fun DrawScope.drawBackgroundGrid(rows: Int, columns: Int, cellWidth: Float, cellHeight: Float) {
+ val lineColor = NureongiColors.TextSecondary.copy(alpha = GRID_LINE_ALPHA)
+ val dashedStroke = Stroke(
+ width = GRID_LINE_STROKE_WIDTH,
+ pathEffect = PathEffect.dashPathEffect(GRID_LINE_DASH_PATTERN),
+ )
+
+ for (row in 0 until rows) {
+ drawLine(
+ color = lineColor,
+ start = cellOffset(row, 0, cellWidth, cellHeight),
+ end = cellOffset(row, columns - 1, cellWidth, cellHeight),
+ strokeWidth = dashedStroke.width,
+ pathEffect = dashedStroke.pathEffect,
+ )
+ }
+ for (column in 0 until columns) {
+ drawLine(
+ color = lineColor,
+ start = cellOffset(0, column, cellWidth, cellHeight),
+ end = cellOffset(rows - 1, column, cellWidth, cellHeight),
+ strokeWidth = dashedStroke.width,
+ pathEffect = dashedStroke.pathEffect,
+ )
+ }
+ for (row in 0 until rows) {
+ for (column in 0 until columns) {
+ drawCircle(
+ color = NureongiColors.TextSecondary.copy(alpha = GRID_DOT_ALPHA),
+ radius = GRID_DOT_RADIUS,
+ center = cellOffset(row, column, cellWidth, cellHeight),
+ )
+ }
+ }
+}
+
+private fun DrawScope.drawHighlightedRoute(
+ path: List,
+ rows: Int,
+ columns: Int,
+ cellWidth: Float,
+ cellHeight: Float,
+ textMeasurer: TextMeasurer,
+ labelStyle: TextStyle,
+ blinkAlpha: Float,
+ dashPhase: Float,
+) {
+ // 현재 사용자가 머물고 있는 위치(HIGHLIGHTED) 노드의 인덱스를 탐색
+ val currentIndex = path.indexOfFirst { it.state == RouteNodeUiModel.State.HIGHLIGHTED }
+
+ // 1. 선로(간선) 그리기
+ for (index in 0 until path.size - 1) {
+ val from = path[index]
+ val to = path[index + 1]
+
+ val startPoint = cellOffset(from.row, from.column, cellWidth, cellHeight)
+ val endPoint = cellOffset(to.row, to.column, cellWidth, cellHeight)
+
+ // 현재 위치 인덱스를 기준으로 이 세그먼트의 운행 상태를 판정
+ val isPassed = currentIndex != -1 && index < currentIndex
+ val isActiveSegment = currentIndex != -1 && index == currentIndex
+
+ when {
+ isPassed -> {
+ // 지나온 간선: 어둡고 가느다란 정적 실선 처리
+ drawLine(
+ color = NureongiColors.Accent.copy(alpha = 0.20f),
+ start = startPoint,
+ end = endPoint,
+ strokeWidth = ROUTE_LINE_STROKE_WIDTH
+ )
+ }
+ isActiveSegment -> {
+ // 현재 이동 중인 유일한 간선: 밝고 뚜렷하게 움직이는 점선 애니메이션 적용
+ val dashIntervals = floatArrayOf(25f, 15f)
+ val stroke = Stroke(
+ width = ROUTE_LINE_STROKE_WIDTH,
+ pathEffect = PathEffect.dashPathEffect(dashIntervals, phase = -dashPhase)
+ )
+ drawLine(
+ color = NureongiColors.Accent,
+ start = startPoint,
+ end = endPoint,
+ strokeWidth = stroke.width,
+ pathEffect = stroke.pathEffect
+ )
+ }
+ else -> {
+ // 예정된 간선 (index > currentIndex): 아직 진입하지 않은 정적인 중간 밝기의 실선
+ drawLine(
+ color = NureongiColors.Accent.copy(alpha = 0.50f),
+ start = startPoint,
+ end = endPoint,
+ strokeWidth = ROUTE_LINE_STROKE_WIDTH
+ )
+ }
+ }
+ }
+
+ // 2. 노드 및 라벨 그리기
+ path.forEachIndexed { nodeIndex, node ->
+ val center = cellOffset(node.row, node.column, cellWidth, cellHeight)
+ val isDestination = nodeIndex == path.size - 1
+ val isCurrentLocation = currentIndex != -1 && nodeIndex == currentIndex
+
+ if (isCurrentLocation) {
+ // 현재 위치 (HIGHLIGHTED): 반짝반짝 파동 애니메이션 (퍼져나가며 투명화)
+ val maxPulseRange = 24.dp.toPx()
+ val haloRadius = HIGHLIGHTED_NODE_RADIUS + (maxPulseRange * blinkAlpha)
+ val haloAlpha = (1f - blinkAlpha) * 0.8f
+ drawCircle(
+ color = NureongiColors.Accent.copy(alpha = haloAlpha),
+ radius = haloRadius,
+ center = center
+ )
+
+ // 현재 위치 중심 원 (꽉 찬 노란색 원)
+ drawCircle(
+ color = NureongiColors.Accent,
+ radius = HIGHLIGHTED_NODE_RADIUS,
+ center = center
+ )
+ // 시각적 구분을 위한 코어 영역 미세 구멍
+ drawCircle(
+ color = NureongiColors.Background,
+ radius = HIGHLIGHTED_NODE_RADIUS * 0.3f,
+ center = center
+ )
+ }
+
+ // 목적지 노드 그리기 (마지막 노드이고 현재 위치가 아닐 때, 혹은 현재 위치와 겹칠 때 테두리 유지)
+ if (isDestination) {
+ if (!isCurrentLocation) {
+ // 목적지: 이중 원 형태 (정적)
+ drawCircle(
+ color = NureongiColors.Accent,
+ radius = HIGHLIGHTED_NODE_RADIUS,
+ center = center
+ )
+ drawCircle(
+ color = NureongiColors.Background,
+ radius = HIGHLIGHTED_NODE_RADIUS * HIGHLIGHTED_NODE_HOLE_RADIUS_RATIO,
+ center = center
+ )
+ } else {
+ // 현재 위치와 목적지가 같을 때 (도착 상태)
+ drawCircle(
+ color = NureongiColors.Background,
+ radius = HIGHLIGHTED_NODE_RADIUS * HIGHLIGHTED_NODE_HOLE_RADIUS_RATIO,
+ center = center
+ )
+ }
+ }
+
+ // 일반 경유 노드 (목적지도 아니고 현재 위치도 아님)
+ if (!isDestination && !isCurrentLocation) {
+ val color = when {
+ currentIndex != -1 && nodeIndex < currentIndex -> {
+ NureongiColors.Accent.copy(alpha = 0.3f) // 지나온 노드: 어둡게 반투명 처리
+ }
+ currentIndex != -1 && nodeIndex > currentIndex -> {
+ NureongiColors.Accent.copy(alpha = 0.7f) // 예정된 노드: 중간 밝기
+ }
+ else -> NureongiColors.Accent
+ }
+ drawCircle(color = color, radius = NODE_RADIUS, center = center)
+ }
+
+ // 시각장애인 편의성: 라벨이 격자망선/경로선과 겹쳐서 가독성이 훼손되지 않도록 스마트 배치 및 마스킹 적용
+ node.label?.let { label ->
+ val layout = textMeasurer.measure(label, style = labelStyle)
+ val radius = if (isCurrentLocation || isDestination) {
+ HIGHLIGHTED_NODE_RADIUS
+ } else {
+ NODE_RADIUS
+ }
+
+ val column = node.column
+ val row = node.row
+
+ // 인접한 경로선의 방향을 계산하여 라벨이 그려지지 않아야 할 방향 탐지
+ var hasRightNeighbor = false
+ var hasLeftNeighbor = false
+ var hasUpNeighbor = false
+ var hasDownNeighbor = false
+
+ val idx = path.indexOf(node)
+ if (idx != -1) {
+ val neighbors = listOfNotNull(
+ if (idx > 0) path[idx - 1] else null,
+ if (idx < path.size - 1) path[idx + 1] else null
+ )
+ for (neighbor in neighbors) {
+ if (neighbor.row == row && neighbor.column > column) hasRightNeighbor = true
+ if (neighbor.row == row && neighbor.column < column) hasLeftNeighbor = true
+ if (neighbor.row < row && neighbor.column == column) hasUpNeighbor = true
+ if (neighbor.row > row && neighbor.column == column) hasDownNeighbor = true
+ }
+ }
+
+ // 경로선이 없는 미점유 방향 및 캔버스 화면 경계를 따져 최적의 텍스트 배치 방향 결정
+ val rightAvailable = !hasRightNeighbor && column < columns - 1
+ val leftAvailable = !hasLeftNeighbor && column > 0
+ val upAvailable = !hasUpNeighbor && row > 0
+ val downAvailable = !hasDownNeighbor && row < rows - 1
+
+ val direction = when {
+ rightAvailable -> "RIGHT"
+ leftAvailable -> "LEFT"
+ upAvailable -> "UP"
+ downAvailable -> "DOWN"
+ else -> "RIGHT"
+ }
+
+ val topLeft = when (direction) {
+ "RIGHT" -> Offset(center.x + radius + LABEL_OFFSET, center.y - layout.size.height / 2f)
+ "LEFT" -> Offset(center.x - radius - LABEL_OFFSET - layout.size.width, center.y - layout.size.height / 2f)
+ "UP" -> Offset(center.x - layout.size.width / 2f, center.y - radius - LABEL_OFFSET - layout.size.height)
+ "DOWN" -> Offset(center.x - layout.size.width / 2f, center.y + radius + LABEL_OFFSET)
+ else -> Offset(center.x + radius + LABEL_OFFSET, center.y - layout.size.height / 2f)
+ }
+
+ // 텍스트가 캔버스 바깥으로 아예 이탈하는 일을 강제 차단 (Clamping)
+ val textX = topLeft.x.coerceIn(0f, size.width - layout.size.width)
+ val textY = topLeft.y.coerceIn(0f, size.height - layout.size.height)
+ val finalTopLeft = Offset(textX, textY)
+
+ // 글자 뒤편 격자나 선들을 덮어 씌우는 마스킹 배경(RoundRect) 드로잉
+ val paddingX = 4.dp.toPx()
+ val paddingY = 2.dp.toPx()
+ drawRoundRect(
+ color = NureongiColors.Surface, // 지도 배경과 일치시켜 이질감 없는 카드 마스킹 제공
+ topLeft = Offset(finalTopLeft.x - paddingX, finalTopLeft.y - paddingY),
+ size = androidx.compose.ui.geometry.Size(
+ layout.size.width + paddingX * 2f,
+ layout.size.height + paddingY * 2f
+ ),
+ cornerRadius = androidx.compose.ui.geometry.CornerRadius(4.dp.toPx(), 4.dp.toPx())
+ )
+
+ drawText(
+ textLayoutResult = layout,
+ topLeft = finalTopLeft,
+ )
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun TactileMiniMapDefaultPreview() {
+ NureongiTheme {
+ Box(modifier = Modifier.background(NureongiColors.Background).padding(16.dp)) {
+ TactileMiniMap(
+ uiModel = MiniMapUiModel(
+ title = "일반 경로 (개찰구 → 갈림길 → 2번 출구)",
+ rows = 5,
+ columns = 3,
+ path = listOf(
+ RouteNodeUiModel(row = 2, column = 1, label = "개찰구", state = RouteNodeUiModel.State.PASSED),
+ RouteNodeUiModel(row = 1, column = 1, label = "갈림길", state = RouteNodeUiModel.State.HIGHLIGHTED), // 현재 위치가 갈림길
+ RouteNodeUiModel(row = 1, column = 2, label = "2번 출구", state = RouteNodeUiModel.State.NEUTRAL), // 목적지
+ )
+ )
+ )
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun TactileMiniMapShortPreview() {
+ NureongiTheme {
+ Box(modifier = Modifier.background(NureongiColors.Background).padding(16.dp)) {
+ TactileMiniMap(
+ uiModel = MiniMapUiModel(
+ title = "짧은 경로 (출발지 → 화장실)",
+ rows = 3,
+ columns = 3,
+ path = listOf(
+ RouteNodeUiModel(row = 2, column = 0, label = "출발지", state = RouteNodeUiModel.State.HIGHLIGHTED), // 현재 출발지
+ RouteNodeUiModel(row = 2, column = 2, label = "화장실", state = RouteNodeUiModel.State.NEUTRAL), // 목적지
+ )
+ )
+ )
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun TactileMiniMapComplexPreview() {
+ NureongiTheme {
+ Box(modifier = Modifier.background(NureongiColors.Background).padding(16.dp)) {
+ TactileMiniMap(
+ uiModel = MiniMapUiModel(
+ title = "꺾인 경로 (승강장 → 대합실 → 엘리베이터)",
+ rows = 5,
+ columns = 5,
+ path = listOf(
+ RouteNodeUiModel(row = 4, column = 1, label = "승강장", state = RouteNodeUiModel.State.PASSED),
+ RouteNodeUiModel(row = 2, column = 1, label = "대합실", state = RouteNodeUiModel.State.HIGHLIGHTED), // 현재 대합실
+ RouteNodeUiModel(row = 2, column = 3, label = "갈림길", state = RouteNodeUiModel.State.NEUTRAL),
+ RouteNodeUiModel(row = 0, column = 3, label = "엘리베이터", state = RouteNodeUiModel.State.NEUTRAL), // 목적지
+ )
+ )
+ )
+ }
+ }
+}
diff --git a/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/component/VoiceGuideButton.kt b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/component/VoiceGuideButton.kt
new file mode 100644
index 00000000..896d3641
--- /dev/null
+++ b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/component/VoiceGuideButton.kt
@@ -0,0 +1,58 @@
+package com.woowa.nureongi.ui.component
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.woowa.nureongi.ui.theme.NureongiColors
+import com.woowa.nureongi.ui.theme.NureongiTheme
+import nureongi.shared.generated.resources.Res
+import nureongi.shared.generated.resources.ic_volume
+import org.jetbrains.compose.resources.painterResource
+
+@Composable
+fun VoiceGuideButton(
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Box(
+ modifier = modifier
+ .clip(CircleShape)
+ .background(NureongiColors.Accent)
+ .clickable(
+ onClickLabel = "음성 안내 다시 듣기",
+ role = Role.Button,
+ onClick = onClick,
+ ),
+ contentAlignment = Alignment.Center,
+ ) {
+ Image(
+ painter = painterResource(Res.drawable.ic_volume),
+ contentDescription = null,
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun VoiceGuideButtonPreview() {
+ NureongiTheme {
+ Box(
+ modifier = Modifier
+ .background(NureongiColors.Background)
+ .padding(20.dp),
+ ) {
+ VoiceGuideButton(onClick = {})
+ }
+ }
+}
diff --git a/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/model/CurrentLocationSelectionUiState.kt b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/model/CurrentLocationSelectionUiState.kt
new file mode 100644
index 00000000..c18ae452
--- /dev/null
+++ b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/model/CurrentLocationSelectionUiState.kt
@@ -0,0 +1,26 @@
+package com.woowa.nureongi.ui.model
+
+data class CurrentLocationSelectionUiState(
+ val locations: List = emptyList(),
+ val selectedLocationId: String? = null,
+ val isLoading: Boolean = false,
+ val isVoiceListening: Boolean = false,
+ val voiceSelectionMessage: String? = null,
+ val error: UiError? = null,
+) {
+ val selectedLocation: CurrentLocationItemUiModel?
+ get() = locations.firstOrNull { it.id == selectedLocationId }
+
+ val selectedLocationIdForResult: String?
+ get() = if (isLoading) null else selectedLocation?.id
+}
+
+data class CurrentLocationItemUiModel(
+ val id: String,
+ val place: PlaceUiModel,
+)
+
+internal val PreviewCurrentLocationSelectionUiState = CurrentLocationSelectionUiState(
+ locations = PreviewStationMapData.toCurrentLocationItems(),
+ selectedLocationId = "a",
+)
diff --git a/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/model/DestinationSelectionUiState.kt b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/model/DestinationSelectionUiState.kt
new file mode 100644
index 00000000..93ad6c09
--- /dev/null
+++ b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/model/DestinationSelectionUiState.kt
@@ -0,0 +1,47 @@
+package com.woowa.nureongi.ui.model
+
+data class DestinationSelectionUiState(
+ val currentLocation: CurrentLocationUiModel? = null,
+ val destinations: List = emptyList(),
+ val selectedDestinationId: String? = null,
+ val isLoading: Boolean = false,
+ val error: UiError? = null,
+) {
+ val currentLocationName: String
+ get() = currentLocation?.name ?: "현재 위치를 선택해 주세요"
+
+ val selectedDestination: DestinationItemUiModel?
+ get() = destinations.firstOrNull { it.id == selectedDestinationId }
+
+ val startGuidanceDestinationId: String?
+ get() = if (!isLoading && currentLocation != null && currentLocation.nodeId != selectedDestination?.id) {
+ selectedDestination?.id
+ } else {
+ null
+ }
+
+ val canStartGuidance: Boolean
+ get() = startGuidanceDestinationId != null
+
+ val startGuidanceButtonText: String
+ get() = selectedDestination?.let { "${it.place.name}까지 안내 시작" }
+ ?: "목적지를 선택하세요"
+}
+
+data class CurrentLocationUiModel(
+ val nodeId: String,
+ val name: String,
+)
+
+data class DestinationItemUiModel(
+ val id: String,
+ val place: PlaceUiModel,
+)
+
+internal val PreviewDestinationSelectionUiState = DestinationSelectionUiState(
+ currentLocation = CurrentLocationUiModel(
+ nodeId = "a",
+ name = "옆 강의실",
+ ),
+ destinations = PreviewStationMapData.toDestinationItems(),
+)
diff --git a/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/model/GuidanceUiState.kt b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/model/GuidanceUiState.kt
new file mode 100644
index 00000000..eb30d1d8
--- /dev/null
+++ b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/model/GuidanceUiState.kt
@@ -0,0 +1,111 @@
+package com.woowa.nureongi.ui.model
+
+data class GuidanceUiState(
+ val destinationName: String,
+ val steps: List,
+ val currentStepIndex: Int = 0,
+ val isArrived: Boolean = false,
+ val arrivalGuidance: GuidanceStepUiModel,
+ val miniMap: MiniMapUiModel,
+) {
+ init {
+ require(steps.isNotEmpty())
+ require(currentStepIndex in steps.indices)
+ }
+
+ val currentStep: Int
+ get() = currentStepIndex + 1
+
+ val totalSteps: Int
+ get() = steps.size
+
+ val currentGuidance: GuidanceStepUiModel
+ get() = if (isArrived) arrivalGuidance else steps[currentStepIndex]
+
+ val remainingDistanceText: String
+ get() = currentGuidance.remainingDistanceText
+
+ val remainingTactileBlockText: String
+ get() = currentGuidance.remainingTactileBlockText
+
+ val nextButtonText: String
+ get() = currentGuidance.actionButtonText
+
+ /**
+ * currentStepIndex를 기준으로 miniMap의 path 노드 상태를 자동으로 계산해서 반환한다.
+ * - 현재 위치 노드(currentStepIndex): HIGHLIGHTED (반짝임 애니메이션)
+ * - 지나온 노드(index < currentStepIndex): PASSED (어둡게)
+ * - 아직 가지 않은 노드(index > currentStepIndex): NEUTRAL (기본)
+ * 단, 도착 상태이면 마지막 노드를 HIGHLIGHTED로 표시한다.
+ */
+ val currentMiniMap: MiniMapUiModel
+ get() {
+ val highlightedIndex = if (isArrived) miniMap.path.lastIndex else currentStepIndex
+ val updatedPath = miniMap.path.mapIndexed { index, node ->
+ val state = when {
+ index == highlightedIndex -> RouteNodeUiModel.State.HIGHLIGHTED
+ index < highlightedIndex -> RouteNodeUiModel.State.PASSED
+ else -> RouteNodeUiModel.State.NEUTRAL
+ }
+ node.copy(state = state)
+ }
+ return miniMap.copy(path = updatedPath)
+ }
+}
+
+data class GuidanceStepUiModel(
+ val instruction: String,
+ val landmark: String,
+ val guideMessage: String,
+ val remainingDistanceText: String,
+ val remainingTactileBlockText: String,
+ val actionButtonText: String,
+)
+
+internal val PreviewGuidanceUiState = GuidanceUiState(
+ destinationName = "2번 출구",
+ steps = listOf(
+ GuidanceStepUiModel(
+ instruction = "8m 직진",
+ landmark = "점형 블록",
+ guideMessage = "2번 출구까지 안내를 시작합니다. 앞으로 8미터 직진하세요. 8미터 앞에 점형 블록이 있습니다.",
+ remainingDistanceText = "20m",
+ remainingTactileBlockText = "2개",
+ actionButtonText = "다음 점형 블록 도착 ›",
+ ),
+ GuidanceStepUiModel(
+ instruction = "오른쪽 회전",
+ landmark = "점형 블록",
+ guideMessage = "점형 블록에서 오른쪽으로 회전하세요.",
+ remainingDistanceText = "12m",
+ remainingTactileBlockText = "1개",
+ actionButtonText = "다음 점형 블록 도착 ›",
+ ),
+ GuidanceStepUiModel(
+ instruction = "12m 직진",
+ landmark = "2번 출구",
+ guideMessage = "앞으로 12미터 직진하면 2번 출구에 도착합니다.",
+ remainingDistanceText = "12m",
+ remainingTactileBlockText = "0개",
+ actionButtonText = "목적지 도착 ›",
+ ),
+ ),
+ arrivalGuidance = GuidanceStepUiModel(
+ instruction = "도착",
+ landmark = "2번 출구",
+ guideMessage = "2번 출구에 도착했습니다. 안내를 종료하려면 안내 종료 버튼을 누르세요.",
+ remainingDistanceText = "0m",
+ remainingTactileBlockText = "0개",
+ actionButtonText = "안내 종료",
+ ),
+ miniMap = MiniMapUiModel(
+ title = "판교역 · 점자 블럭 지도",
+ rows = 5,
+ columns = 3,
+ path = listOf(
+ RouteNodeUiModel(row = 2, column = 1, state = RouteNodeUiModel.State.HIGHLIGHTED), // 출발지이자 현재 위치
+ RouteNodeUiModel(row = 1, column = 1, state = RouteNodeUiModel.State.NEUTRAL),
+ RouteNodeUiModel(row = 1, column = 2, label = "2번 출구", state = RouteNodeUiModel.State.NEUTRAL), // 목적지 (마지막 노드)
+ )
+ )
+)
diff --git a/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/model/MiniMapUiModel.kt b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/model/MiniMapUiModel.kt
new file mode 100644
index 00000000..30233ac2
--- /dev/null
+++ b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/model/MiniMapUiModel.kt
@@ -0,0 +1,8 @@
+package com.woowa.nureongi.ui.model
+
+data class MiniMapUiModel(
+ val title: String,
+ val rows: Int,
+ val columns: Int,
+ val path: List,
+)
diff --git a/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/model/PlaceUiModel.kt b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/model/PlaceUiModel.kt
new file mode 100644
index 00000000..bb2b361f
--- /dev/null
+++ b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/model/PlaceUiModel.kt
@@ -0,0 +1,6 @@
+package com.woowa.nureongi.ui.model
+
+data class PlaceUiModel(
+ val name: String,
+ val location: String,
+)
diff --git a/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/model/RouteNodeUiModel.kt b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/model/RouteNodeUiModel.kt
new file mode 100644
index 00000000..8bb38744
--- /dev/null
+++ b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/model/RouteNodeUiModel.kt
@@ -0,0 +1,14 @@
+package com.woowa.nureongi.ui.model
+
+data class RouteNodeUiModel(
+ val row: Int,
+ val column: Int,
+ val label: String? = null,
+ val state: State = State.NEUTRAL,
+) {
+ enum class State {
+ NEUTRAL,
+ PASSED,
+ HIGHLIGHTED,
+ }
+}
diff --git a/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/model/StationMapUiMapper.kt b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/model/StationMapUiMapper.kt
new file mode 100644
index 00000000..f4bc88d4
--- /dev/null
+++ b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/model/StationMapUiMapper.kt
@@ -0,0 +1,32 @@
+package com.woowa.nureongi.ui.model
+
+import com.woowa.nureongi.domain.data.StationMapData
+import com.woowa.nureongi.domain.data.SeohyeonBasementStationMapData
+
+internal fun StationMapData.toCurrentLocationItems(): List {
+ return station.navigationPoints.map { navigationPoint ->
+ val node = requireNotNull(station.findNode(navigationPoint.nodeId))
+ CurrentLocationItemUiModel(
+ id = navigationPoint.nodeId,
+ place = PlaceUiModel(
+ name = node.name,
+ location = node.landmark.orEmpty(),
+ ),
+ )
+ }
+}
+
+internal fun StationMapData.toDestinationItems(): List {
+ return station.navigationPoints.map { navigationPoint ->
+ val node = requireNotNull(station.findNode(navigationPoint.nodeId))
+ DestinationItemUiModel(
+ id = navigationPoint.nodeId,
+ place = PlaceUiModel(
+ name = node.name,
+ location = node.landmark.orEmpty(),
+ ),
+ )
+ }
+}
+
+internal val PreviewStationMapData = SeohyeonBasementStationMapData.getMapData()
diff --git a/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/model/UiError.kt b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/model/UiError.kt
new file mode 100644
index 00000000..55399f4d
--- /dev/null
+++ b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/model/UiError.kt
@@ -0,0 +1,5 @@
+package com.woowa.nureongi.ui.model
+
+data class UiError(
+ val message: String,
+)
diff --git a/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/navigation/GuidanceRouteCalculator.kt b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/navigation/GuidanceRouteCalculator.kt
new file mode 100644
index 00000000..32fe9a55
--- /dev/null
+++ b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/navigation/GuidanceRouteCalculator.kt
@@ -0,0 +1,158 @@
+package com.woowa.nureongi.ui.navigation
+
+import com.woowa.nureongi.domain.data.StationMapData
+import com.woowa.nureongi.domain.data.SeohyeonBasementStationMapData
+import com.woowa.nureongi.domain.model.Route
+import com.woowa.nureongi.domain.model.RouteStep
+import com.woowa.nureongi.domain.service.Navigatable
+import com.woowa.nureongi.domain.service.Navigator
+import com.woowa.nureongi.ui.model.CurrentLocationUiModel
+import com.woowa.nureongi.ui.model.DestinationItemUiModel
+import com.woowa.nureongi.ui.model.GuidanceStepUiModel
+import com.woowa.nureongi.ui.model.GuidanceUiState
+import com.woowa.nureongi.ui.model.MiniMapUiModel
+import com.woowa.nureongi.ui.model.RouteNodeUiModel
+
+private const val TACTILE_BLOCK_GUIDE_NAME = "점형 블록"
+
+internal fun interface GuidanceRouteCalculator {
+ fun calculate(
+ currentLocation: CurrentLocationUiModel,
+ destination: DestinationItemUiModel,
+ ): GuidanceRouteCalculationResult
+}
+
+internal sealed interface GuidanceRouteCalculationResult {
+ data class Success(
+ val guidanceState: GuidanceUiState,
+ ) : GuidanceRouteCalculationResult
+
+ data class Failure(
+ val message: String,
+ ) : GuidanceRouteCalculationResult
+}
+
+internal class MapGuidanceRouteCalculator(
+ private val mapData: StationMapData = SeohyeonBasementStationMapData.getMapData(),
+ private val navigator: Navigatable = Navigator(),
+) : GuidanceRouteCalculator {
+ override fun calculate(
+ currentLocation: CurrentLocationUiModel,
+ destination: DestinationItemUiModel,
+ ): GuidanceRouteCalculationResult {
+ if (currentLocation.nodeId == destination.id) {
+ return GuidanceRouteCalculationResult.Failure(
+ message = "현재 위치와 목적지가 같습니다. 다른 목적지를 선택해 주세요.",
+ )
+ }
+
+ val station = mapData.station
+ val startPoint = station.findNavigationPoint(currentLocation.nodeId)
+ val destinationPoint = station.findNavigationPoint(destination.id)
+ val destinationNode = station.findNode(destination.id)
+ if (startPoint == null || destinationPoint == null || destinationNode == null) {
+ return GuidanceRouteCalculationResult.Failure(
+ message = "경로를 찾을 수 없습니다. 현재 위치나 목적지를 다시 선택해 주세요.",
+ )
+ }
+
+ val route = runCatching {
+ navigator.findRoute(
+ station = station,
+ from = startPoint,
+ destination = destinationPoint,
+ )
+ }.getOrElse {
+ return GuidanceRouteCalculationResult.Failure(
+ message = "경로를 찾을 수 없습니다. 현재 위치나 목적지를 다시 선택해 주세요.",
+ )
+ }
+
+ return GuidanceRouteCalculationResult.Success(
+ guidanceState = route.toGuidanceUiState(
+ destinationName = destinationNode.name,
+ mapData = mapData,
+ ),
+ )
+ }
+}
+
+private fun Route.toGuidanceUiState(
+ destinationName: String,
+ mapData: StationMapData,
+): GuidanceUiState {
+ val pathNodes = listOf(steps.first().fromNode) + steps.map(RouteStep::toNode)
+
+ return GuidanceUiState(
+ destinationName = destinationName,
+ steps = steps.mapIndexed { index, step ->
+ val previousAngle = if (index == 0) {
+ startPoint.initialAngle
+ } else {
+ steps[index - 1].edge.angle
+ }
+ val movement = movementInstruction(previousAngle, step.edge.angle)
+ val isLastStep = index == steps.lastIndex
+ val targetName = if (isLastStep) {
+ destinationName
+ } else {
+ TACTILE_BLOCK_GUIDE_NAME
+ }
+
+ GuidanceStepUiModel(
+ instruction = "$movement ${formatDistance(step.edge.distance)} 이동",
+ landmark = targetName,
+ guideMessage = "$movement ${formatDistance(step.edge.distance)} 이동하면 ${targetName}에 도착합니다.",
+ remainingDistanceText = formatDistance(step.remainingDistance),
+ remainingTactileBlockText = "${steps.size - index}개",
+ actionButtonText = if (isLastStep) {
+ "목적지 도착 ›"
+ } else {
+ "다음 점형 블록 도착 ›"
+ },
+ )
+ },
+ arrivalGuidance = GuidanceStepUiModel(
+ instruction = "도착",
+ landmark = destinationName,
+ guideMessage = "${destinationName}에 도착했습니다. 안내를 종료하려면 안내 종료 버튼을 누르세요.",
+ remainingDistanceText = "0m",
+ remainingTactileBlockText = "0개",
+ actionButtonText = "안내 종료",
+ ),
+ miniMap = MiniMapUiModel(
+ title = "${mapData.station.name} · 점자 블록 지도",
+ rows = mapData.rows,
+ columns = mapData.columns,
+ path = pathNodes.mapIndexed { index, node ->
+ val position = requireNotNull(mapData.nodePositions[node.id])
+ RouteNodeUiModel(
+ row = position.row,
+ column = position.column,
+ label = destinationName.takeIf { index == pathNodes.lastIndex },
+ )
+ },
+ ),
+ )
+}
+
+private fun movementInstruction(
+ currentAngle: Int,
+ targetAngle: Int,
+): String {
+ val clockwiseDifference = (targetAngle - currentAngle + 360) % 360
+ return when (clockwiseDifference) {
+ in 0..30, in 330..359 -> "직진하여"
+ in 31..150 -> "오른쪽으로 회전한 뒤"
+ in 151..210 -> "뒤로 돌아"
+ else -> "왼쪽으로 회전한 뒤"
+ }
+}
+
+private fun formatDistance(distance: Float): String {
+ return if (distance % 1f == 0f) {
+ "${distance.toInt()}m"
+ } else {
+ "${distance}m"
+ }
+}
diff --git a/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/navigation/NureongiAppRoute.kt b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/navigation/NureongiAppRoute.kt
new file mode 100644
index 00000000..0d1dc3ff
--- /dev/null
+++ b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/navigation/NureongiAppRoute.kt
@@ -0,0 +1,126 @@
+package com.woowa.nureongi.ui.navigation
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.animation.EnterTransition
+import androidx.compose.animation.ExitTransition
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.viewmodel.compose.viewModel
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.rememberNavController
+import androidx.navigation.toRoute
+import com.woowa.nureongi.ui.screen.CurrentLocationSelectionScreen
+import com.woowa.nureongi.ui.screen.DestinationSelectionScreen
+import com.woowa.nureongi.ui.screen.GuidanceScreen
+import com.woowa.nureongi.ui.theme.NureongiColors
+import com.woowa.nureongi.ui.accessibility.rememberScreenReaderEnabled
+import com.woowa.nureongi.ui.voice.rememberVoiceGuide
+
+@Composable
+internal fun NureongiAppRoute(
+ modifier: Modifier = Modifier,
+ viewModel: NureongiAppViewModel = viewModel { NureongiAppViewModel() },
+) {
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+ val navController = rememberNavController()
+
+ NavHost(
+ navController = navController,
+ startDestination = DestinationSelectionNavRoute,
+ modifier = modifier
+ .fillMaxSize()
+ .background(NureongiColors.Background),
+ enterTransition = { EnterTransition.None },
+ exitTransition = { ExitTransition.None },
+ popEnterTransition = { EnterTransition.None },
+ popExitTransition = { ExitTransition.None },
+ ) {
+ composable {
+ CurrentLocationSelectionScreen(
+ state = uiState.currentLocationState,
+ onBackClick = navController::popBackStack,
+ onLocationSelected = { locationId ->
+ viewModel.onLocationSelected(locationId)?.let {
+ navController.popBackStack(
+ inclusive = false,
+ )
+ }
+ },
+ onVoiceLocationSelectionStarted = viewModel::onVoiceLocationSelectionStarted,
+ onVoiceLocationRecognized = { recognizedTexts ->
+ viewModel.onVoiceLocationRecognized(recognizedTexts)?.let {
+ navController.popBackStack(
+ inclusive = false,
+ )
+ }
+ },
+ onVoiceLocationRecognitionFailed = viewModel::onVoiceLocationRecognitionFailed,
+ )
+ }
+ composable {
+ DestinationSelectionScreen(
+ state = uiState.destinationState,
+ onDestinationSelected = viewModel::onDestinationSelected,
+ onChangeLocationClick = navController::navigateToCurrentLocationSelection,
+ onStartGuidance = {
+ viewModel.onStartGuidance()?.let { request ->
+ navController.navigateToGuidance(request)
+ }
+ },
+ )
+ }
+ composable { backStackEntry ->
+ val route: GuidanceNavRoute = backStackEntry.toRoute()
+ LaunchedEffect(route) {
+ viewModel.onGuidanceDestinationEntered(
+ currentLocationId = route.currentLocationId,
+ destinationId = route.destinationId,
+ )
+ }
+
+ val guidanceState = uiState.guidanceState
+ if (guidanceState == null) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(NureongiColors.Background),
+ )
+ } else {
+ val voiceGuide = rememberVoiceGuide()
+ val isScreenReaderEnabled = rememberScreenReaderEnabled()
+ val guideMessage = guidanceState.currentGuidance.guideMessage
+
+ LaunchedEffect(
+ guidanceState.currentStepIndex,
+ guidanceState.isArrived,
+ ) {
+ if (!isScreenReaderEnabled) {
+ voiceGuide.speak(guideMessage)
+ }
+ }
+
+ GuidanceScreen(
+ state = guidanceState,
+ onNextStepClick = {
+ voiceGuide.stop()
+ viewModel.onNextGuidanceStep()
+ },
+ onCloseClick = {
+ voiceGuide.stop()
+ navController.finishGuidance()
+ },
+ onVoiceGuideClick = {
+ voiceGuide.speak(guideMessage)
+ },
+ onUserInteraction = voiceGuide::stop,
+ )
+ }
+ }
+ }
+}
diff --git a/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/navigation/NureongiAppUiState.kt b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/navigation/NureongiAppUiState.kt
new file mode 100644
index 00000000..9d3e25ea
--- /dev/null
+++ b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/navigation/NureongiAppUiState.kt
@@ -0,0 +1,25 @@
+package com.woowa.nureongi.ui.navigation
+
+import com.woowa.nureongi.domain.data.StationMapData
+import com.woowa.nureongi.ui.model.CurrentLocationSelectionUiState
+import com.woowa.nureongi.ui.model.DestinationSelectionUiState
+import com.woowa.nureongi.ui.model.GuidanceUiState
+import com.woowa.nureongi.ui.model.toCurrentLocationItems
+import com.woowa.nureongi.ui.model.toDestinationItems
+
+internal data class NureongiAppUiState(
+ val currentLocationState: CurrentLocationSelectionUiState = CurrentLocationSelectionUiState(),
+ val destinationState: DestinationSelectionUiState = DestinationSelectionUiState(),
+ val guidanceState: GuidanceUiState? = null,
+)
+
+internal fun StationMapData.toInitialAppUiState(): NureongiAppUiState {
+ return NureongiAppUiState(
+ currentLocationState = CurrentLocationSelectionUiState(
+ locations = toCurrentLocationItems(),
+ ),
+ destinationState = DestinationSelectionUiState(
+ destinations = toDestinationItems(),
+ ),
+ )
+}
diff --git a/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/navigation/NureongiAppViewModel.kt b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/navigation/NureongiAppViewModel.kt
new file mode 100644
index 00000000..8b83b634
--- /dev/null
+++ b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/navigation/NureongiAppViewModel.kt
@@ -0,0 +1,203 @@
+package com.woowa.nureongi.ui.navigation
+
+import androidx.lifecycle.ViewModel
+import com.woowa.nureongi.domain.data.StationMapDataSource
+import com.woowa.nureongi.domain.data.SeohyeonBasementStationMapData
+import com.woowa.nureongi.ui.model.CurrentLocationUiModel
+import com.woowa.nureongi.ui.model.GuidanceUiState
+import com.woowa.nureongi.ui.model.UiError
+import com.woowa.nureongi.ui.speech.findBestVoiceLocationMatch
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+
+internal class NureongiAppViewModel(
+ mapDataSource: StationMapDataSource = SeohyeonBasementStationMapData,
+ initialState: NureongiAppUiState? = null,
+ routeCalculator: GuidanceRouteCalculator? = null,
+) : ViewModel() {
+ private val mapData = mapDataSource.getMapData()
+ private val routeCalculator = routeCalculator ?: MapGuidanceRouteCalculator(mapData)
+ private val _uiState = MutableStateFlow(
+ initialState ?: mapData.toInitialAppUiState(),
+ )
+ val uiState = _uiState.asStateFlow()
+
+ fun onVoiceLocationSelectionStarted() {
+ _uiState.update { state ->
+ if (state.currentLocationState.isLoading || state.currentLocationState.locations.isEmpty()) {
+ state
+ } else {
+ state.copy(
+ currentLocationState = state.currentLocationState.copy(
+ isVoiceListening = true,
+ voiceSelectionMessage = "출발지를 말씀해주세요.",
+ ),
+ )
+ }
+ }
+ }
+
+ fun onVoiceLocationRecognized(recognizedTexts: List): CurrentLocationUiModel? {
+ val state = _uiState.value
+ val match = findBestVoiceLocationMatch(
+ recognizedTexts = recognizedTexts,
+ locations = state.currentLocationState.locations,
+ )
+
+ if (match == null) {
+ _uiState.value = state.copy(
+ currentLocationState = state.currentLocationState.copy(
+ isVoiceListening = false,
+ voiceSelectionMessage = "출발지를 찾지 못했습니다. 다시 말씀해주세요.",
+ ),
+ )
+ return null
+ }
+
+ _uiState.value = state.copy(
+ currentLocationState = state.currentLocationState.copy(
+ isVoiceListening = false,
+ voiceSelectionMessage = "${match.locationName}을 현재 위치로 설정합니다.",
+ ),
+ )
+ return onLocationSelected(match.locationId)
+ }
+
+ fun onVoiceLocationRecognitionFailed(message: String) {
+ _uiState.update { state ->
+ state.copy(
+ currentLocationState = state.currentLocationState.copy(
+ isVoiceListening = false,
+ voiceSelectionMessage = message,
+ ),
+ )
+ }
+ }
+
+ fun onLocationSelected(locationId: String): CurrentLocationUiModel? {
+ val state = _uiState.value
+ if (state.currentLocationState.isLoading) {
+ return null
+ }
+ val selectedLocation = state.currentLocationState.locations
+ .firstOrNull { location -> location.id == locationId }
+ ?: return null
+ val currentLocation = CurrentLocationUiModel(
+ nodeId = selectedLocation.id,
+ name = selectedLocation.place.name,
+ )
+ val updatedState = state.copy(
+ currentLocationState = state.currentLocationState.copy(
+ selectedLocationId = locationId,
+ error = null,
+ ),
+ destinationState = state.destinationState.copy(
+ currentLocation = currentLocation,
+ selectedDestinationId = state.destinationState.selectedDestinationId,
+ ),
+ guidanceState = null,
+ )
+ _uiState.value = validateSelection(updatedState)
+ return currentLocation
+ }
+
+ fun onDestinationSelected(destinationId: String) {
+ _uiState.update { state ->
+ if (
+ state.destinationState.isLoading ||
+ state.destinationState.destinations.none { destination -> destination.id == destinationId }
+ ) {
+ state
+ } else {
+ val updatedState = state.copy(
+ destinationState = state.destinationState.copy(
+ selectedDestinationId = destinationId,
+ ),
+ )
+ validateSelection(updatedState)
+ }
+ }
+ }
+
+ private fun validateSelection(state: NureongiAppUiState): NureongiAppUiState {
+ val currentLocation = state.destinationState.currentLocation
+ val selectedDestination = state.destinationState.selectedDestination
+ val error = if (currentLocation != null && selectedDestination != null && currentLocation.nodeId == selectedDestination.id) {
+ UiError("현재 위치와 목적지가 같습니다. 다른 목적지를 선택해 주세요.")
+ } else {
+ null
+ }
+ return state.copy(
+ destinationState = state.destinationState.copy(error = error)
+ )
+ }
+
+ fun onStartGuidance(): GuidanceNavRoute? {
+ val state = _uiState.value
+ if (state.destinationState.isLoading) {
+ return null
+ }
+ val currentLocation = state.destinationState.currentLocation
+ ?: return updateRouteError(state, "현재 위치를 먼저 선택해 주세요.")
+ val destination = state.destinationState.selectedDestination
+ ?: return updateRouteError(state, "목적지를 먼저 선택해 주세요.")
+
+ return when (val result = routeCalculator.calculate(currentLocation, destination)) {
+ is GuidanceRouteCalculationResult.Success -> {
+ _uiState.value = state.copy(
+ destinationState = state.destinationState.copy(error = null),
+ guidanceState = result.guidanceState,
+ )
+ GuidanceNavRoute(
+ currentLocationId = currentLocation.nodeId,
+ destinationId = destination.id,
+ )
+ }
+
+ is GuidanceRouteCalculationResult.Failure -> updateRouteError(state, result.message)
+ }
+ }
+
+ fun onNextGuidanceStep() {
+ _uiState.update { state ->
+ state.copy(guidanceState = state.guidanceState?.nextStep())
+ }
+ }
+
+ fun onGuidanceDestinationEntered(
+ currentLocationId: String,
+ destinationId: String,
+ ) {
+ if (_uiState.value.guidanceState != null) {
+ return
+ }
+
+ onLocationSelected(currentLocationId)
+ onDestinationSelected(destinationId)
+ onStartGuidance()
+ }
+
+ private fun updateRouteError(
+ state: NureongiAppUiState,
+ message: String,
+ ): GuidanceNavRoute? {
+ _uiState.value = state.withRouteError(message)
+ return null
+ }
+}
+
+private fun NureongiAppUiState.withRouteError(message: String): NureongiAppUiState {
+ return copy(
+ destinationState = destinationState.copy(error = UiError(message)),
+ guidanceState = null,
+ )
+}
+
+private fun GuidanceUiState.nextStep(): GuidanceUiState {
+ return when {
+ isArrived -> this
+ currentStepIndex < steps.lastIndex -> copy(currentStepIndex = currentStepIndex + 1)
+ else -> copy(isArrived = true)
+ }
+}
diff --git a/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/navigation/NureongiNavController.kt b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/navigation/NureongiNavController.kt
new file mode 100644
index 00000000..5321b5a5
--- /dev/null
+++ b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/navigation/NureongiNavController.kt
@@ -0,0 +1,19 @@
+package com.woowa.nureongi.ui.navigation
+
+import androidx.navigation.NavController
+
+internal fun NavController.navigateToCurrentLocationSelection() {
+ navigate(CurrentLocationNavRoute) {
+ launchSingleTop = true
+ }
+}
+
+internal fun NavController.navigateToGuidance(route: GuidanceNavRoute) {
+ navigate(route) {
+ launchSingleTop = true
+ }
+}
+
+internal fun NavController.finishGuidance(): Boolean {
+ return popBackStack(inclusive = false)
+}
diff --git a/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/navigation/NureongiNavRoute.kt b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/navigation/NureongiNavRoute.kt
new file mode 100644
index 00000000..91c2905e
--- /dev/null
+++ b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/navigation/NureongiNavRoute.kt
@@ -0,0 +1,15 @@
+package com.woowa.nureongi.ui.navigation
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+internal data object CurrentLocationNavRoute
+
+@Serializable
+internal data object DestinationSelectionNavRoute
+
+@Serializable
+internal data class GuidanceNavRoute(
+ val currentLocationId: String,
+ val destinationId: String,
+)
diff --git a/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/screen/CurrentLocationSelectionScreen.kt b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/screen/CurrentLocationSelectionScreen.kt
new file mode 100644
index 00000000..fc6d320e
--- /dev/null
+++ b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/screen/CurrentLocationSelectionScreen.kt
@@ -0,0 +1,201 @@
+package com.woowa.nureongi.ui.screen
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.semantics.LiveRegionMode
+import androidx.compose.ui.semantics.liveRegion
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.woowa.nureongi.ui.component.BackNavigationTopBar
+import com.woowa.nureongi.ui.component.CtaButton
+import com.woowa.nureongi.ui.component.PlaceListItem
+import com.woowa.nureongi.ui.model.CurrentLocationItemUiModel
+import com.woowa.nureongi.ui.model.CurrentLocationSelectionUiState
+import com.woowa.nureongi.ui.model.PreviewCurrentLocationSelectionUiState
+import com.woowa.nureongi.ui.speech.rememberSpeechToTextRecognizer
+import com.woowa.nureongi.ui.theme.NureongiColors
+import com.woowa.nureongi.ui.theme.NureongiTheme
+import com.woowa.nureongi.ui.theme.NureongiTypography
+
+@Composable
+fun CurrentLocationSelectionScreen(
+ state: CurrentLocationSelectionUiState,
+ onBackClick: () -> Unit,
+ onLocationSelected: (String) -> Unit,
+ onVoiceLocationSelectionStarted: () -> Unit,
+ onVoiceLocationRecognized: (List) -> Unit,
+ onVoiceLocationRecognitionFailed: (String) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val speechToTextRecognizer = rememberSpeechToTextRecognizer()
+ val voiceSelectionEnabled = !state.isLoading &&
+ !state.isVoiceListening &&
+ speechToTextRecognizer.isAvailable &&
+ state.locations.isNotEmpty()
+
+ DisposableEffect(speechToTextRecognizer) {
+ onDispose(speechToTextRecognizer::stopListening)
+ }
+
+ Scaffold(
+ modifier = modifier
+ .fillMaxSize()
+ .background(NureongiColors.Background)
+ ) { innerPadding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(innerPadding)
+ .padding(vertical = 10.dp, horizontal = 15.dp),
+ verticalArrangement = Arrangement.spacedBy(24.dp),
+ ) {
+ CurrentLocationSelectionHeader(onBackClick = onBackClick)
+ VoiceCurrentLocationButton(
+ enabled = voiceSelectionEnabled,
+ isListening = state.isVoiceListening,
+ isSpeechRecognitionAvailable = speechToTextRecognizer.isAvailable,
+ onClick = {
+ onVoiceLocationSelectionStarted()
+ speechToTextRecognizer.startListening(
+ onResult = onVoiceLocationRecognized,
+ onError = onVoiceLocationRecognitionFailed,
+ )
+ },
+ )
+ state.voiceSelectionMessage?.let { message ->
+ VoiceSelectionMessage(message = message)
+ }
+ LocationList(
+ locations = state.locations,
+ selectedLocationId = state.selectedLocationId,
+ onLocationSelected = onLocationSelected,
+ modifier = Modifier.weight(1f),
+ )
+ }
+ }
+}
+
+@Composable
+private fun CurrentLocationSelectionHeader(
+ onBackClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier = modifier,
+ verticalArrangement = Arrangement.spacedBy(20.dp),
+ ) {
+ BackNavigationTopBar(
+ title = "현재 위치",
+ onBackClick = onBackClick,
+ )
+ Text(
+ text = "지금 서 있는 점형 블럭을 선택하세요.",
+ style = NureongiTypography.ItemTitle,
+ color = NureongiColors.TextSecondary,
+ )
+ }
+}
+
+@Composable
+private fun VoiceCurrentLocationButton(
+ enabled: Boolean,
+ isListening: Boolean,
+ isSpeechRecognitionAvailable: Boolean,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val text = when {
+ !isSpeechRecognitionAvailable -> "음성 인식 사용 불가"
+ isListening -> "출발지 듣는 중"
+ else -> "음성으로 출발지 선택"
+ }
+ CtaButton(
+ text = text,
+ onClick = onClick,
+ enabled = enabled,
+ containerColor = if (enabled) NureongiColors.Accent else NureongiColors.Disabled,
+ contentColor = if (enabled) NureongiColors.OnAccent else NureongiColors.OnDisabled,
+ modifier = modifier.height(56.dp),
+ )
+}
+
+@Composable
+private fun VoiceSelectionMessage(
+ message: String,
+ modifier: Modifier = Modifier,
+) {
+ Text(
+ text = message,
+ style = NureongiTypography.ItemDescription,
+ color = NureongiColors.TextSecondary,
+ modifier = modifier.semantics {
+ liveRegion = LiveRegionMode.Polite
+ },
+ )
+}
+
+@Composable
+private fun LocationList(
+ locations: List,
+ selectedLocationId: String?,
+ onLocationSelected: (String) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ LazyColumn(
+ modifier = modifier,
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ items(
+ items = locations,
+ key = { location -> location.id },
+ ) { location ->
+ PlaceListItem(
+ place = location.place,
+ selected = location.id == selectedLocationId,
+ onClick = { onLocationSelected(location.id) },
+ )
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun CurrentLocationSelectionScreenPreview() {
+ NureongiTheme {
+ CurrentLocationSelectionScreen(
+ state = PreviewCurrentLocationSelectionUiState,
+ onBackClick = {},
+ onLocationSelected = {},
+ onVoiceLocationSelectionStarted = {},
+ onVoiceLocationRecognized = {},
+ onVoiceLocationRecognitionFailed = {},
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun UnselectedCurrentLocationSelectionScreenPreview() {
+ NureongiTheme {
+ CurrentLocationSelectionScreen(
+ state = PreviewCurrentLocationSelectionUiState.copy(selectedLocationId = null),
+ onBackClick = {},
+ onLocationSelected = {},
+ onVoiceLocationSelectionStarted = {},
+ onVoiceLocationRecognized = {},
+ onVoiceLocationRecognitionFailed = {},
+ )
+ }
+}
diff --git a/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/screen/DestinationSelectionScreen.kt b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/screen/DestinationSelectionScreen.kt
new file mode 100644
index 00000000..22e4b481
--- /dev/null
+++ b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/screen/DestinationSelectionScreen.kt
@@ -0,0 +1,262 @@
+package com.woowa.nureongi.ui.screen
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.BottomAppBar
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.semantics.LiveRegionMode
+import androidx.compose.ui.semantics.heading
+import androidx.compose.ui.semantics.liveRegion
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.woowa.nureongi.ui.accessibility.rememberScreenReaderEnabled
+import com.woowa.nureongi.ui.component.BrailleIcon
+import com.woowa.nureongi.ui.component.CtaButton
+import com.woowa.nureongi.ui.component.CurrentLocationBar
+import com.woowa.nureongi.ui.component.PlaceListItem
+import com.woowa.nureongi.ui.model.DestinationItemUiModel
+import com.woowa.nureongi.ui.model.DestinationSelectionUiState
+import com.woowa.nureongi.ui.model.PreviewDestinationSelectionUiState
+import com.woowa.nureongi.ui.theme.NureongiColors
+import com.woowa.nureongi.ui.theme.NureongiTheme
+import com.woowa.nureongi.ui.theme.NureongiTypography
+import com.woowa.nureongi.ui.voice.rememberVoiceGuide
+
+@Composable
+fun DestinationSelectionScreen(
+ state: DestinationSelectionUiState,
+ onDestinationSelected: (String) -> Unit,
+ onChangeLocationClick: () -> Unit,
+ onStartGuidance: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val voiceGuide = rememberVoiceGuide()
+ val isScreenReaderEnabled = rememberScreenReaderEnabled()
+
+ LaunchedEffect(state.error) {
+ val errorMessage = state.error?.message
+ if (errorMessage != null && !isScreenReaderEnabled) {
+ voiceGuide.speak(errorMessage)
+ }
+ }
+
+ DestinationSelectionContent(
+ currentLocationName = state.currentLocationName,
+ destinations = state.destinations,
+ selectedDestinationId = state.selectedDestinationId,
+ startGuidanceButtonText = state.startGuidanceButtonText,
+ canStartGuidance = state.canStartGuidance,
+ errorMessage = state.error?.message,
+ onDestinationSelected = onDestinationSelected,
+ onChangeLocationClick = onChangeLocationClick,
+ onStartGuidance = onStartGuidance,
+ modifier = modifier,
+ )
+}
+
+@Composable
+private fun DestinationSelectionContent(
+ currentLocationName: String,
+ destinations: List,
+ selectedDestinationId: String?,
+ startGuidanceButtonText: String,
+ canStartGuidance: Boolean,
+ errorMessage: String?,
+ onDestinationSelected: (String) -> Unit,
+ onChangeLocationClick: () -> Unit,
+ onStartGuidance: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Scaffold(
+ bottomBar = {
+ BottomAppBar(
+ containerColor = NureongiColors.Background,
+ ) {
+ DestinationSelectionCtaButton(
+ modifier = Modifier
+ .height(72.dp),
+ text = startGuidanceButtonText,
+ enabled = canStartGuidance,
+ onStartGuidance = onStartGuidance,
+ )
+ }
+ },
+ modifier = modifier
+ .fillMaxSize()
+ .background(NureongiColors.Background),
+ ) { innerPadding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(innerPadding)
+ .padding(vertical = 10.dp, horizontal = 15.dp),
+ verticalArrangement = Arrangement.spacedBy(20.dp),
+ ) {
+ DestinationSelectionHeader()
+ Text(
+ text = "현재 위치를 선택해주세요.",
+ style = NureongiTypography.SectionHeader,
+ color = NureongiColors.TextPrimary,
+ modifier = Modifier.semantics { heading() }
+ )
+ CurrentLocationBar(
+ locationName = currentLocationName,
+ onChangeClick = onChangeLocationClick,
+ )
+ errorMessage?.let { message ->
+ DestinationSelectionErrorMessage(message = message)
+ }
+ DestinationOptions(
+ modifier = Modifier.weight(1f),
+ destinations = destinations,
+ selectedDestinationId = selectedDestinationId,
+ onDestinationSelected = onDestinationSelected,
+ )
+ }
+ }
+}
+
+@Composable
+private fun DestinationSelectionErrorMessage(
+ modifier: Modifier = Modifier,
+ message: String,
+) {
+ Text(
+ text = message,
+ style = NureongiTypography.ItemDescription,
+ color = NureongiColors.Accent,
+ modifier = modifier.semantics {
+ liveRegion = LiveRegionMode.Assertive
+ },
+ )
+}
+
+@Composable
+private fun DestinationSelectionHeader(
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier = modifier,
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ BrailleIcon(
+ dotColor = NureongiColors.Accent,
+ backgroundColor = NureongiColors.Background,
+ modifier = Modifier.size(65.dp)
+ )
+ Column {
+ Text(
+ text = "누렁이",
+ style = NureongiTypography.SectionHeader,
+ color = NureongiColors.TextPrimary,
+ modifier = Modifier.semantics { heading() },
+ )
+ Text(
+ text = "점자 블록 길안내",
+ style = NureongiTypography.ItemDescription,
+ color = NureongiColors.Accent,
+ )
+ }
+ }
+}
+
+@Composable
+private fun DestinationOptions(
+ destinations: List,
+ selectedDestinationId: String?,
+ onDestinationSelected: (String) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier = modifier,
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ Text(
+ text = "어디로 갈까요?",
+ style = NureongiTypography.SectionHeader,
+ color = NureongiColors.TextPrimary,
+ modifier = Modifier.semantics { heading() },
+ )
+ Text(
+ text = "목적지를 선택하세요.",
+ style = NureongiTypography.ItemDescription,
+ color = NureongiColors.TextSecondary,
+ )
+ }
+
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ items(
+ items = destinations,
+ key = { destination -> destination.id },
+ ) { destination ->
+ PlaceListItem(
+ place = destination.place,
+ selected = destination.id == selectedDestinationId,
+ onClick = { onDestinationSelected(destination.id) },
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun DestinationSelectionCtaButton(
+ text: String,
+ enabled: Boolean,
+ onStartGuidance: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ CtaButton(
+ text = text,
+ onClick = onStartGuidance,
+ enabled = enabled,
+ containerColor = if (enabled) NureongiColors.Accent else NureongiColors.Disabled,
+ contentColor = if (enabled) NureongiColors.OnAccent else NureongiColors.OnDisabled,
+ modifier = modifier,
+ )
+}
+
+@Preview
+@Composable
+private fun DestinationSelectionScreenPreview() {
+ NureongiTheme {
+ DestinationSelectionScreen(
+ state = PreviewDestinationSelectionUiState.copy(selectedDestinationId = "e"),
+ onDestinationSelected = {},
+ onChangeLocationClick = {},
+ onStartGuidance = {},
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun UnselectedDestinationSelectionScreenPreview() {
+ NureongiTheme {
+ DestinationSelectionScreen(
+ state = PreviewDestinationSelectionUiState,
+ onDestinationSelected = {},
+ onChangeLocationClick = {},
+ onStartGuidance = {},
+ )
+ }
+}
diff --git a/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/screen/GuidanceScreen.kt b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/screen/GuidanceScreen.kt
new file mode 100644
index 00000000..29b35296
--- /dev/null
+++ b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/screen/GuidanceScreen.kt
@@ -0,0 +1,247 @@
+package com.woowa.nureongi.ui.screen
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.BottomAppBar
+import androidx.compose.material3.Scaffold
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.semantics.LiveRegionMode
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.liveRegion
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.woowa.nureongi.ui.component.CtaButton
+import com.woowa.nureongi.ui.component.DirectionGuideCard
+import com.woowa.nureongi.ui.component.NavigationTopBar
+import com.woowa.nureongi.ui.component.SegmentedProgressIndicator
+import com.woowa.nureongi.ui.component.StatTile
+import com.woowa.nureongi.ui.component.StraightArrowIcon
+import com.woowa.nureongi.ui.component.TactileMiniMap
+import com.woowa.nureongi.ui.component.VoiceGuideButton
+import com.woowa.nureongi.ui.model.GuidanceStepUiModel
+import com.woowa.nureongi.ui.model.GuidanceUiState
+import com.woowa.nureongi.ui.model.PreviewGuidanceUiState
+import com.woowa.nureongi.ui.theme.NureongiColors
+import com.woowa.nureongi.ui.theme.NureongiTheme
+import com.woowa.nureongi.ui.voice.stopVoiceGuideOnInteraction
+
+@Composable
+fun GuidanceScreen(
+ state: GuidanceUiState,
+ onNextStepClick: () -> Unit,
+ onCloseClick: () -> Unit,
+ onVoiceGuideClick: () -> Unit,
+ onUserInteraction: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Scaffold(
+ bottomBar = {
+ BottomAppBar(
+ containerColor = NureongiColors.Background,
+ ) {
+ GuidanceBottomActions(
+ nextButtonText = state.nextButtonText,
+ onVoiceGuideClick = onVoiceGuideClick,
+ onNextStepClick = if (state.isArrived) onCloseClick else onNextStepClick,
+ )
+ }
+ },
+ modifier = modifier
+ .fillMaxSize()
+ .background(NureongiColors.Background)
+ .stopVoiceGuideOnInteraction(onUserInteraction)
+ ) { innerPadding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(innerPadding)
+ .padding(vertical = 10.dp, horizontal = 15.dp),
+ ) {
+ GuidanceHeader(
+ destinationName = state.destinationName,
+ currentStep = state.currentStep,
+ totalSteps = state.totalSteps,
+ onCloseClick = onCloseClick,
+ )
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .verticalScroll(rememberScrollState())
+ .padding(top = 24.dp),
+ verticalArrangement = Arrangement.spacedBy(20.dp),
+ ) {
+ CurrentGuidanceCard(
+ guidance = state.currentGuidance,
+ isArrived = state.isArrived,
+ )
+ GuidanceSummaryCards(
+ remainingDistanceText = state.remainingDistanceText,
+ remainingTactileBlockText = state.remainingTactileBlockText,
+ )
+ TactileMiniMap(uiModel = state.currentMiniMap)
+ }
+ }
+ }
+}
+
+@Composable
+fun GuidanceHeader(
+ destinationName: String,
+ currentStep: Int,
+ totalSteps: Int,
+ onCloseClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ NavigationTopBar(
+ destinationName = destinationName,
+ currentStep = currentStep,
+ totalSteps = totalSteps,
+ onCloseClick = onCloseClick,
+ modifier = modifier,
+ )
+}
+
+@Composable
+fun StepProgressBar(
+ currentStep: Int,
+ totalSteps: Int,
+ modifier: Modifier = Modifier,
+) {
+ SegmentedProgressIndicator(
+ totalSteps = totalSteps,
+ completedSteps = currentStep,
+ modifier = modifier,
+ )
+}
+
+@Composable
+fun CurrentGuidanceCard(
+ guidance: GuidanceStepUiModel,
+ isArrived: Boolean,
+ modifier: Modifier = Modifier,
+) {
+ DirectionGuideCard(
+ instruction = guidance.instruction,
+ landmark = guidance.landmark,
+ guideMessage = guidance.guideMessage,
+ modifier = modifier.semantics {
+ liveRegion = if (isArrived) {
+ LiveRegionMode.Assertive
+ } else {
+ LiveRegionMode.Polite
+ }
+ },
+ leadingIcon = {
+ StraightArrowIcon(
+ modifier = Modifier.size(64.dp)
+ )
+ },
+ )
+}
+
+@Composable
+fun GuidanceSummaryCards(
+ remainingDistanceText: String,
+ remainingTactileBlockText: String,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier = modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ StatTile(
+ value = remainingDistanceText,
+ label = "남은 거리",
+ modifier = Modifier.weight(1f),
+ )
+ StatTile(
+ value = remainingTactileBlockText,
+ label = "남은 점형 블록",
+ modifier = Modifier.weight(1f),
+ )
+ }
+}
+
+@Composable
+fun NextStepButton(
+ text: String,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ CtaButton(
+ text = text,
+ onClick = onClick,
+ containerColor = NureongiColors.Accent,
+ contentColor = NureongiColors.OnAccent,
+ modifier = modifier.height(72.dp),
+ )
+}
+
+@Composable
+fun GuidanceBottomActions(
+ nextButtonText: String,
+ onVoiceGuideClick: () -> Unit,
+ onNextStepClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier = modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ VoiceGuideButton(
+ onClick = onVoiceGuideClick,
+ modifier = Modifier
+ .size(72.dp)
+ .semantics {
+ contentDescription = "현재 안내 음성으로 다시 듣기"
+ },
+ )
+ NextStepButton(
+ text = nextButtonText,
+ onClick = onNextStepClick,
+ modifier = Modifier.weight(1f),
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun GuidanceScreenPreview() {
+ NureongiTheme {
+ GuidanceScreen(
+ state = PreviewGuidanceUiState,
+ onNextStepClick = {},
+ onCloseClick = {},
+ onVoiceGuideClick = {},
+ onUserInteraction = {},
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun ArrivedGuidanceScreenPreview() {
+ NureongiTheme {
+ GuidanceScreen(
+ state = PreviewGuidanceUiState.copy(
+ currentStepIndex = PreviewGuidanceUiState.steps.lastIndex,
+ isArrived = true,
+ ),
+ onNextStepClick = {},
+ onCloseClick = {},
+ onVoiceGuideClick = {},
+ onUserInteraction = {},
+ )
+ }
+}
diff --git a/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/speech/SpeechToTextRecognizer.kt b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/speech/SpeechToTextRecognizer.kt
new file mode 100644
index 00000000..533cf3d1
--- /dev/null
+++ b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/speech/SpeechToTextRecognizer.kt
@@ -0,0 +1,17 @@
+package com.woowa.nureongi.ui.speech
+
+import androidx.compose.runtime.Composable
+
+internal interface SpeechToTextRecognizer {
+ val isAvailable: Boolean
+
+ fun startListening(
+ onResult: (List) -> Unit,
+ onError: (String) -> Unit,
+ )
+
+ fun stopListening()
+}
+
+@Composable
+internal expect fun rememberSpeechToTextRecognizer(): SpeechToTextRecognizer
diff --git a/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/speech/VoiceLocationMatcher.kt b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/speech/VoiceLocationMatcher.kt
new file mode 100644
index 00000000..cdfea484
--- /dev/null
+++ b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/speech/VoiceLocationMatcher.kt
@@ -0,0 +1,122 @@
+package com.woowa.nureongi.ui.speech
+
+import com.woowa.nureongi.ui.model.CurrentLocationItemUiModel
+
+internal data class VoiceLocationMatch(
+ val locationId: String,
+ val locationName: String,
+ val recognizedText: String,
+)
+
+internal fun findBestVoiceLocationMatch(
+ recognizedTexts: List,
+ locations: List,
+): VoiceLocationMatch? {
+ return recognizedTexts
+ .flatMap { recognizedText ->
+ locations.map { location ->
+ VoiceLocationMatchCandidate(
+ recognizedText = recognizedText,
+ location = location,
+ score = recognizedText.scoreWith(location),
+ )
+ }
+ }
+ .maxByOrNull(VoiceLocationMatchCandidate::score)
+ ?.takeIf { it.score > 0f }
+ ?.let { candidate ->
+ VoiceLocationMatch(
+ locationId = candidate.location.id,
+ locationName = candidate.location.place.name,
+ recognizedText = candidate.recognizedText,
+ )
+ }
+}
+
+private data class VoiceLocationMatchCandidate(
+ val recognizedText: String,
+ val location: CurrentLocationItemUiModel,
+ val score: Float,
+)
+
+private fun String.scoreWith(location: CurrentLocationItemUiModel): Float {
+ val input = normalizeForVoiceMatch()
+ if (input.isBlank()) {
+ return 0f
+ }
+
+ return location.matchKeywords()
+ .map(String::normalizeForVoiceMatch)
+ .filter(String::isNotBlank)
+ .maxOfOrNull { keyword ->
+ when {
+ input.contains(keyword) || keyword.contains(input) -> 1f
+ else -> normalizedSimilarity(input, keyword)
+ }
+ }
+ ?: 0f
+}
+
+private fun CurrentLocationItemUiModel.matchKeywords(): List {
+ val name = place.name
+ return buildList {
+ add(name)
+ name.split("/", "·", ",")
+ .map(String::trim)
+ .filter(String::isNotBlank)
+ .forEach(::add)
+ add(name.replace(" ", ""))
+ }.distinct()
+}
+
+private fun String.normalizeForVoiceMatch(): String {
+ return lowercase()
+ .replace(Regex("""[\s/·,().\-]"""), "")
+}
+
+private fun normalizedSimilarity(
+ first: String,
+ second: String,
+): Float {
+ val maxLength = maxOf(first.length, second.length)
+ if (maxLength == 0) {
+ return 1f
+ }
+
+ return 1f - levenshteinDistance(first, second).toFloat() / maxLength
+}
+
+private fun levenshteinDistance(
+ first: String,
+ second: String,
+): Int {
+ if (first == second) {
+ return 0
+ }
+ if (first.isEmpty()) {
+ return second.length
+ }
+ if (second.isEmpty()) {
+ return first.length
+ }
+
+ var previous = IntArray(second.length + 1) { it }
+ var current = IntArray(second.length + 1)
+
+ first.forEachIndexed { firstIndex, firstChar ->
+ current[0] = firstIndex + 1
+ second.forEachIndexed { secondIndex, secondChar ->
+ val cost = if (firstChar == secondChar) 0 else 1
+ current[secondIndex + 1] = minOf(
+ current[secondIndex] + 1,
+ previous[secondIndex + 1] + 1,
+ previous[secondIndex] + cost,
+ )
+ }
+ val temp = previous
+ previous = current
+ current = temp
+ }
+
+ return previous[second.length]
+}
diff --git a/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/theme/NureongiColors.kt b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/theme/NureongiColors.kt
new file mode 100644
index 00000000..ac7d4f0e
--- /dev/null
+++ b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/theme/NureongiColors.kt
@@ -0,0 +1,22 @@
+package com.woowa.nureongi.ui.theme
+
+import androidx.compose.ui.graphics.Color
+
+object NureongiColors {
+ val Background = Color(0xFF121212)
+ val Surface = Color(0xFF1E1E1E)
+ val SelectedSurface = Color(0xFF2A250E)
+ val SurfaceBorder = Color(0xFF303030)
+ val IconSurface = Color(0xFF252525)
+
+ val Accent = Color(0xFFFFC400)
+ val OnAccent = Color(0xFF000000)
+
+ val TextPrimary = Color(0xFFFFFFFF)
+ val TextSecondary = Color(0xFFA0A0A0)
+
+ val Disabled = Color(0xFF2C2C2C)
+ val OnDisabled = Color(0xFF6E6E6E)
+
+ val NeutralSurface = Color(0xFFFFFFFF)
+}
diff --git a/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/theme/NureongiTheme.kt b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/theme/NureongiTheme.kt
new file mode 100644
index 00000000..e13fe861
--- /dev/null
+++ b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/theme/NureongiTheme.kt
@@ -0,0 +1,53 @@
+package com.woowa.nureongi.ui.theme
+
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Typography
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import nureongi.shared.generated.resources.Res
+import nureongi.shared.generated.resources.koddiudongothic_bold
+import nureongi.shared.generated.resources.koddiudongothic_extrabold
+import nureongi.shared.generated.resources.koddiudongothic_regular
+import org.jetbrains.compose.resources.Font
+
+private val nureongiColorScheme = darkColorScheme(
+ background = NureongiColors.Background,
+ surface = NureongiColors.Surface,
+ surfaceVariant = NureongiColors.Surface,
+ primary = NureongiColors.Accent,
+ onPrimary = NureongiColors.OnAccent,
+ onBackground = NureongiColors.TextPrimary,
+ onSurface = NureongiColors.TextPrimary,
+ onSurfaceVariant = NureongiColors.TextSecondary,
+)
+
+@Composable
+fun NureongiTheme(content: @Composable () -> Unit) {
+ val fontFamily = nureongiFontFamily()
+
+ CompositionLocalProvider(LocalNureongiFontFamily provides fontFamily) {
+ MaterialTheme(
+ colorScheme = nureongiColorScheme,
+ typography = nureongiMaterialTypography(),
+ content = content,
+ )
+ }
+}
+
+@Composable
+private fun nureongiFontFamily(): FontFamily = FontFamily(
+ Font(Res.font.koddiudongothic_regular, FontWeight.Normal),
+ Font(Res.font.koddiudongothic_bold, FontWeight.SemiBold),
+ Font(Res.font.koddiudongothic_bold, FontWeight.Bold),
+ Font(Res.font.koddiudongothic_extrabold, FontWeight.ExtraBold),
+)
+
+@Composable
+private fun nureongiMaterialTypography() = Typography(
+ titleMedium = NureongiTypography.SectionHeader,
+ bodyLarge = NureongiTypography.ItemTitle,
+ bodyMedium = NureongiTypography.ItemDescription,
+)
diff --git a/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/theme/NureongiTypography.kt b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/theme/NureongiTypography.kt
new file mode 100644
index 00000000..6d3508a3
--- /dev/null
+++ b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/theme/NureongiTypography.kt
@@ -0,0 +1,102 @@
+package com.woowa.nureongi.ui.theme
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.ReadOnlyComposable
+import androidx.compose.runtime.compositionLocalOf
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+
+internal val LocalNureongiFontFamily = compositionLocalOf { FontFamily.Default }
+
+object NureongiTypography {
+ val ScreenTitle: TextStyle
+ @Composable
+ @ReadOnlyComposable
+ get() = screenTitle.withFontFamily(LocalNureongiFontFamily.current)
+
+ val SectionHeader: TextStyle
+ @Composable
+ @ReadOnlyComposable
+ get() = sectionHeader.withFontFamily(LocalNureongiFontFamily.current)
+
+ val ItemTitle: TextStyle
+ @Composable
+ @ReadOnlyComposable
+ get() = itemTitle.withFontFamily(LocalNureongiFontFamily.current)
+
+ val ItemDescription: TextStyle
+ @Composable
+ @ReadOnlyComposable
+ get() = itemDescription.withFontFamily(LocalNureongiFontFamily.current)
+
+ val GuidanceMessageStyle: TextStyle
+ @Composable
+ @ReadOnlyComposable
+ get() = guidanceMessageStyle.withFontFamily(LocalNureongiFontFamily.current)
+
+ val GuidanceInstructionStyle: TextStyle
+ @Composable
+ @ReadOnlyComposable
+ get() = guidanceInstructionStyle.withFontFamily(LocalNureongiFontFamily.current)
+
+ val StatValueStyle: TextStyle
+ @Composable
+ @ReadOnlyComposable
+ get() = statValueStyle.withFontFamily(LocalNureongiFontFamily.current)
+
+ val StatLabelStyle: TextStyle
+ @Composable
+ @ReadOnlyComposable
+ get() = statLabelStyle.withFontFamily(LocalNureongiFontFamily.current)
+}
+
+private fun TextStyle.withFontFamily(fontFamily: FontFamily): TextStyle = copy(
+ fontFamily = fontFamily,
+)
+
+private val screenTitle = TextStyle(
+ fontSize = 28.sp,
+ lineHeight = 34.sp,
+ fontWeight = FontWeight.ExtraBold,
+)
+
+private val sectionHeader = TextStyle(
+ fontSize = 18.sp,
+ fontWeight = FontWeight.Bold,
+)
+
+private val itemTitle = TextStyle(
+ fontSize = 16.sp,
+ fontWeight = FontWeight.SemiBold,
+)
+
+private val itemDescription = TextStyle(
+ fontSize = 13.sp,
+ fontWeight = FontWeight.Normal,
+)
+
+private val guidanceMessageStyle = TextStyle(
+ fontSize = 24.sp,
+ lineHeight = 34.sp,
+ fontWeight = FontWeight.Bold,
+)
+
+private val guidanceInstructionStyle = TextStyle(
+ fontSize = 36.sp,
+ lineHeight = 40.sp,
+ fontWeight = FontWeight.ExtraBold,
+)
+
+private val statValueStyle = TextStyle(
+ fontSize = 32.sp,
+ lineHeight = 36.sp,
+ fontWeight = FontWeight.ExtraBold,
+)
+
+private val statLabelStyle = TextStyle(
+ fontSize = 16.sp,
+ lineHeight = 20.sp,
+ fontWeight = FontWeight.Bold,
+)
diff --git a/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/voice/VoiceGuide.kt b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/voice/VoiceGuide.kt
new file mode 100644
index 00000000..01d4d2be
--- /dev/null
+++ b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/voice/VoiceGuide.kt
@@ -0,0 +1,12 @@
+package com.woowa.nureongi.ui.voice
+
+import androidx.compose.runtime.Composable
+
+internal interface VoiceGuide {
+ fun speak(message: String)
+
+ fun stop()
+}
+
+@Composable
+internal expect fun rememberVoiceGuide(): VoiceGuide
diff --git a/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/voice/VoiceGuideInteractionModifier.kt b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/voice/VoiceGuideInteractionModifier.kt
new file mode 100644
index 00000000..26021f45
--- /dev/null
+++ b/nureongi/shared/src/commonMain/kotlin/com/woowa/nureongi/ui/voice/VoiceGuideInteractionModifier.kt
@@ -0,0 +1,7 @@
+package com.woowa.nureongi.ui.voice
+
+import androidx.compose.ui.Modifier
+
+internal expect fun Modifier.stopVoiceGuideOnInteraction(
+ onInteraction: () -> Unit,
+): Modifier
diff --git a/nureongi/shared/src/commonTest/kotlin/com/woowa/nureongi/SharedCommonTest.kt b/nureongi/shared/src/commonTest/kotlin/com/woowa/nureongi/SharedCommonTest.kt
new file mode 100644
index 00000000..f48a8195
--- /dev/null
+++ b/nureongi/shared/src/commonTest/kotlin/com/woowa/nureongi/SharedCommonTest.kt
@@ -0,0 +1,12 @@
+package com.woowa.nureongi
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class SharedCommonTest {
+
+ @Test
+ fun example() {
+ assertEquals(3, 1 + 2)
+ }
+}
\ No newline at end of file
diff --git a/nureongi/shared/src/commonTest/kotlin/com/woowa/nureongi/domain/data/SeohyeonBasementStationMapDataTest.kt b/nureongi/shared/src/commonTest/kotlin/com/woowa/nureongi/domain/data/SeohyeonBasementStationMapDataTest.kt
new file mode 100644
index 00000000..7efd4cd4
--- /dev/null
+++ b/nureongi/shared/src/commonTest/kotlin/com/woowa/nureongi/domain/data/SeohyeonBasementStationMapDataTest.kt
@@ -0,0 +1,96 @@
+package com.woowa.nureongi.domain.data
+
+import com.woowa.nureongi.domain.model.EdgeCategory
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class SeohyeonBasementStationMapDataTest {
+ private val mapData = SeohyeonBasementStationMapData.getMapData()
+ private val station = mapData.station
+
+ @Test
+ fun `provides seohyeon basement and platform map data`() {
+ assertEquals("seohyeon-basement", station.id)
+ assertEquals("서현역 지하철역", station.name)
+ assertEquals(70, station.nodes.size)
+ assertEquals(19, station.navigationPoints.size)
+ }
+
+ @Test
+ fun `connects confirmed basement first floor paths`() {
+ assertEdge("exit-3", "n1", 12f)
+ assertEdge("n1", "n2", 20f)
+ assertEdge("n2", "restroom-women", 14f)
+ assertEdge("n3", "n4", 45f)
+ assertEdge("n4", "n6", 31f)
+ assertEdge("n7", "gate-upper-outside", 4f)
+ assertEdge("n13", "gate-lower-outside", 3f)
+ assertEdge("n24", "stairs-lower-suwon-a", 7f)
+ assertEdge("n30", "stairs-lower-wangsimni-b", 3f)
+ }
+
+ @Test
+ fun `connects fare gates and vertical movements with categories`() {
+ assertEdge("gate-upper-outside", "gate-upper-inside", 1f, EdgeCategory.FARE_GATE)
+ assertEdge("gate-lower-outside", "gate-lower-inside", 1f, EdgeCategory.FARE_GATE)
+ assertEdge("stairs-upper-suwon", "b2-upper-suwon-entry", 1f, EdgeCategory.STAIRS)
+ assertEdge("elevator-lower-suwon", "b2-lower-suwon-elevator-entry", 1f, EdgeCategory.ELEVATOR)
+ assertEdge("elevator-lower-wangsimni", "b2-lower-wangsimni-elevator-entry", 1f, EdgeCategory.ELEVATOR)
+ }
+
+ @Test
+ fun `connects subway platform destinations`() {
+ assertEdge("b2-upper-suwon-n1", "platform-suwon-2-2", 15f)
+ assertEdge("stairs-upper-wangsimni", "platform-wangsimni-7-4-upper", 1f, EdgeCategory.STAIRS)
+ assertEdge("b2-lower-suwon-a-n1", "platform-suwon-5-4", 15f)
+ assertEdge("b2-lower-suwon-b-n1", "platform-suwon-7-4", 17f)
+ assertEdge("b2-lower-suwon-elevator-entry", "platform-suwon-6-4", 10f)
+ assertEdge("b2-lower-wangsimni-a-n1", "platform-wangsimni-4-1", 17f)
+ assertEdge("b2-lower-wangsimni-b-n1", "platform-wangsimni-2-2", 17f)
+ assertEdge("b2-lower-wangsimni-elevator-n2", "platform-wangsimni-3-1", 2f)
+ }
+
+ @Test
+ fun `does not expose stairs and elevators as navigation points`() {
+ val navigationPointIds = station.navigationPoints.map { it.nodeId }.toSet()
+
+ assertTrue("platform-suwon-2-2" in navigationPointIds)
+ assertTrue("platform-wangsimni-3-1" in navigationPointIds)
+ assertNull(station.findNavigationPoint("stairs-lower-suwon-a"))
+ assertNull(station.findNavigationPoint("stairs-lower-wangsimni-b"))
+ assertNull(station.findNavigationPoint("elevator-lower-suwon"))
+ assertNull(station.findNavigationPoint("elevator-lower-wangsimni"))
+ }
+
+ @Test
+ fun `all edges have reverse edges`() {
+ station.edges.forEach { edge ->
+ val reverseEdge = station.edges.firstOrNull { candidate ->
+ candidate.from == edge.to && candidate.to == edge.from
+ }
+
+ assertNotNull(reverseEdge)
+ assertEquals(edge.distance, reverseEdge.distance)
+ assertEquals((edge.angle + 180) % 360, reverseEdge.angle)
+ assertEquals(edge.category, reverseEdge.category)
+ }
+ }
+
+ private fun assertEdge(
+ fromNodeId: String,
+ toNodeId: String,
+ distance: Float,
+ category: EdgeCategory? = null,
+ ) {
+ val edge = station.edges.firstOrNull { edge ->
+ edge.from.id == fromNodeId && edge.to.id == toNodeId
+ }
+
+ assertNotNull(edge)
+ assertEquals(distance, edge.distance)
+ assertEquals(category, edge.category)
+ }
+}
diff --git a/nureongi/shared/src/commonTest/kotlin/com/woowa/nureongi/domain/data/WoowaEleventhFloorMapDataTest.kt b/nureongi/shared/src/commonTest/kotlin/com/woowa/nureongi/domain/data/WoowaEleventhFloorMapDataTest.kt
new file mode 100644
index 00000000..f9009756
--- /dev/null
+++ b/nureongi/shared/src/commonTest/kotlin/com/woowa/nureongi/domain/data/WoowaEleventhFloorMapDataTest.kt
@@ -0,0 +1,90 @@
+package com.woowa.nureongi.domain.data
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertTrue
+
+class WoowaEleventhFloorMapDataTest {
+ private val mapData = WoowaEleventhFloorMapData.getMapData()
+ private val station = mapData.station
+
+ @Test
+ fun `11층의 노드 간선 안내 지점 데이터를 제공한다`() {
+ assertEquals("woowa-eleventh-floor", station.id)
+ assertEquals("우아한테크코스 11층", station.name)
+ assertEquals(26, station.nodes.size)
+ assertEquals(54, station.edges.size)
+ assertEquals(12, station.navigationPoints.size)
+ }
+
+ @Test
+ fun `일반 경유 노드는 출발지나 목적지로 노출하지 않는다`() {
+ val junctionNodeId = "b"
+
+ assertTrue(station.findNode(junctionNodeId) != null)
+ assertTrue(station.navigationPoints.none { it.nodeId == junctionNodeId })
+ }
+
+ @Test
+ fun `안내 지점은 출발 시 최초 방향 판단을 위한 절대각도를 가진다`() {
+ val sideClassroom = station.findNavigationPoint("a")
+
+ assertNotNull(sideClassroom)
+ assertEquals(90, sideClassroom.initialAngle)
+ }
+
+ @Test
+ fun `모든 노드는 같은 MapData 안에 미니맵 좌표를 가진다`() {
+ assertEquals(13, mapData.rows)
+ assertEquals(14, mapData.columns)
+ assertEquals(station.nodes.map { it.id }.toSet(), mapData.nodePositions.keys)
+ }
+
+ @Test
+ fun `Y는 C 위에 있고 Z는 G 왼쪽에 배치된다`() {
+ val y = mapData.nodePositions.getValue("y")
+ val c = mapData.nodePositions.getValue("c")
+ val z = mapData.nodePositions.getValue("z")
+ val g = mapData.nodePositions.getValue("g")
+
+ assertTrue(y.row < c.row)
+ assertEquals(y.column, c.column)
+ assertEquals(z.row, g.row)
+ assertTrue(z.column < g.column)
+ }
+
+ @Test
+ fun `모든 경로 구간은 반대 방향 경로 구간을 가진다`() {
+ station.edges.forEach { edge ->
+ val reverseEdge = station.edges.firstOrNull { candidate ->
+ candidate.from == edge.to && candidate.to == edge.from
+ }
+
+ assertNotNull(reverseEdge)
+ assertEquals(edge.distance, reverseEdge.distance)
+ assertEquals((edge.angle + 180) % 360, reverseEdge.angle)
+ }
+ }
+
+ @Test
+ fun `모든 노드에서 다른 모든 노드로 이동할 수 있다`() {
+ station.nodes.forEach { start ->
+ val reachableNodeIds = mutableSetOf(start.id)
+ val pendingNodeIds = mutableListOf(start.id)
+
+ while (pendingNodeIds.isNotEmpty()) {
+ val currentNodeId = pendingNodeIds.removeAt(pendingNodeIds.lastIndex)
+ station.edges
+ .filter { edge -> edge.from.id == currentNodeId }
+ .forEach { edge ->
+ if (reachableNodeIds.add(edge.to.id)) {
+ pendingNodeIds.add(edge.to.id)
+ }
+ }
+ }
+
+ assertTrue(station.nodes.all { node -> node.id in reachableNodeIds })
+ }
+ }
+}
diff --git a/nureongi/shared/src/commonTest/kotlin/com/woowa/nureongi/domain/service/NavigatorTest.kt b/nureongi/shared/src/commonTest/kotlin/com/woowa/nureongi/domain/service/NavigatorTest.kt
new file mode 100644
index 00000000..f4545862
--- /dev/null
+++ b/nureongi/shared/src/commonTest/kotlin/com/woowa/nureongi/domain/service/NavigatorTest.kt
@@ -0,0 +1,44 @@
+package com.woowa.nureongi.domain.service
+
+import com.woowa.nureongi.domain.data.WoowaEleventhFloorMapData
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class NavigatorTest {
+ private val station = WoowaEleventhFloorMapData.getMapData().station
+ private val navigator = Navigator()
+
+ @Test
+ fun `최단 거리 경로를 RouteStep 목록으로 반환한다`() {
+ val start = station.findNavigationPoint("a")!!
+ val destination = station.findNavigationPoint("e")!!
+
+ val route = navigator.findRoute(station, start, destination)
+
+ assertEquals(listOf("b", "f", "e"), route.steps.map { it.toNode.id })
+ assertEquals(8.5f, route.totalDistance)
+ assertEquals(listOf(8.5f, 7f, 1.5f), route.steps.map { it.remainingDistance })
+ assertEquals(90, route.startPoint.initialAngle)
+ }
+
+ @Test
+ fun `stepIndex로 경로 진행 상태를 계산한다`() {
+ val start = station.findNavigationPoint("a")!!
+ val destination = station.findNavigationPoint("e")!!
+ val route = navigator.findRoute(station, start, destination)
+
+ assertFalse(route.isArrived(0))
+ assertEquals("b", route.currentStep(0)?.toNode?.id)
+ assertEquals(8.5f, route.remainingDistance(0))
+ assertEquals(1, route.advance(0))
+
+ val arrivedIndex = route.steps.size
+ assertTrue(route.isArrived(arrivedIndex))
+ assertNull(route.currentStep(arrivedIndex))
+ assertEquals(0f, route.remainingDistance(arrivedIndex))
+ assertEquals(arrivedIndex, route.advance(arrivedIndex))
+ }
+}
diff --git a/nureongi/shared/src/commonTest/kotlin/com/woowa/nureongi/ui/navigation/NureongiAppViewModelTest.kt b/nureongi/shared/src/commonTest/kotlin/com/woowa/nureongi/ui/navigation/NureongiAppViewModelTest.kt
new file mode 100644
index 00000000..58efbda2
--- /dev/null
+++ b/nureongi/shared/src/commonTest/kotlin/com/woowa/nureongi/ui/navigation/NureongiAppViewModelTest.kt
@@ -0,0 +1,373 @@
+package com.woowa.nureongi.ui.navigation
+
+import com.woowa.nureongi.domain.data.StationMapDataSource
+import com.woowa.nureongi.domain.data.SeohyeonBasementStationMapData
+import com.woowa.nureongi.ui.model.CurrentLocationUiModel
+import com.woowa.nureongi.ui.model.DestinationItemUiModel
+import com.woowa.nureongi.ui.model.DestinationSelectionUiState
+import com.woowa.nureongi.ui.model.GuidanceStepUiModel
+import com.woowa.nureongi.ui.model.GuidanceUiState
+import com.woowa.nureongi.ui.model.MiniMapUiModel
+import com.woowa.nureongi.ui.model.PlaceUiModel
+import com.woowa.nureongi.ui.model.RouteNodeUiModel
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertIs
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+
+class NureongiAppViewModelTest {
+ @Test
+ fun `하나의 MapData를 화면 목록과 경로 서비스가 함께 사용한다`() {
+ var loadCount = 0
+ val mapDataSource = StationMapDataSource {
+ loadCount += 1
+ SeohyeonBasementStationMapData.getMapData()
+ }
+ val viewModel = NureongiAppViewModel(mapDataSource = mapDataSource)
+
+ val state = viewModel.uiState.value
+ assertEquals(
+ state.currentLocationState.locations.map { it.id },
+ state.destinationState.destinations.map { it.id },
+ )
+ viewModel.onLocationSelected("exit-3")
+ viewModel.onDestinationSelected("platform-suwon-2-2")
+ viewModel.onStartGuidance()
+
+ assertEquals(1, loadCount)
+ assertNull(viewModel.uiState.value.guidanceState?.miniMap?.path?.get(1)?.label)
+ }
+
+ @Test
+ fun `초기 상태는 현재 위치와 목적지가 선택되지 않아 안내를 시작할 수 없다`() {
+ val state = NureongiAppViewModel().uiState.value
+
+ assertNull(state.currentLocationState.selectedLocationId)
+ assertNull(state.destinationState.currentLocation)
+ assertNull(state.destinationState.selectedDestinationId)
+ assertEquals("현재 위치를 선택해 주세요", state.destinationState.currentLocationName)
+ assertEquals("목적지를 선택하세요", state.destinationState.startGuidanceButtonText)
+ assertEquals(false, state.destinationState.canStartGuidance)
+ }
+
+ @Test
+ fun `현재 위치를 선택하면 목적지 화면에서 사용할 위치 상태를 갱신한다`() {
+ val viewModel = NureongiAppViewModel()
+
+ val currentLocation = viewModel.onLocationSelected("exit-3")
+
+ val state = viewModel.uiState.value
+ val expectedName = SeohyeonBasementStationMapData.getMapData().station.findNode("exit-3")!!.name
+ assertEquals("exit-3", currentLocation?.nodeId)
+ assertEquals("exit-3", state.currentLocationState.selectedLocationId)
+ assertEquals("exit-3", state.destinationState.currentLocation?.nodeId)
+ assertEquals(expectedName, state.destinationState.currentLocationName)
+ }
+
+ @Test
+ fun `존재하지 않는 위치와 목적지 선택은 상태에 반영하지 않는다`() {
+ val viewModel = NureongiAppViewModel()
+
+ val currentLocation = viewModel.onLocationSelected("unknown")
+ viewModel.onDestinationSelected("unknown")
+
+ val state = viewModel.uiState.value
+ assertNull(currentLocation)
+ assertNull(state.currentLocationState.selectedLocationId)
+ assertNull(state.destinationState.selectedDestinationId)
+ }
+
+ @Test
+ fun `목적지를 선택하고 안내를 시작하면 계산된 안내 상태를 저장한다`() {
+ val expectedGuidance = guidanceState(destinationName = "2번 출구")
+ val calculator = RecordingRouteCalculator(
+ result = GuidanceRouteCalculationResult.Success(expectedGuidance),
+ )
+ val viewModel = NureongiAppViewModel(routeCalculator = calculator)
+
+ viewModel.onLocationSelected("exit-3")
+ viewModel.onDestinationSelected("platform-suwon-2-2")
+ val request = viewModel.onStartGuidance()
+
+ val state = viewModel.uiState.value
+ assertEquals(
+ GuidanceNavRoute(
+ currentLocationId = "exit-3",
+ destinationId = "platform-suwon-2-2",
+ ),
+ request,
+ )
+ assertEquals(expectedGuidance, state.guidanceState)
+ assertEquals("exit-3", calculator.currentLocation?.nodeId)
+ assertEquals("platform-suwon-2-2", calculator.destination?.id)
+ assertNull(state.destinationState.error)
+ }
+
+ @Test
+ fun `경로 계산에 실패하면 목적지 화면에 오류를 표시한다`() {
+ val calculator = RecordingRouteCalculator(
+ result = GuidanceRouteCalculationResult.Failure("경로 없음"),
+ )
+ val viewModel = NureongiAppViewModel(routeCalculator = calculator)
+
+ viewModel.onLocationSelected("exit-3")
+ viewModel.onDestinationSelected("platform-suwon-2-2")
+ val request = viewModel.onStartGuidance()
+
+ val state = viewModel.uiState.value
+ assertNull(request)
+ assertEquals("경로 없음", state.destinationState.error?.message)
+ assertNull(state.guidanceState)
+ }
+
+ @Test
+ fun `로딩 중에는 화면 선택 이벤트를 무시한다`() {
+ val initialState = NureongiAppUiState(
+ currentLocationState = NureongiAppUiState()
+ .currentLocationState
+ .copy(isLoading = true),
+ destinationState = DestinationSelectionUiState(isLoading = true),
+ )
+ val viewModel = NureongiAppViewModel(initialState = initialState)
+
+ val currentLocation = viewModel.onLocationSelected("exit-3")
+ viewModel.onDestinationSelected("platform-suwon-2-2")
+ val request = viewModel.onStartGuidance()
+
+ val state = viewModel.uiState.value
+ assertNull(currentLocation)
+ assertNull(request)
+ assertNull(state.currentLocationState.selectedLocationId)
+ assertNull(state.destinationState.selectedDestinationId)
+ assertNull(state.guidanceState)
+ }
+
+ @Test
+ fun `안내 destination에 직접 진입하면 ID로 안내 상태를 복원한다`() {
+ val viewModel = NureongiAppViewModel(
+ routeCalculator = RecordingRouteCalculator(
+ result = GuidanceRouteCalculationResult.Success(guidanceState()),
+ ),
+ )
+
+ viewModel.onGuidanceDestinationEntered(
+ currentLocationId = "exit-3",
+ destinationId = "platform-suwon-2-2",
+ )
+
+ assertNotNull(viewModel.uiState.value.guidanceState)
+ }
+
+ @Test
+ fun `다음 안내 단계로 진행하고 마지막 단계 이후 도착 상태가 된다`() {
+ val initialGuidance = guidanceState().copy(
+ steps = listOf(
+ guidanceStep("첫 번째"),
+ guidanceStep("두 번째"),
+ ),
+ )
+ val viewModel = NureongiAppViewModel(
+ routeCalculator = RecordingRouteCalculator(
+ result = GuidanceRouteCalculationResult.Success(initialGuidance),
+ ),
+ )
+ viewModel.onLocationSelected("exit-3")
+ viewModel.onDestinationSelected("platform-suwon-2-2")
+ viewModel.onStartGuidance()
+
+ viewModel.onNextGuidanceStep()
+ assertEquals(1, viewModel.uiState.value.guidanceState?.currentStepIndex)
+
+ viewModel.onNextGuidanceStep()
+ assertEquals(true, viewModel.uiState.value.guidanceState?.isArrived)
+ }
+
+ @Test
+ fun `현재 위치와 같은 목적지를 선택하면 경로 계산 오류를 반환한다`() {
+ val calculator = MapGuidanceRouteCalculator()
+
+ val result = calculator.calculate(
+ currentLocation = CurrentLocationUiModel("exit-3", "옆 강의실"),
+ destination = DestinationItemUiModel(
+ id = "exit-3",
+ place = PlaceUiModel("옆 강의실", ""),
+ ),
+ )
+
+ val failure = assertIs(result)
+ assertEquals(
+ "현재 위치와 목적지가 같습니다. 다른 목적지를 선택해 주세요.",
+ failure.message,
+ )
+ }
+
+ @Test
+ fun `등록된 위치와 목적지의 경로를 안내 상태로 계산한다`() {
+ val calculator = MapGuidanceRouteCalculator()
+
+ val result = calculator.calculate(
+ currentLocation = CurrentLocationUiModel("exit-3", "start"),
+ destination = DestinationItemUiModel(
+ id = "platform-suwon-2-2",
+ place = PlaceUiModel("destination", ""),
+ ),
+ )
+
+ val success = assertIs(result)
+ val station = SeohyeonBasementStationMapData.getMapData().station
+ assertEquals(station.findNode("platform-suwon-2-2")!!.name, success.guidanceState.destinationName)
+ assertNull(success.guidanceState.miniMap.path.first().label)
+ assertNull(success.guidanceState.miniMap.path[1].label)
+ assertEquals(station.findNode("platform-suwon-2-2")!!.name, success.guidanceState.miniMap.path.last().label)
+ assertEquals(11, success.guidanceState.steps.size)
+ assertEquals("점형 블록", success.guidanceState.steps.first().landmark)
+ assertEquals(station.findNode("platform-suwon-2-2")!!.name, success.guidanceState.steps.last().landmark)
+ assertEquals(false, success.guidanceState.steps.first().guideMessage.contains(station.findNode("n1")!!.name))
+ assertEquals(true, success.guidanceState.steps[0].instruction.contains("12m"))
+ assertEquals(true, success.guidanceState.steps[1].instruction.contains("44m"))
+ }
+
+ @Test
+ fun `목적지와 현재 위치를 동일하게 선택하는 즉시 화면에 오류를 노출한다`() {
+ val viewModel = NureongiAppViewModel()
+
+ viewModel.onLocationSelected("exit-3")
+ viewModel.onDestinationSelected("exit-3")
+
+ val state = viewModel.uiState.value
+ assertEquals(
+ "현재 위치와 목적지가 같습니다. 다른 목적지를 선택해 주세요.",
+ state.destinationState.error?.message,
+ )
+ assertEquals(false, state.destinationState.canStartGuidance)
+ }
+
+ @Test
+ fun `목적지와 현재 위치가 같은 상태에서 서로 다른 목적지로 바꾸면 오류가 즉시 해제된다`() {
+ val viewModel = NureongiAppViewModel()
+
+ viewModel.onLocationSelected("exit-3")
+ viewModel.onDestinationSelected("exit-3")
+ assertEquals(
+ "현재 위치와 목적지가 같습니다. 다른 목적지를 선택해 주세요.",
+ viewModel.uiState.value.destinationState.error?.message,
+ )
+
+ // 다른 목적지 선택
+ viewModel.onDestinationSelected("platform-suwon-2-2")
+ assertNull(viewModel.uiState.value.destinationState.error)
+ assertEquals(true, viewModel.uiState.value.destinationState.canStartGuidance)
+ }
+
+ @Test
+ fun `voice location selection state is managed by viewmodel`() {
+ val viewModel = NureongiAppViewModel()
+ val firstLocation = viewModel.uiState.value.currentLocationState.locations.first()
+
+ viewModel.onVoiceLocationSelectionStarted()
+
+ assertEquals(true, viewModel.uiState.value.currentLocationState.isVoiceListening)
+ assertEquals(
+ "출발지를 말씀해주세요.",
+ viewModel.uiState.value.currentLocationState.voiceSelectionMessage,
+ )
+
+ val selectedLocation = viewModel.onVoiceLocationRecognized(listOf(firstLocation.place.name))
+
+ val state = viewModel.uiState.value
+ assertEquals(firstLocation.id, selectedLocation?.nodeId)
+ assertEquals(firstLocation.id, state.currentLocationState.selectedLocationId)
+ assertEquals(false, state.currentLocationState.isVoiceListening)
+ assertEquals(
+ "${firstLocation.place.name}을 현재 위치로 설정합니다.",
+ state.currentLocationState.voiceSelectionMessage,
+ )
+ }
+
+ @Test
+ fun `voice location selection failure updates viewmodel state`() {
+ val viewModel = NureongiAppViewModel()
+
+ viewModel.onVoiceLocationSelectionStarted()
+ val selectedLocation = viewModel.onVoiceLocationRecognized(listOf("unknown voice input"))
+
+ val state = viewModel.uiState.value.currentLocationState
+ assertNull(selectedLocation)
+ assertEquals(false, state.isVoiceListening)
+ assertEquals(
+ "출발지를 찾지 못했습니다. 다시 말씀해주세요.",
+ state.voiceSelectionMessage,
+ )
+ }
+
+ @Test
+ fun `voice location recognition error updates viewmodel state`() {
+ val viewModel = NureongiAppViewModel()
+
+ viewModel.onVoiceLocationSelectionStarted()
+ viewModel.onVoiceLocationRecognitionFailed("microphone permission required")
+
+ val state = viewModel.uiState.value.currentLocationState
+ assertEquals(false, state.isVoiceListening)
+ assertEquals("microphone permission required", state.voiceSelectionMessage)
+ }
+}
+
+private class RecordingRouteCalculator(
+ private val result: GuidanceRouteCalculationResult,
+) : GuidanceRouteCalculator {
+ var currentLocation: CurrentLocationUiModel? = null
+ private set
+ var destination: DestinationItemUiModel? = null
+ private set
+
+ override fun calculate(
+ currentLocation: CurrentLocationUiModel,
+ destination: DestinationItemUiModel,
+ ): GuidanceRouteCalculationResult {
+ this.currentLocation = currentLocation
+ this.destination = destination
+ return result
+ }
+}
+
+private fun guidanceState(
+ destinationName: String = "목적지",
+): GuidanceUiState {
+ val step = GuidanceStepUiModel(
+ instruction = "직진",
+ landmark = destinationName,
+ guideMessage = "$destinationName 방향으로 직진하세요.",
+ remainingDistanceText = "8m",
+ remainingTactileBlockText = "1개",
+ actionButtonText = "목적지 도착 ›",
+ )
+ return GuidanceUiState(
+ destinationName = destinationName,
+ steps = listOf(step),
+ arrivalGuidance = step.copy(
+ instruction = "도착",
+ remainingDistanceText = "0m",
+ remainingTactileBlockText = "0개",
+ actionButtonText = "안내 종료",
+ ),
+ miniMap = MiniMapUiModel(
+ title = "테스트 지도",
+ rows = 1,
+ columns = 1,
+ path = listOf(RouteNodeUiModel(0, 0, destinationName)),
+ ),
+ )
+}
+
+private fun guidanceStep(instruction: String): GuidanceStepUiModel {
+ return GuidanceStepUiModel(
+ instruction = instruction,
+ landmark = "목적지",
+ guideMessage = instruction,
+ remainingDistanceText = "8m",
+ remainingTactileBlockText = "1개",
+ actionButtonText = "다음",
+ )
+}
diff --git a/nureongi/shared/src/commonTest/kotlin/com/woowa/nureongi/ui/speech/VoiceLocationMatcherTest.kt b/nureongi/shared/src/commonTest/kotlin/com/woowa/nureongi/ui/speech/VoiceLocationMatcherTest.kt
new file mode 100644
index 00000000..015f0fa4
--- /dev/null
+++ b/nureongi/shared/src/commonTest/kotlin/com/woowa/nureongi/ui/speech/VoiceLocationMatcherTest.kt
@@ -0,0 +1,49 @@
+package com.woowa.nureongi.ui.speech
+
+import com.woowa.nureongi.ui.model.CurrentLocationItemUiModel
+import com.woowa.nureongi.ui.model.PlaceUiModel
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class VoiceLocationMatcherTest {
+ @Test
+ fun `음성 인식 결과와 가장 유사한 출발지를 찾는다`() {
+ val locations = listOf(
+ location("a", "옆 강의실"),
+ location("e", "우물가"),
+ location("r", "여자화장실"),
+ )
+
+ val result = findBestVoiceLocationMatch(
+ recognizedTexts = listOf("우물까"),
+ locations = locations,
+ )
+
+ assertEquals("e", result?.locationId)
+ }
+
+ @Test
+ fun `슬래시로 묶인 장소명은 개별 이름으로도 찾는다`() {
+ val locations = listOf(
+ location("o", "수성 / 화성"),
+ location("p", "금성 / 지구"),
+ )
+
+ val result = findBestVoiceLocationMatch(
+ recognizedTexts = listOf("화성"),
+ locations = locations,
+ )
+
+ assertEquals("o", result?.locationId)
+ }
+
+ private fun location(
+ id: String,
+ name: String,
+ ): CurrentLocationItemUiModel {
+ return CurrentLocationItemUiModel(
+ id = id,
+ place = PlaceUiModel(name = name, location = ""),
+ )
+ }
+}
diff --git a/nureongi/shared/src/iosMain/kotlin/com/woowa/nureongi/MainViewController.kt b/nureongi/shared/src/iosMain/kotlin/com/woowa/nureongi/MainViewController.kt
new file mode 100644
index 00000000..0fbeeeda
--- /dev/null
+++ b/nureongi/shared/src/iosMain/kotlin/com/woowa/nureongi/MainViewController.kt
@@ -0,0 +1,5 @@
+package com.woowa.nureongi
+
+import androidx.compose.ui.window.ComposeUIViewController
+
+fun MainViewController() = ComposeUIViewController { App() }
\ No newline at end of file
diff --git a/nureongi/shared/src/iosMain/kotlin/com/woowa/nureongi/Platform.ios.kt b/nureongi/shared/src/iosMain/kotlin/com/woowa/nureongi/Platform.ios.kt
new file mode 100644
index 00000000..5e153203
--- /dev/null
+++ b/nureongi/shared/src/iosMain/kotlin/com/woowa/nureongi/Platform.ios.kt
@@ -0,0 +1,9 @@
+package com.woowa.nureongi
+
+import platform.UIKit.UIDevice
+
+class IOSPlatform: Platform {
+ override val name: String = UIDevice.currentDevice.systemName() + " " + UIDevice.currentDevice.systemVersion
+}
+
+actual fun getPlatform(): Platform = IOSPlatform()
\ No newline at end of file
diff --git a/nureongi/shared/src/iosMain/kotlin/com/woowa/nureongi/ui/accessibility/ScreenReaderState.ios.kt b/nureongi/shared/src/iosMain/kotlin/com/woowa/nureongi/ui/accessibility/ScreenReaderState.ios.kt
new file mode 100644
index 00000000..9162b75c
--- /dev/null
+++ b/nureongi/shared/src/iosMain/kotlin/com/woowa/nureongi/ui/accessibility/ScreenReaderState.ios.kt
@@ -0,0 +1,34 @@
+package com.woowa.nureongi.ui.accessibility
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import platform.Foundation.NSNotificationCenter
+import platform.Foundation.NSOperationQueue
+import platform.UIKit.UIAccessibilityIsVoiceOverRunning
+import platform.UIKit.UIAccessibilityVoiceOverStatusDidChangeNotification
+
+@Composable
+actual fun rememberScreenReaderEnabled(): Boolean {
+ var isEnabled by remember {
+ mutableStateOf(UIAccessibilityIsVoiceOverRunning())
+ }
+
+ DisposableEffect(Unit) {
+ val observer = NSNotificationCenter.defaultCenter.addObserverForName(
+ name = UIAccessibilityVoiceOverStatusDidChangeNotification,
+ `object` = null,
+ queue = NSOperationQueue.mainQueue,
+ ) { _ ->
+ isEnabled = UIAccessibilityIsVoiceOverRunning()
+ }
+ onDispose {
+ NSNotificationCenter.defaultCenter.removeObserver(observer)
+ }
+ }
+
+ return isEnabled
+}
diff --git a/nureongi/shared/src/iosMain/kotlin/com/woowa/nureongi/ui/speech/SpeechToTextRecognizer.ios.kt b/nureongi/shared/src/iosMain/kotlin/com/woowa/nureongi/ui/speech/SpeechToTextRecognizer.ios.kt
new file mode 100644
index 00000000..ea7ad624
--- /dev/null
+++ b/nureongi/shared/src/iosMain/kotlin/com/woowa/nureongi/ui/speech/SpeechToTextRecognizer.ios.kt
@@ -0,0 +1,22 @@
+package com.woowa.nureongi.ui.speech
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+
+@Composable
+internal actual fun rememberSpeechToTextRecognizer(): SpeechToTextRecognizer {
+ return remember { IosSpeechToTextRecognizer }
+}
+
+private object IosSpeechToTextRecognizer : SpeechToTextRecognizer {
+ override val isAvailable: Boolean = false
+
+ override fun startListening(
+ onResult: (List) -> Unit,
+ onError: (String) -> Unit,
+ ) {
+ onError("iOS 음성 인식은 아직 지원하지 않습니다.")
+ }
+
+ override fun stopListening() = Unit
+}
diff --git a/nureongi/shared/src/iosMain/kotlin/com/woowa/nureongi/ui/voice/VoiceGuide.ios.kt b/nureongi/shared/src/iosMain/kotlin/com/woowa/nureongi/ui/voice/VoiceGuide.ios.kt
new file mode 100644
index 00000000..e795a0ae
--- /dev/null
+++ b/nureongi/shared/src/iosMain/kotlin/com/woowa/nureongi/ui/voice/VoiceGuide.ios.kt
@@ -0,0 +1,46 @@
+package com.woowa.nureongi.ui.voice
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.remember
+import platform.AVFAudio.AVSpeechBoundary
+import platform.AVFAudio.AVSpeechSynthesisVoice
+import platform.AVFAudio.AVSpeechSynthesizer
+import platform.AVFAudio.AVSpeechUtterance
+import platform.AVFAudio.AVSpeechUtteranceDefaultSpeechRate
+
+@Composable
+internal actual fun rememberVoiceGuide(): VoiceGuide {
+ val voiceGuide = remember {
+ IosVoiceGuide()
+ }
+
+ DisposableEffect(voiceGuide) {
+ onDispose(voiceGuide::stop)
+ }
+
+ return voiceGuide
+}
+
+private class IosVoiceGuide : VoiceGuide {
+ private val synthesizer = AVSpeechSynthesizer()
+
+ override fun speak(message: String) {
+ if (message.isBlank()) {
+ return
+ }
+
+ synthesizer.stopSpeakingAtBoundary(AVSpeechBoundary.AVSpeechBoundaryImmediate)
+ val utterance = AVSpeechUtterance(string = message).apply {
+ voice = AVSpeechSynthesisVoice.voiceWithLanguage(KOREAN_LANGUAGE_CODE)
+ rate = AVSpeechUtteranceDefaultSpeechRate
+ }
+ synthesizer.speakUtterance(utterance)
+ }
+
+ override fun stop() {
+ synthesizer.stopSpeakingAtBoundary(AVSpeechBoundary.AVSpeechBoundaryImmediate)
+ }
+}
+
+private const val KOREAN_LANGUAGE_CODE = "ko-KR"
diff --git a/nureongi/shared/src/iosMain/kotlin/com/woowa/nureongi/ui/voice/VoiceGuideInteractionModifier.ios.kt b/nureongi/shared/src/iosMain/kotlin/com/woowa/nureongi/ui/voice/VoiceGuideInteractionModifier.ios.kt
new file mode 100644
index 00000000..dd37cecc
--- /dev/null
+++ b/nureongi/shared/src/iosMain/kotlin/com/woowa/nureongi/ui/voice/VoiceGuideInteractionModifier.ios.kt
@@ -0,0 +1,7 @@
+package com.woowa.nureongi.ui.voice
+
+import androidx.compose.ui.Modifier
+
+internal actual fun Modifier.stopVoiceGuideOnInteraction(
+ onInteraction: () -> Unit,
+): Modifier = this
diff --git a/nureongi/shared/src/iosTest/kotlin/com/woowa/nureongi/SharedLogicIOSTest.kt b/nureongi/shared/src/iosTest/kotlin/com/woowa/nureongi/SharedLogicIOSTest.kt
new file mode 100644
index 00000000..02006688
--- /dev/null
+++ b/nureongi/shared/src/iosTest/kotlin/com/woowa/nureongi/SharedLogicIOSTest.kt
@@ -0,0 +1,12 @@
+package com.woowa.nureongi
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class SharedLogicIOSTest {
+
+ @Test
+ fun example() {
+ assertEquals(3, 1 + 2)
+ }
+}
\ No newline at end of file
diff --git a/reports/week-4.md b/reports/week-4.md
new file mode 100644
index 00000000..9a00cbd1
--- /dev/null
+++ b/reports/week-4.md
@@ -0,0 +1,72 @@
+# Week 4 최종 보고서 - 누렁이
+
+## 4주 흐름 한 줄 요약
+
+카메라로 위험을 감지하던 AI 안내견에서, 점자블록 기반 지하철 역내 길을 찾아주는 음성 안내 네비게이션으로 변화
+
+---
+
+## A. 처음 의도 vs 결과
+
+### 문제 정의의 변천
+
+| 1주차 | 4주차 |
+|------------------------------------------|------------------------------------------------------|
+| 안내견의 수가 부족하다. → 시각 장애인이 안전한 보행을 보장받기 힘들다 | 시각장애인들이 처음 가는 또는 익숙하지 않은, 복잡한 지하철 역에서 부대시설을 찾기 어려워한다 |
+
+### MVP 범위의 변천
+
+| 1주차 만들 것 | 실제 만든 것 |
+|-----------------------------------------------|------------------------------------------------|
+| 카메라 화면 내 목표 객체(화장실, 출구, 엘리베이터 등) 탐지 후 음성으로 유도 | 출발지/목적지 선택 후 최적 경로 탐색 |
+| 목표 탐지 시 직진/좌/우 안내 메시지 음성 출력 (정확도 70~80% 목표) | 점형 블록(꺾임, 계단 등) 기준 다음 경로 음성 안내 |
+| 안내 반응 속도 0.4~1초 이내 실시간 탐지 | 가상 맵(우아한테크코스 11층 교육장, 서현역)에서 남은 거리·점형 블록 정보 표시 |
+| 현재 위치 판단 (범위 제외) | 고대비/다크모드 UI 적용 |
+| — | 음성 버튼으로 안내 메시지 재청취 |
+| | VoiceOver/Talkback 호환 |
+| | STT(Speech-To-Text)로 출발지 설정 가능 |
+
+### 기술 선택 재평가
+
+**다시 한다면 같은 기술? 왜?**
+
+- 바드: No, 각각의 Native 앱을 따로 만드는 게 좋을 것 같다. 작업 단위가 작으면 역할을 분담하기 어려워 플랫폼별로 네이티브 개발을 하면 역할 분담이 좀 더 편해질 것
+ 같다고 생각했다.
+- 사무엘: Yes, 시간과 상황을 고려했을 때 최선의 기술이었다. 다만 시간과 상황에 맞는 맥락이 없다면, STT, TTS, VoiceOver/TalkBack 기능을 각각 구현해줘야해서 바드처럼 Native로 각각 구현하는게 나을 것 같다.
+- 스마일: 휴대폰으로 다시 결정한다면 동일한 선택을 할 것 같다. 빠르게 MVP를 만드는 게 중요하니까. (지팡이도 고려해봄직 할 것 같다)
+
+**고려했다 안 쓴 선택지와 뺀 이유: Flutter, AI (YOLO)**
+
+이유: 3주차에 AI, 카메라 기반 객체 탐지 기능이 MVP 범위에서 통째로 빠지면서, TF-Lite 연동을 위해 선택했던 Flutter의 강점(크로스플랫폼 + TF-Lite)이
+무의미해짐. 팀이 가장 익숙한 기술(KMP/CMP)로 빠르게 MVP를 만들어 사용자에게 검증받는 것이 더 중요하다고 판단해 전환함.
+
+---
+
+## B. 가설 검증 종합
+
+| 주차 | 가설 | 결과 (지지/반박/불명확) | 받아들인 방식 |
+|-----|-----------------------------------------------------|----------------|-------------------------------------------------------------------------------------------------------|
+| 1주차 | 시각 장애인은 택시보다 지하철을 더 많이 이용할 것이다 | 지지 | 중증 시각장애인일수록 버스를 기피하고 지하철을 선호한다는 인터뷰 근거를 확인, 지하철 중심 서비스 설계를 유지함 |
+| 1주차 | 우리 팀의 능력으로 카메라 기반 실시간 객체 탐지 서비스가 실현 가능할 것이다 | 반박 | 2주차에 AI/카메라 기능을 MVP에서 제외하고 점자블록 기반 경로 탐색으로 방향을 전환함 |
+| 2주차 | 지하철 부대시설(화장실, 역무실, 개찰구 등)을 찾는 데 어려움을 느낄 것이다 | 불명확 | 자주 다니는 길은 기억으로 무리 없이 찾지만 초행길에서는 헤맨다는 답변에 따라, 페르소나를 “모든 시각 장애인”에서 “익숙하지 않거나 복잡한 역을 이용하는 시각 장애인”으로 재정의함 |
+| 2주차 | 남/녀 화장실 구분을 쉽게 하지 못할 것이다 | 반박 | 점자 표지판, 냄새·소리로 구분 가능하다는 답변에 따라 “구분”이 아니라 “화장실 자체를 찾는” 경로 안내로 문제를 재정의함 |
+| 2주차 | 작동하지 않는 음성유도기가 있을 것이다 | 지지 | 고장·미설치 사례를 확인하고, 유도기 위치/상태 정보 제공 필요성을 향후 백로그에 반영함 |
+| 2주차 | 역무원이 즉각적인 도움을 제공하기 어려운 상황이 있다 | 지지 | 역당 인력 부족과 공익근무요원 감소 사례를 확인, 기술적 보조 수단의 필요성을 재확인함 |
+| 2주차 | 밝은 색의 잘 구분되는 버튼으로 앱을 구성하는 것이 좋을 것이다 | 반박 | 다크모드 선호, 고대비·2~3색 이내 일률적 색상 사용이 더 적합하다는 답변에 따라 UI 디자인 방향을 변경함 |
+| 3주차 | 미터(m) 단위 거리 안내가 사용자에게 의미가 있을 것이다 | 불명확 | 끝까지 검증하지 못해 다음 인터뷰에서 확인할 항목으로 이연함 |
+| 3주차 | 화면 내 미니맵, 깜빡임/애니메이션 효과가 시각장애인(저시력자 포함)에게 의미가 있을 것이다 | 불명확 | 저시력자 대상 UI 검증이 더 필요하다고 판단, 4주차 추가 인터뷰 항목으로 이연함 |
+
+### 가장 크게 깨진 가설
+
+**무엇이었는가:** 밝은 색의 잘 구분되는 버튼으로 앱을 구성하는 것이 좋을 것이다
+
+**어떻게 받아들였는가:** 밝고 대비가 잘 되는 색상이 아닌, 다크모드를 선호하고, 고대비·2~3색 이내 일률적 색상 사용이 더 적합하다는 답변에 따라 UI 디자인 방향을
+변경함
+
+테마 변경 기능을 넣을 수도 있을 것 같다고 생각했음
+
+### 끝까지 검증 못 한 가설 (있다면)
+
+- 미터(m) 단위 거리 안내가 실제로 사용자에게 의미가 있는가
+- 화면 미니맵, 현재 위치 표시 방식(색상 변경 vs 깜빡임)이 저시력자에게 의미가 있는가
+- 시각 장애인이 가장 이해하기 쉬운 안내 메시지의 형태는 무엇인가