diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..adfa9bf
--- /dev/null
+++ b/.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/README.md b/README.md
index 034a1a1..7dd819a 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,33 @@
-# android
-크루위키 안드로이드 앱입니다!
+This is a Kotlin Multiplatform project targeting Android, iOS.
+
+Asset migration notes for `crew-wiki-next` are in [./docs/asset-migration.md](./docs/asset-migration.md).
+
+* [/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)…
diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts
new file mode 100644
index 0000000..5ec0242
--- /dev/null
+++ b/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.example.crew_wiki"
+ compileSdk = libs.versions.android.compileSdk.get().toInt()
+
+ defaultConfig {
+ applicationId = "com.example.crew_wiki"
+ 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/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..de6216c
--- /dev/null
+++ b/androidApp/src/main/AndroidManifest.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/androidApp/src/main/kotlin/com/example/crew_wiki/MainActivity.kt b/androidApp/src/main/kotlin/com/example/crew_wiki/MainActivity.kt
new file mode 100644
index 0000000..c0734fd
--- /dev/null
+++ b/androidApp/src/main/kotlin/com/example/crew_wiki/MainActivity.kt
@@ -0,0 +1,25 @@
+package com.example.crew_wiki
+
+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/androidApp/src/main/res/drawable-v24/ic_launcher_foreground.xml b/androidApp/src/main/res/drawable-v24/ic_launcher_foreground.xml
new file mode 100644
index 0000000..753b0e3
--- /dev/null
+++ b/androidApp/src/main/res/drawable-v24/ic_launcher_foreground.xml
@@ -0,0 +1,7 @@
+
+
diff --git a/androidApp/src/main/res/drawable/crew_wiki_app_icon.png b/androidApp/src/main/res/drawable/crew_wiki_app_icon.png
new file mode 100644
index 0000000..0d179fa
Binary files /dev/null and b/androidApp/src/main/res/drawable/crew_wiki_app_icon.png differ
diff --git a/androidApp/src/main/res/drawable/ic_launcher_background.xml b/androidApp/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 0000000..8ea560f
--- /dev/null
+++ b/androidApp/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..eca70cf
--- /dev/null
+++ b/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 0000000..eca70cf
--- /dev/null
+++ b/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/androidApp/src/main/res/mipmap-hdpi/ic_launcher.png b/androidApp/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000..a47b078
Binary files /dev/null and b/androidApp/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/androidApp/src/main/res/mipmap-hdpi/ic_launcher_round.png b/androidApp/src/main/res/mipmap-hdpi/ic_launcher_round.png
new file mode 100644
index 0000000..a47b078
Binary files /dev/null and b/androidApp/src/main/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/androidApp/src/main/res/mipmap-mdpi/ic_launcher.png b/androidApp/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000..25ed8cc
Binary files /dev/null and b/androidApp/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/androidApp/src/main/res/mipmap-mdpi/ic_launcher_round.png b/androidApp/src/main/res/mipmap-mdpi/ic_launcher_round.png
new file mode 100644
index 0000000..25ed8cc
Binary files /dev/null and b/androidApp/src/main/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/androidApp/src/main/res/mipmap-xhdpi/ic_launcher.png b/androidApp/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000..65fd176
Binary files /dev/null and b/androidApp/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..65fd176
Binary files /dev/null and b/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher.png b/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000..495eff8
Binary files /dev/null and b/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..495eff8
Binary files /dev/null and b/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000..9905156
Binary files /dev/null and b/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..9905156
Binary files /dev/null and b/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/androidApp/src/main/res/values/strings.xml b/androidApp/src/main/res/values/strings.xml
new file mode 100644
index 0000000..d085252
--- /dev/null
+++ b/androidApp/src/main/res/values/strings.xml
@@ -0,0 +1,3 @@
+
+ 크루위키
+
\ No newline at end of file
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..afc1bdd
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,10 @@
+plugins {
+ // this is necessary to avoid the plugins to be loaded multiple times
+ // in each subproject's classloader
+ 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/docs/asset-migration.md b/docs/asset-migration.md
new file mode 100644
index 0000000..4f78472
--- /dev/null
+++ b/docs/asset-migration.md
@@ -0,0 +1,49 @@
+# Crew Wiki Asset Migration
+
+`crew-wiki-next/client`의 폰트와 이미지 자산을 Compose Multiplatform 리소스로 옮긴 기록입니다.
+
+## 현재 기준 경로
+
+- 공용 UI 리소스: `shared/src/commonMain/composeResources`
+- Android 앱 전용 리소스: `androidApp/src/main/res`
+
+이번 단계에서는 공용 UI에서 바로 쓸 수 있도록 `composeResources` 기준으로 정리했습니다.
+
+## 복사 대상
+
+| 원본 | KMP 대상 | 상태 | 비고 |
+| --- | --- | --- | --- |
+| `public/fonts/BMHANNAProOTF.otf` | `shared/src/commonMain/composeResources/font/bm_hanna_pro.otf` | 사용 가능 | Compose Multiplatform 폰트로 생성 확인 |
+| `Pretendard-1/public/variable/PretendardVariable.ttf` | `shared/src/commonMain/composeResources/font/pretendard_variable.ttf` | 사용 가능 | Variable font 원본 |
+| `Pretendard-1/public/static/Pretendard-Regular.otf` | `shared/src/commonMain/composeResources/font/pretendard_regular.otf` | 사용 가능 | 기본 본문 폰트 |
+| `Pretendard-1/public/static/Pretendard-Medium.otf` | `shared/src/commonMain/composeResources/font/pretendard_medium.otf` | 사용 가능 | Medium weight |
+| `Pretendard-1/public/static/Pretendard-SemiBold.otf` | `shared/src/commonMain/composeResources/font/pretendard_semibold.otf` | 사용 가능 | SemiBold weight |
+| `Pretendard-1/public/static/Pretendard-Bold.otf` | `shared/src/commonMain/composeResources/font/pretendard_bold.otf` | 사용 가능 | Bold weight |
+| `src/app/apple-icon.png` | `shared/src/commonMain/composeResources/drawable/crew_wiki_apple_icon.png` | 사용 가능 | Drawable 리소스로 생성 확인 |
+| `src/app/apple-icon.png` | `shared/src/commonMain/composeResources/files/crew_wiki/icons/icon.png` | 사용 가능 | `icon.svg` 대체용 원본 보관 |
+| `src/app/favicon.ico` | `shared/src/commonMain/composeResources/files/crew_wiki/icons/favicon.ico` | 보관 | Compose drawable로 직접 사용하지 않음 |
+
+## 검증 결과
+
+- `composeResources/font` 아래의 `ttf`, `otf`는 Compose Multiplatform 폰트 리소스로 인식됩니다.
+- 생성된 accessor에서 아래 리소스를 확인했습니다.
+ - `Res.font.bm_hanna_pro`
+ - `Res.font.pretendard_variable`
+ - `Res.font.pretendard_regular`
+ - `Res.font.pretendard_medium`
+ - `Res.font.pretendard_semibold`
+ - `Res.font.pretendard_bold`
+ - `Res.drawable.crew_wiki_apple_icon`
+
+## 적용 상태
+
+- `CrewWikiTheme`에서 기본 타이포그래피는 `Pretendard` 정적 weight 세트를 사용합니다.
+- `BMHANNA`는 display typography에 연결했습니다.
+- `pretendard_variable.ttf`는 리소스로 추가만 되어 있고, 현재 테마에서는 직접 사용하지 않습니다.
+- 샘플 화면 이미지는 `crew_wiki_apple_icon.png`를 사용하도록 교체했습니다.
+
+## 다음 작업 후보
+
+1. 화면별 텍스트 스타일을 정리해서 `BMHANNA`와 `Pretendard` 사용 기준 고정
+2. `icon.png`를 실제 앱 아이콘 세트 또는 필요한 화면 리소스로 연결
+3. CDN 의존 아이콘을 수집해서 `composeResources/drawable`로 고정
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..6f8e6ea
--- /dev/null
+++ b/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/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties
new file mode 100644
index 0000000..050d142
--- /dev/null
+++ b/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/gradle/libs.versions.toml b/gradle/libs.versions.toml
new file mode 100644
index 0000000..dbbb4c5
--- /dev/null
+++ b/gradle/libs.versions.toml
@@ -0,0 +1,59 @@
+[versions]
+agp = "9.0.1"
+android-compileSdk = "36"
+android-minSdk = "24"
+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"
+coil3 = "3.2.0"
+composeMultiplatform = "1.11.1"
+junit = "4.13.2"
+kotlin = "2.4.0"
+ktor = "3.1.3"
+kotlinx-coroutines = "1.9.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" }
+kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
+kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
+ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
+ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
+ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
+ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
+ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
+ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
+# Coil3 - KMP 이미지 로딩
+coil3-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil3" }
+coil3-network-ktor = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil3" }
+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" }
+
+[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/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..8bdaf60
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..75927bd
--- /dev/null
+++ b/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/gradlew b/gradlew
new file mode 100755
index 0000000..adff685
--- /dev/null
+++ b/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/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..e509b2d
--- /dev/null
+++ b/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/iosApp/Configuration/Config.xcconfig b/iosApp/Configuration/Config.xcconfig
new file mode 100644
index 0000000..d6338fb
--- /dev/null
+++ b/iosApp/Configuration/Config.xcconfig
@@ -0,0 +1,7 @@
+TEAM_ID=
+
+PRODUCT_NAME=Crewwiki
+PRODUCT_BUNDLE_IDENTIFIER=com.example.crew_wiki.Crewwiki$(TEAM_ID)
+
+CURRENT_PROJECT_VERSION=1
+MARKETING_VERSION=1.0
\ No newline at end of file
diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj
new file mode 100644
index 0000000..2637b93
--- /dev/null
+++ b/iosApp/iosApp.xcodeproj/project.pbxproj
@@ -0,0 +1,375 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 77;
+ objects = {
+
+/* Begin PBXFileReference section */
+ 9839CB96AB792B664CCC88E5 /* Crewwiki.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Crewwiki.app; sourceTree = BUILT_PRODUCTS_DIR; };
+/* End PBXFileReference section */
+
+/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
+ 43370E3689F72074187CA32A /* Exceptions for "iosApp" folder in "iosApp" target */ = {
+ isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
+ membershipExceptions = (
+ Info.plist,
+ );
+ target = 9E33F53885E66F33C4080028 /* iosApp */;
+ };
+/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
+
+/* Begin PBXFileSystemSynchronizedRootGroup section */
+ 3B6E8CCC4F46CF528F7B5DAE /* Configuration */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ path = Configuration;
+ sourceTree = "";
+ };
+ AD5FBC7893082892A7EA7CD9 /* iosApp */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ exceptions = (
+ 43370E3689F72074187CA32A /* Exceptions for "iosApp" folder in "iosApp" target */,
+ );
+ path = iosApp;
+ sourceTree = "";
+ };
+/* End PBXFileSystemSynchronizedRootGroup section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ 0CCD36424C25AFDB5AA24F1E /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ 590FE12254485CB3E64B9506 = {
+ isa = PBXGroup;
+ children = (
+ 3B6E8CCC4F46CF528F7B5DAE /* Configuration */,
+ AD5FBC7893082892A7EA7CD9 /* iosApp */,
+ B1F70C016D6678BA69B24EE7 /* Products */,
+ );
+ sourceTree = "";
+ };
+ B1F70C016D6678BA69B24EE7 /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ 9839CB96AB792B664CCC88E5 /* Crewwiki.app */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ 9E33F53885E66F33C4080028 /* iosApp */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 293DDA52134211B2137E02A7 /* Build configuration list for PBXNativeTarget "iosApp" */;
+ buildPhases = (
+ CA06DA6C92A68D057E0707B4 /* Compile Kotlin Framework */,
+ E034161D8193128F7566DF95 /* Sources */,
+ 0CCD36424C25AFDB5AA24F1E /* Frameworks */,
+ 1BCE75B0076A58CBE945A795 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ fileSystemSynchronizedGroups = (
+ AD5FBC7893082892A7EA7CD9 /* iosApp */,
+ );
+ name = iosApp;
+ packageProductDependencies = (
+ );
+ productName = iosApp;
+ productReference = 9839CB96AB792B664CCC88E5 /* Crewwiki.app */;
+ productType = "com.apple.product-type.application";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ 0D0271D3EF11335A005A63D6 /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = 1;
+ LastSwiftUpdateCheck = 1620;
+ LastUpgradeCheck = 1620;
+ TargetAttributes = {
+ 9E33F53885E66F33C4080028 = {
+ CreatedOnToolsVersion = 16.2;
+ };
+ };
+ };
+ buildConfigurationList = 4EEC33FB88040298C8566F1C /* Build configuration list for PBXProject "iosApp" */;
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ en,
+ Base,
+ );
+ mainGroup = 590FE12254485CB3E64B9506;
+ minimizedProjectReferenceProxies = 1;
+ preferredProjectObjectVersion = 77;
+ productRefGroup = B1F70C016D6678BA69B24EE7 /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ 9E33F53885E66F33C4080028 /* iosApp */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ 1BCE75B0076A58CBE945A795 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXShellScriptBuildPhase section */
+ CA06DA6C92A68D057E0707B4 /* 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 */
+ E034161D8193128F7566DF95 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin XCBuildConfiguration section */
+ 75D070EB396A3E2D24968442 /* 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 = 8QNP67WLL6;
+ 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",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = "com.nadajinny.crew-wiki.Crewwiki";
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ 8390F61C70C2AFF9793C17E7 /* Release */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReferenceAnchor = 3B6E8CCC4F46CF528F7B5DAE /* 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;
+ };
+ 86ABC2ED355C0516A2C9629C /* 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 = 8QNP67WLL6;
+ 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",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = "com.nadajinny.crew-wiki.Crewwiki";
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Release;
+ };
+ EE563A40BF72F3CA003C2778 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReferenceAnchor = 3B6E8CCC4F46CF528F7B5DAE /* 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;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ 293DDA52134211B2137E02A7 /* Build configuration list for PBXNativeTarget "iosApp" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 75D070EB396A3E2D24968442 /* Debug */,
+ 86ABC2ED355C0516A2C9629C /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 4EEC33FB88040298C8566F1C /* Build configuration list for PBXProject "iosApp" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ EE563A40BF72F3CA003C2778 /* Debug */,
+ 8390F61C70C2AFF9793C17E7 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+ };
+ rootObject = 0D0271D3EF11335A005A63D6 /* Project object */;
+}
diff --git a/iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 0000000..fe1aa71
--- /dev/null
+++ b/iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json b/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json
new file mode 100644
index 0000000..ee7e3ca
--- /dev/null
+++ b/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/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000..4c663a0
--- /dev/null
+++ b/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/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png
new file mode 100644
index 0000000..2f423b7
Binary files /dev/null and b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png differ
diff --git a/iosApp/iosApp/Assets.xcassets/Contents.json b/iosApp/iosApp/Assets.xcassets/Contents.json
new file mode 100644
index 0000000..4aa7c53
--- /dev/null
+++ b/iosApp/iosApp/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
\ No newline at end of file
diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift
new file mode 100644
index 0000000..46a12f4
--- /dev/null
+++ b/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/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist
new file mode 100644
index 0000000..870d68c
--- /dev/null
+++ b/iosApp/iosApp/Info.plist
@@ -0,0 +1,10 @@
+
+
+
+
+ CFBundleDisplayName
+ 크루위키
+ CADisableMinimumFrameDurationOnPhone
+
+
+
diff --git a/iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json b/iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json
new file mode 100644
index 0000000..4aa7c53
--- /dev/null
+++ b/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/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift
new file mode 100644
index 0000000..d83dca6
--- /dev/null
+++ b/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/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 0000000..bd03fff
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,32 @@
+rootProject.name = "Crewwiki"
+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/shared/build.gradle.kts b/shared/build.gradle.kts
new file mode 100644
index 0000000..41a634c
--- /dev/null
+++ b/shared/build.gradle.kts
@@ -0,0 +1,74 @@
+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.example.crew_wiki.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.compose.uiToolingPreview)
+ implementation(libs.ktor.client.okhttp)
+ }
+ iosMain.dependencies {
+ implementation(libs.ktor.client.darwin)
+ }
+ 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.navigation.compose)
+ implementation(libs.androidx.lifecycle.viewmodelCompose)
+ implementation(libs.androidx.lifecycle.runtimeCompose)
+ implementation(libs.kotlinx.serialization.json)
+ implementation(libs.kotlinx.coroutines.core)
+ implementation(libs.ktor.client.core)
+ implementation(libs.ktor.client.content.negotiation)
+ implementation(libs.ktor.client.logging)
+ implementation(libs.ktor.serialization.kotlinx.json)
+ // 이미지 로딩 (KMP) - 마크다운 내 이미지 렌더링용
+ implementation(libs.coil3.compose)
+ implementation(libs.coil3.network.ktor)
+ }
+ commonTest.dependencies {
+ implementation(libs.kotlin.test)
+ }
+ }
+}
+
+dependencies {
+ androidRuntimeClasspath(libs.compose.uiTooling)
+}
diff --git a/shared/src/androidHostTest/kotlin/com/example/crew_wiki/SharedLogicAndroidHostTest.kt b/shared/src/androidHostTest/kotlin/com/example/crew_wiki/SharedLogicAndroidHostTest.kt
new file mode 100644
index 0000000..21c01bc
--- /dev/null
+++ b/shared/src/androidHostTest/kotlin/com/example/crew_wiki/SharedLogicAndroidHostTest.kt
@@ -0,0 +1,12 @@
+package com.example.crew_wiki
+
+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/shared/src/androidMain/kotlin/com/example/crew_wiki/Platform.android.kt b/shared/src/androidMain/kotlin/com/example/crew_wiki/Platform.android.kt
new file mode 100644
index 0000000..9705ba5
--- /dev/null
+++ b/shared/src/androidMain/kotlin/com/example/crew_wiki/Platform.android.kt
@@ -0,0 +1,9 @@
+package com.example.crew_wiki
+
+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/shared/src/androidMain/kotlin/com/example/crew_wiki/Uuid.android.kt b/shared/src/androidMain/kotlin/com/example/crew_wiki/Uuid.android.kt
new file mode 100644
index 0000000..973f165
--- /dev/null
+++ b/shared/src/androidMain/kotlin/com/example/crew_wiki/Uuid.android.kt
@@ -0,0 +1,5 @@
+package com.example.crew_wiki
+
+import java.util.UUID
+
+actual fun randomUuid(): String = UUID.randomUUID().toString()
diff --git a/shared/src/commonMain/composeResources/drawable/compose-multiplatform.xml b/shared/src/commonMain/composeResources/drawable/compose-multiplatform.xml
new file mode 100644
index 0000000..1ffc948
--- /dev/null
+++ b/shared/src/commonMain/composeResources/drawable/compose-multiplatform.xml
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/shared/src/commonMain/composeResources/drawable/crew_wiki_apple_icon.png b/shared/src/commonMain/composeResources/drawable/crew_wiki_apple_icon.png
new file mode 100644
index 0000000..7501220
Binary files /dev/null and b/shared/src/commonMain/composeResources/drawable/crew_wiki_apple_icon.png differ
diff --git a/shared/src/commonMain/composeResources/drawable/crew_wiki_icon.png b/shared/src/commonMain/composeResources/drawable/crew_wiki_icon.png
new file mode 100644
index 0000000..7501220
Binary files /dev/null and b/shared/src/commonMain/composeResources/drawable/crew_wiki_icon.png differ
diff --git a/shared/src/commonMain/composeResources/files/crew_wiki/icons/favicon.ico b/shared/src/commonMain/composeResources/files/crew_wiki/icons/favicon.ico
new file mode 100644
index 0000000..02232ff
Binary files /dev/null and b/shared/src/commonMain/composeResources/files/crew_wiki/icons/favicon.ico differ
diff --git a/shared/src/commonMain/composeResources/files/crew_wiki/icons/icon.png b/shared/src/commonMain/composeResources/files/crew_wiki/icons/icon.png
new file mode 100644
index 0000000..7501220
Binary files /dev/null and b/shared/src/commonMain/composeResources/files/crew_wiki/icons/icon.png differ
diff --git a/shared/src/commonMain/composeResources/font/bm_hanna_pro.otf b/shared/src/commonMain/composeResources/font/bm_hanna_pro.otf
new file mode 100644
index 0000000..372cf07
Binary files /dev/null and b/shared/src/commonMain/composeResources/font/bm_hanna_pro.otf differ
diff --git a/shared/src/commonMain/composeResources/font/pretendard_bold.otf b/shared/src/commonMain/composeResources/font/pretendard_bold.otf
new file mode 100644
index 0000000..8e5e30a
Binary files /dev/null and b/shared/src/commonMain/composeResources/font/pretendard_bold.otf differ
diff --git a/shared/src/commonMain/composeResources/font/pretendard_medium.otf b/shared/src/commonMain/composeResources/font/pretendard_medium.otf
new file mode 100644
index 0000000..0575069
Binary files /dev/null and b/shared/src/commonMain/composeResources/font/pretendard_medium.otf differ
diff --git a/shared/src/commonMain/composeResources/font/pretendard_regular.otf b/shared/src/commonMain/composeResources/font/pretendard_regular.otf
new file mode 100644
index 0000000..08bf4cf
Binary files /dev/null and b/shared/src/commonMain/composeResources/font/pretendard_regular.otf differ
diff --git a/shared/src/commonMain/composeResources/font/pretendard_semibold.otf b/shared/src/commonMain/composeResources/font/pretendard_semibold.otf
new file mode 100644
index 0000000..e7e36ab
Binary files /dev/null and b/shared/src/commonMain/composeResources/font/pretendard_semibold.otf differ
diff --git a/shared/src/commonMain/composeResources/font/pretendard_variable.ttf b/shared/src/commonMain/composeResources/font/pretendard_variable.ttf
new file mode 100644
index 0000000..32b0811
Binary files /dev/null and b/shared/src/commonMain/composeResources/font/pretendard_variable.ttf differ
diff --git a/shared/src/commonMain/kotlin/com/example/crew_wiki/App.kt b/shared/src/commonMain/kotlin/com/example/crew_wiki/App.kt
new file mode 100644
index 0000000..dc808af
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/example/crew_wiki/App.kt
@@ -0,0 +1,19 @@
+package com.example.crew_wiki
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.tooling.preview.Preview
+import coil3.compose.setSingletonImageLoaderFactory
+import com.example.crew_wiki.navigation.CrewWikiNavRoot
+
+@Composable
+@Preview
+fun App() {
+ // Coil3 싱글톤 초기화 (네트워크 이미지 로딩)
+ setSingletonImageLoaderFactory { context ->
+ coil3.ImageLoader.Builder(context).build()
+ }
+
+ CrewWikiTheme {
+ CrewWikiNavRoot()
+ }
+}
diff --git a/shared/src/commonMain/kotlin/com/example/crew_wiki/CrewWikiColors.kt b/shared/src/commonMain/kotlin/com/example/crew_wiki/CrewWikiColors.kt
new file mode 100644
index 0000000..12908fa
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/example/crew_wiki/CrewWikiColors.kt
@@ -0,0 +1,162 @@
+package com.example.crew_wiki
+
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.staticCompositionLocalOf
+import androidx.compose.ui.graphics.Color
+
+@Immutable
+data class CrewWikiColorScale(
+ val c50: Color,
+ val c100: Color,
+ val c200: Color,
+ val c300: Color,
+ val c400: Color,
+ val c500: Color,
+ val c600: Color,
+ val c700: Color,
+ val c800: Color,
+ val c900: Color,
+ val base: Color,
+ val container: Color,
+ val onContainer: Color,
+)
+
+@Immutable
+data class CrewWikiErrorScale(
+ val c50: Color,
+ val c100: Color,
+ val c200: Color,
+ val c300: Color,
+ val c400: Color,
+ val c500: Color,
+ val c600: Color,
+ val c700: Color,
+ val c800: Color,
+ val c900: Color,
+ val base: Color,
+ val container: Color,
+)
+
+@Immutable
+data class CrewWikiGrayscaleScale(
+ val c50: Color,
+ val c100: Color,
+ val c200: Color,
+ val c300: Color,
+ val c400: Color,
+ val c500: Color,
+ val c600: Color,
+ val c700: Color,
+ val c800: Color,
+ val c900: Color,
+ val container: Color,
+ val border: Color,
+ val lightText: Color,
+ val text: Color,
+)
+
+@Immutable
+data class CrewWikiPalette(
+ val black: Color,
+ val white: Color,
+ val primary: CrewWikiColorScale,
+ val secondary: CrewWikiColorScale,
+ val error: CrewWikiErrorScale,
+ val grayscale: CrewWikiGrayscaleScale,
+)
+
+internal val CrewWikiLightPalette = CrewWikiPalette(
+ black = Color(0xFF000000),
+ white = Color(0xFFFFFFFF),
+ primary = CrewWikiColorScale(
+ c50 = Color(0xFFDEF2F4),
+ c100 = Color(0xFFACDEE1),
+ c200 = Color(0xFF72C9CE),
+ c300 = Color(0xFF25B4B9),
+ c400 = Color(0xFF00A4A8),
+ c500 = Color(0xFF009495),
+ c600 = Color(0xFF008787),
+ c700 = Color(0xFF007776),
+ c800 = Color(0xFF006766),
+ c900 = Color(0xFF004B47),
+ base = Color(0xFF25B4B9),
+ container = Color(0xFFDEF2F4),
+ onContainer = Color(0xFF006766),
+ ),
+ secondary = CrewWikiColorScale(
+ c50 = Color(0xFFF6E3F4),
+ c100 = Color(0xFFE7B8E4),
+ c200 = Color(0xFFD788D3),
+ c300 = Color(0xFFC655C1),
+ c400 = Color(0xFFB925B4),
+ c500 = Color(0xFFAB00A8),
+ c600 = Color(0xFF9D00A3),
+ c700 = Color(0xFF8A009D),
+ c800 = Color(0xFF790097),
+ c900 = Color(0xFF58008B),
+ base = Color(0xFFB925B4),
+ container = Color(0xFFF6E3F4),
+ onContainer = Color(0xFF58008B),
+ ),
+ error = CrewWikiErrorScale(
+ c50 = Color(0xFFFFECEF),
+ c100 = Color(0xFFFFCFD4),
+ c200 = Color(0xFFF09E9E),
+ c300 = Color(0xFFE67979),
+ c400 = Color(0xFFF15B57),
+ c500 = Color(0xFFF64C3E),
+ c600 = Color(0xFFE8433E),
+ c700 = Color(0xFFD53A37),
+ c800 = Color(0xFFC83430),
+ c900 = Color(0xFFB92A25),
+ base = Color(0xFFD53A37),
+ container = Color(0xFFFFECEF),
+ ),
+ grayscale = CrewWikiGrayscaleScale(
+ c50 = Color(0xFFF3F4F6),
+ c100 = Color(0xFFE3E3E7),
+ c200 = Color(0xFFD9DADC),
+ c300 = Color(0xFFC7C8CA),
+ c400 = Color(0xFF9FA0A2),
+ c500 = Color(0xFF77787A),
+ c600 = Color(0xFF4F5052),
+ c700 = Color(0xFF36383D),
+ c800 = Color(0xFF27282A),
+ c900 = Color(0xFF18191A),
+ container = Color(0xFFF3F4F6),
+ border = Color(0xFFE3E3E7),
+ lightText = Color(0xFF9FA0A2),
+ text = Color(0xFF27282A),
+ ),
+)
+
+internal val LocalCrewWikiPalette = staticCompositionLocalOf { CrewWikiLightPalette }
+
+internal val CrewWikiLightColorScheme = lightColorScheme(
+ primary = CrewWikiLightPalette.primary.base,
+ onPrimary = CrewWikiLightPalette.white,
+ primaryContainer = CrewWikiLightPalette.primary.container,
+ onPrimaryContainer = CrewWikiLightPalette.primary.onContainer,
+ secondary = CrewWikiLightPalette.secondary.base,
+ onSecondary = CrewWikiLightPalette.white,
+ secondaryContainer = CrewWikiLightPalette.secondary.container,
+ onSecondaryContainer = CrewWikiLightPalette.secondary.onContainer,
+ tertiary = CrewWikiLightPalette.grayscale.c700,
+ onTertiary = CrewWikiLightPalette.white,
+ tertiaryContainer = CrewWikiLightPalette.grayscale.c50,
+ onTertiaryContainer = CrewWikiLightPalette.grayscale.text,
+ background = CrewWikiLightPalette.grayscale.container,
+ onBackground = CrewWikiLightPalette.grayscale.text,
+ surface = CrewWikiLightPalette.white,
+ onSurface = CrewWikiLightPalette.grayscale.text,
+ surfaceVariant = CrewWikiLightPalette.grayscale.c50,
+ onSurfaceVariant = CrewWikiLightPalette.grayscale.c600,
+ outline = CrewWikiLightPalette.grayscale.border,
+ outlineVariant = CrewWikiLightPalette.grayscale.c100,
+ error = CrewWikiLightPalette.error.base,
+ onError = CrewWikiLightPalette.white,
+ errorContainer = CrewWikiLightPalette.error.container,
+ onErrorContainer = CrewWikiLightPalette.error.c900,
+ surfaceTint = CrewWikiLightPalette.primary.base,
+)
diff --git a/shared/src/commonMain/kotlin/com/example/crew_wiki/CrewWikiDesignTokens.kt b/shared/src/commonMain/kotlin/com/example/crew_wiki/CrewWikiDesignTokens.kt
new file mode 100644
index 0000000..406e08d
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/example/crew_wiki/CrewWikiDesignTokens.kt
@@ -0,0 +1,61 @@
+package com.example.crew_wiki
+
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.staticCompositionLocalOf
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+
+@Immutable
+data class CrewWikiSpacing(
+ val xxs: Dp = 2.dp,
+ val xs: Dp = 4.dp,
+ val sm: Dp = 8.dp,
+ val md: Dp = 12.dp,
+ val lg: Dp = 16.dp,
+ val xl: Dp = 24.dp,
+ val xxl: Dp = 32.dp,
+)
+
+@Immutable
+data class CrewWikiRadius(
+ val sm: Dp = 8.dp,
+ val md: Dp = 12.dp,
+ val lg: Dp = 16.dp,
+ val pillXs: Dp = 18.dp,
+ val pillSm: Dp = 22.dp,
+ val pillMd: Dp = 28.dp,
+ val full: Dp = 999.dp,
+)
+
+@Immutable
+data class CrewWikiComponentSize(
+ val buttonXxsHeight: Dp = 24.dp,
+ val buttonXsHeight: Dp = 36.dp,
+ val buttonSmHeight: Dp = 44.dp,
+ val buttonMdHeight: Dp = 56.dp,
+ val fieldHeight: Dp = 44.dp,
+ val largeFieldHeight: Dp = 56.dp,
+)
+
+internal val LocalCrewWikiSpacing = staticCompositionLocalOf { CrewWikiSpacing() }
+internal val LocalCrewWikiRadius = staticCompositionLocalOf { CrewWikiRadius() }
+internal val LocalCrewWikiComponentSize = staticCompositionLocalOf { CrewWikiComponentSize() }
+
+object CrewWikiDesignTokens {
+ val colors: CrewWikiPalette
+ @Composable get() = LocalCrewWikiPalette.current
+
+ val spacing: CrewWikiSpacing
+ @Composable get() = LocalCrewWikiSpacing.current
+
+ val radius: CrewWikiRadius
+ @Composable get() = LocalCrewWikiRadius.current
+
+ val components: CrewWikiComponentSize
+ @Composable get() = LocalCrewWikiComponentSize.current
+
+ val typography
+ @Composable get() = MaterialTheme.typography
+}
diff --git a/shared/src/commonMain/kotlin/com/example/crew_wiki/CrewWikiTheme.kt b/shared/src/commonMain/kotlin/com/example/crew_wiki/CrewWikiTheme.kt
new file mode 100644
index 0000000..ebf11df
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/example/crew_wiki/CrewWikiTheme.kt
@@ -0,0 +1,160 @@
+package com.example.crew_wiki
+
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Shapes
+import androidx.compose.material3.Typography
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+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
+import androidx.compose.foundation.shape.RoundedCornerShape
+import org.jetbrains.compose.resources.Font
+
+import crewwiki.shared.generated.resources.Res
+import crewwiki.shared.generated.resources.bm_hanna_pro
+import crewwiki.shared.generated.resources.pretendard_bold
+import crewwiki.shared.generated.resources.pretendard_medium
+import crewwiki.shared.generated.resources.pretendard_regular
+import crewwiki.shared.generated.resources.pretendard_semibold
+
+@Composable
+fun CrewWikiTheme(content: @Composable () -> Unit) {
+ val palette = CrewWikiLightPalette
+ val spacing = CrewWikiSpacing()
+ val radius = CrewWikiRadius()
+ val componentSize = CrewWikiComponentSize()
+ val pretendard = FontFamily(
+ Font(Res.font.pretendard_regular, FontWeight.Normal),
+ Font(Res.font.pretendard_medium, FontWeight.Medium),
+ Font(Res.font.pretendard_semibold, FontWeight.SemiBold),
+ Font(Res.font.pretendard_bold, FontWeight.Bold),
+ )
+ val bmHanna = FontFamily(
+ Font(Res.font.bm_hanna_pro, FontWeight.Normal),
+ )
+
+ CompositionLocalProvider(
+ LocalCrewWikiPalette provides palette,
+ LocalCrewWikiSpacing provides spacing,
+ LocalCrewWikiRadius provides radius,
+ LocalCrewWikiComponentSize provides componentSize,
+ ) {
+ MaterialTheme(
+ colorScheme = CrewWikiLightColorScheme,
+ typography = crewWikiTypography(
+ pretendard = pretendard,
+ bmHanna = bmHanna,
+ ),
+ shapes = crewWikiShapes(radius),
+ content = content,
+ )
+ }
+}
+
+private fun crewWikiTypography(
+ pretendard: FontFamily,
+ bmHanna: FontFamily,
+): Typography {
+ return Typography(
+ displayLarge = TextStyle(
+ fontFamily = bmHanna,
+ fontWeight = FontWeight.Normal,
+ fontSize = 40.sp,
+ lineHeight = 48.sp,
+ ),
+ displayMedium = TextStyle(
+ fontFamily = bmHanna,
+ fontWeight = FontWeight.Normal,
+ fontSize = 32.sp,
+ lineHeight = 40.sp,
+ ),
+ displaySmall = TextStyle(
+ fontFamily = bmHanna,
+ fontWeight = FontWeight.Normal,
+ fontSize = 24.sp,
+ lineHeight = 32.sp,
+ ),
+ headlineLarge = TextStyle(
+ fontFamily = pretendard,
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 24.sp,
+ lineHeight = 32.sp,
+ ),
+ headlineMedium = TextStyle(
+ fontFamily = pretendard,
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 20.sp,
+ lineHeight = 28.sp,
+ ),
+ headlineSmall = TextStyle(
+ fontFamily = pretendard,
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 18.sp,
+ lineHeight = 24.sp,
+ ),
+ titleLarge = TextStyle(
+ fontFamily = pretendard,
+ fontWeight = FontWeight.Medium,
+ fontSize = 18.sp,
+ lineHeight = 24.sp,
+ ),
+ titleMedium = TextStyle(
+ fontFamily = pretendard,
+ fontWeight = FontWeight.Medium,
+ fontSize = 16.sp,
+ lineHeight = 24.sp,
+ ),
+ titleSmall = TextStyle(
+ fontFamily = pretendard,
+ fontWeight = FontWeight.Medium,
+ fontSize = 14.sp,
+ lineHeight = 20.sp,
+ ),
+ bodyLarge = TextStyle(
+ fontFamily = pretendard,
+ fontWeight = FontWeight.Normal,
+ fontSize = 16.sp,
+ lineHeight = 24.sp,
+ ),
+ bodyMedium = TextStyle(
+ fontFamily = pretendard,
+ fontWeight = FontWeight.Normal,
+ fontSize = 14.sp,
+ lineHeight = 20.sp,
+ ),
+ bodySmall = TextStyle(
+ fontFamily = pretendard,
+ fontWeight = FontWeight.Normal,
+ fontSize = 12.sp,
+ lineHeight = 16.sp,
+ ),
+ labelLarge = TextStyle(
+ fontFamily = pretendard,
+ fontWeight = FontWeight.Medium,
+ fontSize = 14.sp,
+ lineHeight = 20.sp,
+ ),
+ labelMedium = TextStyle(
+ fontFamily = pretendard,
+ fontWeight = FontWeight.Medium,
+ fontSize = 12.sp,
+ lineHeight = 16.sp,
+ ),
+ labelSmall = TextStyle(
+ fontFamily = pretendard,
+ fontWeight = FontWeight.Medium,
+ fontSize = 10.sp,
+ lineHeight = 14.sp,
+ ),
+ )
+}
+
+private fun crewWikiShapes(radius: CrewWikiRadius): Shapes {
+ return Shapes(
+ small = RoundedCornerShape(radius.sm),
+ medium = RoundedCornerShape(radius.md),
+ large = RoundedCornerShape(radius.lg),
+ )
+}
diff --git a/shared/src/commonMain/kotlin/com/example/crew_wiki/Greeting.kt b/shared/src/commonMain/kotlin/com/example/crew_wiki/Greeting.kt
new file mode 100644
index 0000000..bdf2f57
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/example/crew_wiki/Greeting.kt
@@ -0,0 +1,9 @@
+package com.example.crew_wiki
+
+class Greeting {
+ private val platform = getPlatform()
+
+ fun greet(): String {
+ return sayHello(platform.name)
+ }
+}
\ No newline at end of file
diff --git a/shared/src/commonMain/kotlin/com/example/crew_wiki/GreetingUtil.kt b/shared/src/commonMain/kotlin/com/example/crew_wiki/GreetingUtil.kt
new file mode 100644
index 0000000..2001151
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/example/crew_wiki/GreetingUtil.kt
@@ -0,0 +1,4 @@
+package com.example.crew_wiki
+
+fun sayHello(to: String): String =
+ "Hello, $to!"
\ No newline at end of file
diff --git a/shared/src/commonMain/kotlin/com/example/crew_wiki/Platform.kt b/shared/src/commonMain/kotlin/com/example/crew_wiki/Platform.kt
new file mode 100644
index 0000000..f326187
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/example/crew_wiki/Platform.kt
@@ -0,0 +1,7 @@
+package com.example.crew_wiki
+
+interface Platform {
+ val name: String
+}
+
+expect fun getPlatform(): Platform
\ No newline at end of file
diff --git a/shared/src/commonMain/kotlin/com/example/crew_wiki/Uuid.kt b/shared/src/commonMain/kotlin/com/example/crew_wiki/Uuid.kt
new file mode 100644
index 0000000..99c0db4
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/example/crew_wiki/Uuid.kt
@@ -0,0 +1,3 @@
+package com.example.crew_wiki
+
+expect fun randomUuid(): String
diff --git a/shared/src/commonMain/kotlin/com/example/crew_wiki/data/document/DocumentRepository.kt b/shared/src/commonMain/kotlin/com/example/crew_wiki/data/document/DocumentRepository.kt
new file mode 100644
index 0000000..071d71b
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/example/crew_wiki/data/document/DocumentRepository.kt
@@ -0,0 +1,11 @@
+package com.example.crew_wiki.data.document
+
+import com.example.crew_wiki.model.CrewWikiDocumentDetail
+import com.example.crew_wiki.model.PopularDocument
+import com.example.crew_wiki.model.PopularSortType
+
+interface DocumentRepository {
+ fun getDocumentDetail(documentId: String): CrewWikiDocumentDetail?
+
+ fun getPopularDocuments(sortType: PopularSortType): List
+}
diff --git a/shared/src/commonMain/kotlin/com/example/crew_wiki/data/document/InMemoryDocumentRepository.kt b/shared/src/commonMain/kotlin/com/example/crew_wiki/data/document/InMemoryDocumentRepository.kt
new file mode 100644
index 0000000..1a5dd27
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/example/crew_wiki/data/document/InMemoryDocumentRepository.kt
@@ -0,0 +1,241 @@
+package com.example.crew_wiki.data.document
+
+import com.example.crew_wiki.model.CrewWikiDocument
+import com.example.crew_wiki.model.CrewWikiDocumentDetail
+import com.example.crew_wiki.model.OrganizationReference
+import com.example.crew_wiki.model.PopularDocument
+import com.example.crew_wiki.model.PopularSortType
+import com.example.crew_wiki.model.RelatedCrewDocument
+
+class InMemoryDocumentRepository : DocumentRepository {
+ private val documentDetails = listOf(
+ sampleDocumentDetail(
+ documentId = 1,
+ uuid = "sample-document",
+ title = "비모",
+ writer = "비모",
+ organizations = listOf("우테코", "백엔드"),
+ relatedCrews = listOf("우디", "세인", "주디"),
+ contents = """
+ ## 기본 정보
+ 안녕하세요. 저는 크루위키 Android Multiplatform 전환 작업의 샘플 문서입니다.
+ 실제 API가 연결되면 이 영역에 서버에서 내려온 마크다운 본문이 들어오게 됩니다.
+
+ ## 활동과 특징
+ 우아한테크코스에서 백엔드 과정을 진행하고 있고, 문서 구조화와 도메인 모델 정리를 좋아합니다.
+ 지금 화면은 웹의 문서 상세 레이아웃을 Compose Multiplatform으로 옮긴 첫 번째 버전입니다.
+
+ ### 관심사
+ 안드로이드 멀티플랫폼, 문서 구조화, 개발 생산성 개선에 관심이 있습니다.
+ """.trimIndent(),
+ ),
+ sampleDocumentDetail(
+ documentId = 2,
+ uuid = "crew-woody",
+ title = "우디",
+ writer = "우디",
+ organizations = listOf("우테코", "프론트엔드"),
+ relatedCrews = listOf("비모", "주디"),
+ contents = """
+ ## 기본 정보
+ 프론트엔드 크루 우디의 소개 문서입니다.
+
+ ## 활동과 특징
+ 사용성 좋은 인터페이스와 디자인 시스템 구축에 관심이 있습니다.
+ """.trimIndent(),
+ ),
+ sampleDocumentDetail(
+ documentId = 3,
+ uuid = "crew-sain",
+ title = "세인",
+ writer = "세인",
+ organizations = listOf("우테코", "백엔드"),
+ relatedCrews = listOf("비모", "우디"),
+ contents = """
+ ## 기본 정보
+ 백엔드 크루 세인의 문서입니다.
+
+ ## 활동과 특징
+ 서버 성능 최적화와 테스트 자동화에 관심이 있습니다.
+ """.trimIndent(),
+ ),
+ sampleDocumentDetail(
+ documentId = 4,
+ uuid = "crew-judy",
+ title = "주디",
+ writer = "주디",
+ organizations = listOf("우테코", "모바일"),
+ relatedCrews = listOf("비모", "우디"),
+ contents = """
+ ## 기본 정보
+ 모바일 크루 주디의 소개 문서입니다.
+
+ ## 활동과 특징
+ Android 품질 개선과 사용자 경험 설계에 관심이 있습니다.
+ """.trimIndent(),
+ ),
+ sampleDocumentDetail(
+ documentId = 5,
+ uuid = "crew-river",
+ title = "리버",
+ writer = "리버",
+ organizations = listOf("우테코", "백엔드"),
+ relatedCrews = listOf("세인", "비모"),
+ contents = """
+ ## 기본 정보
+ 리버의 문서입니다.
+
+ ## 활동과 특징
+ 데이터 모델링과 클린 아키텍처를 좋아합니다.
+ """.trimIndent(),
+ ),
+ sampleDocumentDetail(
+ documentId = 6,
+ uuid = "crew-hazel",
+ title = "헤이즐",
+ writer = "헤이즐",
+ organizations = listOf("우테코", "프론트엔드"),
+ relatedCrews = listOf("우디", "주디"),
+ contents = """
+ ## 기본 정보
+ 헤이즐의 문서입니다.
+
+ ## 활동과 특징
+ 접근성과 반응형 UI 구성에 관심이 많습니다.
+ """.trimIndent(),
+ ),
+ sampleDocumentDetail(
+ documentId = 7,
+ uuid = "crew-noel",
+ title = "노엘",
+ writer = "노엘",
+ organizations = listOf("우테코", "모바일"),
+ relatedCrews = listOf("주디", "헤이즐"),
+ contents = """
+ ## 기본 정보
+ 노엘의 문서입니다.
+
+ ## 활동과 특징
+ Compose와 앱 아키텍처 설계를 주로 다룹니다.
+ """.trimIndent(),
+ ),
+ sampleDocumentDetail(
+ documentId = 8,
+ uuid = "crew-summer",
+ title = "서머",
+ writer = "서머",
+ organizations = listOf("우테코", "백엔드"),
+ relatedCrews = listOf("리버", "세인"),
+ contents = """
+ ## 기본 정보
+ 서머의 문서입니다.
+
+ ## 활동과 특징
+ 배치 처리와 운영 자동화에 흥미가 있습니다.
+ """.trimIndent(),
+ ),
+ sampleDocumentDetail(
+ documentId = 9,
+ uuid = "crew-dawn",
+ title = "던",
+ writer = "던",
+ organizations = listOf("우테코", "프론트엔드"),
+ relatedCrews = listOf("우디", "헤이즐"),
+ contents = """
+ ## 기본 정보
+ 던의 문서입니다.
+
+ ## 활동과 특징
+ 제품 경험과 마이크로 인터랙션에 관심이 있습니다.
+ """.trimIndent(),
+ ),
+ sampleDocumentDetail(
+ documentId = 10,
+ uuid = "crew-mint",
+ title = "민트",
+ writer = "민트",
+ organizations = listOf("우테코", "백엔드"),
+ relatedCrews = listOf("비모", "리버"),
+ contents = """
+ ## 기본 정보
+ 민트의 문서입니다.
+
+ ## 활동과 특징
+ 장애 대응과 로깅 체계 설계를 즐깁니다.
+ """.trimIndent(),
+ ),
+ )
+
+ private val documentsByUuid = documentDetails.associateBy { it.document.documentUUID }
+
+ private val popularDocuments = listOf(
+ PopularDocument(id = 1, documentUUID = "sample-document", title = "비모", viewCount = 3221),
+ PopularDocument(id = 2, documentUUID = "crew-woody", title = "우디", viewCount = 2980),
+ PopularDocument(id = 3, documentUUID = "crew-sain", title = "세인", viewCount = 2844),
+ PopularDocument(id = 4, documentUUID = "crew-judy", title = "주디", viewCount = 2601),
+ PopularDocument(id = 5, documentUUID = "crew-river", title = "리버", viewCount = 2380),
+ PopularDocument(id = 6, documentUUID = "crew-hazel", title = "헤이즐", viewCount = 2257),
+ PopularDocument(id = 7, documentUUID = "crew-noel", title = "노엘", viewCount = 2108),
+ PopularDocument(id = 8, documentUUID = "crew-summer", title = "서머", viewCount = 1980),
+ PopularDocument(id = 9, documentUUID = "crew-dawn", title = "던", viewCount = 1844),
+ PopularDocument(id = 10, documentUUID = "crew-mint", title = "민트", viewCount = 1705),
+ )
+
+ override fun getDocumentDetail(documentId: String): CrewWikiDocumentDetail? {
+ return documentsByUuid[documentId]
+ }
+
+ override fun getPopularDocuments(sortType: PopularSortType): List {
+ return popularDocuments.sortedByDescending { it.viewCount }
+ }
+}
+
+private fun sampleDocumentDetail(
+ documentId: Long,
+ uuid: String,
+ title: String,
+ writer: String,
+ organizations: List,
+ relatedCrews: List,
+ contents: String,
+): CrewWikiDocumentDetail {
+ return CrewWikiDocumentDetail(
+ document = CrewWikiDocument(
+ documentId = documentId,
+ documentUUID = uuid,
+ title = title,
+ contents = contents,
+ writer = writer,
+ generateTime = "2026-06-22T10:54:00",
+ organizations = organizations.mapIndexed { index, organization ->
+ OrganizationReference(
+ organizationDocumentId = documentId * 100 + index + 1,
+ organizationDocumentUuid = "organization-${documentId}-${index + 1}",
+ title = organization,
+ )
+ },
+ ),
+ relatedCrewDocuments = relatedCrews.map { crew ->
+ RelatedCrewDocument(
+ documentUuid = relatedCrewDocumentUuid(crew),
+ title = crew,
+ )
+ },
+ )
+}
+
+private fun relatedCrewDocumentUuid(title: String): String {
+ return when (title) {
+ "비모" -> "sample-document"
+ "우디" -> "crew-woody"
+ "세인" -> "crew-sain"
+ "주디" -> "crew-judy"
+ "리버" -> "crew-river"
+ "헤이즐" -> "crew-hazel"
+ "노엘" -> "crew-noel"
+ "서머" -> "crew-summer"
+ "던" -> "crew-dawn"
+ "민트" -> "crew-mint"
+ else -> "sample-document"
+ }
+}
diff --git a/shared/src/commonMain/kotlin/com/example/crew_wiki/data/document/NetworkDocumentRepository.kt b/shared/src/commonMain/kotlin/com/example/crew_wiki/data/document/NetworkDocumentRepository.kt
new file mode 100644
index 0000000..85cb820
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/example/crew_wiki/data/document/NetworkDocumentRepository.kt
@@ -0,0 +1,101 @@
+package com.example.crew_wiki.data.document
+
+import com.example.crew_wiki.model.CrewWikiDocument
+import com.example.crew_wiki.model.CrewWikiDocumentDetail
+import com.example.crew_wiki.model.DocumentLogDetail
+import com.example.crew_wiki.model.DocumentLogSummary
+import com.example.crew_wiki.model.OrganizationReference
+import com.example.crew_wiki.model.PopularDocument
+import com.example.crew_wiki.model.PopularSortType
+import com.example.crew_wiki.network.DocumentApiService
+import com.example.crew_wiki.network.dto.DocumentResponseDto
+
+class NetworkDocumentRepository(
+ private val apiService: DocumentApiService,
+) : DocumentRepository {
+
+ override fun getDocumentDetail(documentId: String): CrewWikiDocumentDetail? = null
+ override fun getPopularDocuments(sortType: PopularSortType): List = emptyList()
+
+ // ── 문서 상세 ──────────────────────────────────────────────────────────────
+
+ suspend fun fetchDocumentByUUID(uuid: String): CrewWikiDocumentDetail {
+ val dto = apiService.getDocumentByUUID(uuid)
+ return dto.toDomain()
+ }
+
+ // ── 편집 기록 ──────────────────────────────────────────────────────────────
+
+ /** @return Pair(로그 목록, totalPage) */
+ suspend fun fetchDocumentLogs(
+ uuid: String,
+ pageNumber: Int = 0,
+ pageSize: Int = 10,
+ ): Pair, Int> {
+ val page = apiService.getDocumentLogs(uuid, pageNumber, pageSize)
+ return page.data.map { dto ->
+ DocumentLogSummary(
+ id = dto.id,
+ title = dto.title,
+ version = dto.version,
+ writer = dto.writer,
+ documentBytes = dto.documentBytes,
+ generateTime = dto.generateTime,
+ )
+ } to page.totalPage
+ }
+
+ suspend fun fetchDocumentLog(logId: Long): DocumentLogDetail {
+ val dto = apiService.getDocumentLog(logId)
+ return DocumentLogDetail(
+ logId = dto.logId,
+ title = dto.title,
+ contents = dto.contents,
+ writer = dto.writer,
+ generateTime = dto.generateTime,
+ )
+ }
+
+ // ── 인기 문서 (viewCount 기준) ─────────────────────────────────────────────
+ // Swagger: DocumentListResponse에 editCount 없음 → viewCount 정렬만 지원
+
+ suspend fun fetchPopularDocuments(sortType: PopularSortType): List {
+ val page = apiService.getDocuments(
+ pageNumber = 0,
+ pageSize = 10,
+ sort = "viewCount",
+ sortDirection = "DESC",
+ )
+ return page.data.map { dto ->
+ PopularDocument(
+ id = dto.id,
+ documentUUID = dto.uuid,
+ title = dto.title,
+ viewCount = dto.viewCount,
+ )
+ }
+ }
+
+ // ── 매핑 ──────────────────────────────────────────────────────────────────
+
+ private fun DocumentResponseDto.toDomain(): CrewWikiDocumentDetail {
+ val document = CrewWikiDocument(
+ documentId = documentId,
+ documentUUID = documentUUID,
+ title = title,
+ contents = contents,
+ writer = writer,
+ generateTime = generateTime,
+ viewCount = viewCount,
+ latestVersion = latestVersion,
+ organizations = organizationDocumentResponses.map { org ->
+ OrganizationReference(
+ organizationDocumentId = org.organizationDocumentId,
+ organizationDocumentUuid = org.organizationDocumentUuid,
+ title = org.title,
+ )
+ },
+ )
+ return CrewWikiDocumentDetail(document = document)
+ }
+}
diff --git a/shared/src/commonMain/kotlin/com/example/crew_wiki/data/group/GroupDocumentRepository.kt b/shared/src/commonMain/kotlin/com/example/crew_wiki/data/group/GroupDocumentRepository.kt
new file mode 100644
index 0000000..1dec0c3
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/example/crew_wiki/data/group/GroupDocumentRepository.kt
@@ -0,0 +1,38 @@
+package com.example.crew_wiki.data.group
+
+import com.example.crew_wiki.model.GroupDocumentDetail
+import com.example.crew_wiki.model.LinkedCrewDocument
+import com.example.crew_wiki.model.OrganizationEvent
+import com.example.crew_wiki.network.GroupApiService
+
+class GroupDocumentRepository(
+ private val apiService: GroupApiService,
+) {
+ // GET /organization/uuid/{uuidText}
+ suspend fun fetchGroupDocumentByUUID(uuid: String): GroupDocumentDetail {
+ val dto = apiService.getGroupDocumentByUUID(uuid)
+ return GroupDocumentDetail(
+ organizationDocumentId = dto.organizationDocumentId,
+ organizationDocumentUuid = dto.organizationDocumentUuid,
+ title = dto.title,
+ contents = dto.contents,
+ writer = dto.writer,
+ generateTime = dto.generateTime,
+ events = dto.organizationEventResponses.map { e ->
+ OrganizationEvent(
+ organizationEventUuid = e.organizationEventUuid,
+ title = e.title,
+ contents = e.contents,
+ writer = e.writer,
+ occurredAt = e.occurredAt, // yyyy-MM-dd
+ )
+ },
+ linkedCrewDocuments = dto.linkedCrewDocuments.map { c ->
+ LinkedCrewDocument(
+ documentUuid = c.documentUuid,
+ title = c.title,
+ )
+ },
+ )
+ }
+}
diff --git a/shared/src/commonMain/kotlin/com/example/crew_wiki/data/history/RecentlyViewedStore.kt b/shared/src/commonMain/kotlin/com/example/crew_wiki/data/history/RecentlyViewedStore.kt
new file mode 100644
index 0000000..3a7b247
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/example/crew_wiki/data/history/RecentlyViewedStore.kt
@@ -0,0 +1,23 @@
+package com.example.crew_wiki.data.history
+
+import com.example.crew_wiki.model.RecentDocument
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+/**
+ * 앱 세션 동안 사용자가 확인한 문서를 최신순으로 기록한다.
+ * 앱 재시작 시 초기화되는 메모리 저장소.
+ */
+object RecentlyViewedStore {
+ private const val MAX_SIZE = 50
+
+ private val _viewedDocuments = MutableStateFlow>(emptyList())
+ val viewedDocuments: StateFlow> = _viewedDocuments.asStateFlow()
+
+ fun record(document: RecentDocument) {
+ _viewedDocuments.value = listOf(document) +
+ _viewedDocuments.value.filterNot { it.uuid == document.uuid }
+ .take(MAX_SIZE - 1)
+ }
+}
diff --git a/shared/src/commonMain/kotlin/com/example/crew_wiki/di/AppContainer.kt b/shared/src/commonMain/kotlin/com/example/crew_wiki/di/AppContainer.kt
new file mode 100644
index 0000000..c21ca96
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/example/crew_wiki/di/AppContainer.kt
@@ -0,0 +1,21 @@
+package com.example.crew_wiki.di
+
+import com.example.crew_wiki.data.document.NetworkDocumentRepository
+import com.example.crew_wiki.data.group.GroupDocumentRepository
+import com.example.crew_wiki.network.DocumentApiService
+import com.example.crew_wiki.network.GroupApiService
+import com.example.crew_wiki.network.createCrewWikiHttpClient
+
+/**
+ * 앱 전역 의존성 컨테이너 (간단한 수동 DI)
+ * 실제 프로젝트에서는 Koin/Dagger 등으로 대체 가능
+ */
+object AppContainer {
+ private val httpClient by lazy { createCrewWikiHttpClient() }
+
+ val documentApiService by lazy { DocumentApiService(httpClient) }
+ val groupApiService by lazy { GroupApiService(httpClient) }
+
+ val documentRepository by lazy { NetworkDocumentRepository(documentApiService) }
+ val groupDocumentRepository by lazy { GroupDocumentRepository(groupApiService) }
+}
diff --git a/shared/src/commonMain/kotlin/com/example/crew_wiki/model/DocumentModels.kt b/shared/src/commonMain/kotlin/com/example/crew_wiki/model/DocumentModels.kt
new file mode 100644
index 0000000..ac67052
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/example/crew_wiki/model/DocumentModels.kt
@@ -0,0 +1,104 @@
+package com.example.crew_wiki.model
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+enum class DocumentType {
+ CREW,
+ ORGANIZATION,
+}
+
+/** 최근 편집 문서 목록용 경량 모델 */
+@Serializable
+data class RecentDocument(
+ val uuid: String,
+ val title: String,
+ val generateTime: String,
+ val documentType: String, // "CREW" | "ORGANIZATION"
+)
+
+/**
+ * 문서에 연결된 조직(그룹) 문서 참조
+ * Swagger: OrganizationDocumentResponse
+ */
+@Serializable
+data class OrganizationReference(
+ val organizationDocumentId: Long,
+ val organizationDocumentUuid: String,
+ val title: String,
+)
+
+/**
+ * 크루 문서
+ * Swagger: DocumentResponse
+ */
+@Serializable
+data class CrewWikiDocument(
+ val documentId: Long,
+ val documentUUID: String,
+ val title: String,
+ val contents: String,
+ val writer: String,
+ val generateTime: String,
+ val viewCount: Int = 0,
+ val latestVersion: Long = 0,
+ val organizations: List = emptyList(),
+)
+
+@Serializable
+data class CrewWikiDocumentDetail(
+ val document: CrewWikiDocument,
+ // 조직 문서에서 linkedCrewDocuments로 조회되는 연관 크루 문서
+ val relatedCrewDocuments: List = emptyList(),
+)
+
+@Serializable
+data class RelatedCrewDocument(
+ val documentUuid: String,
+ val title: String,
+)
+
+/**
+ * 편집 기록 목록 항목
+ * Swagger: HistoryResponse
+ */
+@Serializable
+data class DocumentLogSummary(
+ val id: Long,
+ val title: String,
+ val version: Long, // Swagger: int64
+ val writer: String,
+ val documentBytes: Long,
+ val generateTime: String,
+)
+
+/**
+ * 편집 기록 상세
+ * Swagger: HistoryDetailResponse
+ */
+@Serializable
+data class DocumentLogDetail(
+ val logId: Long,
+ val title: String,
+ val contents: String,
+ val writer: String,
+ val generateTime: String,
+)
+
+/**
+ * 인기 문서 (GET /document 정렬 결과)
+ * Swagger: DocumentListResponse — editCount 없음
+ */
+@Serializable
+data class PopularDocument(
+ val id: Long,
+ val documentUUID: String,
+ val title: String,
+ val viewCount: Int,
+)
+
+@Serializable
+enum class PopularSortType {
+ VIEWS,
+ // EDITS: 서버 API에 editCount 미제공 → 조회수 정렬로 대체
+}
diff --git a/shared/src/commonMain/kotlin/com/example/crew_wiki/model/GroupModels.kt b/shared/src/commonMain/kotlin/com/example/crew_wiki/model/GroupModels.kt
new file mode 100644
index 0000000..38ea5a2
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/example/crew_wiki/model/GroupModels.kt
@@ -0,0 +1,30 @@
+package com.example.crew_wiki.model
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class OrganizationEvent(
+ val organizationEventUuid: String,
+ val title: String,
+ val contents: String,
+ val writer: String,
+ val occurredAt: String,
+)
+
+@Serializable
+data class LinkedCrewDocument(
+ val documentUuid: String,
+ val title: String,
+)
+
+@Serializable
+data class GroupDocumentDetail(
+ val organizationDocumentId: Long,
+ val organizationDocumentUuid: String,
+ val title: String,
+ val contents: String,
+ val writer: String,
+ val generateTime: String,
+ val events: List = emptyList(),
+ val linkedCrewDocuments: List = emptyList(),
+)
diff --git a/shared/src/commonMain/kotlin/com/example/crew_wiki/navigation/CrewWikiNavHost.kt b/shared/src/commonMain/kotlin/com/example/crew_wiki/navigation/CrewWikiNavHost.kt
new file mode 100644
index 0000000..6b956c8
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/example/crew_wiki/navigation/CrewWikiNavHost.kt
@@ -0,0 +1,580 @@
+package com.example.crew_wiki.navigation
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.NavigationBar
+import androidx.compose.material3.NavigationBarItem
+import androidx.compose.material3.NavigationBarItemDefaults
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalUriHandler
+import androidx.compose.ui.platform.UriHandler
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewmodel.CreationExtras
+import androidx.lifecycle.viewmodel.compose.viewModel
+import androidx.navigation.NavController
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.currentBackStackEntryAsState
+import androidx.navigation.compose.rememberNavController
+import androidx.navigation.toRoute
+import com.example.crew_wiki.CrewWikiDesignTokens
+import com.example.crew_wiki.data.history.RecentlyViewedStore
+import com.example.crew_wiki.di.AppContainer
+import com.example.crew_wiki.ui.common.CrewWikiTopBar
+import com.example.crew_wiki.ui.common.ErrorScreen
+import com.example.crew_wiki.ui.common.EyeNavIcon
+import com.example.crew_wiki.ui.common.HistoryNavIcon
+import com.example.crew_wiki.ui.common.HomeNavIcon
+import com.example.crew_wiki.ui.common.LoadingScreen
+import com.example.crew_wiki.ui.common.SettingsNavIcon
+import com.example.crew_wiki.ui.document.DocumentDetailScreen
+import com.example.crew_wiki.ui.document.DocumentEditorMode
+import com.example.crew_wiki.ui.document.DocumentEditorScreen
+import com.example.crew_wiki.ui.document.DocumentEditorViewModel
+import com.example.crew_wiki.ui.document.DocumentDetailUiState
+import com.example.crew_wiki.ui.document.DocumentDetailViewModel
+import com.example.crew_wiki.ui.document.DocumentLogDetailScreen
+import com.example.crew_wiki.ui.document.DocumentLogDetailViewModel
+import com.example.crew_wiki.ui.document.DocumentLogsScreen
+import com.example.crew_wiki.ui.document.DocumentLogsViewModel
+import com.example.crew_wiki.ui.group.GroupDetailScreen
+import com.example.crew_wiki.ui.group.GroupDetailUiState
+import com.example.crew_wiki.ui.group.GroupDetailViewModel
+import com.example.crew_wiki.ui.history.RecentEditsScreen
+import com.example.crew_wiki.ui.history.RecentEditsUiState
+import com.example.crew_wiki.ui.history.RecentEditsViewModel
+import com.example.crew_wiki.ui.history.RecentlyViewedScreen
+import com.example.crew_wiki.ui.home.HomeScreen
+import com.example.crew_wiki.ui.home.HomeUiState
+import com.example.crew_wiki.ui.home.HomeViewModel
+import com.example.crew_wiki.ui.popular.PopularDocumentsScreen
+import com.example.crew_wiki.ui.popular.PopularDocumentsViewModel
+import com.example.crew_wiki.ui.popular.PopularUiState
+import com.example.crew_wiki.ui.search.SearchScreen
+import com.example.crew_wiki.ui.search.SearchViewModel
+import com.example.crew_wiki.ui.settings.SettingsScreen
+import kotlinx.coroutines.launch
+import kotlin.reflect.KClass
+
+private data class BottomNavTab(
+ val route: Any,
+ val label: String,
+ val routeMatcher: String,
+ val icon: @Composable (androidx.compose.ui.graphics.Color) -> Unit,
+)
+
+@Composable
+fun CrewWikiNavRoot() {
+ val navController = rememberNavController()
+ val backStackEntry by navController.currentBackStackEntryAsState()
+ val coroutineScope = rememberCoroutineScope()
+ var shuffleLoading by remember { mutableStateOf(false) }
+ val colors = CrewWikiDesignTokens.colors
+ val defaultUriHandler = LocalUriHandler.current
+ val internalUriHandler = remember(navController, defaultUriHandler) {
+ object : UriHandler {
+ override fun openUri(uri: String) {
+ if (!navController.navigateCrewWikiLink(uri)) {
+ defaultUriHandler.openUri(uri)
+ }
+ }
+ }
+ }
+
+ val currentRoute = backStackEntry?.destination?.route ?: ""
+ val bottomTabs = remember {
+ listOf(
+ BottomNavTab(CrewWikiRoute.Home, "홈", "Home") { tint -> HomeNavIcon(tint, Modifier.size(22.dp)) },
+ BottomNavTab(CrewWikiRoute.RecentEdits, "최근 편집", "RecentEdits") { tint -> HistoryNavIcon(tint, Modifier.size(22.dp)) },
+ BottomNavTab(CrewWikiRoute.RecentlyViewed, "최근 확인", "RecentlyViewed") { tint -> EyeNavIcon(tint, Modifier.size(22.dp)) },
+ BottomNavTab(CrewWikiRoute.Settings, "설정", "Settings") { tint -> SettingsNavIcon(tint, Modifier.size(22.dp)) },
+ )
+ }
+ // 하단 탭 화면에서는 뒤로가기 버튼 대신 탭 자체를 보여주고, 그 외 화면(문서 상세 등)에서만 뒤로가기 표시
+ val isTopLevelTab = bottomTabs.any { currentRoute.contains(it.routeMatcher) } || currentRoute.isEmpty()
+
+ CompositionLocalProvider(LocalUriHandler provides internalUriHandler) {
+ Scaffold(
+ topBar = {
+ CrewWikiTopBar(
+ showBack = !isTopLevelTab,
+ onBack = { navController.popBackStack() },
+ onHomeClick = {
+ navController.navigate(CrewWikiRoute.Home) {
+ popUpTo(CrewWikiRoute.Home) { inclusive = false }
+ launchSingleTop = true
+ }
+ },
+ onShuffle = {
+ if (!shuffleLoading) {
+ coroutineScope.launch {
+ shuffleLoading = true
+ try {
+ val randomDoc = AppContainer.documentApiService.getRandomDocument()
+ navController.navigate(CrewWikiRoute.Document(randomDoc.documentUUID))
+ } catch (_: Exception) {
+ // 실패 시 무시
+ } finally {
+ shuffleLoading = false
+ }
+ }
+ }
+ },
+ shuffleLoading = shuffleLoading,
+ onSearch = { navController.navigate(CrewWikiRoute.Search) },
+ )
+ },
+ bottomBar = {
+ NavigationBar(containerColor = MaterialTheme.colorScheme.surface) {
+ bottomTabs.forEach { tab ->
+ val selected = currentRoute.contains(tab.routeMatcher)
+ NavigationBarItem(
+ selected = selected,
+ onClick = {
+ navController.navigate(tab.route) {
+ popUpTo(CrewWikiRoute.Home) { inclusive = false }
+ launchSingleTop = true
+ }
+ },
+ icon = { tab.icon(if (selected) colors.primary.base else colors.grayscale.c500) },
+ label = { Text(tab.label) },
+ colors = NavigationBarItemDefaults.colors(
+ selectedTextColor = colors.primary.base,
+ unselectedTextColor = colors.grayscale.c500,
+ indicatorColor = colors.primary.c50,
+ ),
+ )
+ }
+ }
+ },
+ modifier = Modifier.fillMaxSize(),
+ ) { paddingValues ->
+ NavHost(
+ navController = navController,
+ startDestination = CrewWikiRoute.Home,
+ modifier = Modifier
+ .background(MaterialTheme.colorScheme.background)
+ .fillMaxSize()
+ .padding(paddingValues),
+ ) {
+ addHomeDestination(navController)
+ addPopularDestination(navController)
+ addDocumentDestinations(navController)
+ addGroupDestinations(navController)
+ addSearchDestination(navController)
+ addRecentEditsDestination(navController)
+ addRecentlyViewedDestination(navController)
+ addSettingsDestination()
+ }
+ }
+ }
+}
+
+private fun NavController.navigateCrewWikiLink(uri: String): Boolean {
+ val path = uri.toCrewWikiPath() ?: return false
+
+ return when {
+ path == "/wiki/post" -> {
+ navigate(CrewWikiRoute.Post) { launchSingleTop = true }
+ true
+ }
+
+ groupLogPathRegex.matches(path) -> {
+ val match = groupLogPathRegex.matchEntire(path) ?: return false
+ val groupId = match.groupValues[1]
+ val logId = match.groupValues[2].toIntOrNull() ?: return false
+ navigate(CrewWikiRoute.GroupLog(groupId, logId)) { launchSingleTop = true }
+ true
+ }
+
+ groupLogsPathRegex.matches(path) -> {
+ val groupId = groupLogsPathRegex.matchEntire(path)?.groupValues?.get(1) ?: return false
+ navigate(CrewWikiRoute.GroupLogs(groupId)) { launchSingleTop = true }
+ true
+ }
+
+ groupEditPathRegex.matches(path) -> {
+ val groupId = groupEditPathRegex.matchEntire(path)?.groupValues?.get(1) ?: return false
+ navigate(CrewWikiRoute.GroupEdit(groupId)) { launchSingleTop = true }
+ true
+ }
+
+ groupPathRegex.matches(path) -> {
+ val groupId = groupPathRegex.matchEntire(path)?.groupValues?.get(1) ?: return false
+ navigate(CrewWikiRoute.GroupDetail(groupId)) { launchSingleTop = true }
+ true
+ }
+
+ documentLogPathRegex.matches(path) -> {
+ val match = documentLogPathRegex.matchEntire(path) ?: return false
+ val documentId = match.groupValues[1]
+ val logId = match.groupValues[2].toIntOrNull() ?: return false
+ navigate(CrewWikiRoute.DocumentLog(documentId, logId)) { launchSingleTop = true }
+ true
+ }
+
+ documentLogsPathRegex.matches(path) -> {
+ val documentId = documentLogsPathRegex.matchEntire(path)?.groupValues?.get(1) ?: return false
+ navigate(CrewWikiRoute.DocumentLogs(documentId)) { launchSingleTop = true }
+ true
+ }
+
+ documentEditPathRegex.matches(path) -> {
+ val documentId = documentEditPathRegex.matchEntire(path)?.groupValues?.get(1) ?: return false
+ navigate(CrewWikiRoute.DocumentEdit(documentId)) { launchSingleTop = true }
+ true
+ }
+
+ documentPathRegex.matches(path) -> {
+ val documentId = documentPathRegex.matchEntire(path)?.groupValues?.get(1) ?: return false
+ navigate(CrewWikiRoute.Document(documentId)) { launchSingleTop = true }
+ true
+ }
+
+ else -> false
+ }
+}
+
+private fun String.toCrewWikiPath(): String? {
+ val normalized = substringBefore('#').substringBefore('?')
+ return when {
+ normalized.startsWith("/wiki") -> normalized
+ normalized.startsWith("https://crew-wiki.site/wiki") -> normalized.removePrefix("https://crew-wiki.site")
+ normalized.startsWith("http://crew-wiki.site/wiki") -> normalized.removePrefix("http://crew-wiki.site")
+ normalized.startsWith("https://www.crew-wiki.site/wiki") -> normalized.removePrefix("https://www.crew-wiki.site")
+ normalized.startsWith("http://www.crew-wiki.site/wiki") -> normalized.removePrefix("http://www.crew-wiki.site")
+ else -> null
+ }
+}
+
+private val documentPathRegex = Regex("^/wiki/([A-Za-z0-9-]+)$")
+private val documentEditPathRegex = Regex("^/wiki/([A-Za-z0-9-]+)/edit$")
+private val documentLogsPathRegex = Regex("^/wiki/([A-Za-z0-9-]+)/logs$")
+private val documentLogPathRegex = Regex("^/wiki/([A-Za-z0-9-]+)/log/(\\d+)$")
+private val groupPathRegex = Regex("^/wiki/groups/([A-Za-z0-9-]+)$")
+private val groupEditPathRegex = Regex("^/wiki/groups/([A-Za-z0-9-]+)/edit$")
+private val groupLogsPathRegex = Regex("^/wiki/groups/([A-Za-z0-9-]+)/logs$")
+private val groupLogPathRegex = Regex("^/wiki/groups/([A-Za-z0-9-]+)/log/(\\d+)$")
+
+// ── Home ──────────────────────────────────────────────────────────────────────
+
+private fun NavGraphBuilder.addHomeDestination(navController: NavController) {
+ composable {
+ val vm = viewModel(
+ factory = vmFactory { HomeViewModel(AppContainer.documentRepository) },
+ )
+ val uiState by vm.uiState.collectAsState()
+
+ when (val state = uiState) {
+ is HomeUiState.Loading -> LoadingScreen()
+ is HomeUiState.Error -> ErrorScreen(
+ message = state.message,
+ onRetry = vm::loadMainDocument,
+ )
+ is HomeUiState.Success -> HomeScreen(
+ mainDocument = state.mainDocument,
+ )
+ }
+ }
+}
+
+// ── Popular ───────────────────────────────────────────────────────────────────
+
+private fun NavGraphBuilder.addPopularDestination(navController: NavController) {
+ composable {
+ val vm = viewModel(
+ factory = vmFactory { PopularDocumentsViewModel(AppContainer.documentRepository) },
+ )
+ val uiState by vm.uiState.collectAsState()
+
+ when (val state = uiState) {
+ is PopularUiState.Loading -> LoadingScreen()
+ is PopularUiState.Error -> ErrorScreen(
+ message = state.message,
+ onRetry = vm::loadPopularDocuments,
+ )
+ is PopularUiState.Success -> PopularDocumentsScreen(
+ documents = state.documents,
+ onDocumentClick = { doc ->
+ navController.navigate(CrewWikiRoute.Document(doc.documentUUID))
+ },
+ )
+ }
+ }
+}
+
+// ── Document ──────────────────────────────────────────────────────────────────
+
+private fun NavGraphBuilder.addDocumentDestinations(navController: NavController) {
+ // 문서 상세
+ composable { backStackEntry ->
+ val route = backStackEntry.toRoute()
+ val vm = viewModel(
+ key = route.documentId,
+ factory = vmFactory {
+ DocumentDetailViewModel(AppContainer.documentRepository, route.documentId)
+ },
+ )
+ val uiState by vm.uiState.collectAsState()
+
+ when (val state = uiState) {
+ is DocumentDetailUiState.Loading -> LoadingScreen()
+ is DocumentDetailUiState.NotFound -> ErrorScreen(
+ message = "문서를 찾을 수 없습니다.",
+ onRetry = vm::loadDocument,
+ )
+ is DocumentDetailUiState.Error -> ErrorScreen(
+ message = state.message,
+ onRetry = vm::loadDocument,
+ )
+ is DocumentDetailUiState.Success -> DocumentDetailScreen(
+ documentDetail = state.detail,
+ onEditClick = { navController.navigate(CrewWikiRoute.DocumentEdit(route.documentId)) },
+ onLogsClick = { navController.navigate(CrewWikiRoute.DocumentLogs(route.documentId)) },
+ onWriteClick = { navController.navigate(CrewWikiRoute.Post) },
+ onOrganizationClick = { uuid ->
+ navController.navigate(CrewWikiRoute.GroupDetail(uuid))
+ },
+ )
+ }
+ }
+
+ // 편집 기록 목록
+ composable { backStackEntry ->
+ val route = backStackEntry.toRoute()
+ val vm = viewModel(
+ key = route.documentId,
+ factory = vmFactory {
+ DocumentLogsViewModel(AppContainer.documentRepository, route.documentId)
+ },
+ )
+ DocumentLogsScreen(
+ viewModel = vm,
+ onLogClick = { logId ->
+ navController.navigate(CrewWikiRoute.DocumentLog(route.documentId, logId.toInt()))
+ },
+ )
+ }
+
+ // 편집 기록 상세
+ composable { backStackEntry ->
+ val route = backStackEntry.toRoute()
+ val vm = viewModel(
+ key = route.logId.toString(),
+ factory = vmFactory {
+ DocumentLogDetailViewModel(AppContainer.documentRepository, route.logId.toLong())
+ },
+ )
+ DocumentLogDetailScreen(viewModel = vm)
+ }
+
+ // 문서 수정
+ composable { backStackEntry ->
+ val route = backStackEntry.toRoute()
+ val vm = viewModel(
+ key = "edit-${route.documentId}",
+ factory = vmFactory {
+ DocumentEditorViewModel(
+ AppContainer.documentApiService,
+ AppContainer.groupApiService,
+ )
+ },
+ )
+ DocumentEditorScreen(
+ viewModel = vm,
+ mode = DocumentEditorMode.Edit(route.documentId),
+ onBackClick = { navController.popBackStack() },
+ onSaved = { documentId ->
+ navController.navigate(CrewWikiRoute.Document(documentId)) {
+ popUpTo(CrewWikiRoute.DocumentEdit(route.documentId)) { inclusive = true }
+ }
+ },
+ )
+ }
+
+ // 문서 작성
+ composable {
+ val vm = viewModel(
+ key = "post",
+ factory = vmFactory {
+ DocumentEditorViewModel(
+ AppContainer.documentApiService,
+ AppContainer.groupApiService,
+ )
+ },
+ )
+ DocumentEditorScreen(
+ viewModel = vm,
+ mode = DocumentEditorMode.Post,
+ onBackClick = { navController.popBackStack() },
+ onSaved = { documentId ->
+ navController.navigate(CrewWikiRoute.Document(documentId)) {
+ popUpTo(CrewWikiRoute.Post) { inclusive = true }
+ }
+ },
+ )
+ }
+}
+
+// ── Group ─────────────────────────────────────────────────────────────────────
+
+private fun NavGraphBuilder.addGroupDestinations(navController: NavController) {
+ composable { backStackEntry ->
+ val route = backStackEntry.toRoute()
+ val vm = viewModel(
+ key = route.groupId,
+ factory = vmFactory {
+ GroupDetailViewModel(AppContainer.groupDocumentRepository, route.groupId)
+ },
+ )
+ val uiState by vm.uiState.collectAsState()
+
+ when (val state = uiState) {
+ is GroupDetailUiState.Loading -> LoadingScreen()
+ is GroupDetailUiState.Error -> ErrorScreen(
+ message = state.message,
+ onRetry = vm::loadGroupDocument,
+ )
+ is GroupDetailUiState.Success -> GroupDetailScreen(
+ detail = state.detail,
+ onCrewDocumentClick = { uuid ->
+ navController.navigate(CrewWikiRoute.Document(uuid))
+ },
+ onLogsClick = {
+ navController.navigate(CrewWikiRoute.GroupLogs(route.groupId))
+ },
+ )
+ }
+ }
+
+ composable { backStackEntry ->
+ val route = backStackEntry.toRoute()
+ val vm = viewModel(
+ key = "group-${route.groupId}",
+ factory = vmFactory {
+ DocumentLogsViewModel(AppContainer.documentRepository, route.groupId)
+ },
+ )
+ DocumentLogsScreen(
+ viewModel = vm,
+ onLogClick = { logId ->
+ navController.navigate(CrewWikiRoute.GroupLog(route.groupId, logId.toInt()))
+ },
+ )
+ }
+
+ composable { backStackEntry ->
+ val route = backStackEntry.toRoute()
+ val vm = viewModel(
+ key = "group-log-${route.logId}",
+ factory = vmFactory {
+ DocumentLogDetailViewModel(AppContainer.documentRepository, route.logId.toLong())
+ },
+ )
+ DocumentLogDetailScreen(viewModel = vm)
+ }
+
+ composable {
+ LoadingScreen()
+ }
+}
+
+// ── Search ────────────────────────────────────────────────────────────────────
+
+private fun NavGraphBuilder.addSearchDestination(navController: NavController) {
+ composable {
+ val vm = viewModel(
+ factory = vmFactory { SearchViewModel(AppContainer.documentApiService) },
+ )
+ SearchScreen(
+ viewModel = vm,
+ onResultClick = { uuid, documentType ->
+ if (documentType == "ORGANIZATION") {
+ navController.navigate(CrewWikiRoute.GroupDetail(uuid))
+ } else {
+ navController.navigate(CrewWikiRoute.Document(uuid))
+ }
+ },
+ )
+ }
+}
+
+// ── 최근 편집 ─────────────────────────────────────────────────────────────────
+
+private fun NavGraphBuilder.addRecentEditsDestination(navController: NavController) {
+ composable {
+ val vm = viewModel(
+ factory = vmFactory { RecentEditsViewModel(AppContainer.documentApiService) },
+ )
+ val uiState by vm.uiState.collectAsState()
+
+ when (val state = uiState) {
+ is RecentEditsUiState.Loading -> LoadingScreen()
+ is RecentEditsUiState.Error -> ErrorScreen(
+ message = state.message,
+ onRetry = vm::loadRecentEdits,
+ )
+ is RecentEditsUiState.Success -> RecentEditsScreen(
+ documents = state.documents,
+ onDocumentClick = { doc ->
+ if (doc.documentType == "ORGANIZATION") {
+ navController.navigate(CrewWikiRoute.GroupDetail(doc.uuid))
+ } else {
+ navController.navigate(CrewWikiRoute.Document(doc.uuid))
+ }
+ },
+ )
+ }
+ }
+}
+
+// ── 내가 최근에 확인한 문서 ─────────────────────────────────────────────────────
+
+private fun NavGraphBuilder.addRecentlyViewedDestination(navController: NavController) {
+ composable {
+ val documents by RecentlyViewedStore.viewedDocuments.collectAsState()
+ RecentlyViewedScreen(
+ documents = documents,
+ onDocumentClick = { doc ->
+ if (doc.documentType == "ORGANIZATION") {
+ navController.navigate(CrewWikiRoute.GroupDetail(doc.uuid))
+ } else {
+ navController.navigate(CrewWikiRoute.Document(doc.uuid))
+ }
+ },
+ )
+ }
+}
+
+// ── 설정 ──────────────────────────────────────────────────────────────────────
+
+private fun NavGraphBuilder.addSettingsDestination() {
+ composable {
+ SettingsScreen()
+ }
+}
+
+// ── 헬퍼 ──────────────────────────────────────────────────────────────────────
+
+private fun vmFactory(create: () -> VM): ViewModelProvider.Factory =
+ object : ViewModelProvider.Factory {
+ @Suppress("UNCHECKED_CAST")
+ override fun create(modelClass: KClass, extras: CreationExtras): T =
+ create() as T
+ }
diff --git a/shared/src/commonMain/kotlin/com/example/crew_wiki/navigation/CrewWikiRoute.kt b/shared/src/commonMain/kotlin/com/example/crew_wiki/navigation/CrewWikiRoute.kt
new file mode 100644
index 0000000..c18d5be
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/example/crew_wiki/navigation/CrewWikiRoute.kt
@@ -0,0 +1,68 @@
+package com.example.crew_wiki.navigation
+
+import kotlinx.serialization.Serializable
+
+object CrewWikiRoute {
+ @Serializable
+ data object Home
+
+ @Serializable
+ data object Popular
+
+ @Serializable
+ data object Statistics
+
+ @Serializable
+ data object Post
+
+ @Serializable
+ data class Document(val documentId: String)
+
+ @Serializable
+ data class DocumentEdit(val documentId: String)
+
+ @Serializable
+ data class DocumentLogs(val documentId: String)
+
+ @Serializable
+ data class DocumentLog(
+ val documentId: String,
+ val logId: Int,
+ )
+
+ @Serializable
+ data class GroupDetail(val groupId: String)
+
+ @Serializable
+ data class GroupEdit(val groupId: String)
+
+ @Serializable
+ data class GroupLogs(val groupId: String)
+
+ @Serializable
+ data class GroupLog(
+ val groupId: String,
+ val logId: Int,
+ )
+
+ @Serializable
+ data object RecentEdits
+
+ @Serializable
+ data object RecentlyViewed
+
+ @Serializable
+ data object Settings
+
+ @Serializable
+ data object Search
+
+ @Serializable
+ data object AdminLogin
+
+ @Serializable
+ data object AdminDashboard
+
+ @Serializable
+ data object AdminDocuments
+}
diff --git a/shared/src/commonMain/kotlin/com/example/crew_wiki/network/CrewWikiHttpClient.kt b/shared/src/commonMain/kotlin/com/example/crew_wiki/network/CrewWikiHttpClient.kt
new file mode 100644
index 0000000..18bf708
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/example/crew_wiki/network/CrewWikiHttpClient.kt
@@ -0,0 +1,49 @@
+package com.example.crew_wiki.network
+
+import io.ktor.client.HttpClient
+import io.ktor.client.plugins.HttpResponseValidator
+import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
+import io.ktor.client.plugins.logging.LogLevel
+import io.ktor.client.plugins.logging.Logger
+import io.ktor.client.plugins.logging.Logging
+import io.ktor.http.isSuccess
+import io.ktor.serialization.kotlinx.json.json
+import kotlinx.serialization.json.Json
+
+const val BASE_URL = "https://api.crew-wiki.site"
+
+fun createCrewWikiHttpClient(): HttpClient = HttpClient {
+ install(ContentNegotiation) {
+ json(
+ Json {
+ ignoreUnknownKeys = true
+ isLenient = true
+ coerceInputValues = true
+ },
+ )
+ }
+ install(Logging) {
+ logger = object : Logger {
+ override fun log(message: String) {
+ println("[CrewWiki HTTP] $message")
+ }
+ }
+ level = LogLevel.INFO
+ }
+ // 비2xx 응답 시 JSON 파싱 시도 전에 예외 발생
+ HttpResponseValidator {
+ validateResponse { response ->
+ if (!response.status.isSuccess()) {
+ throw CrewWikiApiException(
+ statusCode = response.status.value,
+ message = "서버 오류: HTTP ${response.status.value}",
+ )
+ }
+ }
+ }
+}
+
+class CrewWikiApiException(
+ val statusCode: Int,
+ message: String,
+) : Exception(message)
diff --git a/shared/src/commonMain/kotlin/com/example/crew_wiki/network/DocumentApiService.kt b/shared/src/commonMain/kotlin/com/example/crew_wiki/network/DocumentApiService.kt
new file mode 100644
index 0000000..b043725
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/example/crew_wiki/network/DocumentApiService.kt
@@ -0,0 +1,104 @@
+package com.example.crew_wiki.network
+
+import com.example.crew_wiki.network.dto.ApiResponse
+import com.example.crew_wiki.network.dto.DocumentListResponseDto
+import com.example.crew_wiki.network.dto.DocumentResponseDto
+import com.example.crew_wiki.network.dto.DocumentSaveRequestDto
+import com.example.crew_wiki.network.dto.DocumentSearchResponseDto
+import com.example.crew_wiki.network.dto.HistoryDetailResponseDto
+import com.example.crew_wiki.network.dto.HistoryResponseDto
+import com.example.crew_wiki.network.dto.OrganizationDocumentSearchResponseDto
+import com.example.crew_wiki.network.dto.PagedResponseDto
+import io.ktor.client.HttpClient
+import io.ktor.client.call.body
+import io.ktor.client.request.delete
+import io.ktor.client.request.get
+import io.ktor.client.request.header
+import io.ktor.client.request.parameter
+import io.ktor.client.request.post
+import io.ktor.client.request.put
+import io.ktor.client.request.setBody
+import io.ktor.http.ContentType
+import io.ktor.http.HttpHeaders
+
+class DocumentApiService(private val client: HttpClient) {
+
+ // GET /document/uuid/{uuidText}
+ suspend fun getDocumentByUUID(uuid: String): DocumentResponseDto =
+ client.get("$BASE_URL/document/uuid/$uuid")
+ .body>().data
+
+ // GET /document/title/{title}
+ suspend fun getDocumentByTitle(title: String): DocumentResponseDto =
+ client.get("$BASE_URL/document/title/$title")
+ .body>().data
+
+ // GET /document/random
+ suspend fun getRandomDocument(): DocumentResponseDto =
+ client.get("$BASE_URL/document/random")
+ .body>().data
+
+ // GET /document (페이지네이션 + 정렬)
+ suspend fun getDocuments(
+ pageNumber: Int = 0,
+ pageSize: Int = 10,
+ sort: String = "viewCount",
+ sortDirection: String = "DESC",
+ ): PagedResponseDto =
+ client.get("$BASE_URL/document") {
+ parameter("pageNumber", pageNumber)
+ parameter("pageSize", pageSize)
+ parameter("sort", sort)
+ parameter("sortDirection", sortDirection)
+ }.body>>().data
+
+ // GET /document/uuid/{uuidText}/log
+ suspend fun getDocumentLogs(
+ uuid: String,
+ pageNumber: Int = 0,
+ pageSize: Int = 10,
+ ): PagedResponseDto =
+ client.get("$BASE_URL/document/uuid/$uuid/log") {
+ parameter("pageNumber", pageNumber)
+ parameter("pageSize", pageSize)
+ parameter("sort", "id")
+ parameter("sortDirection", "DESC")
+ }.body>>().data
+
+ // GET /document/log/{logId}
+ suspend fun getDocumentLog(logId: Long): HistoryDetailResponseDto =
+ client.get("$BASE_URL/document/log/$logId")
+ .body>().data
+
+ // GET /document/search?keyWord=
+ suspend fun searchDocuments(keyword: String): List =
+ client.get("$BASE_URL/document/search") {
+ parameter("keyWord", keyword)
+ }.body>>().data
+
+ // GET /document/{uuidText}/organization-documents
+ suspend fun getOrganizationDocumentsByDocumentUUID(
+ uuid: String,
+ ): List =
+ client.get("$BASE_URL/document/$uuid/organization-documents")
+ .body>>().data
+
+ suspend fun postDocument(request: DocumentSaveRequestDto): DocumentResponseDto =
+ client.post("$BASE_URL/document") {
+ header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
+ setBody(request)
+ }.body>().data
+
+ suspend fun putDocument(request: DocumentSaveRequestDto): DocumentResponseDto =
+ client.put("$BASE_URL/document") {
+ header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
+ setBody(request)
+ }.body>().data
+
+ suspend fun deleteOrganizationFromDocument(
+ documentUuid: String,
+ organizationDocumentUuid: String,
+ ) {
+ client.delete("$BASE_URL/document/$documentUuid/organization-documents/$organizationDocumentUuid")
+ }
+}
diff --git a/shared/src/commonMain/kotlin/com/example/crew_wiki/network/GroupApiService.kt b/shared/src/commonMain/kotlin/com/example/crew_wiki/network/GroupApiService.kt
new file mode 100644
index 0000000..6d92649
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/example/crew_wiki/network/GroupApiService.kt
@@ -0,0 +1,39 @@
+package com.example.crew_wiki.network
+
+import com.example.crew_wiki.network.dto.ApiResponse
+import com.example.crew_wiki.network.dto.OrganizationDocumentAndEventResponseDto
+import com.example.crew_wiki.network.dto.OrganizationDocumentCreateRequestDto
+import com.example.crew_wiki.network.dto.OrganizationDocumentLinkRequestDto
+import com.example.crew_wiki.network.dto.OrganizationDocumentResponseDto
+import io.ktor.client.HttpClient
+import io.ktor.client.call.body
+import io.ktor.client.request.get
+import io.ktor.client.request.header
+import io.ktor.client.request.post
+import io.ktor.client.request.setBody
+import io.ktor.http.ContentType
+import io.ktor.http.HttpHeaders
+
+class GroupApiService(private val client: HttpClient) {
+
+ // GET /organization/uuid/{uuidText}
+ suspend fun getGroupDocumentByUUID(uuid: String): OrganizationDocumentAndEventResponseDto =
+ client.get("$BASE_URL/organization/uuid/$uuid")
+ .body>().data
+
+ suspend fun postOrganizationDocument(
+ request: OrganizationDocumentCreateRequestDto,
+ ): OrganizationDocumentResponseDto =
+ client.post("$BASE_URL/organization") {
+ header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
+ setBody(request)
+ }.body>().data
+
+ suspend fun linkOrganizationDocument(
+ request: OrganizationDocumentLinkRequestDto,
+ ): OrganizationDocumentResponseDto =
+ client.post("$BASE_URL/organization/link") {
+ header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
+ setBody(request)
+ }.body>().data
+}
diff --git a/shared/src/commonMain/kotlin/com/example/crew_wiki/network/dto/ApiDto.kt b/shared/src/commonMain/kotlin/com/example/crew_wiki/network/dto/ApiDto.kt
new file mode 100644
index 0000000..d6543ec
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/example/crew_wiki/network/dto/ApiDto.kt
@@ -0,0 +1,161 @@
+package com.example.crew_wiki.network.dto
+
+import kotlinx.serialization.Serializable
+
+// ── 공통 래퍼 ─────────────────────────────────────────────────────────────────
+
+@Serializable
+data class ApiResponse(
+ val data: T,
+ val code: String,
+)
+
+@Serializable
+data class PagedResponseDto(
+ val page: Int,
+ val totalPage: Int,
+ val data: List,
+)
+
+// ── 문서 (DocumentResponse) ───────────────────────────────────────────────────
+// GET /document/uuid/{uuidText}
+// GET /document/title/{title}
+// GET /document/random
+
+@Serializable
+data class OrganizationDocumentResponseDto(
+ val organizationDocumentId: Long,
+ val organizationDocumentUuid: String,
+ val title: String,
+ val contents: String,
+ val writer: String,
+ val generateTime: String,
+)
+
+@Serializable
+data class DocumentResponseDto(
+ val documentId: Long,
+ val documentUUID: String,
+ val title: String,
+ val contents: String,
+ val writer: String,
+ val generateTime: String,
+ val viewCount: Int = 0,
+ val latestVersion: Long = 0,
+ val organizationDocumentResponses: List = emptyList(),
+)
+
+// ── 문서 목록 (DocumentListResponse) ─────────────────────────────────────────
+// GET /document (페이지네이션)
+
+@Serializable
+data class DocumentListResponseDto(
+ val id: Long,
+ val title: String,
+ val contents: String,
+ val writer: String,
+ val documentBytes: Long,
+ val generateTime: String,
+ val uuid: String,
+ val viewCount: Int = 0,
+ val documentType: String, // "CREW" | "ORGANIZATION"
+)
+
+// ── 검색 (DocumentSearchResponse) ────────────────────────────────────────────
+// GET /document/search?keyWord=
+
+@Serializable
+data class DocumentSearchResponseDto(
+ val title: String,
+ val uuid: String,
+ val documentType: String,
+)
+
+// ── 조직 문서 검색 응답 ────────────────────────────────────────────────────────
+// GET /document/{uuidText}/organization-documents
+
+@Serializable
+data class OrganizationDocumentSearchResponseDto(
+ val uuid: String,
+ val title: String,
+)
+
+// ── 히스토리 목록 (HistoryResponse) ──────────────────────────────────────────
+// GET /document/uuid/{uuidText}/log
+
+@Serializable
+data class HistoryResponseDto(
+ val id: Long,
+ val title: String,
+ val version: Long,
+ val writer: String,
+ val documentBytes: Long,
+ val generateTime: String,
+)
+
+// ── 히스토리 상세 (HistoryDetailResponse) ─────────────────────────────────────
+// GET /document/log/{logId}
+
+@Serializable
+data class HistoryDetailResponseDto(
+ val logId: Long,
+ val title: String,
+ val contents: String,
+ val writer: String,
+ val generateTime: String,
+)
+
+// ── 조직(그룹) 문서 + 이벤트 (OrganizationDocumentAndEventResponse) ─────────
+// GET /organization/uuid/{uuidText}
+
+@Serializable
+data class OrganizationEventResponseDto(
+ val organizationEventUuid: String,
+ val title: String,
+ val contents: String,
+ val writer: String,
+ val occurredAt: String, // format: date (yyyy-MM-dd)
+)
+
+@Serializable
+data class LinkedCrewDocumentResponseDto(
+ val documentUuid: String,
+ val title: String,
+)
+
+@Serializable
+data class DocumentSaveRequestDto(
+ val title: String,
+ val contents: String,
+ val writer: String,
+ val documentBytes: Long,
+ val uuid: String,
+)
+
+@Serializable
+data class OrganizationDocumentCreateRequestDto(
+ val title: String,
+ val contents: String,
+ val writer: String,
+ val documentBytes: Long,
+ val crewDocumentUuid: String,
+ val organizationDocumentUuid: String,
+)
+
+@Serializable
+data class OrganizationDocumentLinkRequestDto(
+ val crewDocumentUuid: String,
+ val organizationDocumentUuid: String,
+)
+
+@Serializable
+data class OrganizationDocumentAndEventResponseDto(
+ val organizationDocumentId: Long,
+ val organizationDocumentUuid: String,
+ val title: String,
+ val contents: String,
+ val writer: String,
+ val generateTime: String,
+ val organizationEventResponses: List = emptyList(),
+ val linkedCrewDocuments: List = emptyList(),
+)
diff --git a/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/common/CrewWikiActionButton.kt b/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/common/CrewWikiActionButton.kt
new file mode 100644
index 0000000..1aa1804
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/common/CrewWikiActionButton.kt
@@ -0,0 +1,70 @@
+package com.example.crew_wiki.ui.common
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import com.example.crew_wiki.CrewWikiDesignTokens
+
+enum class CrewWikiActionButtonStyle {
+ Primary,
+ Secondary,
+ Tertiary,
+}
+
+@Composable
+fun CrewWikiActionButton(
+ text: String,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ style: CrewWikiActionButtonStyle = CrewWikiActionButtonStyle.Primary,
+) {
+ val colors = CrewWikiDesignTokens.colors
+ val radius = CrewWikiDesignTokens.radius
+ val componentSize = CrewWikiDesignTokens.components
+
+ val containerColor = when (style) {
+ CrewWikiActionButtonStyle.Primary -> colors.primary.base
+ CrewWikiActionButtonStyle.Secondary -> colors.white
+ CrewWikiActionButtonStyle.Tertiary -> colors.white
+ }
+ val contentColor = when (style) {
+ CrewWikiActionButtonStyle.Primary -> colors.white
+ CrewWikiActionButtonStyle.Secondary -> colors.primary.base
+ CrewWikiActionButtonStyle.Tertiary -> colors.grayscale.lightText
+ }
+ val border = when (style) {
+ CrewWikiActionButtonStyle.Primary -> null
+ CrewWikiActionButtonStyle.Secondary -> BorderStroke(1.dp, colors.primary.base)
+ CrewWikiActionButtonStyle.Tertiary -> BorderStroke(1.dp, colors.grayscale.border)
+ }
+
+ Card(
+ onClick = onClick,
+ modifier = modifier.defaultMinSize(minHeight = componentSize.buttonXsHeight),
+ shape = androidx.compose.foundation.shape.RoundedCornerShape(radius.pillXs),
+ colors = CardDefaults.cardColors(containerColor = containerColor),
+ border = border,
+ ) {
+ Box(
+ modifier = Modifier.padding(horizontal = 14.dp, vertical = 9.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.labelLarge,
+ fontWeight = FontWeight.Medium,
+ color = contentColor,
+ )
+ }
+ }
+}
diff --git a/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/common/CrewWikiBottomNavIcons.kt b/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/common/CrewWikiBottomNavIcons.kt
new file mode 100644
index 0000000..f1b7f2f
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/common/CrewWikiBottomNavIcons.kt
@@ -0,0 +1,107 @@
+package com.example.crew_wiki.ui.common
+
+import androidx.compose.foundation.Canvas
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Path
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.unit.dp
+
+/** 하단 네비게이션 바용 단순 벡터 아이콘 (외부 아이콘 라이브러리 의존 없이 Canvas로 직접 그림) */
+
+@Composable
+fun HomeNavIcon(tint: Color, modifier: Modifier = Modifier) {
+ Canvas(modifier = modifier) {
+ val w = size.width
+ val h = size.height
+ val roof = Path().apply {
+ moveTo(w * 0.5f, h * 0.08f)
+ lineTo(w * 0.92f, h * 0.45f)
+ lineTo(w * 0.78f, h * 0.45f)
+ lineTo(w * 0.78f, h * 0.88f)
+ lineTo(w * 0.22f, h * 0.88f)
+ lineTo(w * 0.22f, h * 0.45f)
+ lineTo(w * 0.08f, h * 0.45f)
+ close()
+ }
+ drawPath(roof, color = tint, style = Stroke(width = w * 0.09f))
+ drawRect(
+ color = tint,
+ topLeft = Offset(w * 0.42f, h * 0.6f),
+ size = Size(w * 0.16f, h * 0.28f),
+ style = Stroke(width = w * 0.07f),
+ )
+ }
+}
+
+@Composable
+fun HistoryNavIcon(tint: Color, modifier: Modifier = Modifier) {
+ Canvas(modifier = modifier) {
+ val w = size.width
+ val h = size.height
+ val radius = w * 0.38f
+ val center = Offset(w * 0.5f, h * 0.5f)
+ drawCircle(color = tint, radius = radius, center = center, style = Stroke(width = w * 0.09f))
+ // 시계 분침/시침
+ drawLine(
+ color = tint,
+ start = center,
+ end = Offset(center.x, center.y - radius * 0.55f),
+ strokeWidth = w * 0.08f,
+ )
+ drawLine(
+ color = tint,
+ start = center,
+ end = Offset(center.x + radius * 0.4f, center.y),
+ strokeWidth = w * 0.08f,
+ )
+ }
+}
+
+@Composable
+fun EyeNavIcon(tint: Color, modifier: Modifier = Modifier) {
+ Canvas(modifier = modifier) {
+ val w = size.width
+ val h = size.height
+ val eyePath = Path().apply {
+ moveTo(w * 0.06f, h * 0.5f)
+ quadraticBezierTo(w * 0.5f, h * 0.12f, w * 0.94f, h * 0.5f)
+ quadraticBezierTo(w * 0.5f, h * 0.88f, w * 0.06f, h * 0.5f)
+ close()
+ }
+ drawPath(eyePath, color = tint, style = Stroke(width = w * 0.08f))
+ drawCircle(color = tint, radius = w * 0.13f, center = Offset(w * 0.5f, h * 0.5f))
+ }
+}
+
+@Composable
+fun SettingsNavIcon(tint: Color, modifier: Modifier = Modifier) {
+ Canvas(modifier = modifier) {
+ val w = size.width
+ val h = size.height
+ val center = Offset(w * 0.5f, h * 0.5f)
+ val outerRadius = w * 0.42f
+ val innerRadius = w * 0.16f
+ val teeth = 8
+ val gearPath = Path()
+ for (i in 0 until teeth) {
+ val angle = (2 * kotlin.math.PI * i / teeth)
+ val nextAngle = (2 * kotlin.math.PI * (i + 0.5) / teeth)
+ val outerPoint = Offset(
+ center.x + outerRadius * kotlin.math.cos(angle).toFloat(),
+ center.y + outerRadius * kotlin.math.sin(angle).toFloat(),
+ )
+ val innerPoint = Offset(
+ center.x + innerRadius * 2.2f * kotlin.math.cos(nextAngle).toFloat(),
+ center.y + innerRadius * 2.2f * kotlin.math.sin(nextAngle).toFloat(),
+ )
+ if (i == 0) gearPath.moveTo(outerPoint.x, outerPoint.y) else gearPath.lineTo(outerPoint.x, outerPoint.y)
+ gearPath.lineTo(innerPoint.x, innerPoint.y)
+ }
+ gearPath.close()
+ drawPath(gearPath, color = tint)
+ }
+}
diff --git a/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/common/CrewWikiIcons.kt b/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/common/CrewWikiIcons.kt
new file mode 100644
index 0000000..83616c0
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/common/CrewWikiIcons.kt
@@ -0,0 +1,144 @@
+package com.example.crew_wiki.ui.common
+
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.PathFillType
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.graphics.vector.path
+import androidx.compose.ui.unit.dp
+
+/** crew-wiki-next RandomButton의 SVG 경로를 Compose ImageVector로 변환 */
+val ShuffleIcon: ImageVector by lazy {
+ ImageVector.Builder(
+ name = "Shuffle",
+ defaultWidth = 24.dp,
+ defaultHeight = 24.dp,
+ viewportWidth = 36f,
+ viewportHeight = 36f,
+ ).apply {
+ path(
+ fill = SolidColor(Color.White),
+ fillAlpha = 1f,
+ pathFillType = PathFillType.NonZero,
+ ) {
+ // crew-wiki-next RandomButton mobile SVG path (36x36)
+ moveTo(23.7665f, 9.0503f)
+ curveTo(23.4829f, 8.8938f, 23.1547f, 9.1199f, 23.1273f, 9.4908f)
+ curveTo(23.1071f, 9.7609f, 23.0868f, 10.0985f, 23.0726f, 10.4919f)
+ lineTo(22.6472f, 10.4919f)
+ curveTo(21.7065f, 10.4917f, 20.7843f, 10.7688f, 19.984f, 11.292f)
+ curveTo(19.1838f, 11.8153f, 18.5371f, 12.5641f, 18.1164f, 13.4544f)
+ lineTo(17.1034f, 15.5981f)
+ lineTo(16.0905f, 13.4544f)
+ curveTo(15.6699f, 12.5642f, 15.0233f, 11.8156f, 14.2233f, 11.2923f)
+ curveTo(13.4232f, 10.7691f, 12.5012f, 10.4919f, 11.5607f, 10.4919f)
+ lineTo(10.0129f, 10.4919f)
+ curveTo(9.7443f, 10.4919f, 9.4866f, 10.6048f, 9.2967f, 10.8058f)
+ curveTo(9.1067f, 11.0068f, 9.0f, 11.2794f, 9.0f, 11.5637f)
+ curveTo(9.0f, 11.848f, 9.1067f, 12.1206f, 9.2967f, 12.3216f)
+ curveTo(9.4866f, 12.5226f, 9.7443f, 12.6355f, 10.0129f, 12.6355f)
+ lineTo(11.5607f, 12.6355f)
+ curveTo(12.1248f, 12.6358f, 12.6777f, 12.8021f, 13.1575f, 13.116f)
+ curveTo(13.6373f, 13.4298f, 14.025f, 13.8788f, 14.2774f, 14.4126f)
+ lineTo(15.971f, 17.9947f)
+ lineTo(14.2794f, 21.5768f)
+ curveTo(14.0268f, 22.1111f, 13.6386f, 22.5604f, 13.1582f, 22.8743f)
+ curveTo(12.6778f, 23.1882f, 12.1243f, 23.3542f, 11.5597f, 23.3539f)
+ lineTo(10.0129f, 23.3539f)
+ curveTo(9.7443f, 23.3539f, 9.4866f, 23.4668f, 9.2967f, 23.6678f)
+ curveTo(9.1067f, 23.8688f, 9.0f, 24.1414f, 9.0f, 24.4257f)
+ curveTo(9.0f, 24.71f, 9.1067f, 24.9826f, 9.2967f, 25.1836f)
+ curveTo(9.4866f, 25.3846f, 9.7443f, 25.4975f, 10.0129f, 25.4975f)
+ lineTo(11.5607f, 25.4975f)
+ curveTo(12.5012f, 25.4975f, 13.4232f, 25.2203f, 14.2233f, 24.6971f)
+ curveTo(15.0233f, 24.1738f, 15.6699f, 23.4252f, 16.0905f, 22.535f)
+ lineTo(17.1034f, 20.3913f)
+ lineTo(18.1164f, 22.535f)
+ curveTo(18.537f, 23.4252f, 19.1836f, 24.1738f, 19.9836f, 24.6971f)
+ curveTo(20.7837f, 25.2203f, 21.7056f, 25.4975f, 22.6462f, 25.4975f)
+ lineTo(23.0726f, 25.4975f)
+ curveTo(23.0888f, 25.922f, 23.1111f, 26.2821f, 23.1334f, 26.5608f)
+ curveTo(23.1598f, 26.9081f, 23.4596f, 27.0999f, 23.7381f, 26.9467f)
+ curveTo(24.1109f, 26.7409f, 24.6559f, 26.4193f, 25.3082f, 25.9713f)
+ curveTo(25.8228f, 25.6187f, 26.3217f, 25.241f, 26.8033f, 24.8394f)
+ curveTo(27.0545f, 24.6294f, 27.0656f, 24.2242f, 26.8266f, 24.0227f)
+ curveTo(26.3384f, 23.6136f, 25.832f, 23.2294f, 25.3092f, 22.8715f)
+ curveTo(24.8094f, 22.5265f, 24.2946f, 22.2064f, 23.7665f, 21.9123f)
+ curveTo(23.4829f, 21.7558f, 23.1547f, 21.9819f, 23.1273f, 22.3528f)
+ curveTo(23.1071f, 22.6229f, 23.0868f, 22.9605f, 23.0726f, 23.3539f)
+ lineTo(22.6472f, 23.3539f)
+ curveTo(22.0828f, 23.354f, 21.5295f, 23.1879f, 21.0493f, 22.874f)
+ curveTo(20.5691f, 22.5601f, 20.181f, 22.1109f, 19.9285f, 21.5768f)
+ lineTo(18.2359f, 17.9947f)
+ lineTo(19.9275f, 14.4126f)
+ curveTo(20.1801f, 13.8783f, 20.5683f, 13.429f, 21.0487f, 13.1151f)
+ curveTo(21.5291f, 12.8012f, 22.0826f, 12.6352f, 22.6472f, 12.6355f)
+ lineTo(23.0737f, 12.6355f)
+ curveTo(23.0899f, 13.06f, 23.1121f, 13.4201f, 23.1344f, 13.6988f)
+ curveTo(23.1608f, 14.0461f, 23.4606f, 14.2379f, 23.7392f, 14.0847f)
+ curveTo(24.1119f, 13.8789f, 24.6569f, 13.5573f, 25.3092f, 13.1093f)
+ curveTo(25.8238f, 12.7567f, 26.3227f, 12.379f, 26.8043f, 11.9774f)
+ curveTo(27.0555f, 11.7674f, 27.0666f, 11.3622f, 26.8276f, 11.1607f)
+ curveTo(26.3394f, 10.7517f, 25.833f, 10.3675f, 25.3102f, 10.0095f)
+ curveTo(24.8104f, 9.6645f, 24.2956f, 9.3444f, 23.7675f, 9.0503f)
+ close()
+ }
+ }.build()
+}
+
+/** 검색 아이콘 (돋보기) */
+val SearchIcon: ImageVector by lazy {
+ ImageVector.Builder(
+ name = "Search",
+ defaultWidth = 24.dp,
+ defaultHeight = 24.dp,
+ viewportWidth = 24f,
+ viewportHeight = 24f,
+ ).apply {
+ path(fill = SolidColor(Color.White)) {
+ moveTo(15.5f, 14f)
+ lineTo(14.71f, 14f)
+ lineTo(14.43f, 13.73f)
+ curveTo(15.41f, 12.59f, 16f, 11.11f, 16f, 9.5f)
+ curveTo(16f, 5.91f, 13.09f, 3f, 9.5f, 3f)
+ curveTo(5.91f, 3f, 3f, 5.91f, 3f, 9.5f)
+ curveTo(3f, 13.09f, 5.91f, 16f, 9.5f, 16f)
+ curveTo(11.11f, 16f, 12.59f, 15.41f, 13.73f, 14.43f)
+ lineTo(14f, 14.71f)
+ lineTo(14f, 15.5f)
+ lineTo(19f, 20.49f)
+ lineTo(20.49f, 19f)
+ close()
+ moveTo(9.5f, 14f)
+ curveTo(7.01f, 14f, 5f, 11.99f, 5f, 9.5f)
+ curveTo(5f, 7.01f, 7.01f, 5f, 9.5f, 5f)
+ curveTo(11.99f, 5f, 14f, 7.01f, 14f, 9.5f)
+ curveTo(14f, 11.99f, 11.99f, 14f, 9.5f, 14f)
+ close()
+ }
+ }.build()
+}
+
+/** 뒤로가기 화살표 */
+val ArrowBackIcon: ImageVector by lazy {
+ ImageVector.Builder(
+ name = "ArrowBack",
+ defaultWidth = 24.dp,
+ defaultHeight = 24.dp,
+ viewportWidth = 24f,
+ viewportHeight = 24f,
+ ).apply {
+ path(fill = SolidColor(Color.White)) {
+ moveTo(20f, 11f)
+ lineTo(7.83f, 11f)
+ lineTo(13.42f, 5.41f)
+ lineTo(12f, 4f)
+ lineTo(4f, 12f)
+ lineTo(12f, 20f)
+ lineTo(13.41f, 18.59f)
+ lineTo(7.83f, 13f)
+ lineTo(20f, 13f)
+ close()
+ }
+ }.build()
+}
diff --git a/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/common/CrewWikiSurfaceSection.kt b/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/common/CrewWikiSurfaceSection.kt
new file mode 100644
index 0000000..5802007
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/common/CrewWikiSurfaceSection.kt
@@ -0,0 +1,34 @@
+package com.example.crew_wiki.ui.common
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.example.crew_wiki.CrewWikiDesignTokens
+
+@Composable
+fun CrewWikiSurfaceSection(
+ modifier: Modifier = Modifier,
+ contentPadding: PaddingValues = PaddingValues(24.dp),
+ content: @Composable ColumnScope.() -> Unit,
+) {
+ val colors = CrewWikiDesignTokens.colors
+
+ Card(
+ modifier = modifier,
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
+ shape = MaterialTheme.shapes.large,
+ border = BorderStroke(1.dp, colors.primary.c100),
+ ) {
+ androidx.compose.foundation.layout.Column(
+ modifier = Modifier.padding(contentPadding),
+ content = content,
+ )
+ }
+}
diff --git a/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/common/CrewWikiTagChip.kt b/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/common/CrewWikiTagChip.kt
new file mode 100644
index 0000000..3f9cf54
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/common/CrewWikiTagChip.kt
@@ -0,0 +1,45 @@
+package com.example.crew_wiki.ui.common
+
+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.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.ui.unit.dp
+import com.example.crew_wiki.CrewWikiDesignTokens
+
+@Composable
+fun CrewWikiTagChip(
+ text: String,
+ onClick: (() -> Unit)? = null,
+ modifier: Modifier = Modifier,
+) {
+ val colors = CrewWikiDesignTokens.colors
+ val radius = CrewWikiDesignTokens.radius
+
+ Box(
+ modifier = modifier
+ .background(
+ color = colors.primary.c50,
+ shape = RoundedCornerShape(radius.pillXs),
+ )
+ .then(
+ if (onClick != null) {
+ Modifier.clickable(onClick = onClick)
+ } else {
+ Modifier
+ },
+ )
+ .padding(horizontal = 12.dp, vertical = 8.dp),
+ ) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.labelLarge,
+ color = colors.primary.c800,
+ )
+ }
+}
diff --git a/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/common/CrewWikiTopBar.kt b/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/common/CrewWikiTopBar.kt
new file mode 100644
index 0000000..e733372
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/common/CrewWikiTopBar.kt
@@ -0,0 +1,134 @@
+package com.example.crew_wiki.ui.common
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.WindowInsets
+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.layout.statusBars
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+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.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.example.crew_wiki.CrewWikiDesignTokens
+import crewwiki.shared.generated.resources.Res
+import crewwiki.shared.generated.resources.crew_wiki_icon
+import org.jetbrains.compose.resources.painterResource
+
+/**
+ * crew-wiki-next WikiHeader를 Android로 포팅한 상단 앱바.
+ *
+ * - 배경: primary.base (teal #25B4B9)
+ * - 좌측: [뒤로가기] (서브 화면) | 로고 박스 + "크루위키" 텍스트
+ * - 우측: 셔플 아이콘 + 검색 아이콘
+ */
+@Composable
+fun CrewWikiTopBar(
+ showBack: Boolean,
+ onBack: () -> Unit,
+ onHomeClick: () -> Unit,
+ onShuffle: () -> Unit,
+ shuffleLoading: Boolean,
+ onSearch: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val colors = CrewWikiDesignTokens.colors
+
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .background(colors.primary.base)
+ .windowInsetsPadding(WindowInsets.statusBars)
+ .height(56.dp)
+ .padding(horizontal = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ // ── 좌측 ──
+ if (showBack) {
+ IconButton(onClick = onBack) {
+ Icon(
+ imageVector = ArrowBackIcon,
+ contentDescription = "뒤로가기",
+ tint = Color.White,
+ modifier = Modifier.size(24.dp),
+ )
+ }
+ } else {
+ Spacer(modifier = Modifier.width(8.dp))
+ }
+
+ Row(
+ modifier = Modifier.clickable(onClick = onHomeClick),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Image(
+ painter = painterResource(Res.drawable.crew_wiki_icon),
+ contentDescription = "크루위키 로고",
+ modifier = Modifier
+ .size(30.dp)
+ .clip(RoundedCornerShape(6.dp)),
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = "크루위키",
+ style = MaterialTheme.typography.titleLarge.copy(
+ fontFamily = MaterialTheme.typography.displaySmall.fontFamily,
+ ),
+ color = Color.White,
+ fontSize = 18.sp,
+ fontWeight = FontWeight.Bold,
+ )
+ }
+
+ Spacer(modifier = Modifier.weight(1f))
+
+ // ── 우측 ──
+ // 셔플(랜덤) 아이콘
+ IconButton(
+ onClick = onShuffle,
+ enabled = !shuffleLoading,
+ ) {
+ if (shuffleLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(20.dp),
+ color = Color.White,
+ strokeWidth = 2.dp,
+ )
+ } else {
+ Icon(
+ imageVector = ShuffleIcon,
+ contentDescription = "랜덤 문서",
+ tint = Color.White,
+ modifier = Modifier.size(24.dp),
+ )
+ }
+ }
+
+ // 검색 아이콘
+ IconButton(onClick = onSearch) {
+ Icon(
+ imageVector = SearchIcon,
+ contentDescription = "검색",
+ tint = Color.White,
+ modifier = Modifier.size(24.dp),
+ )
+ }
+ }
+}
diff --git a/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/common/ScreenState.kt b/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/common/ScreenState.kt
new file mode 100644
index 0000000..5021500
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/common/ScreenState.kt
@@ -0,0 +1,60 @@
+package com.example.crew_wiki.ui.common
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.example.crew_wiki.CrewWikiDesignTokens
+
+@Composable
+fun LoadingScreen(modifier: Modifier = Modifier) {
+ Box(
+ modifier = modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center,
+ ) {
+ CircularProgressIndicator(color = CrewWikiDesignTokens.colors.primary.base)
+ }
+}
+
+@Composable
+fun ErrorScreen(
+ message: String,
+ onRetry: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier = modifier
+ .fillMaxSize()
+ .padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ Text(
+ text = "오류가 발생했습니다",
+ style = MaterialTheme.typography.titleLarge,
+ color = CrewWikiDesignTokens.colors.grayscale.c800,
+ )
+ Spacer(Modifier.height(8.dp))
+ Text(
+ text = message,
+ style = MaterialTheme.typography.bodyMedium,
+ color = CrewWikiDesignTokens.colors.grayscale.c500,
+ )
+ Spacer(Modifier.height(24.dp))
+ CrewWikiActionButton(
+ text = "다시 시도",
+ onClick = onRetry,
+ style = CrewWikiActionButtonStyle.Primary,
+ )
+ }
+}
diff --git a/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/document/DocumentDetailScreen.kt b/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/document/DocumentDetailScreen.kt
new file mode 100644
index 0000000..3b596a4
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/document/DocumentDetailScreen.kt
@@ -0,0 +1,210 @@
+package com.example.crew_wiki.ui.document
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.relocation.BringIntoViewRequester
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextDecoration
+import androidx.compose.ui.text.withStyle
+import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.launch
+import com.example.crew_wiki.CrewWikiDesignTokens
+import com.example.crew_wiki.model.CrewWikiDocumentDetail
+import com.example.crew_wiki.model.OrganizationReference
+import com.example.crew_wiki.ui.common.CrewWikiActionButton
+import com.example.crew_wiki.ui.common.CrewWikiActionButtonStyle
+import com.example.crew_wiki.ui.common.CrewWikiSurfaceSection
+import com.example.crew_wiki.ui.common.CrewWikiTagChip
+
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+fun DocumentDetailScreen(
+ documentDetail: CrewWikiDocumentDetail,
+ onEditClick: () -> Unit = {},
+ onLogsClick: () -> Unit = {},
+ onWriteClick: () -> Unit = {},
+ onOrganizationClick: (uuid: String) -> Unit = {},
+ modifier: Modifier = Modifier,
+) {
+ val document = documentDetail.document
+ val colors = CrewWikiDesignTokens.colors
+ val spacing = CrewWikiDesignTokens.spacing
+ val coroutineScope = rememberCoroutineScope()
+
+ val content = document.contents.preprocessMarkdown()
+ val headings = remember(content) { extractMarkdownHeadings(content) }
+ val headingRequesters = remember(headings) { headings.map { BringIntoViewRequester() } }
+
+ LazyColumn(
+ modifier = modifier.fillMaxSize(),
+ verticalArrangement = Arrangement.spacedBy(spacing.md),
+ contentPadding = PaddingValues(vertical = 16.dp),
+ ) {
+ item {
+ CrewWikiSurfaceSection(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ ) {
+ Column(
+ modifier = Modifier.padding(horizontal = 6.dp, vertical = 6.dp),
+ verticalArrangement = Arrangement.spacedBy(spacing.xl),
+ ) {
+ // 제목
+ Text(
+ text = document.title,
+ style = MaterialTheme.typography.headlineMedium,
+ fontWeight = FontWeight.Bold,
+ color = colors.grayscale.c800,
+ modifier = Modifier.fillMaxWidth(),
+ )
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.End,
+ ) {
+ Row(horizontalArrangement = Arrangement.spacedBy(spacing.sm)) {
+ CrewWikiActionButton(text = "편집하기", onClick = onEditClick, style = CrewWikiActionButtonStyle.Tertiary)
+ CrewWikiActionButton(text = "편집기록", onClick = onLogsClick, style = CrewWikiActionButtonStyle.Tertiary)
+ CrewWikiActionButton(text = "작성하기", onClick = onWriteClick, style = CrewWikiActionButtonStyle.Primary)
+ }
+ }
+
+ HorizontalDivider(
+ modifier = Modifier.fillMaxWidth(),
+ color = colors.grayscale.border,
+ )
+
+ // 목차
+ TableOfContents(
+ headings = headings,
+ onEntryClick = { headingIndex ->
+ coroutineScope.launch {
+ headingRequesters.getOrNull(headingIndex)?.bringIntoView()
+ }
+ },
+ modifier = Modifier.fillMaxWidth(),
+ )
+
+ // 소속 섹션
+ if (document.organizations.isNotEmpty()) {
+ OrganizationSection(
+ organizations = document.organizations,
+ onOrganizationClick = onOrganizationClick,
+ )
+ }
+
+ // 본문 마크다운 (자체 렌더러)
+ if (content.isNotBlank()) {
+ MarkdownContent(
+ content = content,
+ modifier = Modifier.fillMaxWidth(),
+ headingRequesters = headingRequesters,
+ )
+ }
+
+ // 연관 크루 문서
+ if (documentDetail.relatedCrewDocuments.isNotEmpty()) {
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(spacing.sm),
+ verticalArrangement = Arrangement.spacedBy(spacing.sm),
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ documentDetail.relatedCrewDocuments.forEach { crew ->
+ CrewWikiTagChip(text = crew.title)
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // 푸터
+ item {
+ CrewWikiSurfaceSection(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ ) {
+ Column(
+ modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
+ verticalArrangement = Arrangement.spacedBy(spacing.xs),
+ ) {
+ Text(
+ text = "이 문서는 ${formatDocDate(document.generateTime)}에 마지막으로 편집되었습니다.",
+ style = MaterialTheme.typography.bodySmall,
+ color = colors.grayscale.c800,
+ )
+ Text(
+ text = buildAnnotatedString {
+ append("질문, 제안, 오류 제보는 ")
+ withStyle(
+ SpanStyle(
+ color = colors.primary.base,
+ fontWeight = FontWeight.Medium,
+ textDecoration = TextDecoration.Underline,
+ ),
+ ) { append("문의하기") }
+ append("를 이용해 주세요.")
+ },
+ style = MaterialTheme.typography.bodySmall,
+ color = colors.grayscale.c800,
+ )
+ }
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+private fun OrganizationSection(
+ organizations: List,
+ onOrganizationClick: (uuid: String) -> Unit,
+) {
+ val colors = CrewWikiDesignTokens.colors
+ val spacing = CrewWikiDesignTokens.spacing
+
+ Column(verticalArrangement = Arrangement.spacedBy(spacing.md)) {
+ Text(
+ text = "소속",
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold,
+ color = colors.grayscale.c800,
+ )
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(spacing.sm),
+ verticalArrangement = Arrangement.spacedBy(spacing.sm),
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ organizations.forEach { org ->
+ CrewWikiTagChip(
+ text = org.title,
+ onClick = { onOrganizationClick(org.organizationDocumentUuid) },
+ )
+ }
+ }
+ }
+}
+
+internal fun formatDocDate(raw: String): String = try {
+ val parts = raw.substringBefore("T").split("-")
+ "${parts[0]}년 ${parts[1].trimStart('0')}월 ${parts[2].trimStart('0')}일"
+} catch (_: Exception) { raw }
diff --git a/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/document/DocumentDetailViewModel.kt b/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/document/DocumentDetailViewModel.kt
new file mode 100644
index 0000000..f82c01d
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/document/DocumentDetailViewModel.kt
@@ -0,0 +1,52 @@
+package com.example.crew_wiki.ui.document
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.example.crew_wiki.data.document.NetworkDocumentRepository
+import com.example.crew_wiki.data.history.RecentlyViewedStore
+import com.example.crew_wiki.model.CrewWikiDocumentDetail
+import com.example.crew_wiki.model.RecentDocument
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+
+sealed interface DocumentDetailUiState {
+ data object Loading : DocumentDetailUiState
+ data class Success(val detail: CrewWikiDocumentDetail) : DocumentDetailUiState
+ data object NotFound : DocumentDetailUiState
+ data class Error(val message: String) : DocumentDetailUiState
+}
+
+class DocumentDetailViewModel(
+ private val repository: NetworkDocumentRepository,
+ private val documentUUID: String,
+) : ViewModel() {
+
+ private val _uiState = MutableStateFlow(DocumentDetailUiState.Loading)
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ init {
+ loadDocument()
+ }
+
+ fun loadDocument() {
+ viewModelScope.launch {
+ _uiState.value = DocumentDetailUiState.Loading
+ try {
+ val detail = repository.fetchDocumentByUUID(documentUUID)
+ _uiState.value = DocumentDetailUiState.Success(detail)
+ RecentlyViewedStore.record(
+ RecentDocument(
+ uuid = detail.document.documentUUID,
+ title = detail.document.title,
+ generateTime = detail.document.generateTime,
+ documentType = "CREW",
+ ),
+ )
+ } catch (e: Exception) {
+ _uiState.value = DocumentDetailUiState.Error(e.message ?: "문서를 불러올 수 없습니다.")
+ }
+ }
+ }
+}
diff --git a/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/document/DocumentEditorScreen.kt b/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/document/DocumentEditorScreen.kt
new file mode 100644
index 0000000..24bed46
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/document/DocumentEditorScreen.kt
@@ -0,0 +1,407 @@
+package com.example.crew_wiki.ui.document
+
+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.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+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.lazy.LazyColumn
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.OutlinedTextFieldDefaults
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import com.example.crew_wiki.CrewWikiDesignTokens
+import com.example.crew_wiki.ui.common.CrewWikiActionButton
+import com.example.crew_wiki.ui.common.CrewWikiActionButtonStyle
+import com.example.crew_wiki.ui.common.CrewWikiSurfaceSection
+import com.example.crew_wiki.ui.common.CrewWikiTagChip
+import com.example.crew_wiki.ui.common.LoadingScreen
+
+@Composable
+fun DocumentEditorScreen(
+ viewModel: DocumentEditorViewModel,
+ mode: DocumentEditorMode,
+ onBackClick: () -> Unit,
+ onSaved: (documentId: String) -> Unit,
+) {
+ val uiState by viewModel.uiState.collectAsState()
+
+ LaunchedEffect(mode) {
+ viewModel.load(mode)
+ }
+
+ LaunchedEffect(viewModel) {
+ viewModel.events.collect { event ->
+ when (event) {
+ is DocumentEditorEvent.Saved -> onSaved(event.documentId)
+ }
+ }
+ }
+
+ if (uiState.isLoading) {
+ LoadingScreen()
+ return
+ }
+
+ DocumentEditorContent(
+ uiState = uiState,
+ onTitleChange = viewModel::onTitleChange,
+ onWriterChange = viewModel::onWriterChange,
+ onContentsChange = viewModel::onContentsChange,
+ onOrganizationQueryChange = viewModel::onOrganizationQueryChange,
+ onAddOrganization = viewModel::addOrganizationFromQuery,
+ onSelectOrganization = viewModel::addOrganizationFromSuggestion,
+ onRemoveOrganization = viewModel::removeOrganization,
+ onCancel = onBackClick,
+ onSave = viewModel::save,
+ onDismissConflict = viewModel::dismissConflictDialog,
+ onConflictContentChange = viewModel::onConflictContentChange,
+ onResolveConflict = viewModel::resolveConflict,
+ )
+}
+
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+private fun DocumentEditorContent(
+ uiState: DocumentEditorUiState,
+ onTitleChange: (String) -> Unit,
+ onWriterChange: (String) -> Unit,
+ onContentsChange: (String) -> Unit,
+ onOrganizationQueryChange: (String) -> Unit,
+ onAddOrganization: () -> Unit,
+ onSelectOrganization: (com.example.crew_wiki.network.dto.DocumentSearchResponseDto) -> Unit,
+ onRemoveOrganization: (String) -> Unit,
+ onCancel: () -> Unit,
+ onSave: () -> Unit,
+ onDismissConflict: () -> Unit,
+ onConflictContentChange: (String) -> Unit,
+ onResolveConflict: () -> Unit,
+) {
+ val colors = CrewWikiDesignTokens.colors
+ val spacing = CrewWikiDesignTokens.spacing
+
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ verticalArrangement = Arrangement.spacedBy(spacing.md),
+ ) {
+ item {
+ CrewWikiSurfaceSection(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 16.dp),
+ ) {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(spacing.lg),
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = uiState.screenTitle,
+ style = MaterialTheme.typography.headlineMedium,
+ color = colors.grayscale.c800,
+ fontWeight = FontWeight.Bold,
+ )
+ Row(horizontalArrangement = Arrangement.spacedBy(spacing.sm)) {
+ CrewWikiActionButton(
+ text = "취소하기",
+ onClick = onCancel,
+ style = CrewWikiActionButtonStyle.Tertiary,
+ )
+ CrewWikiActionButton(
+ text = "작성완료",
+ onClick = onSave,
+ style = CrewWikiActionButtonStyle.Primary,
+ )
+ }
+ }
+
+ if (uiState.saveError != null) {
+ Text(
+ text = uiState.saveError,
+ style = MaterialTheme.typography.bodySmall,
+ color = colors.error.base,
+ )
+ }
+
+ Column(verticalArrangement = Arrangement.spacedBy(spacing.sm)) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(spacing.md),
+ ) {
+ EditorField(
+ value = uiState.title,
+ onValueChange = onTitleChange,
+ label = "제목",
+ placeholder = "문서의 제목을 입력해 주세요",
+ enabled = uiState.isTitleEditable,
+ modifier = Modifier.weight(1f),
+ minLines = 1,
+ maxLines = 1,
+ )
+ EditorField(
+ value = uiState.writer,
+ onValueChange = onWriterChange,
+ label = "편집자",
+ placeholder = "편집자",
+ modifier = Modifier.weight(0.42f),
+ minLines = 1,
+ maxLines = 1,
+ )
+ }
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(spacing.md),
+ ) {
+ Text(
+ text = uiState.titleError.orEmpty(),
+ style = MaterialTheme.typography.bodySmall,
+ color = colors.error.base,
+ modifier = Modifier.weight(1f),
+ )
+ Text(
+ text = uiState.writerError.orEmpty(),
+ style = MaterialTheme.typography.bodySmall,
+ color = colors.error.base,
+ modifier = Modifier.weight(0.42f),
+ )
+ }
+ }
+
+ Column(verticalArrangement = Arrangement.spacedBy(spacing.sm)) {
+ Text(
+ text = "소속",
+ style = MaterialTheme.typography.titleMedium,
+ color = colors.grayscale.c800,
+ fontWeight = FontWeight.Medium,
+ )
+ if (uiState.organizations.isNotEmpty()) {
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(spacing.sm),
+ verticalArrangement = Arrangement.spacedBy(spacing.sm),
+ ) {
+ uiState.organizations.forEach { organization ->
+ CrewWikiTagChip(
+ text = "${organization.title} ×",
+ onClick = { onRemoveOrganization(organization.uuid) },
+ )
+ }
+ }
+ }
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(spacing.sm),
+ verticalAlignment = Alignment.Top,
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ EditorField(
+ value = uiState.organizationQuery,
+ onValueChange = onOrganizationQueryChange,
+ label = "소속 추가",
+ placeholder = "소속을 추가해 주세요",
+ minLines = 1,
+ maxLines = 1,
+ )
+ if (uiState.isSearchingOrganizations) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 8.dp),
+ horizontalArrangement = Arrangement.Center,
+ ) {
+ CircularProgressIndicator(
+ color = colors.primary.base,
+ modifier = Modifier.height(18.dp),
+ strokeWidth = 2.dp,
+ )
+ }
+ } else if (uiState.organizationSuggestions.isNotEmpty()) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 8.dp)
+ .background(
+ color = colors.white,
+ shape = RoundedCornerShape(12.dp),
+ ),
+ ) {
+ uiState.organizationSuggestions.forEachIndexed { index, suggestion ->
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { onSelectOrganization(suggestion) }
+ .padding(horizontal = 14.dp, vertical = 12.dp),
+ ) {
+ Text(
+ text = suggestion.title,
+ style = MaterialTheme.typography.bodyMedium,
+ color = colors.grayscale.c800,
+ )
+ Text(
+ text = "조직 문서",
+ style = MaterialTheme.typography.bodySmall,
+ color = colors.grayscale.lightText,
+ )
+ }
+ if (index != uiState.organizationSuggestions.lastIndex) {
+ HorizontalDivider(color = colors.grayscale.border)
+ }
+ }
+ }
+ }
+ }
+ CrewWikiActionButton(
+ text = "추가하기",
+ onClick = onAddOrganization,
+ style = CrewWikiActionButtonStyle.Primary,
+ modifier = Modifier.padding(top = 28.dp),
+ )
+ }
+ }
+
+ Column(verticalArrangement = Arrangement.spacedBy(spacing.sm)) {
+ Text(
+ text = "본문",
+ style = MaterialTheme.typography.titleMedium,
+ color = colors.grayscale.c800,
+ fontWeight = FontWeight.Medium,
+ )
+ EditorField(
+ value = uiState.contents,
+ onValueChange = onContentsChange,
+ label = "본문",
+ placeholder = "마크다운으로 문서를 작성해 주세요",
+ modifier = Modifier.fillMaxWidth(),
+ minLines = 18,
+ maxLines = 24,
+ )
+ if (uiState.contentsError != null) {
+ Text(
+ text = uiState.contentsError,
+ style = MaterialTheme.typography.bodySmall,
+ color = colors.error.base,
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+
+ if (uiState.isConflictDialogVisible) {
+ AlertDialog(
+ onDismissRequest = onDismissConflict,
+ title = {
+ Text(
+ text = "문서 충돌 해결",
+ style = MaterialTheme.typography.titleLarge,
+ color = colors.grayscale.c800,
+ )
+ },
+ text = {
+ Column(verticalArrangement = Arrangement.spacedBy(spacing.sm)) {
+ Text(
+ text = "다른 사용자가 문서를 수정했습니다. 아래 내용을 병합한 뒤 충돌 마커를 모두 제거하고 저장해 주세요.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = colors.grayscale.c700,
+ )
+ OutlinedTextField(
+ value = uiState.conflictContent,
+ onValueChange = onConflictContentChange,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(320.dp),
+ textStyle = MaterialTheme.typography.bodyMedium,
+ colors = editorFieldColors(),
+ )
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = onResolveConflict) {
+ Text("충돌 해결 완료")
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismissConflict) {
+ Text("취소")
+ }
+ },
+ )
+ }
+}
+
+@Composable
+private fun EditorField(
+ value: String,
+ onValueChange: (String) -> Unit,
+ label: String,
+ placeholder: String,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ minLines: Int = 1,
+ maxLines: Int = Int.MAX_VALUE,
+) {
+ val colors = CrewWikiDesignTokens.colors
+
+ Column(modifier = modifier) {
+ Text(
+ text = label,
+ style = MaterialTheme.typography.labelLarge,
+ color = colors.grayscale.c700,
+ modifier = Modifier.padding(bottom = 6.dp),
+ )
+ OutlinedTextField(
+ value = value,
+ onValueChange = onValueChange,
+ modifier = Modifier.fillMaxWidth(),
+ enabled = enabled,
+ textStyle = MaterialTheme.typography.bodyMedium,
+ placeholder = {
+ Text(
+ text = placeholder,
+ color = colors.grayscale.lightText,
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ },
+ minLines = minLines,
+ maxLines = maxLines,
+ colors = editorFieldColors(),
+ )
+ }
+}
+
+@Composable
+private fun editorFieldColors() = OutlinedTextFieldDefaults.colors(
+ focusedBorderColor = CrewWikiDesignTokens.colors.primary.base,
+ unfocusedBorderColor = CrewWikiDesignTokens.colors.grayscale.border,
+ disabledBorderColor = CrewWikiDesignTokens.colors.grayscale.border,
+ focusedTextColor = CrewWikiDesignTokens.colors.grayscale.text,
+ unfocusedTextColor = CrewWikiDesignTokens.colors.grayscale.text,
+ disabledTextColor = CrewWikiDesignTokens.colors.grayscale.c500,
+ focusedLabelColor = CrewWikiDesignTokens.colors.primary.base,
+ cursorColor = CrewWikiDesignTokens.colors.primary.base,
+)
diff --git a/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/document/DocumentEditorViewModel.kt b/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/document/DocumentEditorViewModel.kt
new file mode 100644
index 0000000..9d1f7ca
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/document/DocumentEditorViewModel.kt
@@ -0,0 +1,581 @@
+package com.example.crew_wiki.ui.document
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.example.crew_wiki.randomUuid
+import com.example.crew_wiki.network.DocumentApiService
+import com.example.crew_wiki.network.GroupApiService
+import com.example.crew_wiki.network.dto.DocumentSaveRequestDto
+import com.example.crew_wiki.network.dto.DocumentSearchResponseDto
+import com.example.crew_wiki.network.dto.OrganizationDocumentCreateRequestDto
+import com.example.crew_wiki.network.dto.OrganizationDocumentLinkRequestDto
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.debounce
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
+
+sealed interface DocumentEditorMode {
+ data object Post : DocumentEditorMode
+ data class Edit(val documentId: String) : DocumentEditorMode
+}
+
+data class EditorOrganization(
+ val uuid: String,
+ val title: String,
+ val isNew: Boolean,
+)
+
+data class DocumentEditorUiState(
+ val screenTitle: String = "작성하기",
+ val isLoading: Boolean = false,
+ val isSaving: Boolean = false,
+ val isTitleEditable: Boolean = true,
+ val title: String = "",
+ val writer: String = "",
+ val contents: String = "",
+ val titleError: String? = null,
+ val writerError: String? = null,
+ val contentsError: String? = null,
+ val saveError: String? = null,
+ val organizationQuery: String = "",
+ val organizationSuggestions: List = emptyList(),
+ val organizations: List = emptyList(),
+ val isSearchingOrganizations: Boolean = false,
+ val isConflictDialogVisible: Boolean = false,
+ val conflictContent: String = "",
+)
+
+sealed interface DocumentEditorEvent {
+ data class Saved(val documentId: String) : DocumentEditorEvent
+}
+
+@OptIn(FlowPreview::class)
+class DocumentEditorViewModel(
+ private val documentApiService: DocumentApiService,
+ private val groupApiService: GroupApiService,
+) : ViewModel() {
+
+ private val _uiState = MutableStateFlow(DocumentEditorUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ private val _events = MutableSharedFlow()
+ val events: SharedFlow = _events.asSharedFlow()
+
+ private val organizationQuery = MutableStateFlow("")
+
+ private var mode: DocumentEditorMode? = null
+ private var currentDocumentId: String? = null
+ private var originalVersion: Long = 0
+ private var conflictVersion: Long = -1
+ private var originalOrganizations: List = emptyList()
+
+ init {
+ organizationQuery
+ .debounce(250L)
+ .distinctUntilChanged()
+ .onEach { query ->
+ if (query.isBlank()) {
+ _uiState.value = _uiState.value.copy(
+ organizationSuggestions = emptyList(),
+ isSearchingOrganizations = false,
+ )
+ } else {
+ searchOrganizations(query)
+ }
+ }
+ .launchIn(viewModelScope)
+ }
+
+ fun load(targetMode: DocumentEditorMode) {
+ when (targetMode) {
+ DocumentEditorMode.Post -> {
+ if (mode == DocumentEditorMode.Post && _uiState.value.contents.isNotBlank()) {
+ return
+ }
+ }
+
+ is DocumentEditorMode.Edit -> {
+ if (mode == targetMode && currentDocumentId == targetMode.documentId) {
+ return
+ }
+ }
+ }
+
+ mode = targetMode
+ when (targetMode) {
+ DocumentEditorMode.Post -> initializePost()
+ is DocumentEditorMode.Edit -> loadDocument(targetMode.documentId)
+ }
+ }
+
+ fun onTitleChange(value: String) {
+ if (!_uiState.value.isTitleEditable) return
+
+ val limited = value.take(TITLE_MAX_LENGTH)
+ val error = if (value.length > TITLE_MAX_LENGTH) TITLE_LENGTH_ERROR else null
+ _uiState.value = _uiState.value.copy(
+ title = limited,
+ titleError = error,
+ saveError = null,
+ )
+ }
+
+ fun onWriterChange(value: String) {
+ val onlyKorean = value.filter { it.isKoreanCharacter() }
+ val limited = onlyKorean.take(WRITER_MAX_LENGTH)
+ val error = when {
+ onlyKorean.length != value.length -> WRITER_KOREAN_ONLY_ERROR
+ onlyKorean.length > WRITER_MAX_LENGTH -> WRITER_MAX_LENGTH_ERROR
+ value.length > WRITER_MAX_LENGTH -> WRITER_MAX_LENGTH_ERROR
+ else -> null
+ }
+ _uiState.value = _uiState.value.copy(
+ writer = limited,
+ writerError = error,
+ saveError = null,
+ )
+ }
+
+ fun onContentsChange(value: String) {
+ _uiState.value = _uiState.value.copy(
+ contents = value,
+ contentsError = if (value.isBlank()) CONTENT_REQUIRED_ERROR else null,
+ saveError = null,
+ )
+ }
+
+ fun onOrganizationQueryChange(value: String) {
+ _uiState.value = _uiState.value.copy(
+ organizationQuery = value,
+ saveError = null,
+ )
+ organizationQuery.value = value
+ }
+
+ fun addOrganizationFromSuggestion(suggestion: DocumentSearchResponseDto) {
+ if (suggestion.documentType != ORGANIZATION_TYPE) return
+ if (_uiState.value.organizations.any { it.uuid == suggestion.uuid }) return
+
+ _uiState.value = _uiState.value.copy(
+ organizations = _uiState.value.organizations + EditorOrganization(
+ uuid = suggestion.uuid,
+ title = suggestion.title,
+ isNew = false,
+ ),
+ organizationQuery = "",
+ organizationSuggestions = emptyList(),
+ isSearchingOrganizations = false,
+ saveError = null,
+ )
+ organizationQuery.value = ""
+ }
+
+ fun addOrganizationFromQuery() {
+ val query = _uiState.value.organizationQuery.trim()
+ if (query.isBlank()) return
+
+ viewModelScope.launch {
+ try {
+ val exactMatch = documentApiService.searchDocuments(query)
+ .firstOrNull { it.documentType == ORGANIZATION_TYPE && it.title == query }
+
+ if (exactMatch != null) {
+ addOrganizationFromSuggestion(exactMatch)
+ } else if (_uiState.value.organizations.none { it.title == query }) {
+ _uiState.value = _uiState.value.copy(
+ organizations = _uiState.value.organizations + EditorOrganization(
+ uuid = randomUuid(),
+ title = query,
+ isNew = true,
+ ),
+ organizationQuery = "",
+ organizationSuggestions = emptyList(),
+ saveError = null,
+ )
+ organizationQuery.value = ""
+ } else {
+ _uiState.value = _uiState.value.copy(
+ organizationQuery = "",
+ organizationSuggestions = emptyList(),
+ )
+ organizationQuery.value = ""
+ }
+ } catch (_: Exception) {
+ if (_uiState.value.organizations.none { it.title == query }) {
+ _uiState.value = _uiState.value.copy(
+ organizations = _uiState.value.organizations + EditorOrganization(
+ uuid = randomUuid(),
+ title = query,
+ isNew = true,
+ ),
+ organizationQuery = "",
+ organizationSuggestions = emptyList(),
+ saveError = null,
+ )
+ organizationQuery.value = ""
+ }
+ }
+ }
+ }
+
+ fun removeOrganization(uuid: String) {
+ _uiState.value = _uiState.value.copy(
+ organizations = _uiState.value.organizations.filterNot { it.uuid == uuid },
+ saveError = null,
+ )
+ }
+
+ fun dismissConflictDialog() {
+ _uiState.value = _uiState.value.copy(isConflictDialogVisible = false)
+ }
+
+ fun onConflictContentChange(value: String) {
+ _uiState.value = _uiState.value.copy(conflictContent = value)
+ }
+
+ fun save() {
+ viewModelScope.launch {
+ if (!validateFields()) return@launch
+
+ _uiState.value = _uiState.value.copy(
+ isSaving = true,
+ saveError = null,
+ )
+
+ try {
+ when (val currentMode = mode) {
+ null -> return@launch
+ DocumentEditorMode.Post -> {
+ if (hasDuplicateTitle(_uiState.value.title.trim())) {
+ _uiState.value = _uiState.value.copy(
+ isSaving = false,
+ titleError = DUPLICATE_TITLE_ERROR,
+ )
+ return@launch
+ }
+
+ val documentId = saveDocument(_uiState.value.contents)
+ _events.emit(DocumentEditorEvent.Saved(documentId))
+ _uiState.value = _uiState.value.copy(isSaving = false)
+ }
+
+ is DocumentEditorMode.Edit -> {
+ val latest = documentApiService.getDocumentByUUID(currentMode.documentId)
+ if (latest.latestVersion != originalVersion) {
+ conflictVersion = latest.latestVersion
+ _uiState.value = _uiState.value.copy(
+ isSaving = false,
+ isConflictDialogVisible = true,
+ conflictContent = createConflictText(
+ remoteContent = latest.contents,
+ localContent = _uiState.value.contents,
+ ),
+ saveError = CONFLICT_ERROR,
+ )
+ return@launch
+ }
+
+ val documentId = saveDocument(_uiState.value.contents)
+ _events.emit(DocumentEditorEvent.Saved(documentId))
+ _uiState.value = _uiState.value.copy(isSaving = false)
+ }
+ }
+ } catch (e: Exception) {
+ _uiState.value = _uiState.value.copy(
+ isSaving = false,
+ saveError = e.message ?: SAVE_ERROR,
+ )
+ }
+ }
+ }
+
+ fun resolveConflict() {
+ viewModelScope.launch {
+ val current = _uiState.value
+ if (current.conflictContent.containsConflictMarker()) {
+ _uiState.value = current.copy(saveError = CONFLICT_RESOLVE_ERROR)
+ return@launch
+ }
+
+ val documentId = currentDocumentId ?: return@launch
+ _uiState.value = current.copy(isSaving = true, saveError = null)
+
+ try {
+ val latest = documentApiService.getDocumentByUUID(documentId)
+ if (latest.latestVersion != conflictVersion) {
+ conflictVersion = latest.latestVersion
+ _uiState.value = _uiState.value.copy(
+ isSaving = false,
+ isConflictDialogVisible = true,
+ conflictContent = createConflictText(
+ remoteContent = latest.contents,
+ localContent = current.conflictContent,
+ ),
+ saveError = CONFLICT_CHANGED_ERROR,
+ )
+ return@launch
+ }
+
+ val savedId = saveDocument(current.conflictContent)
+ _uiState.value = _uiState.value.copy(
+ isSaving = false,
+ isConflictDialogVisible = false,
+ conflictContent = "",
+ )
+ _events.emit(DocumentEditorEvent.Saved(savedId))
+ } catch (e: Exception) {
+ _uiState.value = _uiState.value.copy(
+ isSaving = false,
+ saveError = e.message ?: SAVE_ERROR,
+ )
+ }
+ }
+ }
+
+ private fun initializePost() {
+ currentDocumentId = null
+ originalVersion = 0
+ conflictVersion = -1
+ originalOrganizations = emptyList()
+ _uiState.value = DocumentEditorUiState(
+ screenTitle = "작성하기",
+ isTitleEditable = true,
+ contents = DEFAULT_EDITOR_VALUE,
+ )
+ organizationQuery.value = ""
+ }
+
+ private fun loadDocument(documentId: String) {
+ viewModelScope.launch {
+ _uiState.value = _uiState.value.copy(isLoading = true, saveError = null)
+ try {
+ val document = documentApiService.getDocumentByUUID(documentId)
+ currentDocumentId = documentId
+ originalVersion = document.latestVersion
+ originalOrganizations = document.organizationDocumentResponses.map {
+ EditorOrganization(
+ uuid = it.organizationDocumentUuid,
+ title = it.title,
+ isNew = false,
+ )
+ }
+ conflictVersion = -1
+ _uiState.value = DocumentEditorUiState(
+ screenTitle = "편집하기",
+ isLoading = false,
+ isTitleEditable = false,
+ title = document.title,
+ writer = document.writer,
+ contents = document.contents,
+ organizations = originalOrganizations,
+ )
+ organizationQuery.value = ""
+ } catch (e: Exception) {
+ _uiState.value = _uiState.value.copy(
+ isLoading = false,
+ saveError = e.message ?: LOAD_ERROR,
+ )
+ }
+ }
+ }
+
+ private suspend fun searchOrganizations(query: String) {
+ _uiState.value = _uiState.value.copy(isSearchingOrganizations = true)
+ _uiState.value = try {
+ val suggestions = documentApiService.searchDocuments(query)
+ .filter { it.documentType == ORGANIZATION_TYPE }
+ .filterNot { suggestion ->
+ _uiState.value.organizations.any { it.uuid == suggestion.uuid }
+ }
+ _uiState.value.copy(
+ organizationSuggestions = suggestions,
+ isSearchingOrganizations = false,
+ )
+ } catch (_: Exception) {
+ _uiState.value.copy(
+ organizationSuggestions = emptyList(),
+ isSearchingOrganizations = false,
+ )
+ }
+ }
+
+ private suspend fun hasDuplicateTitle(title: String): Boolean =
+ documentApiService.searchDocuments(title)
+ .any { it.title.trim() == title }
+
+ private fun validateFields(): Boolean {
+ val state = _uiState.value
+ val titleError = when {
+ state.title.isBlank() -> TITLE_REQUIRED_ERROR
+ state.title.length > TITLE_MAX_LENGTH -> TITLE_LENGTH_ERROR
+ else -> state.titleError
+ }
+ val writerError = when {
+ state.writer.isBlank() -> WRITER_REQUIRED_ERROR
+ else -> state.writerError
+ }
+ val contentsError = if (state.contents.isBlank()) CONTENT_REQUIRED_ERROR else null
+
+ _uiState.value = state.copy(
+ titleError = titleError,
+ writerError = writerError,
+ contentsError = contentsError,
+ )
+
+ return titleError == null && writerError == null && contentsError == null
+ }
+
+ private suspend fun saveDocument(contents: String): String {
+ val state = _uiState.value
+ val request = DocumentSaveRequestDto(
+ title = state.title.trim(),
+ contents = contents,
+ writer = state.writer.trim(),
+ documentBytes = contents.encodeToByteArray().size.toLong(),
+ uuid = currentDocumentId ?: randomUuid(),
+ )
+
+ val currentMode = mode ?: error("Editor mode is not initialized")
+ val savedDocument = when (currentMode) {
+ DocumentEditorMode.Post -> documentApiService.postDocument(request)
+ is DocumentEditorMode.Edit -> documentApiService.putDocument(request)
+ }
+
+ when (currentMode) {
+ DocumentEditorMode.Post -> syncOrganizationsOnCreate(savedDocument.documentUUID, state.writer.trim())
+ is DocumentEditorMode.Edit -> syncOrganizationsOnEdit(savedDocument.documentUUID, state.writer.trim())
+ }
+
+ return savedDocument.documentUUID
+ }
+
+ private suspend fun syncOrganizationsOnCreate(documentId: String, writer: String) {
+ val organizations = _uiState.value.organizations
+ organizations.filter { it.isNew }.forEach { organization ->
+ groupApiService.postOrganizationDocument(
+ OrganizationDocumentCreateRequestDto(
+ title = organization.title,
+ contents = DEFAULT_ORGANIZATION_EDITOR_VALUE,
+ writer = writer,
+ documentBytes = 0,
+ crewDocumentUuid = documentId,
+ organizationDocumentUuid = organization.uuid,
+ ),
+ )
+ }
+
+ organizations.filterNot { it.isNew }.forEach { organization ->
+ groupApiService.linkOrganizationDocument(
+ OrganizationDocumentLinkRequestDto(
+ crewDocumentUuid = documentId,
+ organizationDocumentUuid = organization.uuid,
+ ),
+ )
+ }
+ }
+
+ private suspend fun syncOrganizationsOnEdit(documentId: String, writer: String) {
+ val currentOrganizations = _uiState.value.organizations
+ val newOrganizations = currentOrganizations.filter { it.isNew }
+ val newlyLinkedOrganizations = currentOrganizations.filter { organization ->
+ !organization.isNew && originalOrganizations.none { it.uuid == organization.uuid }
+ }
+ val deletedOrganizations = originalOrganizations.filter { original ->
+ currentOrganizations.none { it.uuid == original.uuid }
+ }
+
+ newOrganizations.forEach { organization ->
+ groupApiService.postOrganizationDocument(
+ OrganizationDocumentCreateRequestDto(
+ title = organization.title,
+ contents = DEFAULT_ORGANIZATION_EDITOR_VALUE,
+ writer = writer,
+ documentBytes = 0,
+ crewDocumentUuid = documentId,
+ organizationDocumentUuid = organization.uuid,
+ ),
+ )
+ }
+
+ newlyLinkedOrganizations.forEach { organization ->
+ groupApiService.linkOrganizationDocument(
+ OrganizationDocumentLinkRequestDto(
+ crewDocumentUuid = documentId,
+ organizationDocumentUuid = organization.uuid,
+ ),
+ )
+ }
+
+ deletedOrganizations.forEach { organization ->
+ documentApiService.deleteOrganizationFromDocument(documentId, organization.uuid)
+ }
+ }
+
+ private fun createConflictText(remoteContent: String, localContent: String): String = buildString {
+ appendLine("≪≪≪≪≪≪≪ 내 버전")
+ append(localContent)
+ if (!localContent.endsWith("\n")) appendLine()
+ appendLine("-=-=-=-=-=-=-=-=")
+ append(remoteContent)
+ if (!remoteContent.endsWith("\n")) appendLine()
+ append("≫≫≫≫≫≫≫ 최신 버전")
+ }
+
+ private fun Char.isKoreanCharacter(): Boolean =
+ this in 'ㄱ'..'ㅎ' || this in '가'..'힣'
+
+ private fun String.containsConflictMarker(): Boolean =
+ contains("≪≪≪≪≪≪≪") || contains("≫≫≫≫≫≫≫") || contains("-=-=-=-=-=-=-=-=")
+
+ companion object {
+ private const val ORGANIZATION_TYPE = "ORGANIZATION"
+ private const val TITLE_MAX_LENGTH = 12
+ private const val WRITER_MAX_LENGTH = 4
+ private const val TITLE_LENGTH_ERROR = "제목은 12자가 최대에요"
+ private const val DUPLICATE_TITLE_ERROR = "이미 있는 문서입니다"
+ private const val WRITER_KOREAN_ONLY_ERROR = "닉네임은 한글만 입력할 수 있어요"
+ private const val WRITER_MAX_LENGTH_ERROR = "닉네임은 4자가 최대에요"
+ private const val TITLE_REQUIRED_ERROR = "문서의 제목을 입력해 주세요"
+ private const val WRITER_REQUIRED_ERROR = "편집자를 입력해 주세요"
+ private const val CONTENT_REQUIRED_ERROR = "본문을 입력해 주세요"
+ private const val SAVE_ERROR = "저장에 실패했습니다. 잠시 후 다시 시도해 주세요."
+ private const val LOAD_ERROR = "문서를 불러오지 못했습니다."
+ private const val CONFLICT_ERROR = "다른 사용자가 먼저 편집했습니다. 병합 후 다시 저장해 주세요."
+ private const val CONFLICT_RESOLVE_ERROR = "충돌 마커를 제거한 뒤 저장해 주세요."
+ private const val CONFLICT_CHANGED_ERROR = "병합 중 새로운 변경사항이 생겼습니다. 다시 확인해 주세요."
+ private const val DEFAULT_EDITOR_VALUE = """# 프로필
+
+
+ | 닉네임 |
+ |
+
+
+ | 생일 |
+ |
+
+
+ | 소속/기수 |
+ |
+
+
+ | MBTI |
+ |
+
+
"""
+ private const val DEFAULT_ORGANIZATION_EDITOR_VALUE = """# 그룹 정보
+## 결성일
+
+## 그룹 설명
+
+# 구성원
+"""
+ }
+}
diff --git a/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/document/DocumentLogDetailScreen.kt b/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/document/DocumentLogDetailScreen.kt
new file mode 100644
index 0000000..c1dbf5a
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/document/DocumentLogDetailScreen.kt
@@ -0,0 +1,113 @@
+package com.example.crew_wiki.ui.document
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import com.example.crew_wiki.CrewWikiDesignTokens
+import com.example.crew_wiki.model.DocumentLogDetail
+import com.example.crew_wiki.ui.common.CrewWikiSurfaceSection
+import com.example.crew_wiki.ui.common.ErrorScreen
+import com.example.crew_wiki.ui.common.LoadingScreen
+
+@Composable
+fun DocumentLogDetailScreen(
+ viewModel: DocumentLogDetailViewModel,
+ modifier: Modifier = Modifier,
+) {
+ val uiState by viewModel.uiState.collectAsState()
+
+ when (val state = uiState) {
+ is DocumentLogDetailUiState.Loading -> LoadingScreen(modifier)
+ is DocumentLogDetailUiState.Error -> ErrorScreen(
+ message = state.message,
+ onRetry = viewModel::loadLog,
+ modifier = modifier,
+ )
+ is DocumentLogDetailUiState.Success -> DocumentLogDetailContent(
+ log = state.log,
+ modifier = modifier,
+ )
+ }
+}
+
+@Composable
+private fun DocumentLogDetailContent(
+ log: DocumentLogDetail,
+ modifier: Modifier = Modifier,
+) {
+ val colors = CrewWikiDesignTokens.colors
+ val spacing = CrewWikiDesignTokens.spacing
+
+ LazyColumn(
+ modifier = modifier.fillMaxSize(),
+ verticalArrangement = Arrangement.spacedBy(spacing.xl),
+ contentPadding = PaddingValues(vertical = 16.dp),
+ ) {
+ item {
+ CrewWikiSurfaceSection(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ ) {
+ Column(
+ modifier = Modifier.padding(horizontal = 24.dp, vertical = 24.dp),
+ verticalArrangement = Arrangement.spacedBy(spacing.xl),
+ ) {
+ Text(
+ text = log.title,
+ style = MaterialTheme.typography.headlineMedium,
+ fontWeight = FontWeight.Bold,
+ color = colors.grayscale.c800,
+ )
+ Text(
+ text = "편집자: ${log.writer} · ${log.generateTime.formatLogDateTime()}",
+ style = MaterialTheme.typography.bodySmall,
+ color = colors.grayscale.c500,
+ )
+
+ // 본문 마크다운 (자체 렌더러)
+ val content = log.contents.preprocessMarkdown()
+ if (content.isNotBlank()) {
+ MarkdownContent(
+ content = content,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ }
+ }
+ }
+
+ item {
+ CrewWikiSurfaceSection(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ ) {
+ Text(
+ text = "이 버전은 ${log.generateTime.formatLogDateTime()}에 저장된 스냅샷입니다.",
+ modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
+ style = MaterialTheme.typography.bodySmall,
+ color = colors.grayscale.c600,
+ )
+ }
+ }
+ }
+}
+
+private fun String.formatLogDateTime(): String = try {
+ val d = substringBefore("T")
+ val t = substringAfter("T").substring(0, 5)
+ "${d.replace("-", ".")} $t"
+} catch (_: Exception) { this }
diff --git a/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/document/DocumentLogDetailViewModel.kt b/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/document/DocumentLogDetailViewModel.kt
new file mode 100644
index 0000000..db6042d
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/document/DocumentLogDetailViewModel.kt
@@ -0,0 +1,41 @@
+package com.example.crew_wiki.ui.document
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.example.crew_wiki.data.document.NetworkDocumentRepository
+import com.example.crew_wiki.model.DocumentLogDetail
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+
+sealed interface DocumentLogDetailUiState {
+ data object Loading : DocumentLogDetailUiState
+ data class Success(val log: DocumentLogDetail) : DocumentLogDetailUiState
+ data class Error(val message: String) : DocumentLogDetailUiState
+}
+
+class DocumentLogDetailViewModel(
+ private val repository: NetworkDocumentRepository,
+ private val logId: Long,
+) : ViewModel() {
+
+ private val _uiState = MutableStateFlow(DocumentLogDetailUiState.Loading)
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ init {
+ loadLog()
+ }
+
+ fun loadLog() {
+ viewModelScope.launch {
+ _uiState.value = DocumentLogDetailUiState.Loading
+ try {
+ val log = repository.fetchDocumentLog(logId)
+ _uiState.value = DocumentLogDetailUiState.Success(log)
+ } catch (e: Exception) {
+ _uiState.value = DocumentLogDetailUiState.Error(e.message ?: "로그를 불러올 수 없습니다.")
+ }
+ }
+ }
+}
diff --git a/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/document/DocumentLogsScreen.kt b/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/document/DocumentLogsScreen.kt
new file mode 100644
index 0000000..53b92ca
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/document/DocumentLogsScreen.kt
@@ -0,0 +1,242 @@
+package com.example.crew_wiki.ui.document
+
+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.Spacer
+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.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import com.example.crew_wiki.CrewWikiDesignTokens
+import com.example.crew_wiki.model.DocumentLogSummary
+import com.example.crew_wiki.ui.common.ErrorScreen
+import com.example.crew_wiki.ui.common.LoadingScreen
+
+@Composable
+fun DocumentLogsScreen(
+ viewModel: DocumentLogsViewModel,
+ onLogClick: (logId: Long) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val uiState by viewModel.uiState.collectAsState()
+
+ when (val state = uiState) {
+ is DocumentLogsUiState.Loading -> LoadingScreen(modifier)
+ is DocumentLogsUiState.Error -> ErrorScreen(
+ message = state.message,
+ onRetry = viewModel::loadLogs,
+ modifier = modifier,
+ )
+ is DocumentLogsUiState.Success -> DocumentLogsContent(
+ state = state,
+ onLogClick = onLogClick,
+ onLoadMore = viewModel::loadNextPage,
+ modifier = modifier,
+ )
+ }
+}
+
+@Composable
+private fun DocumentLogsContent(
+ state: DocumentLogsUiState.Success,
+ onLogClick: (logId: Long) -> Unit,
+ onLoadMore: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val colors = CrewWikiDesignTokens.colors
+ val listState = rememberLazyListState()
+
+ // 마지막 아이템 도달 시 다음 페이지 로드
+ val shouldLoadMore by remember {
+ derivedStateOf {
+ val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
+ lastVisible >= listState.layoutInfo.totalItemsCount - 3
+ }
+ }
+ LaunchedEffect(shouldLoadMore) {
+ if (shouldLoadMore) onLoadMore()
+ }
+
+ Column(
+ modifier = modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.background),
+ ) {
+ // 헤더
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(colors.primary.c50)
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = "버전",
+ modifier = Modifier.weight(0.15f),
+ style = MaterialTheme.typography.labelLarge,
+ fontWeight = FontWeight.Bold,
+ color = colors.grayscale.c800,
+ textAlign = TextAlign.Center,
+ )
+ Text(
+ text = "생성일시",
+ modifier = Modifier.weight(0.4f),
+ style = MaterialTheme.typography.labelLarge,
+ fontWeight = FontWeight.Bold,
+ color = colors.grayscale.c800,
+ textAlign = TextAlign.Center,
+ )
+ Text(
+ text = "문서 크기",
+ modifier = Modifier.weight(0.25f),
+ style = MaterialTheme.typography.labelLarge,
+ fontWeight = FontWeight.Bold,
+ color = colors.grayscale.c800,
+ textAlign = TextAlign.Center,
+ )
+ Text(
+ text = "편집자",
+ modifier = Modifier.weight(0.2f),
+ style = MaterialTheme.typography.labelLarge,
+ fontWeight = FontWeight.Bold,
+ color = colors.grayscale.c800,
+ textAlign = TextAlign.Center,
+ )
+ }
+
+ // web: gap-4 flex-col
+ LazyColumn(
+ state = listState,
+ modifier = Modifier
+ .weight(1f)
+ .padding(horizontal = 16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ item { Spacer(Modifier.height(4.dp)) }
+ items(state.logs) { log ->
+ DocumentLogItem(
+ log = log,
+ onClick = { onLogClick(log.id) },
+ )
+ }
+
+ if (state.isLoadingMore) {
+ item {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ CircularProgressIndicator(color = colors.primary.base)
+ }
+ }
+ }
+
+ item { Spacer(Modifier.height(24.dp)) }
+ }
+ }
+}
+
+// web: LogContent - rounded-2xl border border-primary-100
+@Composable
+private fun DocumentLogItem(
+ log: DocumentLogSummary,
+ onClick: () -> Unit,
+) {
+ val colors = CrewWikiDesignTokens.colors
+
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(onClick = onClick),
+ shape = MaterialTheme.shapes.large,
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
+ border = BorderStroke(1.dp, colors.primary.c100),
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 8.dp, vertical = 16.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ // 버전
+ Text(
+ text = "${log.version}",
+ modifier = Modifier.weight(0.15f),
+ style = MaterialTheme.typography.bodyMedium,
+ color = colors.grayscale.c800,
+ textAlign = TextAlign.Center,
+ )
+ // 생성일시
+ Text(
+ text = log.generateTime.formatDateTime(),
+ modifier = Modifier.weight(0.45f),
+ style = MaterialTheme.typography.bodySmall,
+ color = colors.grayscale.c800,
+ textAlign = TextAlign.Center,
+ )
+ // 문서 크기
+ Text(
+ text = "${log.documentBytes}B",
+ modifier = Modifier.weight(0.2f),
+ style = MaterialTheme.typography.bodySmall,
+ color = colors.grayscale.c800,
+ textAlign = TextAlign.Center,
+ )
+ // 편집자
+ Text(
+ text = log.writer,
+ modifier = Modifier.weight(0.2f),
+ style = MaterialTheme.typography.bodySmall,
+ color = colors.grayscale.c800,
+ textAlign = TextAlign.Center,
+ )
+ }
+ }
+}
+
+// "2026-06-22T10:54:00" → "2026.06.22 10:54"
+private fun String.formatDateTime(): String {
+ return try {
+ val datePart = substringBefore("T")
+ val timePart = substringAfter("T").substring(0, 5)
+ "${datePart.replace("-", ".")} $timePart"
+ } catch (e: Exception) {
+ this
+ }
+}
+
+// 바이트 → KB 표시
+private fun Long.toReadableSize(): String {
+ if (this < 1024) return "${this}B"
+ val kb = this / 1024
+ val remainder = (this % 1024) / 103 // ≈ 0.1 단위
+ return "${kb}.${remainder}KB"
+}
diff --git a/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/document/DocumentLogsViewModel.kt b/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/document/DocumentLogsViewModel.kt
new file mode 100644
index 0000000..8a71de5
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/document/DocumentLogsViewModel.kt
@@ -0,0 +1,72 @@
+package com.example.crew_wiki.ui.document
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.example.crew_wiki.data.document.NetworkDocumentRepository
+import com.example.crew_wiki.model.DocumentLogSummary
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+
+sealed interface DocumentLogsUiState {
+ data object Loading : DocumentLogsUiState
+ data class Success(
+ val logs: List,
+ val currentPage: Int,
+ val totalPage: Int,
+ val isLoadingMore: Boolean,
+ ) : DocumentLogsUiState
+ data class Error(val message: String) : DocumentLogsUiState
+}
+
+class DocumentLogsViewModel(
+ private val repository: NetworkDocumentRepository,
+ private val documentUUID: String,
+) : ViewModel() {
+
+ private val _uiState = MutableStateFlow(DocumentLogsUiState.Loading)
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ private val loadedLogs = mutableListOf()
+ private var currentPage = 0
+
+ init {
+ loadLogs()
+ }
+
+ fun loadLogs() {
+ viewModelScope.launch {
+ _uiState.value = DocumentLogsUiState.Loading
+ loadedLogs.clear()
+ currentPage = 0
+ fetchPage(0)
+ }
+ }
+
+ fun loadNextPage() {
+ val state = _uiState.value as? DocumentLogsUiState.Success ?: return
+ if (state.isLoadingMore || currentPage + 1 >= state.totalPage) return
+
+ viewModelScope.launch {
+ _uiState.value = state.copy(isLoadingMore = true)
+ fetchPage(currentPage + 1)
+ }
+ }
+
+ private suspend fun fetchPage(page: Int) {
+ try {
+ val (logs, totalPage) = repository.fetchDocumentLogs(documentUUID, page, 10)
+ loadedLogs.addAll(logs)
+ currentPage = page
+ _uiState.value = DocumentLogsUiState.Success(
+ logs = loadedLogs.toList(),
+ currentPage = currentPage,
+ totalPage = totalPage,
+ isLoadingMore = false,
+ )
+ } catch (e: Exception) {
+ _uiState.value = DocumentLogsUiState.Error(e.message ?: "편집 기록을 불러올 수 없습니다.")
+ }
+ }
+}
diff --git a/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/document/MarkdownRenderer.kt b/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/document/MarkdownRenderer.kt
new file mode 100644
index 0000000..b172c85
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/document/MarkdownRenderer.kt
@@ -0,0 +1,1037 @@
+package com.example.crew_wiki.ui.document
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.relocation.BringIntoViewRequester
+import androidx.compose.foundation.relocation.bringIntoViewRequester
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.IntrinsicSize
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+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.layout.Placeable
+import androidx.compose.ui.layout.SubcomposeLayout
+import androidx.compose.ui.platform.LocalUriHandler
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.LinkAnnotation
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.TextLinkStyles
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextDecoration
+import androidx.compose.ui.text.withLink
+import androidx.compose.ui.unit.Constraints
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import coil3.compose.AsyncImage
+import com.example.crew_wiki.CrewWikiDesignTokens
+
+/** 목차(TOC) 항목 — h1~h6 중 본문에 등장한 순서대로, [MarkdownContent]의 headingRequesters 인덱스와 1:1 대응한다. */
+data class MarkdownHeadingOutline(val level: Int, val text: String)
+
+/** [content]에서 헤딩 목록을 추출한다. [MarkdownContent]가 그리는 헤딩과 동일한 순서/개수를 보장한다. */
+fun extractMarkdownHeadings(content: String): List =
+ parseMarkdownBlocks(content).filterIsInstance().map {
+ MarkdownHeadingOutline(level = it.level, text = it.text)
+ }
+
+/**
+ * KMP iOS 안전 마크다운 렌더러 (자체 구현)
+ * 지원: h1~h6, 단락, 표, **bold**, *italic*, `code`, 이미지, 수평선, 순서/비순서 목록,
+ */
+@Composable
+fun MarkdownContent(
+ content: String,
+ modifier: Modifier = Modifier,
+ headingRequesters: List = emptyList(),
+) {
+ val blocks = parseMarkdownBlocks(content)
+ val colors = CrewWikiDesignTokens.colors
+ val spacing = CrewWikiDesignTokens.spacing
+ val uriHandler = LocalUriHandler.current
+ var headingCounter = 0
+
+ Column(modifier = modifier.fillMaxWidth()) {
+ blocks.forEachIndexed { index, block ->
+ when (block) {
+ is MarkdownBlock.Heading -> {
+ val headingIndex = headingCounter++
+ MarkdownHeading(
+ text = block.text,
+ level = block.level,
+ isFirstBlock = index == 0,
+ modifier = headingRequesters.getOrNull(headingIndex)?.let {
+ Modifier.bringIntoViewRequester(it)
+ } ?: Modifier,
+ )
+ }
+
+ is MarkdownBlock.Paragraph -> {
+ if (index > 0) Spacer(Modifier.height(spacing.sm))
+ Text(
+ text = block.annotated,
+ style = MaterialTheme.typography.bodyLarge,
+ color = colors.grayscale.text,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+
+ is MarkdownBlock.BlockQuote -> {
+ Spacer(Modifier.height(14.dp))
+ MarkdownBlockQuote(block.annotated)
+ Spacer(Modifier.height(14.dp))
+ }
+
+ is MarkdownBlock.Table -> {
+ if (index > 0) Spacer(Modifier.height(spacing.md))
+ MarkdownTable(table = block)
+ Spacer(Modifier.height(spacing.md))
+ }
+
+ is MarkdownBlock.HtmlTable -> {
+ if (index > 0) Spacer(Modifier.height(spacing.md))
+ MarkdownHtmlTable(table = block)
+ Spacer(Modifier.height(spacing.md))
+ }
+
+ is MarkdownBlock.Image -> {
+ if (index > 0) Spacer(Modifier.height(spacing.md))
+ MarkdownImageBlock(
+ block = block,
+ uriHandlerOpen = uriHandler::openUri,
+ )
+ Spacer(Modifier.height(spacing.md))
+ }
+
+ is MarkdownBlock.Code -> {
+ if (index > 0) Spacer(Modifier.height(spacing.md))
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.shapes.small)
+ .padding(12.dp),
+ ) {
+ Text(
+ text = block.code,
+ style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ Spacer(Modifier.height(spacing.md))
+ }
+
+ is MarkdownBlock.HorizontalRule -> {
+ Spacer(Modifier.height(spacing.md))
+ HorizontalDivider(color = Color(0xFFEEEEEE))
+ Spacer(Modifier.height(spacing.md))
+ }
+
+ is MarkdownBlock.ListItem -> {
+ if (index == 0 || (blocks[index - 1] !is MarkdownBlock.ListItem && blocks[index - 1] !is MarkdownBlock.ListImage)) {
+ Spacer(Modifier.height(spacing.sm))
+ }
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(start = block.indentLevel.indentPadding()),
+ ) {
+ Text(
+ text = if (block.ordered) "${block.order}." else "•",
+ style = MaterialTheme.typography.bodyLarge,
+ color = colors.grayscale.c600,
+ modifier = Modifier.width(24.dp),
+ )
+ Text(
+ text = block.annotated,
+ style = MaterialTheme.typography.bodyLarge,
+ color = colors.grayscale.text,
+ modifier = Modifier.weight(1f),
+ )
+ }
+ }
+
+ is MarkdownBlock.ListImage -> {
+ if (index == 0 || (blocks[index - 1] !is MarkdownBlock.ListItem && blocks[index - 1] !is MarkdownBlock.ListImage)) {
+ Spacer(Modifier.height(spacing.sm))
+ }
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(start = block.indentLevel.indentPadding()),
+ verticalAlignment = Alignment.Top,
+ ) {
+ Text(
+ text = if (block.ordered) "${block.order}." else "•",
+ style = MaterialTheme.typography.bodyLarge,
+ color = colors.grayscale.c600,
+ modifier = Modifier.width(24.dp),
+ )
+ Box(modifier = Modifier.weight(1f)) {
+ MarkdownImageBlock(
+ block = block.image,
+ uriHandlerOpen = uriHandler::openUri,
+ )
+ }
+ }
+ Spacer(Modifier.height(spacing.md))
+ }
+
+ is MarkdownBlock.Blank -> {
+ Spacer(Modifier.height(spacing.xs))
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun MarkdownImageBlock(
+ block: MarkdownBlock.Image,
+ uriHandlerOpen: (String) -> Unit,
+) {
+ val colors = CrewWikiDesignTokens.colors
+ val imageCaption = block.alt.toVisibleImageCaption()
+ AsyncImage(
+ model = block.url,
+ contentDescription = imageCaption,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(200.dp)
+ .then(
+ if (block.linkUrl != null) {
+ Modifier.clickable { uriHandlerOpen(block.linkUrl) }
+ } else {
+ Modifier
+ },
+ ),
+ )
+ if (imageCaption != null) {
+ Spacer(Modifier.height(4.dp))
+ Text(
+ text = imageCaption,
+ style = MaterialTheme.typography.labelSmall,
+ color = colors.grayscale.c500,
+ )
+ }
+}
+
+@Composable
+private fun MarkdownHeading(
+ text: String,
+ level: Int,
+ isFirstBlock: Boolean,
+ modifier: Modifier = Modifier,
+) {
+ val colors = CrewWikiDesignTokens.colors
+
+ Column(modifier = modifier) {
+ when (level) {
+ 1 -> {
+ Spacer(Modifier.height(if (isFirstBlock) 14.dp else 52.dp))
+ Text(
+ text = text,
+ style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.Bold),
+ color = colors.grayscale.c800,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ Spacer(Modifier.height(7.dp))
+ DoubleHorizontalDivider(
+ primaryColor = Color(0xFF999999),
+ secondaryColor = Color(0xFF999999),
+ )
+ Spacer(Modifier.height(15.dp))
+ }
+
+ 2 -> {
+ Spacer(Modifier.height(20.dp))
+ Text(
+ text = text,
+ style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.Bold),
+ color = colors.grayscale.c800,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ Spacer(Modifier.height(7.dp))
+ HorizontalDivider(
+ modifier = Modifier.fillMaxWidth(),
+ color = Color(0xFFDBDBDB),
+ )
+ Spacer(Modifier.height(13.dp))
+ }
+
+ 3 -> {
+ Spacer(Modifier.height(18.dp))
+ Text(
+ text = text,
+ style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold),
+ color = colors.grayscale.c800,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ Spacer(Modifier.height(2.dp))
+ }
+
+ 4 -> {
+ Spacer(Modifier.height(10.dp))
+ Text(
+ text = text,
+ style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
+ color = colors.grayscale.c800,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ Spacer(Modifier.height(2.dp))
+ }
+
+ else -> {
+ Spacer(Modifier.height(9.dp))
+ Text(
+ text = text,
+ style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Bold),
+ color = colors.grayscale.c800,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ Spacer(Modifier.height(4.dp))
+ }
+ }
+ }
+}
+
+@Composable
+private fun DoubleHorizontalDivider(
+ primaryColor: Color,
+ secondaryColor: Color,
+) {
+ Column(modifier = Modifier.fillMaxWidth()) {
+ HorizontalDivider(
+ modifier = Modifier.fillMaxWidth(),
+ thickness = 1.dp,
+ color = primaryColor,
+ )
+ Spacer(Modifier.height(1.dp))
+ HorizontalDivider(
+ modifier = Modifier.fillMaxWidth(),
+ thickness = 1.dp,
+ color = secondaryColor,
+ )
+ }
+}
+
+@Composable
+private fun MarkdownBlockQuote(
+ text: AnnotatedString,
+) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(IntrinsicSize.Min),
+ ) {
+ Box(
+ modifier = Modifier
+ .width(4.dp)
+ .fillMaxHeight()
+ .background(Color(0xFFE5E5E5)),
+ )
+ Text(
+ text = text,
+ style = MaterialTheme.typography.bodyLarge,
+ color = Color(0xFF999999),
+ modifier = Modifier
+ .weight(1f)
+ .padding(start = 16.dp),
+ )
+ }
+}
+
+// ── 표 렌더러 ──────────────────────────────────────────────────────────────────
+
+private val TableBorderColor = Color(0x1A000000)
+private val TableLabelBackground = Color(0xFF555555)
+private val TableLabelTextColor = Color.White
+private val TableValueBackground = Color.White
+private val TableValueTextColor = Color(0xFF222222)
+private val TableCellMinWidth = 96.dp
+
+@Composable
+private fun MarkdownTable(table: MarkdownBlock.Table) {
+ val colCount = maxOf(table.headers.size, table.rows.maxOfOrNull { it.size } ?: 0)
+ if (colCount == 0) return
+
+ val allRows = listOf(table.headers.normalizeTableRow(colCount)) +
+ table.rows.map { it.normalizeTableRow(colCount) }
+ val rowSpans = remember(allRows) { allRows.map { row -> List(row.size) { 1 } } }
+
+ BoxWithConstraints(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(TableValueBackground)
+ .border(1.dp, TableBorderColor),
+ ) {
+ val availableWidthPx = constraints.maxWidth
+ Row(modifier = Modifier.horizontalScroll(rememberScrollState())) {
+ TableGridLayout(
+ rowSpans = rowSpans,
+ totalColumns = colCount,
+ minTotalWidthPx = availableWidthPx,
+ ) {
+ allRows.forEach { row ->
+ row.forEachIndexed { colIdx, cell ->
+ TableCell(
+ text = parseInline(cell.trim()),
+ isLabelColumn = colIdx == 0,
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun MarkdownHtmlTable(table: MarkdownBlock.HtmlTable) {
+ val totalColumns = table.rows.maxOfOrNull { row -> row.cells.sumOf { it.colspan } } ?: 0
+ if (totalColumns == 0) return
+
+ val rowSpans = remember(table.rows) { table.rows.map { row -> row.cells.map { it.colspan } } }
+
+ BoxWithConstraints(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(TableValueBackground)
+ .border(1.dp, TableBorderColor),
+ ) {
+ val availableWidthPx = constraints.maxWidth
+ Row(modifier = Modifier.horizontalScroll(rememberScrollState())) {
+ TableGridLayout(
+ rowSpans = rowSpans,
+ totalColumns = totalColumns,
+ minTotalWidthPx = availableWidthPx,
+ ) {
+ table.rows.forEach { row ->
+ row.cells.forEach { cell ->
+ HtmlTableCell(cell = cell)
+ }
+ }
+ }
+ }
+ }
+}
+
+/**
+ * 모든 행의 동일한 열이 같은 너비를, 같은 행의 셀들이 같은 높이를 갖도록
+ * 2-pass로 측정/배치하는 표 그리드. colspan을 지원한다.
+ */
+@Composable
+private fun TableGridLayout(
+ rowSpans: List>,
+ totalColumns: Int,
+ minTotalWidthPx: Int,
+ modifier: Modifier = Modifier,
+ cellMinWidth: Dp = TableCellMinWidth,
+ content: @Composable () -> Unit,
+) {
+ SubcomposeLayout(modifier) { _ ->
+ val minWidthPx = cellMinWidth.roundToPx()
+
+ // 1차: 제약 없이 측정하여 각 셀의 자연스러운 크기를 파악한다.
+ val naturalPlaceables = subcompose(0, content).map { it.measure(Constraints()) }
+
+ val colWidths = IntArray(totalColumns) { minWidthPx }
+ val rowHeights = IntArray(rowSpans.size)
+ var idx = 0
+ for ((rowIndex, row) in rowSpans.withIndex()) {
+ var col = 0
+ for (span in row) {
+ val placeable = naturalPlaceables[idx]
+ val perColWidth = (placeable.width + span - 1) / span
+ for (c in col until (col + span).coerceAtMost(totalColumns)) {
+ colWidths[c] = maxOf(colWidths[c], perColWidth)
+ }
+ rowHeights[rowIndex] = maxOf(rowHeights[rowIndex], placeable.height)
+ col += span
+ idx++
+ }
+ }
+
+ // 표가 화면보다 좁으면 남는 너비를 열에 균등 분배해 꽉 채운다.
+ val naturalTotal = colWidths.sum()
+ if (minTotalWidthPx > naturalTotal) {
+ val extra = minTotalWidthPx - naturalTotal
+ val per = extra / totalColumns
+ val remainder = extra % totalColumns
+ for (i in colWidths.indices) {
+ colWidths[i] += per + if (i < remainder) 1 else 0
+ }
+ }
+
+ // 2차: 확정된 열 너비·행 높이로 모든 셀을 동일하게 고정 측정한다.
+ val finalPlaceables = subcompose(1, content)
+ val placeables = arrayOfNulls(finalPlaceables.size)
+ idx = 0
+ for ((rowIndex, row) in rowSpans.withIndex()) {
+ var col = 0
+ for (span in row) {
+ val cellWidth = (col until (col + span).coerceAtMost(totalColumns)).sumOf { colWidths[it] }
+ placeables[idx] = finalPlaceables[idx].measure(
+ Constraints.fixed(cellWidth, rowHeights[rowIndex]),
+ )
+ col += span
+ idx++
+ }
+ }
+
+ layout(colWidths.sum(), rowHeights.sum()) {
+ var y = 0
+ idx = 0
+ for ((rowIndex, row) in rowSpans.withIndex()) {
+ var x = 0
+ var col = 0
+ for (span in row) {
+ placeables[idx]?.placeRelative(x, y)
+ val cellWidth = (col until (col + span).coerceAtMost(totalColumns)).sumOf { colWidths[it] }
+ x += cellWidth
+ col += span
+ idx++
+ }
+ y += rowHeights[rowIndex]
+ }
+ }
+ }
+}
+
+@Composable
+private fun TableCell(
+ text: AnnotatedString,
+ isLabelColumn: Boolean,
+ modifier: Modifier = Modifier,
+) {
+ Box(
+ modifier = modifier
+ .defaultMinSize(minHeight = 72.dp)
+ .background(if (isLabelColumn) TableLabelBackground else TableValueBackground)
+ .border(width = 0.5.dp, color = TableBorderColor),
+ contentAlignment = if (isLabelColumn) Alignment.Center else Alignment.CenterStart,
+ ) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.bodyLarge.copy(
+ fontWeight = if (isLabelColumn) FontWeight.SemiBold else FontWeight.Bold,
+ ),
+ color = if (isLabelColumn) TableLabelTextColor else TableValueTextColor,
+ textAlign = if (isLabelColumn) TextAlign.Center else TextAlign.Start,
+ modifier = Modifier.padding(horizontal = 22.dp, vertical = 18.dp),
+ )
+ }
+}
+
+private fun List.normalizeTableRow(columnCount: Int): List =
+ this + List((columnCount - size).coerceAtLeast(0)) { "" }
+
+@Composable
+private fun HtmlTableCell(
+ cell: MarkdownBlock.HtmlTableCell,
+ modifier: Modifier = Modifier,
+) {
+ val isImageCell = cell.imageUrl != null
+ val backgroundColor = when {
+ isImageCell -> TableValueBackground
+ cell.isHeader -> TableLabelBackground
+ else -> TableValueBackground
+ }
+ val contentAlignment = when {
+ isImageCell -> Alignment.Center
+ cell.align == TableAlign.Center || cell.isHeader -> Alignment.Center
+ cell.align == TableAlign.End -> Alignment.CenterEnd
+ else -> Alignment.CenterStart
+ }
+
+ Box(
+ modifier = modifier
+ .defaultMinSize(minHeight = if (isImageCell) 220.dp else 72.dp)
+ .background(backgroundColor)
+ .border(width = 0.5.dp, color = TableBorderColor),
+ contentAlignment = contentAlignment,
+ ) {
+ when {
+ cell.imageUrl != null -> {
+ AsyncImage(
+ model = cell.imageUrl,
+ contentDescription = cell.text.ifBlank { null },
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 12.dp, vertical = 12.dp),
+ )
+ }
+
+ else -> {
+ Text(
+ text = parseInline(cell.text),
+ style = MaterialTheme.typography.bodyLarge.copy(
+ fontWeight = if (cell.isHeader) FontWeight.SemiBold else FontWeight.Bold,
+ ),
+ color = if (cell.isHeader) TableLabelTextColor else TableValueTextColor,
+ textAlign = when {
+ cell.align == TableAlign.Center || cell.isHeader -> TextAlign.Center
+ cell.align == TableAlign.End -> TextAlign.End
+ else -> TextAlign.Start
+ },
+ modifier = Modifier.padding(horizontal = 22.dp, vertical = 18.dp),
+ )
+ }
+ }
+ }
+}
+
+// ── 블록 타입 ──────────────────────────────────────────────────────────────────
+
+private enum class TableAlign { Start, Center, End }
+
+private sealed interface MarkdownBlock {
+ data class Heading(val level: Int, val text: String) : MarkdownBlock
+ data class Paragraph(val annotated: AnnotatedString) : MarkdownBlock
+ data class BlockQuote(val annotated: AnnotatedString) : MarkdownBlock
+ data class Table(
+ val headers: List,
+ val alignments: List,
+ val rows: List>,
+ ) : MarkdownBlock
+ data class HtmlTable(
+ val rows: List,
+ ) : MarkdownBlock
+ data class HtmlTableRow(
+ val cells: List,
+ )
+ data class HtmlTableCell(
+ val text: String,
+ val imageUrl: String?,
+ val isHeader: Boolean,
+ val colspan: Int,
+ val align: TableAlign,
+ )
+ data class Image(val alt: String, val url: String, val linkUrl: String? = null) : MarkdownBlock
+ data class Code(val language: String, val code: String) : MarkdownBlock
+ data object HorizontalRule : MarkdownBlock
+ data class ListItem(
+ val ordered: Boolean,
+ val order: Int,
+ val annotated: AnnotatedString,
+ val indentLevel: Int,
+ ) : MarkdownBlock
+ data class ListImage(
+ val ordered: Boolean,
+ val order: Int,
+ val image: Image,
+ val indentLevel: Int,
+ ) : MarkdownBlock
+ data object Blank : MarkdownBlock
+}
+
+// ── 파서 ───────────────────────────────────────────────────────────────────────
+
+private val headingRegex = Regex("^(#{1,6})\\s+(.*)")
+private val blockQuoteRegex = Regex("^(\\s*)>\\s?(.*)$")
+private val hrRegex = Regex("^[-*_]{3,}\\s*$")
+private val orderedListRegex = Regex("^(\\s*)(\\d+)\\.\\s*(.*)")
+private val unorderedListRegex = Regex("^(\\s*)[-*+]\\s*(.*)")
+private val fenceStart = Regex("^```(\\w*)")
+private val tableRowRegex = Regex("^\\|(.+)\\|\\s*$")
+private val tableSepRegex = Regex("^\\|[-:| ]+\\|\\s*$")
+private val htmlTableRowRegex = Regex("]*>([\\s\\S]*?)
", RegexOption.IGNORE_CASE)
+private val htmlTableCellRegex = Regex("<(th|td)\\b([^>]*)>([\\s\\S]*?)(?:th|td)>", RegexOption.IGNORE_CASE)
+private val tableTagStartRegex = Regex("", RegexOption.IGNORE_CASE)
+private val nonTableHtmlRegex = Regex("?(?!table\\b|thead\\b|tbody\\b|tr\\b|th\\b|td\\b|img\\b)[A-Za-z][^>]*>", RegexOption.IGNORE_CASE)
+private val htmlColspanRegex = Regex("""colspan\s*=\s*"(\d+)"""", RegexOption.IGNORE_CASE)
+private val htmlAlignRegex = Regex("""align\s*=\s*"([^"]+)"""", RegexOption.IGNORE_CASE)
+private val htmlImageSrcRegex = Regex("""
]*src\s*=\s*"([^"]+)"""", RegexOption.IGNORE_CASE)
+
+private fun parseMarkdownBlocks(raw: String): List {
+ val text = raw.preprocessMarkdown()
+
+ val blocks = mutableListOf()
+ val lines = text.lines()
+ var i = 0
+
+ while (i < lines.size) {
+ val line = lines[i]
+
+ // 코드 블록
+ if (fenceStart.containsMatchIn(line)) {
+ val lang = fenceStart.find(line)!!.groupValues[1]
+ val codeLines = mutableListOf()
+ i++
+ while (i < lines.size && !lines[i].startsWith("```")) {
+ codeLines += lines[i]; i++
+ }
+ blocks += MarkdownBlock.Code(lang, codeLines.joinToString("\n"))
+ i++; continue
+ }
+
+ if (tableTagStartRegex.containsMatchIn(line)) {
+ val tableLines = mutableListOf(line)
+ while (i + 1 < lines.size && !tableTagEndRegex.containsMatchIn(tableLines.last())) {
+ i++
+ tableLines += lines[i]
+ }
+ parseHtmlTableBlock(tableLines.joinToString("\n"))?.let { tableBlock ->
+ blocks += tableBlock
+ i++
+ continue
+ }
+ }
+
+ // 표: 헤더 행 | 구분 행 | 데이터 행
+ if (tableRowRegex.matches(line) && i + 1 < lines.size && tableSepRegex.matches(lines[i + 1])) {
+ val headers = splitTableRow(line)
+ val alignments = parseTableAlignments(lines[i + 1])
+ val rows = mutableListOf>()
+ i += 2
+ while (i < lines.size && tableRowRegex.matches(lines[i])) {
+ rows += splitTableRow(lines[i]); i++
+ }
+ blocks += MarkdownBlock.Table(headers, alignments, rows)
+ continue
+ }
+
+ // 이미지 / 링크가 걸린 이미지
+ parseImageBlock(line.trim())?.let { imageBlock ->
+ blocks += imageBlock
+ i++; continue
+ }
+
+ // 인용문
+ val blockQuoteMatch = blockQuoteRegex.find(line)
+ if (blockQuoteMatch != null) {
+ val quoteLines = mutableListOf(blockQuoteMatch.groupValues[2])
+ while (i + 1 < lines.size) {
+ val next = lines[i + 1]
+ val nextMatch = blockQuoteRegex.find(next) ?: break
+ quoteLines += nextMatch.groupValues[2]
+ i++
+ }
+ blocks += MarkdownBlock.BlockQuote(parseInline(quoteLines.joinToString("\n")))
+ i++; continue
+ }
+
+ // 수평선
+ if (hrRegex.matches(line)) {
+ blocks += MarkdownBlock.HorizontalRule; i++; continue
+ }
+
+ // 제목
+ val headingMatch = headingRegex.find(line)
+ if (headingMatch != null) {
+ blocks += MarkdownBlock.Heading(
+ level = headingMatch.groupValues[1].length,
+ text = headingMatch.groupValues[2].trim(),
+ )
+ i++; continue
+ }
+
+ // 순서 있는 목록
+ val olMatch = orderedListRegex.find(line)
+ if (olMatch != null) {
+ val indentLevel = olMatch.groupValues[1].length / 4
+ val content = olMatch.groupValues[3].trim()
+ val imageBlock = parseImageBlock(content)
+ blocks += if (imageBlock != null) {
+ MarkdownBlock.ListImage(
+ ordered = true,
+ order = olMatch.groupValues[2].toIntOrNull() ?: 1,
+ image = imageBlock,
+ indentLevel = indentLevel,
+ )
+ } else {
+ MarkdownBlock.ListItem(
+ ordered = true,
+ order = olMatch.groupValues[2].toIntOrNull() ?: 1,
+ annotated = parseInline(content),
+ indentLevel = indentLevel,
+ )
+ }
+ i++; continue
+ }
+
+ // 순서 없는 목록
+ val ulMatch = unorderedListRegex.find(line)
+ if (ulMatch != null) {
+ val indentLevel = ulMatch.groupValues[1].length / 4
+ val content = ulMatch.groupValues[2].trim()
+ val imageBlock = parseImageBlock(content)
+ blocks += if (imageBlock != null) {
+ MarkdownBlock.ListImage(
+ ordered = false,
+ order = 0,
+ image = imageBlock,
+ indentLevel = indentLevel,
+ )
+ } else {
+ MarkdownBlock.ListItem(
+ ordered = false, order = 0,
+ annotated = parseInline(content),
+ indentLevel = indentLevel,
+ )
+ }
+ i++; continue
+ }
+
+ // 빈 줄
+ if (line.isBlank()) {
+ if (blocks.lastOrNull() !is MarkdownBlock.Blank) blocks += MarkdownBlock.Blank
+ i++; continue
+ }
+
+ // 일반 단락
+ val paragraphLines = mutableListOf(line)
+ while (i + 1 < lines.size) {
+ val next = lines[i + 1]
+ if (next.isBlank() || headingRegex.containsMatchIn(next) ||
+ hrRegex.matches(next) || fenceStart.containsMatchIn(next) ||
+ tableRowRegex.matches(next) || blockQuoteRegex.matches(next) ||
+ orderedListRegex.matches(next) || unorderedListRegex.matches(next) ||
+ parseImageBlock(next.trim()) != null
+ ) break
+ paragraphLines += next; i++
+ }
+ blocks += MarkdownBlock.Paragraph(parseInline(paragraphLines.joinToString(" ")))
+ i++
+ }
+
+ return blocks
+}
+
+/** `| a | b | c |` → `["a", "b", "c"]` */
+private fun splitTableRow(line: String): List =
+ line.trim().removePrefix("|").removeSuffix("|").split("|")
+
+/** `|:---|:---:|---:|` → [Start, Center, End, ...] */
+private fun parseTableAlignments(line: String): List =
+ splitTableRow(line).map { cell ->
+ val t = cell.trim()
+ when {
+ t.startsWith(":") && t.endsWith(":") -> TableAlign.Center
+ t.endsWith(":") -> TableAlign.End
+ else -> TableAlign.Start
+ }
+ }
+
+// ── 인라인 파서 (**bold**, *italic*, `code`, [link](url)) ────────────────────
+
+private fun parseInline(text: String): AnnotatedString = buildAnnotatedString {
+ var pos = 0
+ while (pos < text.length) {
+ when {
+ pos + 1 < text.length && text.startsWith("~~", pos) -> {
+ val end = text.indexOf("~~", pos + 2)
+ if (end != -1) {
+ pushStyle(SpanStyle(textDecoration = TextDecoration.LineThrough))
+ append(text.substring(pos + 2, end)); pop(); pos = end + 2
+ } else { append(text[pos]); pos++ }
+ }
+ pos + 1 < text.length && text[pos] == '\\' -> {
+ append(text[pos + 1])
+ pos += 2
+ }
+ pos + 1 < text.length && (text.startsWith("**", pos) || text.startsWith("__", pos)) -> {
+ val marker = text.substring(pos, pos + 2)
+ val end = text.indexOf(marker, pos + 2)
+ if (end != -1) {
+ pushStyle(SpanStyle(fontWeight = FontWeight.Bold))
+ append(text.substring(pos + 2, end)); pop(); pos = end + 2
+ } else { append(text[pos]); pos++ }
+ }
+ text[pos] == '*' || text[pos] == '_' -> {
+ val marker = text[pos].toString()
+ val end = text.indexOf(marker, pos + 1)
+ if (end != -1) {
+ pushStyle(SpanStyle(fontStyle = FontStyle.Italic))
+ append(text.substring(pos + 1, end)); pop(); pos = end + 1
+ } else { append(text[pos]); pos++ }
+ }
+ text[pos] == '`' -> {
+ val end = text.indexOf('`', pos + 1)
+ if (end != -1) {
+ pushStyle(SpanStyle(fontFamily = FontFamily.Monospace, background = Color(0x1A000000)))
+ append(text.substring(pos + 1, end)); pop(); pos = end + 1
+ } else { append(text[pos]); pos++ }
+ }
+ text[pos] == '[' -> {
+ val closeBracket = text.indexOf(']', pos + 1)
+ val openParen = if (closeBracket != -1) text.indexOf('(', closeBracket) else -1
+ val closeParen = if (openParen == closeBracket + 1) text.indexOf(')', openParen + 1) else -1
+ if (closeParen != -1) {
+ val linkText = text.substring(pos + 1, closeBracket)
+ val url = text.substring(openParen + 1, closeParen)
+ withLink(
+ LinkAnnotation.Url(
+ url = url,
+ styles = TextLinkStyles(
+ style = SpanStyle(
+ color = Color(0xFF1A73E8),
+ textDecoration = TextDecoration.Underline,
+ ),
+ ),
+ ),
+ ) {
+ append(linkText)
+ }
+ pos = closeParen + 1
+ } else { append(text[pos]); pos++ }
+ }
+ else -> { append(text[pos]); pos++ }
+ }
+ }
+}
+
+/** HTML 전처리: 표 태그는 유지하고,
→ 줄바꿈 및 기타 HTML 태그만 제거 */
+internal fun String.preprocessMarkdown(): String = this
+ .replace(Regex("
", RegexOption.IGNORE_CASE), "\n")
+ .replace(nonTableHtmlRegex, "")
+ .trimEnd()
+
+private fun parseHtmlTableBlock(tableHtml: String): MarkdownBlock.HtmlTable? {
+ val rows = htmlTableRowRegex.findAll(tableHtml)
+ .map { rowMatch ->
+ htmlTableCellRegex.findAll(rowMatch.groupValues[1])
+ .map { cellMatch ->
+ val tagName = cellMatch.groupValues[1]
+ val attributes = cellMatch.groupValues[2]
+ val cellHtml = cellMatch.groupValues[3]
+ MarkdownBlock.HtmlTableCell(
+ text = cellHtml.stripHtmlCellContent(),
+ imageUrl = htmlImageSrcRegex.find(cellHtml)?.groupValues?.get(1),
+ isHeader = tagName.equals("th", ignoreCase = true),
+ colspan = htmlColspanRegex.find(attributes)?.groupValues?.get(1)?.toIntOrNull() ?: 1,
+ align = htmlAlignRegex.find(attributes)?.groupValues?.get(1).toTableAlign(),
+ )
+ }
+ .toList()
+ }
+ .filter { it.isNotEmpty() }
+ .toList()
+
+ if (rows.isEmpty()) return null
+ return MarkdownBlock.HtmlTable(rows = rows.map { MarkdownBlock.HtmlTableRow(it) })
+}
+
+private fun String.stripHtmlCellContent(): String = this
+ .replace(Regex("
", RegexOption.IGNORE_CASE), "\n")
+ .replace(Regex("<[^>]+>"), "")
+ .replace(" ", " ")
+ .trim()
+
+private fun String?.toTableAlign(): TableAlign = when (this?.lowercase()) {
+ "center" -> TableAlign.Center
+ "right", "end" -> TableAlign.End
+ else -> TableAlign.Start
+}
+
+private fun String.toVisibleImageCaption(): String? {
+ val normalized = trim()
+ if (normalized.isBlank()) return null
+ if (normalized.equals("image", ignoreCase = true)) return null
+ return normalized
+}
+
+private fun Int.indentPadding() = (this * 20).dp
+
+private fun parseImageBlock(line: String): MarkdownBlock.Image? {
+ if (line.startsWith("![")) {
+ val image = parseMarkdownImage(line) ?: return null
+ if (image.nextIndex != line.length) return null
+ return MarkdownBlock.Image(
+ alt = image.alt,
+ url = image.url,
+ )
+ }
+
+ if (line.startsWith("[![")) {
+ val linkCloseBracket = findMatchingBracket(line, 0) ?: return null
+ val linkCloseParen = if (linkCloseBracket + 1 < line.length && line[linkCloseBracket + 1] == '(') {
+ findMatchingParen(line, linkCloseBracket + 1)
+ } else {
+ null
+ } ?: return null
+
+ val inner = line.substring(1, linkCloseBracket)
+ val image = parseMarkdownImage(inner) ?: return null
+ if (image.nextIndex != inner.length) return null
+
+ return MarkdownBlock.Image(
+ alt = image.alt,
+ url = image.url,
+ linkUrl = line.substring(linkCloseBracket + 2, linkCloseParen),
+ )
+ }
+
+ return null
+}
+
+private data class ParsedMarkdownImage(
+ val alt: String,
+ val url: String,
+ val nextIndex: Int,
+)
+
+private fun parseMarkdownImage(text: String, startIndex: Int = 0): ParsedMarkdownImage? {
+ if (!text.startsWith("![", startIndex)) return null
+
+ val closeBracket = findMatchingBracket(text, startIndex + 1) ?: return null
+ if (closeBracket + 1 >= text.length || text[closeBracket + 1] != '(') return null
+
+ val closeParen = findMatchingParen(text, closeBracket + 1) ?: return null
+ return ParsedMarkdownImage(
+ alt = text.substring(startIndex + 2, closeBracket),
+ url = text.substring(closeBracket + 2, closeParen),
+ nextIndex = closeParen + 1,
+ )
+}
+
+private fun findMatchingBracket(text: String, openIndex: Int): Int? {
+ if (openIndex !in text.indices || text[openIndex] != '[') return null
+ var depth = 0
+ for (index in openIndex until text.length) {
+ when (text[index]) {
+ '[' -> depth++
+ ']' -> {
+ depth--
+ if (depth == 0) return index
+ }
+ }
+ }
+ return null
+}
+
+private fun findMatchingParen(text: String, openIndex: Int): Int? {
+ if (openIndex !in text.indices || text[openIndex] != '(') return null
+ var depth = 0
+ for (index in openIndex until text.length) {
+ when (text[index]) {
+ '(' -> depth++
+ ')' -> {
+ depth--
+ if (depth == 0) return index
+ }
+ }
+ }
+ return null
+}
diff --git a/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/document/MarkdownSectionParser.kt b/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/document/MarkdownSectionParser.kt
new file mode 100644
index 0000000..4e09de0
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/document/MarkdownSectionParser.kt
@@ -0,0 +1,4 @@
+package com.example.crew_wiki.ui.document
+
+// 이 파일은 더 이상 사용되지 않습니다.
+// 마크다운 렌더링은 com.mikepenz:multiplatform-markdown-renderer 를 사용합니다.
diff --git a/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/document/TableOfContents.kt b/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/document/TableOfContents.kt
new file mode 100644
index 0000000..a674e04
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/document/TableOfContents.kt
@@ -0,0 +1,141 @@
+package com.example.crew_wiki.ui.document
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.shrinkVertically
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.MaterialTheme
+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.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import com.example.crew_wiki.CrewWikiDesignTokens
+
+private const val MAX_TOC_LEVEL = 3
+
+private data class TocEntry(
+ val headingIndex: Int,
+ val level: Int,
+ val text: String,
+ val number: String,
+)
+
+private val TOC_LEVEL_INDENT: Map = mapOf(1 to 0, 2 to 15, 3 to 30)
+
+/**
+ * 본문의 h1~h3 헤딩으로 목차를 구성한다. (web의 TOC.tsx와 동일한 번호 매기기 규칙)
+ * [headings]는 [extractMarkdownHeadings]의 결과이며, 인덱스가 [MarkdownContent]의
+ * headingRequesters 인덱스와 1:1로 대응한다.
+ */
+@Composable
+fun TableOfContents(
+ headings: List,
+ onEntryClick: (headingIndex: Int) -> Unit,
+ modifier: Modifier = Modifier,
+ initiallyExpanded: Boolean = true,
+) {
+ val colors = CrewWikiDesignTokens.colors
+ var expanded by remember { mutableStateOf(initiallyExpanded) }
+
+ val entries = remember(headings) { buildTocEntries(headings) }
+ if (entries.isEmpty()) return
+
+ Card(
+ modifier = modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
+ shape = MaterialTheme.shapes.medium,
+ border = BorderStroke(1.dp, colors.grayscale.c100),
+ ) {
+ Column {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { expanded = !expanded }
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = "목차",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ color = colors.grayscale.c800,
+ )
+ Text(
+ text = if (expanded) "▴" else "▾",
+ style = MaterialTheme.typography.titleMedium,
+ color = colors.grayscale.c600,
+ )
+ }
+
+ AnimatedVisibility(visible = expanded, enter = expandVertically(), exit = shrinkVertically()) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(PaddingValues(start = 16.dp, end = 16.dp, bottom = 12.dp)),
+ ) {
+ entries.forEach { entry ->
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { onEntryClick(entry.headingIndex) }
+ .padding(
+ start = (TOC_LEVEL_INDENT[entry.level] ?: 0).dp,
+ top = 4.dp,
+ bottom = 4.dp,
+ ),
+ ) {
+ Text(
+ text = entry.number,
+ style = MaterialTheme.typography.bodySmall,
+ color = colors.primary.base,
+ )
+ Text(
+ text = " ${entry.text}",
+ style = MaterialTheme.typography.bodySmall,
+ color = colors.grayscale.c800,
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+private fun buildTocEntries(headings: List): List {
+ val counts = intArrayOf(0, 0, 0)
+ val entries = mutableListOf()
+
+ headings.forEachIndexed { headingIndex, heading ->
+ val level = heading.level
+ if (level !in 1..MAX_TOC_LEVEL) return@forEachIndexed
+
+ for (idx in counts.indices) {
+ counts[idx] = when {
+ idx < level - 1 -> counts[idx]
+ idx == level - 1 -> counts[idx] + 1
+ else -> 0
+ }
+ }
+ val number = counts.take(level).joinToString(".")
+ entries += TocEntry(headingIndex = headingIndex, level = level, text = heading.text, number = number)
+ }
+
+ return entries
+}
diff --git a/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/group/GroupDetailScreen.kt b/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/group/GroupDetailScreen.kt
new file mode 100644
index 0000000..a5e9883
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/group/GroupDetailScreen.kt
@@ -0,0 +1,226 @@
+package com.example.crew_wiki.ui.group
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+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.lazy.LazyColumn
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import com.example.crew_wiki.CrewWikiDesignTokens
+import com.example.crew_wiki.model.GroupDocumentDetail
+import com.example.crew_wiki.model.LinkedCrewDocument
+import com.example.crew_wiki.model.OrganizationEvent
+import com.example.crew_wiki.ui.common.CrewWikiActionButton
+import com.example.crew_wiki.ui.common.CrewWikiActionButtonStyle
+import com.example.crew_wiki.ui.common.CrewWikiSurfaceSection
+import com.example.crew_wiki.ui.common.CrewWikiTagChip
+import com.example.crew_wiki.ui.document.MarkdownContent
+import com.example.crew_wiki.ui.document.preprocessMarkdown
+
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+fun GroupDetailScreen(
+ detail: GroupDocumentDetail,
+ onCrewDocumentClick: (uuid: String) -> Unit,
+ onLogsClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val colors = CrewWikiDesignTokens.colors
+ val spacing = CrewWikiDesignTokens.spacing
+
+ LazyColumn(
+ modifier = modifier.fillMaxSize(),
+ verticalArrangement = Arrangement.spacedBy(spacing.xl),
+ contentPadding = PaddingValues(vertical = 16.dp),
+ ) {
+ // 메인 카드 (제목 + 본문 + 연관 문서)
+ item {
+ CrewWikiSurfaceSection(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ ) {
+ Column(
+ modifier = Modifier.padding(horizontal = 24.dp, vertical = 24.dp),
+ verticalArrangement = Arrangement.spacedBy(spacing.xl),
+ ) {
+ Text(
+ text = detail.title,
+ style = MaterialTheme.typography.headlineMedium,
+ fontWeight = FontWeight.Bold,
+ color = colors.grayscale.c800,
+ modifier = Modifier.fillMaxWidth(),
+ )
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.End,
+ ) {
+ CrewWikiActionButton(
+ text = "편집기록",
+ onClick = onLogsClick,
+ style = CrewWikiActionButtonStyle.Tertiary,
+ )
+ }
+
+ // 본문 마크다운 (자체 렌더러)
+ val content = detail.contents.preprocessMarkdown()
+ if (content.isNotBlank()) {
+ MarkdownContent(
+ content = content,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+
+ // 연관 크루 문서
+ if (detail.linkedCrewDocuments.isNotEmpty()) {
+ LinkedCrewSection(
+ crews = detail.linkedCrewDocuments,
+ onCrewClick = onCrewDocumentClick,
+ )
+ }
+ }
+ }
+ }
+
+ // 타임라인
+ if (detail.events.isNotEmpty()) {
+ item {
+ CrewWikiSurfaceSection(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ ) {
+ Column(
+ modifier = Modifier.padding(horizontal = 24.dp, vertical = 24.dp),
+ verticalArrangement = Arrangement.spacedBy(spacing.lg),
+ ) {
+ Text(
+ text = "타임라인",
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold,
+ color = colors.grayscale.c800,
+ )
+ detail.events.forEach { event ->
+ TimelineEventCard(event = event)
+ }
+ }
+ }
+ }
+ }
+
+ // 푸터
+ item {
+ CrewWikiSurfaceSection(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ ) {
+ Text(
+ text = "이 문서는 ${detail.generateTime.formatGroupDate()}에 마지막으로 편집되었습니다.",
+ modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
+ style = MaterialTheme.typography.bodySmall,
+ color = colors.grayscale.c600,
+ )
+ }
+ }
+
+ item { Spacer(Modifier.height(24.dp)) }
+ }
+}
+
+@Composable
+private fun LinkedCrewSection(
+ crews: List,
+ onCrewClick: (uuid: String) -> Unit,
+) {
+ val spacing = CrewWikiDesignTokens.spacing
+ val colors = CrewWikiDesignTokens.colors
+
+ Column(verticalArrangement = Arrangement.spacedBy(spacing.md)) {
+ Text(
+ text = "연관 크루 문서",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold,
+ color = colors.grayscale.c800,
+ )
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(spacing.sm),
+ verticalArrangement = Arrangement.spacedBy(spacing.sm),
+ ) {
+ crews.forEach { crew ->
+ CrewWikiTagChip(
+ text = crew.title,
+ modifier = Modifier.clickable { onCrewClick(crew.documentUuid) },
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun TimelineEventCard(event: OrganizationEvent) {
+ val colors = CrewWikiDesignTokens.colors
+ val spacing = CrewWikiDesignTokens.spacing
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(spacing.md),
+ verticalAlignment = Alignment.Top,
+ ) {
+ Card(
+ colors = CardDefaults.cardColors(containerColor = colors.primary.c100),
+ shape = MaterialTheme.shapes.small,
+ ) {
+ Text(
+ text = event.occurredAt.formatGroupDate(),
+ modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
+ style = MaterialTheme.typography.labelSmall,
+ color = colors.primary.c800,
+ )
+ }
+ Column(
+ modifier = Modifier.weight(1f),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ Text(
+ text = event.title,
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.SemiBold,
+ color = colors.grayscale.c800,
+ )
+ if (event.contents.isNotBlank()) {
+ Text(
+ text = event.contents,
+ style = MaterialTheme.typography.bodySmall,
+ color = colors.grayscale.c600,
+ )
+ }
+ Text(
+ text = "작성: ${event.writer}",
+ style = MaterialTheme.typography.labelSmall,
+ color = colors.grayscale.c400,
+ )
+ }
+ }
+}
+
+private fun String.formatGroupDate(): String = try {
+ substringBefore("T").replace("-", ".")
+} catch (_: Exception) { this }
diff --git a/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/group/GroupDetailViewModel.kt b/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/group/GroupDetailViewModel.kt
new file mode 100644
index 0000000..b34e740
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/group/GroupDetailViewModel.kt
@@ -0,0 +1,51 @@
+package com.example.crew_wiki.ui.group
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.example.crew_wiki.data.group.GroupDocumentRepository
+import com.example.crew_wiki.data.history.RecentlyViewedStore
+import com.example.crew_wiki.model.GroupDocumentDetail
+import com.example.crew_wiki.model.RecentDocument
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+
+sealed interface GroupDetailUiState {
+ data object Loading : GroupDetailUiState
+ data class Success(val detail: GroupDocumentDetail) : GroupDetailUiState
+ data class Error(val message: String) : GroupDetailUiState
+}
+
+class GroupDetailViewModel(
+ private val repository: GroupDocumentRepository,
+ private val groupUUID: String,
+) : ViewModel() {
+
+ private val _uiState = MutableStateFlow(GroupDetailUiState.Loading)
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ init {
+ loadGroupDocument()
+ }
+
+ fun loadGroupDocument() {
+ viewModelScope.launch {
+ _uiState.value = GroupDetailUiState.Loading
+ try {
+ val detail = repository.fetchGroupDocumentByUUID(groupUUID)
+ _uiState.value = GroupDetailUiState.Success(detail)
+ RecentlyViewedStore.record(
+ RecentDocument(
+ uuid = detail.organizationDocumentUuid,
+ title = detail.title,
+ generateTime = detail.generateTime,
+ documentType = "ORGANIZATION",
+ ),
+ )
+ } catch (e: Exception) {
+ _uiState.value = GroupDetailUiState.Error(e.message ?: "그룹 문서를 불러올 수 없습니다.")
+ }
+ }
+ }
+}
diff --git a/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/history/RecentEditsScreen.kt b/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/history/RecentEditsScreen.kt
new file mode 100644
index 0000000..c09a8cb
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/history/RecentEditsScreen.kt
@@ -0,0 +1,96 @@
+package com.example.crew_wiki.ui.history
+
+import androidx.compose.foundation.BorderStroke
+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.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.example.crew_wiki.CrewWikiDesignTokens
+import com.example.crew_wiki.model.RecentDocument
+
+@Composable
+fun RecentEditsScreen(
+ documents: List,
+ onDocumentClick: (RecentDocument) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val colors = CrewWikiDesignTokens.colors
+
+ if (documents.isEmpty()) {
+ Box(
+ modifier = modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.background),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = "편집된 문서가 없습니다.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = colors.grayscale.c500,
+ )
+ }
+ return
+ }
+
+ LazyColumn(
+ modifier = modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.background),
+ contentPadding = PaddingValues(horizontal = 16.dp, vertical = 16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ items(documents) { doc ->
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { onDocumentClick(doc) },
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
+ shape = MaterialTheme.shapes.medium,
+ border = BorderStroke(1.dp, colors.primary.c100),
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 20.dp, vertical = 14.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = "[${doc.generateTime.formatRecentEditDate()}] ${doc.title}",
+ style = MaterialTheme.typography.bodyMedium,
+ color = colors.grayscale.c800,
+ modifier = Modifier.weight(1f),
+ )
+ if (doc.documentType == "ORGANIZATION") {
+ Text(
+ text = "그룹",
+ style = MaterialTheme.typography.labelSmall,
+ color = colors.primary.c600,
+ modifier = Modifier.padding(start = 8.dp),
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+private fun String.formatRecentEditDate(): String = try {
+ substringBefore("T").replace("-", ".")
+} catch (_: Exception) {
+ this
+}
diff --git a/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/history/RecentEditsViewModel.kt b/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/history/RecentEditsViewModel.kt
new file mode 100644
index 0000000..7bf57b0
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/history/RecentEditsViewModel.kt
@@ -0,0 +1,53 @@
+package com.example.crew_wiki.ui.history
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.example.crew_wiki.model.RecentDocument
+import com.example.crew_wiki.network.DocumentApiService
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+
+sealed interface RecentEditsUiState {
+ data object Loading : RecentEditsUiState
+ data class Success(val documents: List) : RecentEditsUiState
+ data class Error(val message: String) : RecentEditsUiState
+}
+
+class RecentEditsViewModel(
+ private val apiService: DocumentApiService,
+) : ViewModel() {
+
+ private val _uiState = MutableStateFlow(RecentEditsUiState.Loading)
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ init {
+ loadRecentEdits()
+ }
+
+ fun loadRecentEdits() {
+ viewModelScope.launch {
+ _uiState.value = RecentEditsUiState.Loading
+ try {
+ val page = apiService.getDocuments(
+ pageNumber = 0,
+ pageSize = 50,
+ sort = "generateTime",
+ sortDirection = "DESC",
+ )
+ val docs = page.data.map { dto ->
+ RecentDocument(
+ uuid = dto.uuid,
+ title = dto.title,
+ generateTime = dto.generateTime,
+ documentType = dto.documentType,
+ )
+ }
+ _uiState.value = RecentEditsUiState.Success(docs)
+ } catch (e: Exception) {
+ _uiState.value = RecentEditsUiState.Error(e.message ?: "불러오기 실패")
+ }
+ }
+ }
+}
diff --git a/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/history/RecentlyViewedScreen.kt b/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/history/RecentlyViewedScreen.kt
new file mode 100644
index 0000000..366741b
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/history/RecentlyViewedScreen.kt
@@ -0,0 +1,90 @@
+package com.example.crew_wiki.ui.history
+
+import androidx.compose.foundation.BorderStroke
+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.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.example.crew_wiki.CrewWikiDesignTokens
+import com.example.crew_wiki.model.RecentDocument
+
+@Composable
+fun RecentlyViewedScreen(
+ documents: List,
+ onDocumentClick: (RecentDocument) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val colors = CrewWikiDesignTokens.colors
+
+ if (documents.isEmpty()) {
+ Box(
+ modifier = modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.background),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = "최근에 확인한 문서가 없습니다.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = colors.grayscale.c500,
+ )
+ }
+ return
+ }
+
+ LazyColumn(
+ modifier = modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.background),
+ contentPadding = PaddingValues(horizontal = 16.dp, vertical = 16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ items(documents) { doc ->
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { onDocumentClick(doc) },
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
+ shape = MaterialTheme.shapes.medium,
+ border = BorderStroke(1.dp, colors.primary.c100),
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 20.dp, vertical = 14.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = doc.title,
+ style = MaterialTheme.typography.bodyMedium,
+ color = colors.grayscale.c800,
+ modifier = Modifier.weight(1f),
+ )
+ if (doc.documentType == "ORGANIZATION") {
+ Text(
+ text = "그룹",
+ style = MaterialTheme.typography.labelSmall,
+ color = colors.primary.c600,
+ modifier = Modifier.padding(start = 8.dp),
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/home/HomeScreen.kt b/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/home/HomeScreen.kt
new file mode 100644
index 0000000..5b24eb6
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/home/HomeScreen.kt
@@ -0,0 +1,50 @@
+package com.example.crew_wiki.ui.home
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.example.crew_wiki.model.CrewWikiDocumentDetail
+import com.example.crew_wiki.ui.common.CrewWikiSurfaceSection
+import com.example.crew_wiki.ui.document.MarkdownContent
+import com.example.crew_wiki.ui.document.preprocessMarkdown
+
+@Composable
+fun HomeScreen(
+ mainDocument: CrewWikiDocumentDetail?,
+ modifier: Modifier = Modifier,
+) {
+ LazyColumn(
+ modifier = modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.background),
+ contentPadding = PaddingValues(vertical = 16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ // ── 대문 ──────────────────────────────────────────────────────────────
+ if (mainDocument != null) {
+ item {
+ CrewWikiSurfaceSection(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ ) {
+ val content = mainDocument.document.contents.preprocessMarkdown()
+ if (content.isNotBlank()) {
+ MarkdownContent(
+ content = content,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/home/HomeViewModel.kt b/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/home/HomeViewModel.kt
new file mode 100644
index 0000000..834dd0c
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/home/HomeViewModel.kt
@@ -0,0 +1,43 @@
+package com.example.crew_wiki.ui.home
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.example.crew_wiki.data.document.NetworkDocumentRepository
+import com.example.crew_wiki.model.CrewWikiDocumentDetail
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+
+/** crew-wiki.site의 "대문" 문서 UUID */
+private const val MAIN_DOCUMENT_UUID = "30a6c25d-4b88-11f0-99c4-0a270fc3fae1"
+
+sealed interface HomeUiState {
+ data object Loading : HomeUiState
+ data class Success(val mainDocument: CrewWikiDocumentDetail?) : HomeUiState
+ data class Error(val message: String) : HomeUiState
+}
+
+class HomeViewModel(
+ private val documentRepository: NetworkDocumentRepository,
+) : ViewModel() {
+
+ private val _uiState = MutableStateFlow(HomeUiState.Loading)
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ init {
+ loadMainDocument()
+ }
+
+ fun loadMainDocument() {
+ viewModelScope.launch {
+ _uiState.value = HomeUiState.Loading
+ try {
+ val mainDocument = documentRepository.fetchDocumentByUUID(MAIN_DOCUMENT_UUID)
+ _uiState.value = HomeUiState.Success(mainDocument)
+ } catch (e: Exception) {
+ _uiState.value = HomeUiState.Error(e.message ?: "불러오기 실패")
+ }
+ }
+ }
+}
diff --git a/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/popular/PopularDocumentsScreen.kt b/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/popular/PopularDocumentsScreen.kt
new file mode 100644
index 0000000..d6b1b4d
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/popular/PopularDocumentsScreen.kt
@@ -0,0 +1,336 @@
+package com.example.crew_wiki.ui.popular
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.BoxWithConstraints
+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.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+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.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import com.example.crew_wiki.CrewWikiDesignTokens
+import com.example.crew_wiki.model.PopularDocument
+import com.example.crew_wiki.ui.common.CrewWikiActionButton
+import com.example.crew_wiki.ui.common.CrewWikiActionButtonStyle
+import com.example.crew_wiki.ui.common.CrewWikiSurfaceSection
+
+private enum class SortTab(val displayName: String, val label: String) {
+ VIEWS("조회수", "views"),
+ EDITS("수정수", "edits"),
+}
+
+// crew-wiki-next PopularPage 레이아웃 그대로 구현
+// ※ API에 editCount 미제공 → 수정수 탭도 viewCount 표시
+@Composable
+fun PopularDocumentsScreen(
+ documents: List,
+ onDocumentClick: (PopularDocument) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ var sortTab by remember { mutableStateOf(SortTab.VIEWS) }
+ val topTen = documents.take(10)
+ val topThree = topTen.take(3)
+ val remaining = topTen.drop(3)
+ val spacing = CrewWikiDesignTokens.spacing
+
+ LazyColumn(
+ modifier = modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.background),
+ verticalArrangement = Arrangement.spacedBy(spacing.md),
+ ) {
+ item {
+ CrewWikiSurfaceSection(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 16.dp),
+ ) {
+ Column(
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 24.dp),
+ verticalArrangement = Arrangement.spacedBy(spacing.xl),
+ ) {
+ // 헤더 - web의 PopularHeader
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = "인기문서",
+ style = MaterialTheme.typography.headlineLarge,
+ fontWeight = FontWeight.Bold,
+ color = CrewWikiDesignTokens.colors.grayscale.c800,
+ )
+ // 필터 버튼 - web의 PopularFilterButtons
+ Row(horizontalArrangement = Arrangement.spacedBy(spacing.sm)) {
+ SortTab.entries.forEach { tab ->
+ CrewWikiActionButton(
+ text = tab.displayName,
+ onClick = { sortTab = tab },
+ style = if (sortTab == tab) CrewWikiActionButtonStyle.Primary
+ else CrewWikiActionButtonStyle.Tertiary,
+ )
+ }
+ }
+ }
+
+ // 상위 3개 - web의 ol.grid-cols-3
+ BoxWithConstraints {
+ if (maxWidth >= 600.dp) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(spacing.md),
+ ) {
+ topThree.forEachIndexed { index, doc ->
+ PopularRankingCard(
+ rank = index + 1,
+ document = doc,
+ sortTab = sortTab,
+ onClick = { onDocumentClick(doc) },
+ modifier = Modifier.weight(1f),
+ )
+ }
+ }
+ } else {
+ Column(verticalArrangement = Arrangement.spacedBy(spacing.md)) {
+ topThree.forEachIndexed { index, doc ->
+ PopularRankingCard(
+ rank = index + 1,
+ document = doc,
+ sortTab = sortTab,
+ onClick = { onDocumentClick(doc) },
+ )
+ }
+ }
+ }
+ }
+
+ // 4~10위 - web의 PopularRemainingDocuments
+ PopularRemainingList(
+ documents = remaining,
+ startRank = 4,
+ sortTab = sortTab,
+ onDocumentClick = onDocumentClick,
+ )
+ }
+ }
+ }
+ }
+}
+
+// web: PopularRankingCard
+@Composable
+private fun PopularRankingCard(
+ rank: Int,
+ document: PopularDocument,
+ sortTab: SortTab,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val colors = CrewWikiDesignTokens.colors
+ val rankEmojis = listOf("🥇", "🥈", "🥉")
+ val primaryLabel = sortTab.displayName
+ val primaryCount = document.viewCount // API에 editCount 없음
+ val secondaryLabel = if (sortTab == SortTab.VIEWS) "수정수" else "조회수"
+ val secondaryCount = document.viewCount // API에 editCount 없음
+
+ Card(
+ modifier = modifier
+ .fillMaxWidth()
+ .clickable(onClick = onClick),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
+ shape = MaterialTheme.shapes.medium,
+ border = BorderStroke(1.dp, colors.primary.c100),
+ ) {
+ Column(
+ modifier = Modifier.padding(24.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ // 순위 - web: flex items-center gap-3
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = rankEmojis.getOrElse(rank - 1) { "🏅" },
+ style = MaterialTheme.typography.headlineLarge,
+ )
+ Text(
+ text = "${rank}위",
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.SemiBold,
+ color = colors.grayscale.c600,
+ )
+ }
+ // 제목
+ Text(
+ text = document.title,
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ color = colors.grayscale.c800,
+ )
+ // 통계 - web: flex flex-col gap-2
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ MetricRow(
+ label = primaryLabel,
+ value = primaryCount.toLocaleString(),
+ emphasized = true,
+ )
+ MetricRow(
+ label = secondaryLabel,
+ value = secondaryCount.toLocaleString(),
+ emphasized = false,
+ )
+ }
+ }
+ }
+}
+
+// web: PopularRemainingDocuments + PopularDocumentItem
+@Composable
+private fun PopularRemainingList(
+ documents: List,
+ startRank: Int,
+ sortTab: SortTab,
+ onDocumentClick: (PopularDocument) -> Unit,
+) {
+ val colors = CrewWikiDesignTokens.colors
+
+ if (documents.isEmpty()) {
+ Card(
+ colors = CardDefaults.cardColors(containerColor = colors.grayscale.c50),
+ shape = MaterialTheme.shapes.medium,
+ border = BorderStroke(1.dp, colors.grayscale.c100),
+ ) {
+ Text(
+ text = "등록된 문서가 없습니다.",
+ modifier = Modifier.padding(24.dp),
+ style = MaterialTheme.typography.bodyLarge,
+ color = colors.grayscale.c500,
+ )
+ }
+ return
+ }
+
+ Column {
+ documents.forEachIndexed { index, doc ->
+ PopularDocumentItem(
+ rank = startRank + index,
+ document = doc,
+ sortTab = sortTab,
+ onClick = { onDocumentClick(doc) },
+ )
+ if (index < documents.lastIndex) {
+ HorizontalDivider(color = colors.grayscale.c100)
+ }
+ }
+ }
+}
+
+// web: PopularDocumentItem
+@Composable
+private fun PopularDocumentItem(
+ rank: Int,
+ document: PopularDocument,
+ sortTab: SortTab,
+ onClick: () -> Unit,
+) {
+ val colors = CrewWikiDesignTokens.colors
+ val primaryCount = document.viewCount // API에 editCount 없음
+ val secondaryCount = document.viewCount // API에 editCount 없음
+ val primaryLabel = if (sortTab == SortTab.VIEWS) "조회" else "수정"
+ val secondaryLabel = if (sortTab == SortTab.VIEWS) "수정" else "조회"
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(onClick = onClick)
+ .padding(horizontal = 8.dp, vertical = 16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ // 순위 번호
+ Text(
+ text = rank.toString(),
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.SemiBold,
+ color = colors.grayscale.c600,
+ modifier = Modifier.padding(horizontal = 4.dp),
+ )
+ // 제목
+ Text(
+ text = document.title,
+ modifier = Modifier.weight(1f).padding(horizontal = 8.dp),
+ style = MaterialTheme.typography.titleSmall,
+ color = colors.grayscale.c800,
+ )
+ // 주요 통계
+ Column(horizontalAlignment = Alignment.End) {
+ Text(
+ text = primaryCount.toLocaleString(),
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.SemiBold,
+ color = colors.grayscale.c800,
+ )
+ Text(
+ text = primaryLabel,
+ style = MaterialTheme.typography.labelSmall,
+ color = colors.grayscale.c500,
+ )
+ }
+ // 보조 통계 (수정수 / 조회수)
+ Column(horizontalAlignment = Alignment.End, modifier = Modifier.padding(start = 8.dp)) {
+ Text(
+ text = secondaryCount.toLocaleString(),
+ style = MaterialTheme.typography.bodySmall,
+ color = colors.grayscale.c600,
+ )
+ Text(
+ text = secondaryLabel,
+ style = MaterialTheme.typography.labelSmall,
+ color = colors.grayscale.c500,
+ )
+ }
+ }
+}
+
+@Composable
+private fun MetricRow(label: String, value: String, emphasized: Boolean) {
+ val colors = CrewWikiDesignTokens.colors
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Text(
+ text = label,
+ style = MaterialTheme.typography.bodyMedium,
+ color = colors.grayscale.c600,
+ )
+ Text(
+ text = value,
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = if (emphasized) FontWeight.SemiBold else FontWeight.Normal,
+ color = if (emphasized) colors.grayscale.c800 else colors.grayscale.c500,
+ )
+ }
+}
+
+private fun Int.toLocaleString(): String =
+ toString().reversed().chunked(3).joinToString(",").reversed()
diff --git a/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/popular/PopularDocumentsViewModel.kt b/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/popular/PopularDocumentsViewModel.kt
new file mode 100644
index 0000000..f221c12
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/popular/PopularDocumentsViewModel.kt
@@ -0,0 +1,44 @@
+package com.example.crew_wiki.ui.popular
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.example.crew_wiki.data.document.NetworkDocumentRepository
+import com.example.crew_wiki.model.PopularDocument
+import com.example.crew_wiki.model.PopularSortType
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+
+sealed interface PopularUiState {
+ data object Loading : PopularUiState
+ data class Success(
+ // Swagger: editCount 미제공 → viewCount 기준 단일 목록
+ val documents: List,
+ ) : PopularUiState
+ data class Error(val message: String) : PopularUiState
+}
+
+class PopularDocumentsViewModel(
+ private val repository: NetworkDocumentRepository,
+) : ViewModel() {
+
+ private val _uiState = MutableStateFlow(PopularUiState.Loading)
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ init {
+ loadPopularDocuments()
+ }
+
+ fun loadPopularDocuments() {
+ viewModelScope.launch {
+ _uiState.value = PopularUiState.Loading
+ try {
+ val docs = repository.fetchPopularDocuments(PopularSortType.VIEWS)
+ _uiState.value = PopularUiState.Success(documents = docs)
+ } catch (e: Exception) {
+ _uiState.value = PopularUiState.Error(e.message ?: "알 수 없는 오류가 발생했습니다.")
+ }
+ }
+ }
+}
diff --git a/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/search/SearchScreen.kt b/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/search/SearchScreen.kt
new file mode 100644
index 0000000..7fc4ada
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/search/SearchScreen.kt
@@ -0,0 +1,213 @@
+package com.example.crew_wiki.ui.search
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+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.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.example.crew_wiki.CrewWikiDesignTokens
+import com.example.crew_wiki.network.dto.DocumentSearchResponseDto
+import com.example.crew_wiki.ui.common.SearchIcon
+
+@Composable
+fun SearchScreen(
+ viewModel: SearchViewModel,
+ onResultClick: (uuid: String, documentType: String) -> Unit,
+) {
+ val query by viewModel.query.collectAsState()
+ val uiState by viewModel.uiState.collectAsState()
+ val colors = CrewWikiDesignTokens.colors
+ val focusRequester = remember { FocusRequester() }
+ val keyboard = LocalSoftwareKeyboardController.current
+
+ LaunchedEffect(Unit) {
+ focusRequester.requestFocus()
+ }
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(colors.white),
+ ) {
+ // 검색창
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(colors.primary.base)
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Box(
+ modifier = Modifier
+ .weight(1f)
+ .background(Color.White.copy(alpha = 0.15f), RoundedCornerShape(8.dp))
+ .padding(horizontal = 12.dp, vertical = 10.dp),
+ ) {
+ if (query.isEmpty()) {
+ Text(
+ text = "문서 검색",
+ style = MaterialTheme.typography.bodyMedium,
+ color = Color.White.copy(alpha = 0.6f),
+ fontSize = 14.sp,
+ )
+ }
+ BasicTextField(
+ value = query,
+ onValueChange = viewModel::onQueryChange,
+ modifier = Modifier
+ .fillMaxWidth()
+ .focusRequester(focusRequester),
+ textStyle = MaterialTheme.typography.bodyMedium.copy(
+ color = Color.White,
+ fontSize = 14.sp,
+ ),
+ cursorBrush = SolidColor(Color.White),
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
+ keyboardActions = KeyboardActions(
+ onSearch = { keyboard?.hide() },
+ ),
+ )
+ }
+ Spacer(modifier = Modifier.width(8.dp))
+ Icon(
+ imageVector = SearchIcon,
+ contentDescription = "검색",
+ tint = Color.White,
+ modifier = Modifier.size(24.dp),
+ )
+ }
+
+ // 결과 영역
+ when (val state = uiState) {
+ is SearchUiState.Idle -> {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = "검색어를 입력하세요",
+ style = MaterialTheme.typography.bodyMedium,
+ color = colors.grayscale.lightText,
+ fontSize = 14.sp,
+ )
+ }
+ }
+
+ is SearchUiState.Loading -> {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center,
+ ) {
+ CircularProgressIndicator(color = colors.primary.base)
+ }
+ }
+
+ is SearchUiState.Error -> {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = state.message,
+ style = MaterialTheme.typography.bodyMedium,
+ color = colors.error.base,
+ fontSize = 14.sp,
+ )
+ }
+ }
+
+ is SearchUiState.Success -> {
+ if (state.results.isEmpty()) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = "검색 결과가 없습니다",
+ style = MaterialTheme.typography.bodyMedium,
+ color = colors.grayscale.lightText,
+ fontSize = 14.sp,
+ )
+ }
+ } else {
+ LazyColumn(modifier = Modifier.fillMaxSize()) {
+ items(state.results) { doc ->
+ SearchResultItem(
+ doc = doc,
+ onClick = { onResultClick(doc.uuid, doc.documentType) },
+ )
+ HorizontalDivider(color = colors.grayscale.border)
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun SearchResultItem(
+ doc: DocumentSearchResponseDto,
+ onClick: () -> Unit,
+) {
+ val colors = CrewWikiDesignTokens.colors
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(onClick = onClick)
+ .padding(horizontal = 20.dp, vertical = 14.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = doc.title,
+ style = MaterialTheme.typography.bodyMedium,
+ color = colors.grayscale.text,
+ fontSize = 15.sp,
+ fontWeight = FontWeight.Medium,
+ )
+ Spacer(modifier = Modifier.height(2.dp))
+ Text(
+ text = if (doc.documentType == "ORGANIZATION") "조직 문서" else "일반 문서",
+ style = MaterialTheme.typography.bodySmall,
+ color = colors.grayscale.lightText,
+ fontSize = 12.sp,
+ )
+ }
+ }
+}
diff --git a/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/search/SearchViewModel.kt b/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/search/SearchViewModel.kt
new file mode 100644
index 0000000..74a3aaa
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/search/SearchViewModel.kt
@@ -0,0 +1,64 @@
+package com.example.crew_wiki.ui.search
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.example.crew_wiki.network.DocumentApiService
+import com.example.crew_wiki.network.dto.DocumentSearchResponseDto
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.debounce
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
+
+sealed interface SearchUiState {
+ data object Idle : SearchUiState
+ data object Loading : SearchUiState
+ data class Success(val results: List) : SearchUiState
+ data class Error(val message: String) : SearchUiState
+}
+
+@OptIn(FlowPreview::class)
+class SearchViewModel(
+ private val apiService: DocumentApiService,
+) : ViewModel() {
+
+ private val _query = MutableStateFlow("")
+ val query: StateFlow = _query.asStateFlow()
+
+ private val _uiState = MutableStateFlow(SearchUiState.Idle)
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ init {
+ _query
+ .debounce(300L)
+ .distinctUntilChanged()
+ .onEach { q ->
+ if (q.isBlank()) {
+ _uiState.value = SearchUiState.Idle
+ } else {
+ search(q)
+ }
+ }
+ .launchIn(viewModelScope)
+ }
+
+ fun onQueryChange(query: String) {
+ _query.value = query
+ }
+
+ private fun search(keyword: String) {
+ viewModelScope.launch {
+ _uiState.value = SearchUiState.Loading
+ _uiState.value = try {
+ val results = apiService.searchDocuments(keyword)
+ SearchUiState.Success(results)
+ } catch (e: Exception) {
+ SearchUiState.Error(e.message ?: "검색 실패")
+ }
+ }
+ }
+}
diff --git a/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/settings/SettingsScreen.kt b/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/settings/SettingsScreen.kt
new file mode 100644
index 0000000..bc1123a
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/example/crew_wiki/ui/settings/SettingsScreen.kt
@@ -0,0 +1,68 @@
+package com.example.crew_wiki.ui.settings
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import com.example.crew_wiki.CrewWikiDesignTokens
+import com.example.crew_wiki.ui.common.CrewWikiSurfaceSection
+
+@Composable
+fun SettingsScreen(modifier: Modifier = Modifier) {
+ val colors = CrewWikiDesignTokens.colors
+ val spacing = CrewWikiDesignTokens.spacing
+
+ LazyColumn(
+ modifier = modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.background),
+ contentPadding = PaddingValues(vertical = 16.dp),
+ verticalArrangement = Arrangement.spacedBy(spacing.md),
+ ) {
+ item {
+ CrewWikiSurfaceSection(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ ) {
+ Column(verticalArrangement = Arrangement.spacedBy(spacing.sm)) {
+ Text(
+ text = "크루위키",
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Bold,
+ color = colors.grayscale.c800,
+ )
+ Text(
+ text = "우아한테크코스 크루들의 정보들을 담은 위키",
+ style = MaterialTheme.typography.bodyMedium,
+ color = colors.grayscale.c600,
+ )
+ }
+ }
+ }
+
+ item {
+ CrewWikiSurfaceSection(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ ) {
+ Text(
+ text = "질문, 제안, 오류 제보는 문의하기를 이용해 주세요.",
+ style = MaterialTheme.typography.bodySmall,
+ color = colors.grayscale.c800,
+ )
+ }
+ }
+ }
+}
diff --git a/shared/src/commonTest/kotlin/com/example/crew_wiki/SharedCommonTest.kt b/shared/src/commonTest/kotlin/com/example/crew_wiki/SharedCommonTest.kt
new file mode 100644
index 0000000..30fbe9a
--- /dev/null
+++ b/shared/src/commonTest/kotlin/com/example/crew_wiki/SharedCommonTest.kt
@@ -0,0 +1,12 @@
+package com.example.crew_wiki
+
+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/shared/src/iosMain/kotlin/com/example/crew_wiki/MainViewController.kt b/shared/src/iosMain/kotlin/com/example/crew_wiki/MainViewController.kt
new file mode 100644
index 0000000..d4831c5
--- /dev/null
+++ b/shared/src/iosMain/kotlin/com/example/crew_wiki/MainViewController.kt
@@ -0,0 +1,5 @@
+package com.example.crew_wiki
+
+import androidx.compose.ui.window.ComposeUIViewController
+
+fun MainViewController() = ComposeUIViewController { App() }
\ No newline at end of file
diff --git a/shared/src/iosMain/kotlin/com/example/crew_wiki/Platform.ios.kt b/shared/src/iosMain/kotlin/com/example/crew_wiki/Platform.ios.kt
new file mode 100644
index 0000000..ce77813
--- /dev/null
+++ b/shared/src/iosMain/kotlin/com/example/crew_wiki/Platform.ios.kt
@@ -0,0 +1,9 @@
+package com.example.crew_wiki
+
+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/shared/src/iosMain/kotlin/com/example/crew_wiki/Uuid.ios.kt b/shared/src/iosMain/kotlin/com/example/crew_wiki/Uuid.ios.kt
new file mode 100644
index 0000000..bfa0f32
--- /dev/null
+++ b/shared/src/iosMain/kotlin/com/example/crew_wiki/Uuid.ios.kt
@@ -0,0 +1,5 @@
+package com.example.crew_wiki
+
+import platform.Foundation.NSUUID
+
+actual fun randomUuid(): String = NSUUID().UUIDString()
diff --git a/shared/src/iosTest/kotlin/com/example/crew_wiki/SharedLogicIOSTest.kt b/shared/src/iosTest/kotlin/com/example/crew_wiki/SharedLogicIOSTest.kt
new file mode 100644
index 0000000..a14f79b
--- /dev/null
+++ b/shared/src/iosTest/kotlin/com/example/crew_wiki/SharedLogicIOSTest.kt
@@ -0,0 +1,12 @@
+package com.example.crew_wiki
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class SharedLogicIOSTest {
+
+ @Test
+ fun example() {
+ assertEquals(3, 1 + 2)
+ }
+}
\ No newline at end of file