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 깜빡임)이 저시력자에게 의미가 있는가 +- 시각 장애인이 가장 이해하기 쉬운 안내 메시지의 형태는 무엇인가