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]*?)", RegexOption.IGNORE_CASE) +private val tableTagStartRegex = Regex("", RegexOption.IGNORE_CASE) +private val nonTableHtmlRegex = Regex("]*>", 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