diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml
index 2f93f0e..8c4acd0 100644
--- a/.github/workflows/android.yml
+++ b/.github/workflows/android.yml
@@ -8,7 +8,6 @@ on:
jobs:
build:
-
runs-on: ubuntu-latest
steps:
@@ -21,6 +20,17 @@ jobs:
distribution: 'temurin'
cache: gradle
+ - name: Set up Android SDK
+ uses: android-actions/setup-android@v3
+ with:
+ api-level: 35
+ target: default
+ arch: x86_64
+
+ - name: Create local.properties
+ run: |
+ echo "sdk.dir=$ANDROID_SDK_ROOT" > local.properties
+
- name: Grant execute permission for gradlew
run: chmod +x gradlew
diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml
index b268ef3..d30de07 100644
--- a/.idea/deploymentTargetSelector.xml
+++ b/.idea/deploymentTargetSelector.xml
@@ -4,6 +4,14 @@
+
+
+
+
+
+
+
+
diff --git a/.idea/encodings.xml b/.idea/encodings.xml
new file mode 100644
index 0000000..c2bae49
--- /dev/null
+++ b/.idea/encodings.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
index 0ad17cb..8978d23 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -1,4 +1,3 @@
-
diff --git a/.idea/other.xml b/.idea/other.xml
index 457b2de..5898d34 100644
--- a/.idea/other.xml
+++ b/.idea/other.xml
@@ -47,6 +47,17 @@
+
+
+
+
+
+
+
+
+
+
+
@@ -741,6 +752,17 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.kotlin/errors/errors-1757918002973.log b/.kotlin/errors/errors-1757918002973.log
new file mode 100644
index 0000000..b3a87a6
--- /dev/null
+++ b/.kotlin/errors/errors-1757918002973.log
@@ -0,0 +1,66 @@
+kotlin version: 2.1.0
+error message: Daemon compilation failed: Connection to the Kotlin daemon has been unexpectedly lost. This might be caused by the daemon being killed by another process or the operating system, or by JVM crash.
+org.jetbrains.kotlin.gradle.tasks.DaemonCrashedException: Connection to the Kotlin daemon has been unexpectedly lost. This might be caused by the daemon being killed by another process or the operating system, or by JVM crash.
+ at org.jetbrains.kotlin.gradle.tasks.TasksUtilsKt.wrapAndRethrowCompilationException(tasksUtils.kt:55)
+ at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.compileWithDaemon(GradleKotlinCompilerWork.kt:243)
+ at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.compileWithDaemonOrFallbackImpl(GradleKotlinCompilerWork.kt:159)
+ at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.run(GradleKotlinCompilerWork.kt:111)
+ at org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction.execute(GradleCompilerRunnerWithWorkers.kt:76)
+ at org.gradle.workers.internal.DefaultWorkerServer.execute(DefaultWorkerServer.java:63)
+ at org.gradle.workers.internal.NoIsolationWorkerFactory$1$1.create(NoIsolationWorkerFactory.java:66)
+ at org.gradle.workers.internal.NoIsolationWorkerFactory$1$1.create(NoIsolationWorkerFactory.java:62)
+ at org.gradle.internal.classloader.ClassLoaderUtils.executeInClassloader(ClassLoaderUtils.java:100)
+ at org.gradle.workers.internal.NoIsolationWorkerFactory$1.lambda$execute$0(NoIsolationWorkerFactory.java:62)
+ at org.gradle.workers.internal.AbstractWorker$1.call(AbstractWorker.java:44)
+ at org.gradle.workers.internal.AbstractWorker$1.call(AbstractWorker.java:41)
+ at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:200)
+ at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:195)
+ at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:66)
+ at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:59)
+ at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:157)
+ at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:59)
+ at org.gradle.internal.operations.DefaultBuildOperationRunner.call(DefaultBuildOperationRunner.java:53)
+ at org.gradle.internal.operations.DefaultBuildOperationExecutor.call(DefaultBuildOperationExecutor.java:73)
+ at org.gradle.workers.internal.AbstractWorker.executeWrappedInBuildOperation(AbstractWorker.java:41)
+ at org.gradle.workers.internal.NoIsolationWorkerFactory$1.execute(NoIsolationWorkerFactory.java:59)
+ at org.gradle.workers.internal.DefaultWorkerExecutor.lambda$submitWork$0(DefaultWorkerExecutor.java:174)
+ at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:264)
+ at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.runExecution(DefaultConditionalExecutionQueue.java:187)
+ at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.access$700(DefaultConditionalExecutionQueue.java:120)
+ at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner$1.run(DefaultConditionalExecutionQueue.java:162)
+ at org.gradle.internal.Factories$1.create(Factories.java:31)
+ at org.gradle.internal.work.DefaultWorkerLeaseService.withLocks(DefaultWorkerLeaseService.java:264)
+ at org.gradle.internal.work.DefaultWorkerLeaseService.runAsWorkerThread(DefaultWorkerLeaseService.java:128)
+ at org.gradle.internal.work.DefaultWorkerLeaseService.runAsWorkerThread(DefaultWorkerLeaseService.java:133)
+ at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.runBatch(DefaultConditionalExecutionQueue.java:157)
+ at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.run(DefaultConditionalExecutionQueue.java:126)
+ at java.base/java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:539)
+ at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:264)
+ at org.gradle.internal.concurrent.ExecutorPolicy$CatchAndRecordFailures.onExecute(ExecutorPolicy.java:64)
+ at org.gradle.internal.concurrent.AbstractManagedExecutor$1.run(AbstractManagedExecutor.java:47)
+ at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1136)
+ at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:635)
+ at java.base/java.lang.Thread.run(Thread.java:840)
+Caused by: java.rmi.UnmarshalException: Error unmarshaling return header; nested exception is:
+ java.net.SocketException: Connection reset
+ at java.rmi/sun.rmi.transport.StreamRemoteCall.executeCall(StreamRemoteCall.java:255)
+ at java.rmi/sun.rmi.server.UnicastRef.invoke(UnicastRef.java:165)
+ at java.rmi/java.rmi.server.RemoteObjectInvocationHandler.invokeRemoteMethod(RemoteObjectInvocationHandler.java:215)
+ at java.rmi/java.rmi.server.RemoteObjectInvocationHandler.invoke(RemoteObjectInvocationHandler.java:160)
+ at jdk.proxy4/jdk.proxy4.$Proxy139.compile(Unknown Source)
+ at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.incrementalCompilationWithDaemon(GradleKotlinCompilerWork.kt:331)
+ at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.compileWithDaemon(GradleKotlinCompilerWork.kt:235)
+ ... 38 more
+Caused by: java.net.SocketException: Connection reset
+ at java.base/sun.nio.ch.NioSocketImpl.implRead(NioSocketImpl.java:328)
+ at java.base/sun.nio.ch.NioSocketImpl.read(NioSocketImpl.java:355)
+ at java.base/sun.nio.ch.NioSocketImpl$1.read(NioSocketImpl.java:808)
+ at java.base/java.net.Socket$SocketInputStream.read(Socket.java:966)
+ at java.base/java.io.BufferedInputStream.fill(BufferedInputStream.java:244)
+ at java.base/java.io.BufferedInputStream.read(BufferedInputStream.java:263)
+ at java.base/java.io.DataInputStream.readUnsignedByte(DataInputStream.java:288)
+ at java.base/java.io.DataInputStream.readByte(DataInputStream.java:268)
+ at java.rmi/sun.rmi.transport.StreamRemoteCall.executeCall(StreamRemoteCall.java:241)
+ ... 44 more
+
+
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index c6c2c04..1151de4 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -1,3 +1,5 @@
+import java.util.Properties
+
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.jetbrains.kotlin.android)
@@ -7,21 +9,38 @@ plugins {
id("com.google.dagger.hilt.android")
id("kotlin-kapt")
+ // Parcelable 이용을 위한 플러그인 추가
+ id("kotlin-parcelize")
+
// Compose Compiler 플러그인 추가
id("org.jetbrains.kotlin.plugin.compose")
+
+ id("org.jetbrains.kotlin.plugin.serialization") version "1.9.22"
+}
+
+val properties = Properties().apply {
+ load(rootProject.file("local.properties").inputStream())
}
android {
namespace = "com.example.chaining"
- compileSdk = 34
+ compileSdk = 35
defaultConfig {
applicationId = "com.example.chaining"
minSdk = 24
- targetSdk = 34
+ targetSdk = 35
versionCode = 1
versionName = "1.0"
+ buildConfigField("String", "DATA_OPEN_API_KEY", properties["DATA_OPEN_API_KEY"].toString())
+
+ buildConfigField(
+ "String",
+ "GOOGLE_API_WEB_CLIENT_ID",
+ properties["GOOGLE_API_WEB_CLIENT_ID"].toString()
+ )
+
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
@@ -46,6 +65,7 @@ android {
}
buildFeatures {
compose = true
+ buildConfig = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.15"
@@ -55,28 +75,59 @@ android {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
+ lint {
+ baseline = file("lint-baseline.xml")
+ disable += listOf("StateFlowValueCalledInComposition", "CoroutineCreationDuringComposition")
+
+ }
}
dependencies {
+ // Google Sign-In (Credentials API 포함)
+ implementation("androidx.credentials:credentials:1.5.0")
+ implementation("androidx.credentials:credentials-play-services-auth:1.5.0")
+
+ // Google Identity Services (Google 로그인 팝업 등을 위해 필요)
+ implementation("com.google.android.gms:play-services-auth:21.0.0")
+ implementation("com.google.android.libraries.identity.googleid:googleid:1.1.0")
+
// 구글 Firebase 사용
implementation(platform("com.google.firebase:firebase-bom:33.16.0"))
+ implementation("com.google.firebase:firebase-database-ktx")
implementation("com.google.firebase:firebase-analytics")
implementation("com.google.firebase:firebase-auth-ktx")
+ implementation("com.google.firebase:firebase-storage-ktx")
// Hilt 의존성 주입 (DI) 라이브러리 사용
implementation("com.google.dagger:hilt-android:2.55")
kapt("com.google.dagger:hilt-android-compiler:2.55")
+ implementation("androidx.hilt:hilt-navigation-compose:1.2.0")
// Room (로컬 DB) 의존성 주입
- implementation("androidx.room:room-runtime:2.6.1")
- kapt("androidx.room:room-compiler:2.6.1")
- implementation("androidx.room:room-ktx:2.6.1")
+ implementation("androidx.room:room-runtime:2.7.2")
+ kapt("androidx.room:room-compiler:2.7.2")
+ implementation("androidx.room:room-ktx:2.7.2")
// Retrofit + Coroutine (API 통신) 의존성 주입
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
+ // Navigation 라이브러리 의존성 주입
+ implementation("androidx.navigation:navigation-compose:2.7.7")
+
+ // Coil (Jetpack Compose용)
+ implementation("io.coil-kt:coil-compose:2.6.0")
+
+ // @kotlinx.serialization.Serializable을 쓰기 위한 의존성 주입
+ implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1")
+
+ // JSON 파싱을 위한 의존성 주입
+ implementation("com.google.code.gson:gson:2.10.1")
+
+ // Http 통신 로그를 보기 위한 라이브러리
+ implementation("com.squareup.okhttp3:logging-interceptor:4.11.0")
+
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
diff --git a/app/google-services.json b/app/google-services.json
index 7626976..528d66d 100644
--- a/app/google-services.json
+++ b/app/google-services.json
@@ -1,6 +1,7 @@
{
"project_info": {
"project_number": "719736077401",
+ "firebase_url": "https://chaining-88dbd-default-rtdb.firebaseio.com",
"project_id": "chaining-88dbd",
"storage_bucket": "chaining-88dbd.firebasestorage.app"
},
@@ -12,7 +13,36 @@
"package_name": "com.example.chaining"
}
},
- "oauth_client": [],
+ "oauth_client": [
+ {
+ "client_id": "719736077401-cfndlb3a481dqti68btf9an9phu3odia.apps.googleusercontent.com",
+ "client_type": 1,
+ "android_info": {
+ "package_name": "com.example.chaining",
+ "certificate_hash": "17e272bb5052dc6523c3c639813e19cd8e720ffa"
+ }
+ },
+ {
+ "client_id": "719736077401-q1onru1a7hb5due08ugcqtvi5eeadu5a.apps.googleusercontent.com",
+ "client_type": 1,
+ "android_info": {
+ "package_name": "com.example.chaining",
+ "certificate_hash": "712070ac693fda20b1cf67db9dbd01dfa6ed0a26"
+ }
+ },
+ {
+ "client_id": "719736077401-t6tu672tn4m7mt7arskef2gk2oj8u3cn.apps.googleusercontent.com",
+ "client_type": 1,
+ "android_info": {
+ "package_name": "com.example.chaining",
+ "certificate_hash": "572654d77cdf963728ceebe9a15c3ea4e1a972a7"
+ }
+ },
+ {
+ "client_id": "719736077401-smtbf85pqoghs04i3vam5rflrjfoaovu.apps.googleusercontent.com",
+ "client_type": 3
+ }
+ ],
"api_key": [
{
"current_key": "AIzaSyDIcZyjKx3aaxdM75g8P7PvZgU4WJSco8s"
@@ -20,7 +50,12 @@
],
"services": {
"appinvite_service": {
- "other_platform_oauth_client": []
+ "other_platform_oauth_client": [
+ {
+ "client_id": "719736077401-smtbf85pqoghs04i3vam5rflrjfoaovu.apps.googleusercontent.com",
+ "client_type": 3
+ }
+ ]
}
}
}
diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml
new file mode 100644
index 0000000..ab0cc0b
--- /dev/null
+++ b/app/lint-baseline.xml
@@ -0,0 +1,1397 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 7405ee9..a50a373 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -12,18 +12,21 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Chaining"
+ android:networkSecurityConfig="@xml/network_security_config"
tools:targetApi="31">
+ android:theme="@style/Theme.Chaining"
+ android:windowSoftInputMode="adjustResize">
-
+
\ No newline at end of file
diff --git a/app/src/main/assets/english_quizzes.json b/app/src/main/assets/english_quizzes.json
new file mode 100644
index 0000000..c948133
--- /dev/null
+++ b/app/src/main/assets/english_quizzes.json
@@ -0,0 +1,302 @@
+[
+ {
+ "id": "eng_lv1_so_01",
+ "language": "ENGLISH",
+ "level": 1,
+ "type": "SENTENCE_ORDER",
+ "problem": "Hello / ? / to / nice / you / meet / .",
+ "translation": "안녕하세요? 만나서 반가워요.",
+ "options": [],
+ "answer": "Hello? Nice to meet you."
+ },
+ {
+ "id": "eng_lv1_so_02",
+ "language": "ENGLISH",
+ "level": 1,
+ "type": "SENTENCE_ORDER",
+ "problem": "is / your / what / name / ?",
+ "translation": "이름이 뭐예요?",
+ "options": [],
+ "answer": "What is your name?"
+ },
+ {
+ "id": "eng_lv1_mc_01",
+ "language": "ENGLISH",
+ "level": 1,
+ "type": "MULTIPLE_CHOICE",
+ "problem": "'Thank you'의 가장 적절한 한국어 의미는?",
+ "translation": "",
+ "options": ["안녕하세요", "죄송합니다", "감사합니다", "실례합니다"],
+ "answer": "감사합니다"
+ },
+ {
+ "id": "eng_lv1_mc_02",
+ "language": "ENGLISH",
+ "level": 1,
+ "type": "MULTIPLE_CHOICE",
+ "problem": "'Water'는(은) 무슨 뜻인가요?",
+ "translation": "",
+ "options": ["물", "음식", "돈", "집"],
+ "answer": "물"
+ },
+ {
+ "id": "eng_lv1_fb_01",
+ "language": "ENGLISH",
+ "level": 1,
+ "type": "FILL_IN_THE_BLANK",
+ "problem": "Where ______ the restroom?",
+ "translation": "화장실은 어디에 있어요?",
+ "options": ["is", "are", "am", "do"],
+ "answer": "is"
+ },
+ {
+ "id": "eng_lv1_fb_02",
+ "language": "ENGLISH",
+ "level": 1,
+ "type": "FILL_IN_THE_BLANK",
+ "problem": "How much ______ this?",
+ "translation": "이거 얼마예요?",
+ "options": ["is", "are", "does", "do"],
+ "answer": "is"
+ },
+ {
+ "id": "eng_lv2_so_01",
+ "language": "ENGLISH",
+ "level": 2,
+ "type": "SENTENCE_ORDER",
+ "problem": "give / me / the / please / menu / .",
+ "translation": "메뉴판 좀 주세요.",
+ "options": [],
+ "answer": "Please give me the menu."
+ },
+ {
+ "id": "eng_lv2_so_02",
+ "language": "ENGLISH",
+ "level": 2,
+ "type": "SENTENCE_ORDER",
+ "problem": "is / the / station / where / nearest / subway / ?",
+ "translation": "가장 가까운 지하철역은 어디예요?",
+ "options": [],
+ "answer": "Where is the nearest subway station?"
+ },
+ {
+ "id": "eng_lv2_mc_01",
+ "language": "ENGLISH",
+ "level": 2,
+ "type": "MULTIPLE_CHOICE",
+ "problem": "'I'd like to order'의 의미로 가장 가까운 것은?",
+ "translation": "",
+ "options": ["결제할게요", "주문할게요", "예약했어요", "할인해 주세요"],
+ "answer": "주문할게요"
+ },
+ {
+ "id": "eng_lv2_mc_02",
+ "language": "ENGLISH",
+ "level": 2,
+ "type": "MULTIPLE_CHOICE",
+ "problem": "'Just a moment'은 어떤 상황에 사용하나요?",
+ "translation": "",
+ "options": ["매우 기쁠 때", "작별 인사를 할 때", "잠시 기다려달라고 할 때", "물건을 사고 싶을 때"],
+ "answer": "잠시 기다려달라고 할 때"
+ },
+ {
+ "id": "eng_lv2_fb_01",
+ "language": "ENGLISH",
+ "level": 2,
+ "type": "FILL_IN_THE_BLANK",
+ "problem": "Does this bus ______ to City Hall?",
+ "translation": "이 버스, 시청에 가요?",
+ "options": ["go", "come", "eat", "sleep"],
+ "answer": "go"
+ },
+ {
+ "id": "eng_lv2_fb_02",
+ "language": "ENGLISH",
+ "level": 2,
+ "type": "FILL_IN_THE_BLANK",
+ "problem": "The ______, please.",
+ "translation": "계산서 좀 주세요.",
+ "options": ["seat", "bill", "clothes", "book"],
+ "answer": "bill"
+ },
+ {
+ "id": "eng_lv3_so_01",
+ "language": "ENGLISH",
+ "level": 3,
+ "type": "SENTENCE_ORDER",
+ "problem": "you / recommend / the / popular / dish / most / could / ?",
+ "translation": "가장 인기 있는 메뉴 추천해 주시겠어요?",
+ "options": [],
+ "answer": "Could you recommend the most popular dish?"
+ },
+ {
+ "id": "eng_lv3_so_02",
+ "language": "ENGLISH",
+ "level": 3,
+ "type": "SENTENCE_ORDER",
+ "problem": "it / to / how / take / get / long / the / will / airport / by / taxi / ?",
+ "translation": "공항까지 택시로 얼마나 걸릴까요?",
+ "options": [],
+ "answer": "How long will it take to get to the airport by taxi?"
+ },
+ {
+ "id": "eng_lv3_mc_01",
+ "language": "ENGLISH",
+ "level": 3,
+ "type": "MULTIPLE_CHOICE",
+ "problem": "'Please split the bill'은 무슨 뜻인가요?",
+ "translation": "",
+ "options": ["같이 계산해 주세요", "따로따로 계산해 주세요", "영수증 주세요", "카드로 계산할 수 있나요?"],
+ "answer": "따로따로 계산해 주세요"
+ },
+ {
+ "id": "eng_lv3_mc_02",
+ "language": "ENGLISH",
+ "level": 3,
+ "type": "MULTIPLE_CHOICE",
+ "problem": "'Transfer'가 교통수단과 함께 쓰일 때의 의미는?",
+ "translation": "",
+ "options": ["환불", "탑승", "환승", "하차"],
+ "answer": "환승"
+ },
+ {
+ "id": "eng_lv3_fb_01",
+ "language": "ENGLISH",
+ "level": 3,
+ "type": "FILL_IN_THE_BLANK",
+ "problem": "I'd like a window ______, please.",
+ "translation": "창가 쪽 자리로 부탁드립니다.",
+ "options": ["seat", "table", "room", "car"],
+ "answer": "seat"
+ },
+ {
+ "id": "eng_lv3_fb_02",
+ "language": "ENGLISH",
+ "level": 3,
+ "type": "FILL_IN_THE_BLANK",
+ "problem": "What is the Wi-Fi ______?",
+ "translation": "와이파이 비밀번호가 뭐예요?",
+ "options": ["password", "pretty", "expensive", "far"],
+ "answer": "password"
+ },
+ {
+ "id": "eng_lv4_so_01",
+ "language": "ENGLISH",
+ "level": 4,
+ "type": "SENTENCE_ORDER",
+ "problem": "chance / a / is / withdraw / can / I / money / where / bank / nearby / there / by / any / ?",
+ "translation": "혹시 이 근처에 돈을 인출할 만한 은행이 있나요?",
+ "options": [],
+ "answer": "By any chance, is there a bank nearby where I can withdraw money?"
+ },
+ {
+ "id": "eng_lv4_so_02",
+ "language": "ENGLISH",
+ "level": 4,
+ "type": "SENTENCE_ORDER",
+ "problem": "think / the / doesn't / my / food / I / quite / suit / palate / .",
+ "translation": "음식이 입맛에 조금 안 맞는 것 같아요.",
+ "options": [],
+ "answer": "I think the food doesn't quite suit my palate."
+ },
+ {
+ "id": "eng_lv4_mc_01",
+ "language": "ENGLISH",
+ "level": 4,
+ "type": "MULTIPLE_CHOICE",
+ "problem": "'It's on the house'는 식당에서 무슨 뜻일까요?",
+ "translation": "",
+ "options": ["화장실입니다", "식당에서 무료로 제공하는 것입니다", "직원의 휴식 시간입니다", "메인 요리입니다"],
+ "answer": "식당에서 무료로 제공하는 것입니다"
+ },
+ {
+ "id": "eng_lv4_mc_02",
+ "language": "ENGLISH",
+ "level": 4,
+ "type": "MULTIPLE_CHOICE",
+ "problem": "'Keep up the good work'의 뉘앙스와 가장 비슷한 한국어는?",
+ "translation": "",
+ "options": ["수고하세요", "시끄러워요", "피곤해요", "안녕하세요"],
+ "answer": "수고하세요"
+ },
+ {
+ "id": "eng_lv4_fb_01",
+ "language": "ENGLISH",
+ "level": 4,
+ "type": "FILL_IN_THE_BLANK",
+ "problem": "Is this seat ______?",
+ "translation": "이 자리에 다른 분이 앉으셨어요?",
+ "options": ["taken", "stood", "slept", "came"],
+ "answer": "taken"
+ },
+ {
+ "id": "eng_lv4_fb_02",
+ "language": "ENGLISH",
+ "level": 4,
+ "type": "FILL_IN_THE_BLANK",
+ "problem": "I like K-dramas, so I ______ to learn Korean.",
+ "translation": "제가 한국 드라마를 좋아해서 한국어를 배우게 됐어요.",
+ "options": ["came", "taught", "made", "watched"],
+ "answer": "came"
+ },
+ {
+ "id": "eng_lv5_so_01",
+ "language": "ENGLISH",
+ "level": 5,
+ "type": "SENTENCE_ORDER",
+ "problem": "as / wasn't / good / I / it / but / bad / it / expected / wasn't / to / be / honest / , / .",
+ "translation": "솔직히 기대했던 것만큼은 아니지만 나쁘지 않았어요.",
+ "options": [],
+ "answer": "To be honest, it wasn't as good as I expected, but it wasn't bad."
+ },
+ {
+ "id": "eng_lv5_so_02",
+ "language": "ENGLISH",
+ "level": 5,
+ "type": "SENTENCE_ORDER",
+ "problem": "is / the / wagging / the / this / a / of / dog / tail / case / .",
+ "translation": "이럴 때 배보다 배꼽이 더 크다고 하죠.",
+ "options": [],
+ "answer": "This is a case of the tail wagging the dog."
+ },
+ {
+ "id": "eng_lv5_mc_01",
+ "language": "ENGLISH",
+ "level": 5,
+ "type": "MULTIPLE_CHOICE",
+ "problem": "'To be good at reading the room'은 무슨 뜻인가요?",
+ "translation": "",
+ "options": ["달리기를 잘하다", "눈치가 빠르다 (분위기나 사회적 신호를 잘 파악하다)", "시력이 좋다", "글을 빨리 읽다"],
+ "answer": "눈치가 빠르다 (분위기나 사회적 신호를 잘 파악하다)"
+ },
+ {
+ "id": "eng_lv5_mc_02",
+ "language": "ENGLISH",
+ "level": 5,
+ "type": "MULTIPLE_CHOICE",
+ "problem": "'A deep sense of connection and affection'의 의미와 가장 가까운 한국어 단어는?",
+ "translation": "",
+ "options": ["국 (Guk)", "정 (Jeong)", "설 (Seol)", "옷 (Ot)"],
+ "answer": "정 (Jeong)"
+ },
+ {
+ "id": "eng_lv5_fb_01",
+ "language": "ENGLISH",
+ "level": 5,
+ "type": "FILL_IN_THE_BLANK",
+ "problem": "He breaks promises as ______ as he eats meals.",
+ "translation": "그 친구는 약속을 밥 먹듯이 어겨서 믿을 수가 없어.",
+ "options": ["habitually", "beautifully", "sleepily", "fashionably"],
+ "answer": "habitually"
+ },
+ {
+ "id": "eng_lv5_fb_02",
+ "language": "ENGLISH",
+ "level": 5,
+ "type": "FILL_IN_THE_BLANK",
+ "problem": "Korea's delivery culture is, ______, impressive.",
+ "translation": "한국의 배달 문화는 새삼 감탄스러울 정도예요.",
+ "options": ["once again", "daringly", "never", "perhaps"],
+ "answer": "once again"
+ }
+]
\ No newline at end of file
diff --git a/app/src/main/assets/korean_quizzes.json b/app/src/main/assets/korean_quizzes.json
new file mode 100644
index 0000000..cc436e4
--- /dev/null
+++ b/app/src/main/assets/korean_quizzes.json
@@ -0,0 +1,302 @@
+[
+ {
+ "id": "kor_lv1_so_01",
+ "language": "KOREAN",
+ "level": 1,
+ "type": "SENTENCE_ORDER",
+ "problem": "안녕하세요 / ? / 만나서 / 반가워요",
+ "translation": "Hello? Nice to meet you.",
+ "options": [],
+ "answer": "안녕하세요? 만나서 반가워요."
+ },
+ {
+ "id": "kor_lv1_so_02",
+ "language": "KOREAN",
+ "level": 1,
+ "type": "SENTENCE_ORDER",
+ "problem": "이름이 / 뭐예요 / ?",
+ "translation": "What is your name?",
+ "options": [],
+ "answer": "이름이 뭐예요?"
+ },
+ {
+ "id": "kor_lv1_mc_01",
+ "language": "KOREAN",
+ "level": 1,
+ "type": "MULTIPLE_CHOICE",
+ "problem": "What does '감사합니다' mean?",
+ "translation": "",
+ "options": ["Hello", "Sorry", "Thank you", "Excuse me"],
+ "answer": "Thank you"
+ },
+ {
+ "id": "kor_lv1_mc_02",
+ "language": "KOREAN",
+ "level": 1,
+ "type": "MULTIPLE_CHOICE",
+ "problem": "What is '물'?",
+ "translation": "",
+ "options": ["Water", "Food", "Money", "House"],
+ "answer": "Water"
+ },
+ {
+ "id": "kor_lv1_fb_01",
+ "language": "KOREAN",
+ "level": 1,
+ "type": "FILL_IN_THE_BLANK",
+ "problem": "화장실은 ______에 있어요?",
+ "translation": "Where is the restroom?",
+ "options": ["어디", "언제", "누구", "무엇"],
+ "answer": "어디 (Where)"
+ },
+ {
+ "id": "kor_lv1_fb_02",
+ "language": "KOREAN",
+ "level": 1,
+ "type": "FILL_IN_THE_BLANK",
+ "problem": "이거 ______예요?",
+ "translation": "How much is this?",
+ "options": ["얼마", "왜", "어떻게", "몇"],
+ "answer": "얼마 (How much)"
+ },
+ {
+ "id": "kor_lv2_so_01",
+ "language": "KOREAN",
+ "level": 2,
+ "type": "SENTENCE_ORDER",
+ "problem": "메뉴판 / 좀 / 주세요 / .",
+ "translation": "Please give me the menu.",
+ "options": [],
+ "answer": "메뉴판 좀 주세요."
+ },
+ {
+ "id": "kor_lv2_so_02",
+ "language": "KOREAN",
+ "level": 2,
+ "type": "SENTENCE_ORDER",
+ "problem": "가장 / 가까운 / 어디예요 / 지하철역은 / ?",
+ "translation": "Where is the nearest subway station?",
+ "options": [],
+ "answer": "가장 가까운 지하철역은 어디예요?"
+ },
+ {
+ "id": "kor_lv2_mc_01",
+ "language": "KOREAN",
+ "level": 2,
+ "type": "MULTIPLE_CHOICE",
+ "problem": "What is the meaning of '주문할게요'?",
+ "translation": "",
+ "options": ["I want to pay", "I'd like to order", "I have a reservation", "Please give me a discount"],
+ "answer": "I'd like to order"
+ },
+ {
+ "id": "kor_lv2_mc_02",
+ "language": "KOREAN",
+ "level": 2,
+ "type": "MULTIPLE_CHOICE",
+ "problem": "When do you use '잠시만요'?",
+ "translation": "",
+ "options": ["When you are very happy", "When you want to say goodbye", "When you ask someone to wait for a moment", "When you want to buy something"],
+ "answer": "When you ask someone to wait for a moment"
+ },
+ {
+ "id": "kor_lv2_fb_01",
+ "language": "KOREAN",
+ "level": 2,
+ "type": "FILL_IN_THE_BLANK",
+ "problem": "이 버스, 시청에 ______?",
+ "translation": "Does this bus go to the city hall?",
+ "options": ["가요", "와요", "먹어요", "자요"],
+ "answer": "가요 (go)"
+ },
+ {
+ "id": "kor_lv2_fb_02",
+ "language": "KOREAN",
+ "level": 2,
+ "type": "FILL_IN_THE_BLANK",
+ "problem": "계산서 좀 ______.",
+ "translation": "The bill, please.",
+ "options": ["앉으세요", "주세요", "입으세요", "읽으세요"],
+ "answer": "주세요 (give me)"
+ },
+ {
+ "id": "kor_lv3_so_01",
+ "language": "KOREAN",
+ "level": 3,
+ "type": "SENTENCE_ORDER",
+ "problem": "추천해 / 메뉴 / 가장 / 인기 / 있는 / 주시겠어요 / ?",
+ "translation": "Could you recommend the most popular menu item?",
+ "options": [],
+ "answer": "가장 인기 있는 메뉴 추천해 주시겠어요?"
+ },
+ {
+ "id": "kor_lv3_so_02",
+ "language": "KOREAN",
+ "level": 3,
+ "type": "SENTENCE_ORDER",
+ "problem": "공항까지 / 택시로 / 얼마나 / 걸릴까요 / ?",
+ "translation": "How long will it take to get to the airport by taxi?",
+ "options": [],
+ "answer": "공항까지 택시로 얼마나 걸릴까요?"
+ },
+ {
+ "id": "kor_lv3_mc_01",
+ "language": "KOREAN",
+ "level": 3,
+ "type": "MULTIPLE_CHOICE",
+ "problem": "What does '따로따로 계산해 주세요' mean?",
+ "translation": "",
+ "options": ["We'll pay together.", "Please split the bill.", "Please give us a receipt.", "Can I pay with a credit card?"],
+ "answer": "Please split the bill."
+ },
+ {
+ "id": "kor_lv3_mc_02",
+ "language": "KOREAN",
+ "level": 3,
+ "type": "MULTIPLE_CHOICE",
+ "problem": "What is '환승'?",
+ "translation": "",
+ "options": ["Refund", "To board", "To transfer (e.g., subway)", "To get off"],
+ "answer": "To transfer (e.g., subway)"
+ },
+ {
+ "id": "kor_lv3_fb_01",
+ "language": "KOREAN",
+ "level": 3,
+ "type": "FILL_IN_THE_BLANK",
+ "problem": "창가 쪽 자리로 ______.",
+ "translation": "I'd like a window seat, please.",
+ "options": ["부탁드립니다", "괜찮습니다", "모릅니다", "없습니다"],
+ "answer": "부탁드립니다 (I'd like to request)"
+ },
+ {
+ "id": "kor_lv3_fb_02",
+ "language": "KOREAN",
+ "level": 3,
+ "type": "FILL_IN_THE_BLANK",
+ "problem": "와이파이 비밀번호가 ______?",
+ "translation": "What is the Wi-Fi password?",
+ "options": ["뭐예요", "예뻐요", "비싸요", "멀어요"],
+ "answer": "뭐예요 (what is)"
+ },
+ {
+ "id": "kor_lv4_so_01",
+ "language": "KOREAN",
+ "level": 4,
+ "type": "SENTENCE_ORDER",
+ "problem": "혹시 / 이 / 근처에 / 있나요 / 돈을 / 인출할 / 만한 / 은행이 / ?",
+ "translation": "By any chance, is there a bank nearby where I can withdraw money?",
+ "options": [],
+ "answer": "혹시 이 근처에 돈을 인출할 만한 은행이 있나요?"
+ },
+ {
+ "id": "kor_lv4_so_02",
+ "language": "KOREAN",
+ "level": 4,
+ "type": "SENTENCE_ORDER",
+ "problem": "음식이 / 조금 / 것 / 같아요 / 입맛에 / 안 / 맞는 / .",
+ "translation": "I think the food doesn't quite suit my palate.",
+ "options": [],
+ "answer": "음식이 입맛에 조금 안 맞는 것 같아요."
+ },
+ {
+ "id": "kor_lv4_mc_01",
+ "language": "KOREAN",
+ "level": 4,
+ "type": "MULTIPLE_CHOICE",
+ "problem": "In a restaurant, what is '서비스'?",
+ "translation": "",
+ "options": ["The restroom", "An item that is free of charge, given by the restaurant", "The employee's break time", "The main dish"],
+ "answer": "An item that is free of charge, given by the restaurant"
+ },
+ {
+ "id": "kor_lv4_mc_02",
+ "language": "KOREAN",
+ "level": 4,
+ "type": "MULTIPLE_CHOICE",
+ "problem": "What is the nuance of '수고하세요'?",
+ "translation": "",
+ "options": ["A greeting when leaving a store or workplace, meaning 'Keep up the good work.'", "A phrase used to scold someone.", "A way to say 'I'm very tired.'", "A common way to say 'Hello' to a friend."],
+ "answer": "A greeting when leaving a store or workplace, meaning 'Keep up the good work.'"
+ },
+ {
+ "id": "kor_lv4_fb_01",
+ "language": "KOREAN",
+ "level": 4,
+ "type": "FILL_IN_THE_BLANK",
+ "problem": "이 자리에 다른 분이 ______?",
+ "translation": "Is this seat taken? (Did someone else sit here?)",
+ "options": ["앉으셨어요", "서셨어요", "주무셨어요", "오셨어요"],
+ "answer": "앉으셨어요 (sat down)"
+ },
+ {
+ "id": "kor_lv4_fb_02",
+ "language": "KOREAN",
+ "level": 4,
+ "type": "FILL_IN_THE_BLANK",
+ "problem": "제가 한국 드라마를 좋아해서 한국어를 ______ 됐어요.",
+ "translation": "I like K-dramas, so I came to learn Korean.",
+ "options": ["배우게", "가르치게", "만들게", "보게"],
+ "answer": "배우게 (came to learn)"
+ },
+ {
+ "id": "kor_lv5_so_01",
+ "language": "KOREAN",
+ "level": 5,
+ "type": "SENTENCE_ORDER",
+ "problem": "기대했던 / 아니지만 / 나쁘지 / 것만큼은 / 않았어요 / 솔직히 / .",
+ "translation": "To be honest, it wasn't as good as I expected, but it wasn't bad.",
+ "options": [],
+ "answer": "솔직히 기대했던 것만큼은 아니지만 나쁘지 않았어요."
+ },
+ {
+ "id": "kor_lv5_so_02",
+ "language": "KOREAN",
+ "level": 5,
+ "type": "SENTENCE_ORDER",
+ "problem": "배보다 / 큰 / 배꼽이 / 이럴 / 더 / 때 / 하죠 / 다고 / .",
+ "translation": "This is a case of 'the tail wagging the dog.' (The belly button is bigger than the belly.)",
+ "options": [],
+ "answer": "이럴 때 배보다 배꼽이 더 크다고 하죠."
+ },
+ {
+ "id": "kor_lv5_mc_01",
+ "language": "KOREAN",
+ "level": 5,
+ "type": "MULTIPLE_CHOICE",
+ "problem": "What does '눈치가 빠르다' mean?",
+ "translation": "",
+ "options": ["To be quick at running", "To be good at reading the room or understanding social cues", "To have sharp eyesight", "To be a fast reader"],
+ "answer": "To be good at reading the room or understanding social cues"
+ },
+ {
+ "id": "kor_lv5_mc_02",
+ "language": "KOREAN",
+ "level": 5,
+ "type": "MULTIPLE_CHOICE",
+ "problem": "What is '정'?",
+ "translation": "",
+ "options": ["A specific type of spicy soup", "A unique Korean cultural concept of deep connection, affection, and attachment", "The name of a traditional holiday", "A type of formal clothing"],
+ "answer": "A unique Korean cultural concept of deep connection, affection, and attachment"
+ },
+ {
+ "id": "kor_lv5_fb_01",
+ "language": "KOREAN",
+ "level": 5,
+ "type": "FILL_IN_THE_BLANK",
+ "problem": "그 친구는 약속을 밥 ______ 어겨서 믿을 수가 없어.",
+ "translation": "I can't trust that friend because they break promises as habitually as they eat meals.",
+ "options": ["먹듯이", "보듯이", "자듯이", "입듯이"],
+ "answer": "먹듯이 (like eating)"
+ },
+ {
+ "id": "kor_lv5_fb_02",
+ "language": "KOREAN",
+ "level": 5,
+ "type": "FILL_IN_THE_BLANK",
+ "problem": "한국의 배달 문화는 ______ 감탄스러울 정도예요.",
+ "translation": "Korea's delivery culture is, once again, impressive.",
+ "options": ["새삼", "감히", "차마", "결코"],
+ "answer": "새삼 (newly, once again)"
+ }
+]
\ No newline at end of file
diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png
new file mode 100644
index 0000000..b133336
Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ
diff --git a/app/src/main/java/com/example/chaining/ChainingApp.kt b/app/src/main/java/com/example/chaining/ChainingApp.kt
index 573e214..b2ce2ce 100644
--- a/app/src/main/java/com/example/chaining/ChainingApp.kt
+++ b/app/src/main/java/com/example/chaining/ChainingApp.kt
@@ -1,7 +1,32 @@
package com.example.chaining
import android.app.Application
+import com.example.chaining.data.repository.AreaRepository
+import com.google.firebase.database.FirebaseDatabase
import dagger.hilt.android.HiltAndroidApp
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
+import javax.inject.Inject
@HiltAndroidApp
-class ChainingApp : Application()
+class ChainingApp : Application() {
+ @Inject
+ lateinit var areaRepository: AreaRepository
+
+ private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+
+ override fun onCreate() {
+ super.onCreate()
+ // DB 인스턴스가 생성되기 전에 반드시 호출
+ FirebaseDatabase.getInstance().setPersistenceEnabled(true)
+ preloadData()
+ }
+
+ private fun preloadData() {
+ applicationScope.launch {
+ areaRepository.refreshAreasIfNeeded()
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/chaining/MainActivity.kt b/app/src/main/java/com/example/chaining/MainActivity.kt
index 15772cf..d5cfa1e 100644
--- a/app/src/main/java/com/example/chaining/MainActivity.kt
+++ b/app/src/main/java/com/example/chaining/MainActivity.kt
@@ -4,24 +4,35 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
+import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
-import androidx.compose.ui.tooling.preview.Preview
-import com.example.chaining.ui.theme.chainingTheme
+import androidx.navigation.compose.rememberNavController
+import com.example.chaining.ui.navigation.NavGraph
+import com.example.chaining.ui.theme.ChainingTheme
+import com.google.firebase.FirebaseApp
+import dagger.hilt.android.AndroidEntryPoint
+@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
+ FirebaseApp.initializeApp(this)
enableEdgeToEdge()
setContent {
- chainingTheme {
- Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
- greeting(
- name = "Android",
+ ChainingTheme(dynamicColor = false) {
+ val navController = rememberNavController()
+ Scaffold(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.background),
+ ) { innerPadding ->
+ NavGraph(
+ navController = navController,
modifier = Modifier.padding(innerPadding),
)
}
@@ -29,22 +40,3 @@ class MainActivity : ComponentActivity() {
}
}
}
-
-@Composable
-fun greeting(
- name: String,
- modifier: Modifier = Modifier,
-) {
- Text(
- text = "Hi $name!",
- modifier = modifier,
- )
-}
-
-@Preview(showBackground = true)
-@Composable
-fun greetingPreview() {
- chainingTheme {
- greeting("Android")
- }
-}
diff --git a/app/src/main/java/com/example/chaining/data/local/AppDatabase.kt b/app/src/main/java/com/example/chaining/data/local/AppDatabase.kt
new file mode 100644
index 0000000..bdbcfdc
--- /dev/null
+++ b/app/src/main/java/com/example/chaining/data/local/AppDatabase.kt
@@ -0,0 +1,30 @@
+package com.example.chaining.data.local
+
+import androidx.room.Database
+import androidx.room.RoomDatabase
+import androidx.room.TypeConverters
+import com.example.chaining.data.local.dao.AreaDao
+import com.example.chaining.data.local.dao.NotificationDao
+import com.example.chaining.data.local.dao.UserDao
+import com.example.chaining.data.local.entity.AreaEntity
+import com.example.chaining.data.local.entity.NotificationEntity
+import com.example.chaining.data.local.entity.UserEntity
+
+@Database(
+ entities = [UserEntity::class, NotificationEntity::class, AreaEntity::class],
+ version = 8,
+ exportSchema = false,
+)
+@TypeConverters(Converters::class)
+abstract class AppDatabase : RoomDatabase() {
+ abstract fun userDao(): UserDao
+
+ abstract fun notificationDao(): NotificationDao
+
+ abstract fun areaDao(): AreaDao
+
+ companion object {
+ @Volatile
+ private var instance: AppDatabase? = null
+ }
+}
diff --git a/app/src/main/java/com/example/chaining/data/local/Converters.kt b/app/src/main/java/com/example/chaining/data/local/Converters.kt
new file mode 100644
index 0000000..41761c2
--- /dev/null
+++ b/app/src/main/java/com/example/chaining/data/local/Converters.kt
@@ -0,0 +1,46 @@
+package com.example.chaining.data.local
+
+import androidx.room.TypeConverter
+import com.example.chaining.domain.model.Application
+import com.example.chaining.domain.model.LanguagePref
+import com.example.chaining.domain.model.RecruitPost
+import com.example.chaining.domain.model.UserSummary
+import kotlinx.serialization.json.Json
+
+class Converters {
+ @TypeConverter
+ fun fromLanguagePrefMap(map: Map): String = Json.encodeToString(map)
+
+ @TypeConverter
+ fun toLanguagePrefMap(value: String): Map = if (value.isEmpty()) emptyMap() else Json.decodeFromString(value)
+
+ @TypeConverter
+ fun fromApplicationMap(map: Map): String = Json.encodeToString(map)
+
+ @TypeConverter
+ fun fromPostMap(map: Map): String = Json.encodeToString(map)
+
+ @TypeConverter
+ fun toApplicationMap(value: String): Map = if (value.isEmpty()) emptyMap() else Json.decodeFromString(value)
+
+ @TypeConverter
+ fun toPostMap(value: String): Map = if (value.isEmpty()) emptyMap() else Json.decodeFromString(value)
+
+ @TypeConverter
+ fun fromLikedPosts(map: Map): String = Json.encodeToString(map)
+
+ @TypeConverter
+ fun toLikedPosts(value: String): Map = if (value.isEmpty()) emptyMap() else Json.decodeFromString(value)
+
+ @TypeConverter
+ fun fromFollowMap(map: Map): String = Json.encodeToString(map)
+
+ @TypeConverter
+ fun toFollowMap(value: String): Map = if (value.isEmpty()) emptyMap() else Json.decodeFromString(value)
+
+ @TypeConverter
+ fun fromUserSummary(value: UserSummary?): String = value?.let { Json.encodeToString(it) } ?: ""
+
+ @TypeConverter
+ fun toUserSummary(value: String?): UserSummary? = if (value.isNullOrEmpty()) null else Json.decodeFromString(value)
+}
diff --git a/app/src/main/java/com/example/chaining/data/local/dao/AreaDao.kt b/app/src/main/java/com/example/chaining/data/local/dao/AreaDao.kt
new file mode 100644
index 0000000..ec737c9
--- /dev/null
+++ b/app/src/main/java/com/example/chaining/data/local/dao/AreaDao.kt
@@ -0,0 +1,27 @@
+package com.example.chaining.data.local.dao
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import androidx.room.Transaction
+import com.example.chaining.data.local.entity.AreaEntity
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+interface AreaDao {
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insertAll(areas: List)
+
+ @Query("SELECT * FROM area_codes ORDER BY region_name ASC")
+ fun getAll(): Flow>
+
+ @Query("DELETE FROM area_codes")
+ suspend fun deleteAll()
+
+ @Transaction
+ suspend fun clearAndInsert(areas: List) {
+ deleteAll()
+ insertAll(areas)
+ }
+}
diff --git a/app/src/main/java/com/example/chaining/data/local/dao/NotificationDao.kt b/app/src/main/java/com/example/chaining/data/local/dao/NotificationDao.kt
new file mode 100644
index 0000000..196687b
--- /dev/null
+++ b/app/src/main/java/com/example/chaining/data/local/dao/NotificationDao.kt
@@ -0,0 +1,20 @@
+package com.example.chaining.data.local.dao
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import com.example.chaining.data.local.entity.NotificationEntity
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+interface NotificationDao {
+ @Query("SELECT * FROM notification_table WHERE uid = :uid ORDER BY createdAt DESC")
+ fun getNotifications(uid: String): Flow>
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insertDao(notifications: List)
+
+ @Query("DELETE FROM notification_table WHERE uid = :uid")
+ suspend fun deleteAll(uid: String)
+}
diff --git a/app/src/main/java/com/example/chaining/data/local/dao/UserDao.kt b/app/src/main/java/com/example/chaining/data/local/dao/UserDao.kt
new file mode 100644
index 0000000..ef80499
--- /dev/null
+++ b/app/src/main/java/com/example/chaining/data/local/dao/UserDao.kt
@@ -0,0 +1,24 @@
+package com.example.chaining.data.local.dao
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import androidx.room.Update
+import com.example.chaining.data.local.entity.UserEntity
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+interface UserDao {
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insertUser(user: UserEntity)
+
+ @Update
+ suspend fun updateUser(user: UserEntity)
+
+ @Query("SELECT * FROM user_table WHERE id = :id")
+ fun getUser(id: String): Flow
+
+ @Query("DELETE FROM user_table WHERE id = :id")
+ suspend fun deleteUser(id: String)
+}
diff --git a/app/src/main/java/com/example/chaining/data/local/entity/AreaEntity.kt b/app/src/main/java/com/example/chaining/data/local/entity/AreaEntity.kt
new file mode 100644
index 0000000..323afbf
--- /dev/null
+++ b/app/src/main/java/com/example/chaining/data/local/entity/AreaEntity.kt
@@ -0,0 +1,21 @@
+package com.example.chaining.data.local.entity
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+
+@Entity(tableName = "area_codes")
+data class AreaEntity(
+ @PrimaryKey(autoGenerate = true)
+ val id: Int = 0,
+ @ColumnInfo(name = "region_code")
+ val regionCode: String,
+ @ColumnInfo(name = "region_name")
+ val regionName: String,
+ @ColumnInfo(name = "sub_region_code")
+ val subRegionCode: String,
+ @ColumnInfo(name = "sub_region_name")
+ val subRegionName: String,
+ @ColumnInfo(name = "row_num")
+ val rowNum: Int,
+)
diff --git a/app/src/main/java/com/example/chaining/data/local/entity/NotificationEntity.kt b/app/src/main/java/com/example/chaining/data/local/entity/NotificationEntity.kt
new file mode 100644
index 0000000..528df73
--- /dev/null
+++ b/app/src/main/java/com/example/chaining/data/local/entity/NotificationEntity.kt
@@ -0,0 +1,27 @@
+package com.example.chaining.data.local.entity
+
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+import com.example.chaining.domain.model.UserSummary
+
+@Entity(tableName = "notification_table")
+data class NotificationEntity(
+ // 알림 ID
+ @PrimaryKey val id: String = "",
+ // 알림 종류
+ val type: String = "",
+ // 관련 모집글
+ val postId: String? = null,
+ // 지원서일 경우 지원서 ID
+ val applicationId: String? = null,
+ // 팔로우나 신청자 ID
+ val sender: UserSummary? = UserSummary(),
+ // 지원서 승인/거절 상태
+ val status: String? = null,
+ // 타임 스탬프
+ val createdAt: Long = 0L,
+ val closeAt: Long? = 0L,
+ // 읽음 여부
+ val isRead: Boolean = false,
+ val uid: String,
+)
diff --git a/app/src/main/java/com/example/chaining/data/local/entity/UserEntity.kt b/app/src/main/java/com/example/chaining/data/local/entity/UserEntity.kt
new file mode 100644
index 0000000..9e7a572
--- /dev/null
+++ b/app/src/main/java/com/example/chaining/data/local/entity/UserEntity.kt
@@ -0,0 +1,37 @@
+package com.example.chaining.data.local.entity
+
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+import com.example.chaining.domain.model.Application
+import com.example.chaining.domain.model.LanguagePref
+import com.example.chaining.domain.model.RecruitPost
+import com.example.chaining.domain.model.UserSummary
+
+@Entity(tableName = "user_table")
+data class UserEntity(
+ // DB key (uid)
+ @PrimaryKey val id: String = "",
+ val nickname: String = "",
+ val profileImageUrl: String = "",
+ // 출신국
+ val country: String = "",
+ // 거주지
+ val residence: String = "",
+ // 선호 여행지
+ val preferredDestinations: String = "",
+ val preferredLanguages: Map = emptyMap(),
+ // 모집/지원 현황 공개 여부
+ val isPublic: Boolean = true,
+ // 내가 모집한 글 (Post ID만 저장)
+ val recruitPosts: Map = emptyMap(),
+ // 내가 지원한 글 (Application ID만 저장)
+ val applications: Map = emptyMap(),
+ // 서버 타임스탬프
+ val createdAt: Long = 0L,
+ // Soft Delete 플래그 추가
+ val isDeleted: Boolean = false,
+ // 관심글 postId
+ val likedPosts: Map = emptyMap(),
+ val following: Map = emptyMap(),
+ val follower: Map = emptyMap(),
+)
diff --git a/app/src/main/java/com/example/chaining/data/model/AreaCodeResponse.kt b/app/src/main/java/com/example/chaining/data/model/AreaCodeResponse.kt
new file mode 100644
index 0000000..5d7ff58
--- /dev/null
+++ b/app/src/main/java/com/example/chaining/data/model/AreaCodeResponse.kt
@@ -0,0 +1,51 @@
+package com.example.chaining.data.model
+
+import com.google.gson.annotations.SerializedName
+
+data class AreaCodeResponse(
+ @SerializedName("response")
+ val response: AreaCodeResponse,
+) {
+ data class AreaCodeResponse(
+ @SerializedName("body")
+ val body: AreaCodeBody,
+ @SerializedName("header")
+ val header: AreaCodeHeader,
+ ) {
+ data class AreaCodeBody(
+ @SerializedName("items")
+ val items: AreaCodeItems,
+ @SerializedName("numOfRows")
+ val numOfRows: Int,
+ @SerializedName("pageNo")
+ val pageNo: Int,
+ @SerializedName("totalCount")
+ val totalCount: Int,
+ ) {
+ data class AreaCodeItems(
+ @SerializedName("item")
+ val item: List,
+ ) {
+ data class AreaCodeItem(
+ @SerializedName("lDongRegnCd")
+ val lDongRegnCd: String,
+ @SerializedName("lDongRegnNm")
+ val lDongRegnNm: String,
+ @SerializedName("lDongSignguCd")
+ val lDongSignguCd: String,
+ @SerializedName("lDongSignguNm")
+ val lDongSignguNm: String,
+ @SerializedName("rnum")
+ val rnum: Int,
+ )
+ }
+ }
+
+ data class AreaCodeHeader(
+ @SerializedName("resultCode")
+ val resultCode: String,
+ @SerializedName("resultMsg")
+ val resultMsg: String,
+ )
+ }
+}
diff --git a/app/src/main/java/com/example/chaining/data/model/FeedApiResponse.kt b/app/src/main/java/com/example/chaining/data/model/FeedApiResponse.kt
new file mode 100644
index 0000000..ef9c576
--- /dev/null
+++ b/app/src/main/java/com/example/chaining/data/model/FeedApiResponse.kt
@@ -0,0 +1,45 @@
+package com.example.chaining.data.model
+
+import com.google.gson.annotations.SerializedName
+
+// API 전체 응답 구조
+data class FeedApiResponse(
+ val response: FeedResponse,
+)
+
+// 응답의 헤더와 본문
+data class FeedResponse(
+ val header: FeedResponseHeader,
+ val body: FeedResponseBody,
+)
+
+// 응답 결과 코드와 메시지
+data class FeedResponseHeader(
+ val resultCode: String,
+ val resultMsg: String,
+)
+
+// 실제 데이터 목록과 페이지 정보
+data class FeedResponseBody(
+ val items: FeedApiItems,
+ val numOfRows: Int,
+ val pageNo: Int,
+ val totalCount: Int,
+)
+
+// 관광지 정보 리스트
+data class FeedApiItems(
+ val item: List,
+)
+
+// 피드 UI에 필요한 관광지 개별 정보
+data class TourItem(
+ // 주소
+ @SerializedName("addr1") val address: String,
+ // 관광지명
+ @SerializedName("title") val title: String,
+ // 대표 이미지 URL
+ @SerializedName("firstimage") val imageUrl: String?,
+ // 콘텐츠 ID
+ @SerializedName("contentid") val contentId: String,
+)
diff --git a/app/src/main/java/com/example/chaining/data/model/FilterState.kt b/app/src/main/java/com/example/chaining/data/model/FilterState.kt
new file mode 100644
index 0000000..cb919c7
--- /dev/null
+++ b/app/src/main/java/com/example/chaining/data/model/FilterState.kt
@@ -0,0 +1,14 @@
+package com.example.chaining.data.model
+
+data class FilterState(
+ // 여행지 스타일 (예: 액티비티, 힐링, 문화)
+ val travelStyle: String? = null,
+ // 여행지 (예: 서울, 제주)
+ val travelLocation: String? = null,
+ // 언어 (예: 영어, 중국어)
+ val language: String? = null,
+ // 언어 레벨 (예: 1, 2, 3) - null은 '상관 없음'
+ val languageLevel: Int? = null,
+ // 정렬 방식 ("latest", "interest", "deadline")
+ val sortBy: String = "latest",
+)
diff --git a/app/src/main/java/com/example/chaining/data/repository/ApplicationRepository.kt b/app/src/main/java/com/example/chaining/data/repository/ApplicationRepository.kt
new file mode 100644
index 0000000..ff3ef06
--- /dev/null
+++ b/app/src/main/java/com/example/chaining/data/repository/ApplicationRepository.kt
@@ -0,0 +1,161 @@
+package com.example.chaining.data.repository
+
+import com.example.chaining.domain.model.Application
+import com.google.firebase.auth.FirebaseAuth
+import com.google.firebase.database.DataSnapshot
+import com.google.firebase.database.DatabaseError
+import com.google.firebase.database.DatabaseException
+import com.google.firebase.database.DatabaseReference
+import com.google.firebase.database.ValueEventListener
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.tasks.await
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class ApplicationRepository
+ @Inject
+ constructor(
+ private val auth: FirebaseAuth,
+ private val rootRef: DatabaseReference,
+ ) {
+ private fun uidOrThrow(): String = auth.currentUser?.uid ?: error("로그인이 필요합니다.")
+
+ private fun applicationsRef(): DatabaseReference = rootRef.child("applications")
+
+ /** Create (지원서 제출) */
+ suspend fun submitApplication(application: Application): Result {
+ return try {
+ val uid = uidOrThrow()
+
+ val applicationRef = applicationsRef().push()
+ val applicationId = applicationRef.key ?: error("지원서 ID 생성 실패")
+ val finalApplicant = application.applicant.copy(id = uid)
+
+ val newApplication =
+ application.copy(
+ applicationId = applicationId,
+ createdAt = System.currentTimeMillis(),
+ applicant = finalApplicant,
+ )
+
+ // 멀티패스 업데이트 경로 구성
+ val updates =
+ hashMapOf(
+ // 1. applications 노드에 지원서 저장
+ "/applications/$applicationId" to newApplication,
+ // 2. posts/{postId}/applications/{applicationId} = true
+ "/posts/${application.postId}/applications/$applicationId" to newApplication,
+ // 3. users/{uid}/myApplications/{applicationId} = true
+ "/users/$uid/applications/$applicationId" to newApplication,
+ )
+
+ // 원자적 업데이트 수행
+ rootRef.updateChildren(updates).await()
+
+ Result.success(applicationId)
+ } catch (e: DatabaseException) {
+ if (e.message?.contains("Permission denied", ignoreCase = true) == true) {
+ Result.failure(Exception("먼저 마이페이지에서 프로필 정보를 모두 입력하세요."))
+ } else {
+ Result.failure(Exception("데이터베이스 오류가 발생했습니다: ${e.message}"))
+ }
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ fun observeMyApplicationStatus(): Flow> =
+ callbackFlow {
+ val uid = uidOrThrow()
+ val ref = applicationsRef().orderByChild("applicant/id").equalTo(uid)
+
+ val listener =
+ object : ValueEventListener {
+ override fun onDataChange(snapshot: DataSnapshot) {
+ val applications =
+ snapshot.children.mapNotNull { snap ->
+ snap.getValue(Application::class.java)?.let { app ->
+ app.copy(applicationId = snap.key ?: "")
+ }
+ }
+ trySend(applications).isSuccess
+ }
+
+ override fun onCancelled(error: DatabaseError) {
+ close(error.toException())
+ }
+ }
+
+ ref.addValueEventListener(listener)
+ awaitClose { ref.removeEventListener(listener) }
+ }
+
+ /** Read (지원서 보기) */
+ suspend fun getApplication(id: String): Application? {
+ val snap = applicationsRef().child(id).get().await()
+
+ return snap.getValue(Application::class.java)?.copy(
+ applicationId = id,
+ )
+ }
+
+ /** Read (내가 쓴 지원서 전체 정보, 한 번만 가져오기) */
+ suspend fun getMyApplications(): List {
+ val uid = uidOrThrow()
+ val snap = applicationsRef().orderByChild("applicant/id").equalTo(uid).get().await()
+
+ return snap.children.mapNotNull { it.getValue(Application::class.java) }
+ }
+
+ /** Read (실시간 구독 - 내 계정, 변경사항이 있을 때마다 계속 가져오기) */
+ fun observeMyApplications(): Flow> =
+ callbackFlow {
+ val uid = uidOrThrow()
+ val ref = applicationsRef().orderByChild("applicant/id").equalTo(uid)
+
+ val listener =
+ object : ValueEventListener {
+ override fun onDataChange(snapshot: DataSnapshot) {
+ val applications =
+ snapshot.children.mapNotNull { it.getValue(Application::class.java) }
+ trySend(applications).isSuccess
+ }
+
+ override fun onCancelled(error: DatabaseError) {
+ close(error.toException())
+ }
+ }
+
+ ref.addValueEventListener(listener)
+ awaitClose { ref.removeEventListener(listener) }
+ }
+
+ /** Update (지원서 승인) */
+ suspend fun updateStatus(
+ application: Application,
+ value: String,
+ ) {
+ // 멀티패스 업데이트 경로 구성
+ val updates =
+ hashMapOf(
+ // 1. applications 노드에 지원서 저장
+ "/applications/${application.applicationId}/status" to value,
+ // 2. posts/{postId}/applications/{applicationId}
+ "/posts/${application.postId}/applications/${application.applicationId}/status" to value,
+ // 3. users/{uid}/myApplications/{applicationId}
+ "/users/${application.applicant.id}/applications/${application.applicationId}/status" to value,
+ )
+
+ // 원자적 업데이트 수행
+ rootRef.updateChildren(updates).await()
+ }
+
+ /** Delete (Soft Delete) */
+ suspend fun deleteApplication(id: String) {
+ val updates = mapOf("isDeleted" to true)
+ applicationsRef().child(id).updateChildren(updates).await()
+ }
+ }
diff --git a/app/src/main/java/com/example/chaining/data/repository/AreaRepository.kt b/app/src/main/java/com/example/chaining/data/repository/AreaRepository.kt
new file mode 100644
index 0000000..c535b0a
--- /dev/null
+++ b/app/src/main/java/com/example/chaining/data/repository/AreaRepository.kt
@@ -0,0 +1,100 @@
+package com.example.chaining.data.repository
+
+import android.util.Log
+import com.example.chaining.BuildConfig
+import com.example.chaining.data.local.dao.AreaDao
+import com.example.chaining.data.local.entity.AreaEntity
+import com.example.chaining.data.model.AreaCodeResponse
+import com.example.chaining.di.EnglishArea
+import com.example.chaining.di.KoreanArea
+import com.example.chaining.network.AreaService
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.withContext
+import java.util.Locale
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class AreaRepository
+ @Inject
+ constructor(
+ @KoreanArea private val korApiService: AreaService,
+ @EnglishArea private val engApiService: AreaService,
+ private val areaDao: AreaDao,
+ ) {
+ // 대한민국 주요 시/도 지역 코드 목록
+ private val majorRegionCodes =
+ listOf(
+ 11, 26, 27, 28, 29, 30, 31, 36, 41, 43, 44, 46, 47, 48, 50, 51, 52,
+ )
+
+ val allAreas: Flow> = areaDao.getAll()
+
+ suspend fun refreshAreasIfNeeded() {
+ val isDbEmpty = allAreas.first().isEmpty()
+ if (isDbEmpty) {
+ Log.d("AreaRepository", "Database is empty. Fetching from network...")
+ try {
+ val apiItems = fetchAllMajorAreaCodes()
+ val entities = apiItems.map { it.toEntity() }
+ areaDao.clearAndInsert(entities)
+ Log.d("AreaRepository", "Successfully fetched and saved to DB.")
+ } catch (e: Exception) {
+ Log.e("AreaRepository", "Failed to refresh areas", e)
+ }
+ } else {
+ Log.d("AreaRepository", "Database is already populated. No need to fetch.")
+ }
+ }
+
+ suspend fun fetchAreaCodes(): List {
+ val currentLanguage = Locale.getDefault().language
+
+ val apiService = if (currentLanguage == "ko") korApiService else engApiService
+
+ val response =
+ apiService.getAreaCodes(
+ serviceKey = BuildConfig.DATA_OPEN_API_KEY,
+ )
+ return response.response.body.items.item
+ }
+
+ suspend fun fetchAllMajorAreaCodes(): List {
+ val currentLanguage = Locale.getDefault().language
+ val apiService = if (currentLanguage == "ko") korApiService else engApiService
+
+ return withContext(Dispatchers.IO) {
+ majorRegionCodes.map { code ->
+ async {
+ try {
+ val response =
+ apiService.getAreaCodes(
+ serviceKey = BuildConfig.DATA_OPEN_API_KEY,
+ lDongRegnCd = code,
+ )
+ response.response.body.items.item.firstOrNull()
+ } catch (e: Exception) {
+ Log.e("AreaRepository", "Failed to fetch area for code $code", e)
+ null
+ }
+ }
+ }
+ .awaitAll()
+ .filterNotNull()
+ }
+ }
+
+ private fun AreaCodeResponse.AreaCodeResponse.AreaCodeBody.AreaCodeItems.AreaCodeItem.toEntity(): AreaEntity {
+ return AreaEntity(
+ regionCode = this.lDongRegnCd,
+ regionName = this.lDongRegnNm,
+ subRegionCode = this.lDongSignguCd,
+ subRegionName = this.lDongSignguNm,
+ rowNum = this.rnum,
+ )
+ }
+ }
diff --git a/app/src/main/java/com/example/chaining/data/repository/FeedRepository.kt b/app/src/main/java/com/example/chaining/data/repository/FeedRepository.kt
new file mode 100644
index 0000000..8323e3f
--- /dev/null
+++ b/app/src/main/java/com/example/chaining/data/repository/FeedRepository.kt
@@ -0,0 +1,49 @@
+package com.example.chaining.data.repository
+
+import com.example.chaining.BuildConfig
+import com.example.chaining.data.model.TourItem
+import com.example.chaining.di.EnglishApiService
+import com.example.chaining.di.KoreanApiService
+import com.example.chaining.network.FeedApiService
+import java.util.Locale
+import javax.inject.Inject
+
+class FeedRepository
+ @Inject
+ constructor(
+ @KoreanApiService private val korApiService: FeedApiService,
+ @EnglishApiService private val engApiService: FeedApiService,
+ ) {
+ suspend fun getTourItems(areaCode: Int?): List {
+ val currentLanguage = Locale.getDefault().language
+ val apiService =
+ if (currentLanguage == "ko") {
+ korApiService
+ } else {
+ engApiService
+ }
+
+ val response =
+ if (currentLanguage == "ko") {
+ apiService.getAreaBasedList(
+ serviceKey = BuildConfig.DATA_OPEN_API_KEY,
+ mobileApp = "Chaining",
+ areaCode = areaCode,
+ contentTypeId = 12,
+ )
+ } else {
+ apiService.getAreaBasedList(
+ serviceKey = BuildConfig.DATA_OPEN_API_KEY,
+ mobileApp = "Chaining",
+ areaCode = areaCode,
+ contentTypeId = 76,
+ )
+ }
+ if (response.response.header.resultCode == "0000") {
+ return response.response.body.items.item
+ } else {
+ // 에러 처리
+ throw Exception(response.response.header.resultMsg)
+ }
+ }
+ }
diff --git a/app/src/main/java/com/example/chaining/data/repository/NotificationRepository.kt b/app/src/main/java/com/example/chaining/data/repository/NotificationRepository.kt
new file mode 100644
index 0000000..cca46a6
--- /dev/null
+++ b/app/src/main/java/com/example/chaining/data/repository/NotificationRepository.kt
@@ -0,0 +1,107 @@
+package com.example.chaining.data.repository
+
+import com.example.chaining.data.local.dao.NotificationDao
+import com.example.chaining.data.local.entity.NotificationEntity
+import com.example.chaining.domain.model.Notification
+import com.google.firebase.auth.FirebaseAuth
+import com.google.firebase.database.DataSnapshot
+import com.google.firebase.database.DatabaseError
+import com.google.firebase.database.DatabaseReference
+import com.google.firebase.database.ValueEventListener
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class NotificationRepository
+ @Inject
+ constructor(
+ private val auth: FirebaseAuth,
+ private val rootRef: DatabaseReference,
+ private val notificationDao: NotificationDao,
+ ) {
+ private fun uidOrThrow(): String = auth.currentUser?.uid ?: error("로그인이 필요합니다.")
+
+ private fun notificationsRef(uid: String): DatabaseReference = rootRef.child("notifications").child(uid)
+
+ /** 알림 목록을 실시간으로 가져오기 */
+ fun observeNotifications(): Flow> =
+ callbackFlow {
+ val uid = uidOrThrow()
+ val ref =
+ notificationsRef(uid)
+ .orderByChild("createdAt")
+ .limitToLast(50)
+
+ val listener =
+ object : ValueEventListener {
+ override fun onDataChange(snapshot: DataSnapshot) {
+ val notifications =
+ snapshot.children.mapNotNull { data ->
+ data.getValue(Notification::class.java)?.copy(id = data.key ?: "")
+ }
+ CoroutineScope(Dispatchers.IO).launch {
+ notificationDao.insertDao(notifications.toEntity(uid))
+ }
+ }
+
+ override fun onCancelled(error: DatabaseError) {
+ close(error.toException())
+ }
+ }
+
+ ref.addValueEventListener(listener)
+
+ val job =
+ launch {
+ notificationDao.getNotifications(uid)
+ .collect { entities ->
+ trySend(entities.toNotifications()).isSuccess
+ }
+ }
+ awaitClose {
+ ref.removeEventListener(listener)
+ job.cancel()
+ }
+ }
+
+ /** 3. Notification → NotificationEntity 변환 함수 */
+ private fun List.toEntity(uid: String): List {
+ return this.map {
+ NotificationEntity(
+ id = it.id,
+ type = it.type,
+ postId = it.postId,
+ applicationId = it.applicationId,
+ sender = it.sender,
+ status = it.status,
+ createdAt = it.createdAt,
+ isRead = it.isRead,
+ uid = uid,
+ closeAt = it.closeAt,
+ )
+ }
+ }
+
+ private fun List.toNotifications(): List {
+ return this.map {
+ Notification(
+ id = it.id,
+ type = it.type,
+ postId = it.postId,
+ applicationId = it.applicationId,
+ sender = it.sender,
+ status = it.status,
+ createdAt = it.createdAt,
+ isRead = it.isRead,
+ uid = it.uid,
+ closeAt = it.closeAt,
+ )
+ }
+ }
+ }
diff --git a/app/src/main/java/com/example/chaining/data/repository/RecruitPostRepository.kt b/app/src/main/java/com/example/chaining/data/repository/RecruitPostRepository.kt
new file mode 100644
index 0000000..7b480f3
--- /dev/null
+++ b/app/src/main/java/com/example/chaining/data/repository/RecruitPostRepository.kt
@@ -0,0 +1,154 @@
+package com.example.chaining.data.repository
+
+import android.util.Log
+import com.example.chaining.domain.model.RecruitPost
+import com.google.firebase.auth.FirebaseAuth
+import com.google.firebase.database.DataSnapshot
+import com.google.firebase.database.DatabaseError
+import com.google.firebase.database.DatabaseException
+import com.google.firebase.database.DatabaseReference
+import com.google.firebase.database.ValueEventListener
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.tasks.await
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class RecruitPostRepository
+ @Inject
+ constructor(
+ private val auth: FirebaseAuth,
+ private val rootRef: DatabaseReference,
+ ) {
+ private fun uidOrThrow(): String = auth.currentUser?.uid ?: error("로그인이 필요합니다.")
+
+ private fun postsRef(): DatabaseReference = rootRef.child("posts")
+
+ /** Create (신규 모집글 생성) */
+ suspend fun createPost(post: RecruitPost): Result {
+ return try {
+ val uid = uidOrThrow()
+
+ val postRef = postsRef().push()
+ val postId = postRef.key ?: error("게시글 ID 생성 실패")
+
+ val finalOwner = post.owner.copy(id = uid)
+
+ val newPost =
+ post.copy(
+ postId = postId,
+ createdAt = System.currentTimeMillis(),
+ owner = finalOwner,
+ )
+
+ val updates =
+ hashMapOf(
+ // 1. posts 노드에 모집글 저장
+ "/posts/$postId" to newPost,
+ // 2. user의 recruitPosts 노드에 모집글 저장
+ "/users/$uid/recruitPosts/$postId" to newPost,
+ )
+
+ rootRef.updateChildren(updates).await()
+
+ Result.success(postId)
+ } catch (e: DatabaseException) {
+ if (e.message?.contains("Permission denied", ignoreCase = true) == true) {
+ Result.failure(Exception("먼저 마이페이지에서 프로필 정보를 모두 입력하세요."))
+ } else {
+ Result.failure(Exception("데이터베이스 오류가 발생했습니다: ${e.message}"))
+ }
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ /** Read (단건) */
+ suspend fun getPost(id: String): RecruitPost? {
+ val snap = postsRef().child(id).get().await()
+
+ return snap.getValue(RecruitPost::class.java)?.copy(
+ postId = id,
+ )
+ }
+
+ /** Read (전체) */
+ suspend fun getAllPosts(): List {
+ val snap = postsRef().get().await()
+ val posts = mutableListOf()
+ Log.d("RecruitPostRepository", "postsRef path = ${postsRef()}")
+
+ for (child in snap.children) {
+ val post = child.getValue(RecruitPost::class.java)
+ if (post != null) {
+ posts.add(post.copy(postId = child.key ?: ""))
+ }
+ }
+
+ return posts
+ }
+
+ /** Read (내가 쓴글 전체 정보, 한 번만 가져오기) */
+ suspend fun getMyPosts(): List {
+ val uid = uidOrThrow()
+ val snap = postsRef().orderByChild("owner/id").equalTo(uid).get().await()
+
+ return snap.children.mapNotNull { it.getValue(RecruitPost::class.java) }
+ }
+
+ /** Read (실시간 구독 - 내 계정, 변경사항이 있을 때마다 계속 가져오기) */
+ fun observeMyPosts(): Flow> =
+ callbackFlow {
+ val uid = uidOrThrow()
+ val ref = postsRef().orderByChild("owner/id").equalTo(uid)
+
+ val listener =
+ object : ValueEventListener {
+ override fun onDataChange(snapshot: DataSnapshot) {
+ val posts = snapshot.children.mapNotNull { it.getValue(RecruitPost::class.java) }
+ trySend(posts).isSuccess
+ }
+
+ override fun onCancelled(error: DatabaseError) {
+ close(error.toException())
+ }
+ }
+
+ ref.addValueEventListener(listener)
+ awaitClose { ref.removeEventListener(listener) }
+ }
+
+ /** 전체 RecruitPost 객체 저장 */
+ suspend fun savePost(post: RecruitPost) {
+ val uid = uidOrThrow()
+
+ val updates =
+ hashMapOf(
+ // 1. posts 노드에 모집글 저장
+ "/posts/${post.postId}" to post,
+ // 2. user의 recruitPosts 노드에 모집글 저장
+ "/users/$uid/recruitPosts/${post.postId}" to post,
+ )
+
+ rootRef.updateChildren(updates).await()
+ }
+
+ /** Delete (Soft Delete) */
+ suspend fun deletePost(postId: String) {
+ val uid = uidOrThrow()
+
+ // 멀티패스 업데이트 경로 구성
+ val updates =
+ hashMapOf(
+ // 1. posts 노드에서 해당 모집글을 찾아 isDeleted를 true로 수정
+ "/posts/$postId/isDeleted" to true,
+ // 2. user의 recruitPosts 노드에서 해당 모집글을 찾아 isDeleted를 true로 수정
+ "/users/$uid/recruitPosts/$postId/isDeleted" to true,
+ )
+
+ // 원자적 업데이트 수행
+ rootRef.updateChildren(updates).await()
+ }
+ }
diff --git a/app/src/main/java/com/example/chaining/data/repository/UserRepository.kt b/app/src/main/java/com/example/chaining/data/repository/UserRepository.kt
new file mode 100644
index 0000000..ee05697
--- /dev/null
+++ b/app/src/main/java/com/example/chaining/data/repository/UserRepository.kt
@@ -0,0 +1,358 @@
+package com.example.chaining.data.repository
+
+import com.example.chaining.data.local.dao.UserDao
+import com.example.chaining.data.local.entity.UserEntity
+import com.example.chaining.domain.model.Application
+import com.example.chaining.domain.model.LanguagePref
+import com.example.chaining.domain.model.Notification
+import com.example.chaining.domain.model.RecruitPost
+import com.example.chaining.domain.model.User
+import com.example.chaining.domain.model.UserSummary
+import com.google.firebase.auth.FirebaseAuth
+import com.google.firebase.database.DataSnapshot
+import com.google.firebase.database.DatabaseError
+import com.google.firebase.database.DatabaseException
+import com.google.firebase.database.DatabaseReference
+import com.google.firebase.database.ValueEventListener
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.tasks.await
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class UserRepository
+ @Inject
+ constructor(
+ private val auth: FirebaseAuth,
+ private val rootRef: DatabaseReference,
+ private val userDao: UserDao,
+ ) {
+ private fun uidOrThrow(): String = auth.currentUser?.uid ?: error("로그인이 필요합니다.")
+
+ private fun usersRef(): DatabaseReference = rootRef.child("users")
+
+ /** Create (신규 유저 추가) */
+ suspend fun addUser(user: User): String {
+ val uid = uidOrThrow()
+ val newUser =
+ user.copy(
+ id = uid,
+ createdAt = System.currentTimeMillis(),
+ )
+ usersRef().child(uid).setValue(newUser).await()
+ // Room에도 저장
+ userDao.insertUser(newUser.toEntity())
+ return uid
+ }
+
+ suspend fun checkUserExists(uid: String): Boolean {
+ val snapshot = usersRef().child(uid).get().await()
+ return snapshot.exists()
+ }
+
+ /** 1. Firebase → Room 동기화 후 Flow 제공, Read (실시간 구독 - 내 계정, 변경사항이 있을 때마다 계속 가져오기) */
+ fun observeMyUser(): Flow =
+ callbackFlow {
+ var valueEventListener: ValueEventListener? = null
+ var userRef: DatabaseReference? = null
+
+ val authStateListener =
+ FirebaseAuth.AuthStateListener { firebaseAuth ->
+ valueEventListener?.let { userRef?.removeEventListener(it) }
+
+ val currentUser = firebaseAuth.currentUser
+ if (currentUser == null) {
+ trySend(null)
+ } else {
+ val uid = currentUser.uid
+ userRef = usersRef().child(uid)
+
+ valueEventListener =
+ object : ValueEventListener {
+ override fun onDataChange(snapshot: DataSnapshot) {
+ val user = snapshot.getValue(User::class.java)?.copy(id = uid)
+
+ trySend(user)
+
+ user?.let {
+ CoroutineScope(Dispatchers.IO).launch {
+ userDao.insertUser(it.toEntity())
+ }
+ }
+ }
+
+ override fun onCancelled(error: DatabaseError) {
+ // 실제 에러가 발생하면 Flow를 닫음
+ close(error.toException())
+ }
+ }
+ // 새로 생성한 ValueEventListener를 등록
+ userRef?.addValueEventListener(valueEventListener!!)
+ }
+ }
+
+ // AuthStateListener 등록
+ auth.addAuthStateListener(authStateListener)
+
+ // 4. Flow의 관찰(collect)이 중단되면 모든 리스너를 제거
+ awaitClose {
+ valueEventListener?.let { userRef?.removeEventListener(it) }
+ auth.removeAuthStateListener(authStateListener)
+ }
+ }
+// fun observeMyUser(): Flow = callbackFlow {
+// val uid = uidOrThrow()
+// val ref = usersRef().child(uid)
+//
+// val listener = object : ValueEventListener {
+// override fun onDataChange(snapshot: DataSnapshot) {
+// val user = snapshot.getValue(User::class.java)?.copy(id = uid)
+// if (user != null) {
+// // Firebase → Room DB에 저장
+// val entity = user.toEntity()
+// CoroutineScope(Dispatchers.IO).launch {
+// userDao.insertUser(entity)
+// }
+// }
+// }
+//
+// override fun onCancelled(error: DatabaseError) {
+// close(error.toException())
+// }
+// }
+//
+// ref.addValueEventListener(listener)
+//
+// // Room DB Flow 구독 → UI에 전달
+// val dbFlow = userDao.getUser(uid)
+// val job = CoroutineScope(Dispatchers.IO).launch {
+// dbFlow.collect { entity ->
+// val user = entity?.toUser() // UserEntity → User 변환
+// trySend(user).isSuccess
+// }
+// }
+//
+// awaitClose {
+// ref.removeEventListener(listener)
+// job.cancel()
+// }
+// }
+
+ /** Update (관심글 추가 / 삭제) */
+ suspend fun toggleLikedPost(
+ uid: String,
+ postId: String,
+ ) {
+ val likedRef = usersRef().child(uid).child("likedPosts").child(postId)
+ val snapshot = likedRef.get().await()
+ val isCurrentlyLiked = snapshot.exists()
+
+ val updates = hashMapOf()
+
+ if (isCurrentlyLiked) {
+ // 좋아요 해제
+ updates["/users/$uid/likedPosts/$postId"] = null
+ updates["/posts/$postId/whoLiked/$uid"] = null
+ } else {
+ // 좋아요 추가
+ updates["/users/$uid/likedPosts/$postId"] = true
+ updates["/posts/$postId/whoLiked/$uid"] = true
+ }
+
+ // 원자적 업데이트 수행
+ rootRef.updateChildren(updates).await()
+
+ // Room DB에도 반영 (copyWith 사용)
+ val current = userDao.getUser(uid).firstOrNull() ?: return
+ val newLikedPosts = current.likedPosts.toMutableMap()
+ if (isCurrentlyLiked) newLikedPosts.remove(postId) else newLikedPosts[postId] = true
+
+ val updatedEntity = current.copyWith(mapOf("likedPosts" to newLikedPosts))
+ userDao.updateUser(updatedEntity)
+ }
+
+ /** 프로필 사진 변경 */
+ suspend fun updateProfileImage(newUrl: String) {
+ val uid = uidOrThrow()
+ usersRef().child(uid).child("profileImageUrl").setValue(newUrl).await()
+
+ val current = userDao.getUser(uid).firstOrNull() ?: return
+ val updatedEntity = current.copy(profileImageUrl = newUrl)
+ userDao.updateUser(updatedEntity)
+ }
+
+ /** 테스트 결과 변경 */
+ suspend fun updateTestResult(languagePref: LanguagePref) {
+ val uid = uidOrThrow()
+
+ usersRef().child(uid)
+ .child("preferredLanguages")
+ .child(languagePref.language)
+ .setValue(languagePref)
+ .await()
+
+ val currentEntity = userDao.getUser(uid).firstOrNull() ?: return
+ val updatedMap =
+ currentEntity.preferredLanguages.toMutableMap().apply {
+ this[languagePref.language] = languagePref
+ }
+ val updatedEntity = currentEntity.copy(preferredLanguages = updatedMap)
+ userDao.updateUser(updatedEntity)
+ }
+
+ /** Update (팔로우 추가 / 삭제) */
+ suspend fun toggleFollow(
+ myInfo: UserSummary,
+ otherInfo: UserSummary,
+ ): Result {
+ return try {
+ val followedRef = usersRef().child(myInfo.id).child("following").child(otherInfo.id)
+ val snapshot = followedRef.get().await()
+ val isCurrentlyFollowed = snapshot.exists()
+
+ val updates = hashMapOf()
+
+ if (isCurrentlyFollowed) {
+ // 팔로우 해제
+ updates["/users/${myInfo.id}/following/${otherInfo.id}"] = null
+ updates["/users/${otherInfo.id}/follower/${myInfo.id}"] = null
+ } else {
+ // 팔로우 추가
+ updates["/users/${myInfo.id}/following/${otherInfo.id}"] = otherInfo
+ updates["/users/${otherInfo.id}/follower/${myInfo.id}"] = myInfo
+
+ val newNotificationKey =
+ rootRef.child("notifications")
+ .child(otherInfo.id).push().key ?: error("알림 ID 생성 실패")
+ val notification =
+ Notification(
+ id = newNotificationKey,
+ type = "follow",
+ sender = myInfo,
+ createdAt = System.currentTimeMillis(),
+ isRead = false,
+ uid = otherInfo.id,
+ )
+
+ updates["/notifications/${otherInfo.id}/$newNotificationKey"] = notification
+ }
+
+ // 원자적 업데이트 수행
+ rootRef.updateChildren(updates).await()
+
+ // Room DB에도 반영 (copyWith 사용)
+ val current =
+ userDao.getUser(myInfo.id).firstOrNull() ?: return Result.failure(
+ Exception("로컬 DB에 사용자 정보가 없어 팔로우 상태를 업데이트할 수 없습니다."),
+ )
+ val newFollowing = current.following.toMutableMap()
+ if (isCurrentlyFollowed) {
+ newFollowing.remove(otherInfo.id)
+ } else {
+ newFollowing[otherInfo.id] =
+ otherInfo
+ }
+
+ val updatedEntity = current.copyWith(mapOf("following" to newFollowing))
+ userDao.updateUser(updatedEntity)
+
+ Result.success(Unit)
+ } catch (e: DatabaseException) {
+ Result.failure(Exception("데이터베이스 오류가 발생했습니다."))
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ /** 전체 User 객체 저장 */
+ suspend fun updateMyUser(user: User) {
+ val uid = uidOrThrow()
+
+ // Firebase에 전체 User 저장
+ usersRef().child(uid).setValue(user).await()
+
+ // Room에도 전체 User 저장
+ userDao.insertUser(user.toEntity())
+ }
+
+ /** Delete (Soft Delete) */
+ suspend fun deleteMyUser() {
+ val uid = uidOrThrow()
+ val updates = mapOf("isDeleted" to true)
+ usersRef().child(uid).updateChildren(updates).await()
+ // Room에서도 Soft Delete 반영
+ val current = userDao.getUser(uid).firstOrNull() ?: return
+ userDao.updateUser(current.copy(isDeleted = true))
+ }
+
+ /** 3. User → UserEntity 변환 함수 */
+ private fun User.toEntity(): UserEntity {
+ return UserEntity(
+ id = id,
+ nickname = nickname,
+ profileImageUrl = profileImageUrl,
+ country = country,
+ residence = residence,
+ preferredDestinations = preferredDestinations,
+ preferredLanguages = preferredLanguages,
+ isPublic = isPublic,
+ recruitPosts = recruitPosts,
+ applications = applications,
+ createdAt = createdAt,
+ isDeleted = isDeleted,
+ likedPosts = likedPosts,
+ following = following,
+ follower = follower,
+ )
+ }
+
+ /** 4. UserEntity → User 변환 함수 */
+ private fun UserEntity.toUser(): User {
+ return User(
+ id = id,
+ nickname = nickname,
+ profileImageUrl = profileImageUrl,
+ country = country,
+ residence = residence,
+ preferredDestinations = preferredDestinations,
+ preferredLanguages = preferredLanguages,
+ isPublic = isPublic,
+ recruitPosts = recruitPosts,
+ applications = applications,
+ createdAt = createdAt,
+ isDeleted = isDeleted,
+ likedPosts = likedPosts,
+ following = following,
+ follower = follower,
+ )
+ }
+
+ /** 5. Room UserEntity를 업데이트할 수 있는 복사 함수 */
+ private fun UserEntity.copyWith(updates: Map): UserEntity {
+ return this.copy(
+ nickname = updates["nickname"] as? String ?: nickname,
+ profileImageUrl = updates["profileImageUrl"] as? String ?: profileImageUrl,
+ country = updates["country"] as? String ?: country,
+ residence = updates["residence"] as? String ?: residence,
+ preferredDestinations =
+ updates["preferredDestinations"] as? String
+ ?: preferredDestinations,
+ isPublic = updates["isPublic"] as? Boolean ?: isPublic,
+ // 필요시 나머지 필드도 추가
+ likedPosts = updates["likedPosts"] as? Map ?: likedPosts,
+ preferredLanguages =
+ updates["preferredLanguages"] as? Map
+ ?: preferredLanguages,
+ recruitPosts = updates["recruitPosts"] as? Map ?: recruitPosts,
+ applications = updates["applications"] as? Map ?: applications,
+ following = updates["following"] as? Map ?: following,
+ follower = updates["follower"] as? Map ?: follower,
+ )
+ }
+ }
diff --git a/app/src/main/java/com/example/chaining/di/AreaModule.kt b/app/src/main/java/com/example/chaining/di/AreaModule.kt
new file mode 100644
index 0000000..4064d0a
--- /dev/null
+++ b/app/src/main/java/com/example/chaining/di/AreaModule.kt
@@ -0,0 +1,57 @@
+package com.example.chaining.di
+
+import com.example.chaining.data.local.dao.AreaDao
+import com.example.chaining.data.repository.AreaRepository
+import com.example.chaining.network.AreaService
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import retrofit2.Retrofit
+import retrofit2.converter.gson.GsonConverterFactory
+import javax.inject.Qualifier
+import javax.inject.Singleton
+
+@Qualifier
+@Retention(AnnotationRetention.BINARY)
+annotation class KoreanArea
+
+@Qualifier
+@Retention(AnnotationRetention.BINARY)
+annotation class EnglishArea
+
+@Module
+@InstallIn(SingletonComponent::class)
+object AreaModule {
+ @Provides
+ @Singleton
+ @KoreanArea
+ fun provideKoreanAreaApi(): AreaService {
+ return Retrofit.Builder()
+ .baseUrl("https://apis.data.go.kr/B551011/KorService2/")
+ .addConverterFactory(GsonConverterFactory.create())
+ .build()
+ .create(AreaService::class.java)
+ }
+
+ @Provides
+ @Singleton
+ @EnglishArea
+ fun provideEnglishAreaApi(): AreaService {
+ return Retrofit.Builder()
+ .baseUrl("https://apis.data.go.kr/B551011/EngService2/")
+ .addConverterFactory(GsonConverterFactory.create())
+ .build()
+ .create(AreaService::class.java)
+ }
+
+ @Provides
+ @Singleton
+ fun provideAreaRepository(
+ @KoreanArea korAreaService: AreaService,
+ @EnglishArea engAreaService: AreaService,
+ areaDao: AreaDao,
+ ): AreaRepository {
+ return AreaRepository(korAreaService, engAreaService, areaDao)
+ }
+}
diff --git a/app/src/main/java/com/example/chaining/di/DatabaseModule.kt b/app/src/main/java/com/example/chaining/di/DatabaseModule.kt
new file mode 100644
index 0000000..1ea3076
--- /dev/null
+++ b/app/src/main/java/com/example/chaining/di/DatabaseModule.kt
@@ -0,0 +1,37 @@
+package com.example.chaining.di
+
+import android.content.Context
+import androidx.room.Room
+import com.example.chaining.data.local.AppDatabase
+import com.example.chaining.data.local.dao.AreaDao
+import com.example.chaining.data.local.dao.NotificationDao
+import com.example.chaining.data.local.dao.UserDao
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object DatabaseModule {
+ @Provides
+ @Singleton
+ fun provideDatabase(
+ @ApplicationContext context: Context,
+ ): AppDatabase {
+ return Room.databaseBuilder(context, AppDatabase::class.java, "app_database")
+ .fallbackToDestructiveMigration(true)
+ .build()
+ }
+
+ @Provides
+ fun provideUserDao(db: AppDatabase): UserDao = db.userDao()
+
+ @Provides
+ fun provideNotificationDao(db: AppDatabase): NotificationDao = db.notificationDao()
+
+ @Provides
+ fun provideAreaDao(db: AppDatabase): AreaDao = db.areaDao()
+}
diff --git a/app/src/main/java/com/example/chaining/di/FeedModule.kt b/app/src/main/java/com/example/chaining/di/FeedModule.kt
new file mode 100644
index 0000000..8529c03
--- /dev/null
+++ b/app/src/main/java/com/example/chaining/di/FeedModule.kt
@@ -0,0 +1,113 @@
+package com.example.chaining.di
+
+import com.example.chaining.data.repository.FeedRepository
+import com.example.chaining.network.FeedApiService
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import okhttp3.OkHttpClient
+import okhttp3.logging.HttpLoggingInterceptor
+import retrofit2.Retrofit
+import retrofit2.converter.gson.GsonConverterFactory
+import javax.inject.Qualifier
+import javax.inject.Singleton
+
+@Qualifier
+@Retention(AnnotationRetention.BINARY)
+annotation class KoreanApiService
+
+@Qualifier
+@Retention(AnnotationRetention.BINARY)
+annotation class EnglishApiService
+
+@Module
+@InstallIn(SingletonComponent::class)
+object FeedModule {
+ // HttpLoggingInterceptor를 제공하는 함수 추가
+ @Provides
+ @Singleton
+ fun provideLoggingInterceptor(): HttpLoggingInterceptor {
+ return HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY)
+ }
+
+ // OkHttpClient를 제공하는 함수 추가
+ @Provides
+ @Singleton
+ fun provideOkHttpClient(interceptor: HttpLoggingInterceptor): OkHttpClient {
+ return OkHttpClient.Builder()
+ .addInterceptor(interceptor)
+ .build()
+ }
+
+// @Provides
+// @Singleton
+// fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
+// return Retrofit.Builder()
+// .baseUrl("https://apis.data.go.kr/B551011/KorService2/")
+// .client(okHttpClient)
+// .addConverterFactory(GsonConverterFactory.create())
+// .build()
+// }
+
+ @Provides
+ @Singleton
+ @KoreanApiService
+ fun provideKoreanRetrofit(okHttpClient: OkHttpClient): Retrofit {
+ return Retrofit.Builder()
+ .baseUrl("https://apis.data.go.kr/B551011/KorService2/")
+ .client(okHttpClient)
+ .addConverterFactory(GsonConverterFactory.create())
+ .build()
+ }
+
+ @Provides
+ @Singleton
+ @EnglishApiService
+ fun provideEnglishRetrofit(okHttpClient: OkHttpClient): Retrofit {
+ return Retrofit.Builder()
+ // 사용자가 알려준 대로, 마지막 경로만 EngService2로 변경합니다.
+ .baseUrl("https://apis.data.go.kr/B551011/EngService2/")
+ .client(okHttpClient)
+ .addConverterFactory(GsonConverterFactory.create())
+ .build()
+ }
+
+// @Provides
+// @Singleton
+// fun provideTourApiService(retrofit: Retrofit): FeedApiService {
+// return retrofit.create(FeedApiService::class.java)
+// }
+
+ @Provides
+ @Singleton
+ @KoreanApiService
+ fun provideKorApiService(
+ @KoreanApiService retrofit: Retrofit,
+ ): FeedApiService {
+ return retrofit.create(FeedApiService::class.java)
+ }
+
+ @Provides
+ @Singleton
+ @EnglishApiService
+ fun provideEngApiService(
+ @EnglishApiService retrofit: Retrofit,
+ ): FeedApiService {
+ return retrofit.create(FeedApiService::class.java)
+ }
+
+ // @Provides
+// @Singleton
+// fun provideFeedRepository(apiService: FeedApiService): FeedRepository {
+// return FeedRepository(apiService)
+// }
+ @Provides
+ @Singleton
+ fun provideFeedRepository(
+ @KoreanApiService korApiService: FeedApiService,
+ @EnglishApiService engApiService: FeedApiService,
+ ): FeedRepository {
+ return FeedRepository(korApiService, engApiService)
+ }
+}
diff --git a/app/src/main/java/com/example/chaining/di/FirebaseModule.kt b/app/src/main/java/com/example/chaining/di/FirebaseModule.kt
new file mode 100644
index 0000000..b79d11f
--- /dev/null
+++ b/app/src/main/java/com/example/chaining/di/FirebaseModule.kt
@@ -0,0 +1,30 @@
+package com.example.chaining.di
+
+import com.google.firebase.auth.FirebaseAuth
+import com.google.firebase.database.DatabaseReference
+import com.google.firebase.database.FirebaseDatabase
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object FirebaseModule {
+ @Provides
+ @Singleton
+ fun provideFirebaseAuth(): FirebaseAuth = FirebaseAuth.getInstance()
+
+ @Provides
+ @Singleton
+ fun provideFirebaseDatabase(): FirebaseDatabase {
+ // 특정 URL을 쓰고 싶다면:
+ // return FirebaseDatabase.getInstance("https://.firebasedatabase.app")
+ return FirebaseDatabase.getInstance("https://chaining-88dbd-default-rtdb.firebaseio.com/")
+ }
+
+ @Provides
+ @Singleton
+ fun provideRootRef(db: FirebaseDatabase): DatabaseReference = db.reference
+}
diff --git a/app/src/main/java/com/example/chaining/domain/model/Application.kt b/app/src/main/java/com/example/chaining/domain/model/Application.kt
new file mode 100644
index 0000000..49e9c15
--- /dev/null
+++ b/app/src/main/java/com/example/chaining/domain/model/Application.kt
@@ -0,0 +1,23 @@
+package com.example.chaining.domain.model
+
+import com.google.firebase.database.PropertyName
+
+@kotlinx.serialization.Serializable
+data class Application(
+ val applicationId: String = "",
+ // 어떤 모집글에 지원했는지
+ val postId: String = "",
+ // 모집글 작성자 정보
+ val owner: UserSummary = UserSummary(),
+ // 모집글 제목 (캐싱)
+ val recruitPostTitle: String = "",
+ // 지원자 간단 프로필
+ val applicant: UserSummary = UserSummary(),
+ // 자기 소개
+ val introduction: String = "",
+ val createdAt: Long = 0L,
+ var status: String = "PENDING",
+ // Soft Delete 플래그 추가
+ @get:PropertyName("isDeleted")
+ val isDeleted: Boolean = false,
+)
diff --git a/app/src/main/java/com/example/chaining/domain/model/Notification.kt b/app/src/main/java/com/example/chaining/domain/model/Notification.kt
new file mode 100644
index 0000000..8bd0923
--- /dev/null
+++ b/app/src/main/java/com/example/chaining/domain/model/Notification.kt
@@ -0,0 +1,25 @@
+package com.example.chaining.domain.model
+
+import com.google.firebase.database.PropertyName
+
+data class Notification(
+ // 알림 ID
+ val id: String = "",
+ // 알림 종류
+ val type: String = "",
+ // 관련 모집글
+ val postId: String? = null,
+ // 지원서일 경우 지원서 ID
+ val applicationId: String? = null,
+ // 팔로우나 신청자
+ val sender: UserSummary? = UserSummary(),
+ // 지원서 승인/거절 상태
+ val status: String? = null,
+ // 타임 스탬프
+ val createdAt: Long = 0L,
+ val closeAt: Long? = 0L,
+ // 읽음 여부
+ @get:PropertyName("isRead")
+ val isRead: Boolean = false,
+ val uid: String = "",
+)
diff --git a/app/src/main/java/com/example/chaining/domain/model/QuizItem.kt b/app/src/main/java/com/example/chaining/domain/model/QuizItem.kt
new file mode 100644
index 0000000..7629eb5
--- /dev/null
+++ b/app/src/main/java/com/example/chaining/domain/model/QuizItem.kt
@@ -0,0 +1,31 @@
+package com.example.chaining.domain.model
+
+data class QuizItem(
+ // 고유 ID
+ val id: String = "",
+ // 언어 종류 ("KOREAN" 또는 "ENGLISH")
+ val language: String = "",
+ // 난이도 (1-5)
+ val level: Int = 0,
+ // 퀴즈 유형 (SENTENCE_ORDER, MULTIPLE_CHOICE, FILL_IN_THE_BLANK)
+ val type: String = "",
+ // 문제 내용
+ val problem: String = "",
+ // 문제 번역
+ val translation: String = "",
+ // 객관식 보기 목록
+ val options: List = emptyList(),
+ // 정답
+ val answer: String = "",
+)
+
+enum class QuizType {
+ // 문장 순서 맞추기
+ SENTENCE_ORDER,
+
+ // 단어 의미 맞추기
+ MULTIPLE_CHOICE,
+
+ // 문장 빈칸 채우기
+ FILL_IN_THE_BLANK,
+}
diff --git a/app/src/main/java/com/example/chaining/domain/model/RecruitPost.kt b/app/src/main/java/com/example/chaining/domain/model/RecruitPost.kt
new file mode 100644
index 0000000..10fe581
--- /dev/null
+++ b/app/src/main/java/com/example/chaining/domain/model/RecruitPost.kt
@@ -0,0 +1,46 @@
+package com.example.chaining.domain.model
+
+import com.google.firebase.database.PropertyName
+
+@kotlinx.serialization.Serializable
+data class RecruitPost(
+ val postId: String = "",
+ // 제목
+ val title: String = "",
+ // 선호 여행지 스타일
+ val preferredDestinations: String = "",
+ // 선호 여행지 or 장소
+ val preferredLocations: String = "",
+ // 여행 일자
+ val tourAt: Long = 0L,
+ // 자차 여부
+ val hasCar: String = "",
+ // 모집 마감일
+ val closeAt: Long = 0L,
+ // 선호하는 언어 정보
+ val preferredLanguages: Map = emptyMap(),
+ // 모집글 내용
+ val content: String = "",
+ // 작성 시각
+ val createdAt: Long = 0L,
+ // 카톡 오픈채팅 링크
+ val kakaoOpenChatUrl: String = "",
+ // 작성자 프로필 (간단 정보)
+ val owner: UserSummary = UserSummary(),
+ // 지원자 리스트
+ val applications: Map = emptyMap(),
+ // 삭제 여부
+ @get:PropertyName("isDeleted")
+ val isDeleted: Boolean = false,
+ // 관심을 누른 사람들의 uid
+ val whoLiked: Map = emptyMap(),
+)
+
+@kotlinx.serialization.Serializable
+// 간단 버전 (닉네임/사진 정도만)
+data class UserSummary(
+ val id: String = "",
+ val nickname: String = "",
+ val profileImageUrl: String = "",
+ val country: String = "",
+)
diff --git a/app/src/main/java/com/example/chaining/domain/model/User.kt b/app/src/main/java/com/example/chaining/domain/model/User.kt
new file mode 100644
index 0000000..bc00339
--- /dev/null
+++ b/app/src/main/java/com/example/chaining/domain/model/User.kt
@@ -0,0 +1,43 @@
+package com.example.chaining.domain.model
+
+import com.google.firebase.database.PropertyName
+
+@kotlinx.serialization.Serializable
+data class User(
+ // DB key (uid)
+ val id: String = "",
+ val nickname: String = "",
+ val profileImageUrl: String = "",
+ // 출신국
+ val country: String = "",
+ // 거주지
+ val residence: String = "",
+ // 선호 여행지
+ val preferredDestinations: String = "",
+ val preferredLanguages: Map = emptyMap(),
+ // 모집/지원 현황 공개 여부
+ @get:PropertyName("isPublic")
+ val isPublic: Boolean = true,
+ // 내가 모집한 글
+ val recruitPosts: Map = emptyMap(),
+ // 내가 지원한 글
+ val applications: Map = emptyMap(),
+ // 서버 타임스탬프
+ val createdAt: Long = 0L,
+ // Soft Delete 플래그 추가
+ @get:PropertyName("isDeleted")
+ val isDeleted: Boolean = false,
+ // 관심글 postId
+ val likedPosts: Map = emptyMap(),
+ // 팔로잉
+ val following: Map = emptyMap(),
+ // 팔로워
+ val follower: Map = emptyMap(),
+)
+
+@kotlinx.serialization.Serializable
+data class LanguagePref(
+ val language: String = "",
+ // 0 ~ 10
+ val level: Int = 0,
+)
diff --git a/app/src/main/java/com/example/chaining/network/AreaService.kt b/app/src/main/java/com/example/chaining/network/AreaService.kt
new file mode 100644
index 0000000..17f7c66
--- /dev/null
+++ b/app/src/main/java/com/example/chaining/network/AreaService.kt
@@ -0,0 +1,29 @@
+package com.example.chaining.network
+
+import com.example.chaining.data.model.AreaCodeResponse
+import retrofit2.http.GET
+import retrofit2.http.Query
+
+// https://apis.data.go.kr/B551011/KorService2/ldongCode2?serviceKey=DATA_OPEN_API_KEY&numOfRows=1000&lDongListYn=Y&pageNo=1&lDongRegnCd=11&MobileOS=AND&MobileApp=AppTest
+
+interface AreaService {
+ @GET("ldongCode2")
+ suspend fun getAreaCodes(
+ // API 키
+ @Query("serviceKey", encoded = true) serviceKey: String,
+ // 지역 코드
+ @Query("lDongRegnCd") lDongRegnCd: Int = 11,
+ // 불러올 페이지 수
+ @Query("pageNo") PageNo: Int = 1,
+ // 불러올 행의 수
+ @Query("numOfRows") numOfRows: Int = 1000,
+ // 목록조회 여부
+ @Query("lDongListYn") lDongListYn: String = "Y",
+ // OS 종류
+ @Query("MobileOS") mobileOS: String = "AND",
+ // 앱 명
+ @Query("MobileApp") mobileAPP: String = "Chaining",
+ // 데이터 타입
+ @Query("_type") type: String = "json",
+ ): AreaCodeResponse
+}
diff --git a/app/src/main/java/com/example/chaining/network/FeedApiService.kt b/app/src/main/java/com/example/chaining/network/FeedApiService.kt
new file mode 100644
index 0000000..e96b103
--- /dev/null
+++ b/app/src/main/java/com/example/chaining/network/FeedApiService.kt
@@ -0,0 +1,33 @@
+package com.example.chaining.network
+
+import com.example.chaining.data.model.FeedApiResponse
+import retrofit2.http.GET
+import retrofit2.http.Query
+
+interface FeedApiService {
+ // 지역기반 관광정보 조회 API 엔드포인트
+ @GET("areaBasedList2")
+ suspend fun getAreaBasedList(
+ @Query("serviceKey", encoded = true) serviceKey: String,
+ // 한 페이지 결과 수
+ @Query("numOfRows") numOfRows: Int = 10,
+ // 페이지 번호
+ @Query("pageNo") pageNo: Int = 1,
+ // OS 구분 (안드로이드)
+ @Query("MobileOS") mobileOS: String = "AND",
+ // 앱 이름
+ @Query("MobileApp") mobileApp: String = "Chaining",
+ // 응답 타입 (JSON)
+ @Query("_type") type: String = "json",
+ // 정렬 기준 (A=제목순, C=수정일순, D=생성일순)
+ @Query("arrange") arrange: String = "O",
+ // 관광지 타입 (12=관광지)
+ @Query("contentTypeId") contentTypeId: Int? = 12,
+ // 지역 코드 (생략 시 전국)
+ @Query("areaCode") areaCode: Int? = null,
+ // 시군구 코드 (선택적)
+ // @Query("sigunguCode") sigunguCode: Int? = null,
+ // 대분류 (선택적)
+ // @Query("cat1") cat1: String? = null,
+ ): FeedApiResponse
+}
diff --git a/app/src/main/java/com/example/chaining/ui/component/CardItem.kt b/app/src/main/java/com/example/chaining/ui/component/CardItem.kt
new file mode 100644
index 0000000..1c4d999
--- /dev/null
+++ b/app/src/main/java/com/example/chaining/ui/component/CardItem.kt
@@ -0,0 +1,299 @@
+package com.example.chaining.ui.component
+
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.spring
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+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.shape.RoundedCornerShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+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.draw.scale
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import coil.compose.AsyncImage
+import com.example.chaining.R
+import com.example.chaining.domain.model.Application
+import com.example.chaining.domain.model.RecruitPost
+import com.example.chaining.domain.model.UserSummary
+
+@Suppress("FunctionName")
+@Composable
+fun CardItem(
+ onClick: () -> Unit,
+ // "모집글" or "지원서"
+ type: String,
+ recruitPost: RecruitPost? = null,
+ application: Application? = null,
+ remainingTime: String? = null,
+ onLeftButtonClick: () -> Unit = {},
+ onRightButtonClick: () -> Unit = {},
+ currentUserId: String? = "",
+ isLiked: Boolean? = false,
+) {
+ val title =
+ when (type) {
+ "모집글" -> recruitPost?.title ?: stringResource(id = R.string.community_no_title)
+ "지원서" -> application?.recruitPostTitle ?: stringResource(id = R.string.community_no_title)
+ else -> stringResource(id = R.string.community_no_title)
+ }
+
+ val remainingTimeText = remainingTime ?: stringResource(id = R.string.community_unknown)
+
+ val timeText =
+ when (type) {
+ "모집글" -> stringResource(id = R.string.time_left_recruit, remainingTimeText)
+ "지원서" -> stringResource(id = R.string.time_left_application, remainingTimeText)
+ else -> ""
+ }
+
+// val profile = when (type) {
+// "모집글" -> recruitPost?.owner ?: UserSummary()
+// "지원서" -> application?.applicant ?: UserSummary()
+// else -> UserSummary()
+// }
+
+ val leftButtonText =
+ if (type == "모집글") {
+ stringResource(id = R.string.community_apply_button)
+ } else {
+ stringResource(
+ id = R.string.application_yes,
+ )
+ }
+ val rightButtonText =
+ if (type == "모집글") {
+ stringResource(id = R.string.community_interest_button)
+ } else {
+ stringResource(
+ id = R.string.application_no,
+ )
+ }
+
+ val rightText =
+ if (type == "모집글") stringResource(id = R.string.community_see_post) else stringResource(id = R.string.view_application)
+
+ val profile =
+ if (type == "모집글") {
+ UserSummary(
+ id = recruitPost?.owner?.id ?: "",
+ nickname =
+ recruitPost?.owner?.nickname
+ ?: stringResource(id = R.string.community_unknown),
+ profileImageUrl = recruitPost?.owner?.profileImageUrl ?: "",
+ country = recruitPost?.owner?.country ?: stringResource(id = R.string.community_unknown),
+ )
+ } else {
+ UserSummary(
+ id = application?.applicant?.id ?: "",
+ nickname =
+ application?.applicant?.nickname
+ ?: stringResource(id = R.string.community_unknown),
+ profileImageUrl = application?.applicant?.profileImageUrl ?: "",
+ country =
+ application?.applicant?.country
+ ?: stringResource(id = R.string.community_unknown),
+ )
+ }
+
+ val buttonColor by animateColorAsState(
+ targetValue = if (isLiked == true) Color(0xFFFF4D4D) else Color(0xFFEBEFFA),
+ animationSpec = tween(durationMillis = 300),
+ label = "likeColor",
+ )
+
+ val scale = remember { Animatable(1f) }
+
+ LaunchedEffect(isLiked) {
+ if (isLiked == true) {
+ scale.animateTo(
+ targetValue = 1.05f,
+ animationSpec = spring(stiffness = 500f),
+ )
+ scale.animateTo(
+ targetValue = 1f,
+ animationSpec = spring(stiffness = 500f),
+ )
+ }
+ }
+
+ Card(
+ onClick = onClick,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(top = 8.dp),
+ shape = RoundedCornerShape(16.dp),
+ colors =
+ CardDefaults.cardColors(
+ containerColor = Color(0xFF4285F4),
+ ),
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ ) {
+ Text(
+ text = title,
+ color = Color.White,
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Bold,
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ painter = painterResource(id = R.drawable.clock),
+ contentDescription = "남은 시간",
+ tint = Color.White,
+ // 아이콘 크기 조절
+ modifier = Modifier.size(20.dp),
+ )
+
+ Spacer(modifier = Modifier.width(10.dp))
+
+ Text(
+ text = timeText,
+ color = Color.White,
+ fontSize = 18.sp,
+ fontWeight = FontWeight.Bold,
+ )
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(12.dp),
+ colors =
+ CardDefaults.cardColors(
+ containerColor = Color.White,
+ ),
+ ) {
+ Column(
+ modifier = Modifier.padding(12.dp),
+ ) {
+ Row(
+ modifier = Modifier.padding(12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ // 프로필 사진
+ AsyncImage(
+ model = if (profile.profileImageUrl.isNotBlank()) profile.profileImageUrl else R.drawable.test_profile,
+ contentDescription = "모집자/신청자 프로필 사진",
+ contentScale = ContentScale.Crop,
+ modifier =
+ Modifier
+ .size(48.dp)
+ .clip(RoundedCornerShape(15.dp)),
+ )
+
+ Spacer(modifier = Modifier.width(12.dp))
+
+ Column(
+ modifier = Modifier.weight(1f),
+ ) {
+ Text(
+ text = profile.nickname,
+ fontWeight = FontWeight.Bold,
+ color = Color.Black,
+ )
+ Text(
+ text = profile.country,
+ fontSize = 12.sp,
+ color = Color.Gray,
+ )
+ }
+
+ // 지원서 보기 텍스트
+ Text(
+ text = rightText,
+ fontSize = 12.sp,
+ color = Color.Gray,
+ )
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ // 왼쪽 버튼
+ Button(
+ onClick = onLeftButtonClick,
+ modifier = Modifier.weight(3f),
+ shape = RoundedCornerShape(20.dp),
+ colors =
+ ButtonDefaults.buttonColors(
+ containerColor = Color(0xFF4285F4),
+ contentColor = Color.White,
+ ),
+ ) {
+ Text(text = leftButtonText)
+ }
+
+ // 오른쪽 버튼
+ if (type == "모집글") {
+ Button(
+ onClick = onRightButtonClick,
+ modifier =
+ Modifier
+ .weight(2f)
+ .scale(scale.value),
+ shape = RoundedCornerShape(20.dp),
+ colors =
+ ButtonDefaults.buttonColors(
+ containerColor = buttonColor,
+ contentColor = if (isLiked == true) Color.White else Color.Gray,
+ ),
+ ) {
+ Text(text = rightButtonText)
+ }
+ } else {
+ Button(
+ onClick = onRightButtonClick,
+ modifier = Modifier.weight(2f),
+ shape = RoundedCornerShape(20.dp),
+ colors =
+ ButtonDefaults.buttonColors(
+ containerColor = Color(0xFFEBEFFA),
+ contentColor = Color.Gray,
+ ),
+ ) {
+ Text(text = rightButtonText)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/chaining/ui/component/DatePickerFieldToModal.kt b/app/src/main/java/com/example/chaining/ui/component/DatePickerFieldToModal.kt
new file mode 100644
index 0000000..c5d5007
--- /dev/null
+++ b/app/src/main/java/com/example/chaining/ui/component/DatePickerFieldToModal.kt
@@ -0,0 +1,173 @@
+package com.example.chaining.ui.component
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+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.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.DateRange
+import androidx.compose.material3.DatePicker
+import androidx.compose.material3.DatePickerDefaults
+import androidx.compose.material3.DatePickerDialog
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.rememberDatePickerState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.example.chaining.R
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+@Suppress("FunctionName")
+@Composable
+fun DatePickerFieldToModal(
+ modifier: Modifier = Modifier,
+ label: String,
+ selectedDate: Long?,
+ onDateSelected: (Long?) -> Unit,
+) {
+ var showModal by remember { mutableStateOf(false) }
+
+ // 날짜 포맷 변환
+ val formattedDate = selectedDate?.let { convertMillisToDate(it) } ?: ""
+
+ Box(
+ modifier =
+ modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(16.dp))
+ .background(MaterialTheme.colorScheme.surface)
+ .border(
+ width = 1.dp,
+ color =
+ if (showModal) {
+ Color(0xFF4285F4)
+ } else {
+ MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
+ },
+ shape = RoundedCornerShape(16.dp),
+ )
+ .clickable { showModal = true }
+ .padding(horizontal = 16.dp, vertical = 14.dp),
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column {
+ Text(
+ text = label,
+ style =
+ MaterialTheme.typography.labelMedium.copy(
+ color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
+ ),
+ )
+ Spacer(modifier = Modifier.height(2.dp))
+ Text(
+ text = if (formattedDate.isEmpty()) "YYYY/MM/DD" else formattedDate,
+ style =
+ MaterialTheme.typography.bodyLarge.copy(
+ color =
+ if (formattedDate.isEmpty()) {
+ MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f)
+ } else {
+ MaterialTheme.colorScheme.onSurface
+ },
+ ),
+ )
+ }
+
+ Icon(
+ imageVector = Icons.Default.DateRange,
+ contentDescription = stringResource(id = R.string.datepicker_icon_description),
+ tint = Color(0xFF4285F4),
+ modifier = Modifier.size(24.dp),
+ )
+ }
+ }
+
+ // 모달
+ if (showModal) {
+ DatePickerModal(
+ onDateSelected = {
+ onDateSelected(it)
+ },
+ onDismiss = { showModal = false },
+ )
+ }
+}
+
+@Suppress("FunctionName")
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun DatePickerModal(
+ onDateSelected: (Long?) -> Unit,
+ onDismiss: () -> Unit,
+) {
+ val datePickerState = rememberDatePickerState()
+
+ DatePickerDialog(
+ onDismissRequest = onDismiss,
+ colors =
+ DatePickerDefaults.colors(
+ containerColor = Color(0xFFFEFEFE),
+ ),
+ confirmButton = {
+ TextButton(
+ onClick = {
+ onDateSelected(datePickerState.selectedDateMillis)
+ onDismiss()
+ },
+ ) {
+ Text(text = stringResource(id = R.string.choose), color = Color(0xFF4285F4))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) {
+ Text(text = stringResource(id = R.string.mypage_cancel), color = Color(0xFF637387))
+ }
+ },
+ ) {
+ DatePicker(
+ colors =
+ DatePickerDefaults.colors(
+ containerColor = Color(0xFFFEFEFE),
+ selectedDayContainerColor = Color(0xFF4285F4),
+ selectedDayContentColor = Color.White,
+ todayDateBorderColor = Color(0xFF4285F4),
+ todayContentColor = Color(0xFF4285F4),
+ dayContentColor = Color.Black,
+ weekdayContentColor = Color.Gray,
+ ),
+ state = datePickerState,
+ )
+ }
+}
+
+fun convertMillisToDate(millis: Long): String {
+ val formatter = SimpleDateFormat("yyyy/MM/dd", Locale.getDefault())
+ return formatter.format(Date(millis))
+}
diff --git a/app/src/main/java/com/example/chaining/ui/component/FeedItem.kt b/app/src/main/java/com/example/chaining/ui/component/FeedItem.kt
new file mode 100644
index 0000000..5be5ba5
--- /dev/null
+++ b/app/src/main/java/com/example/chaining/ui/component/FeedItem.kt
@@ -0,0 +1,87 @@
+package com.example.chaining.ui.component
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import coil.compose.AsyncImage
+import com.example.chaining.R
+
+@Suppress("FunctionName")
+@Composable
+fun FeedItem(
+ modifier: Modifier = Modifier,
+ region: String,
+ place: String,
+ address: String,
+ imageUrl: String,
+) {
+ Card(
+ modifier = modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(16.dp),
+ colors =
+ CardDefaults.cardColors(
+ // 파란색 배경
+ containerColor = Color(0xFF4285F4),
+ ),
+ elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
+ ) {
+ Column {
+ // 사진
+ AsyncImage(
+ model = imageUrl,
+ contentDescription = "$region $place 사진",
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ // 사진의 높이를 지정
+ .height(200.dp)
+ .clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)),
+ // 이미지가 공간을 꽉 채우도록 설정
+ contentScale = ContentScale.Crop,
+ )
+ // 2. 하단 텍스트 영역 (파란색 배경)
+ Column(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .background(Color(0xFF4285F4))
+ .clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp))
+ // 텍스트와 배경 사이의 내부 여백
+ .padding(16.dp),
+ ) {
+ // 지역명과 명소명
+ Text(
+ text = stringResource(id = R.string.feed_item_region_place, region, place),
+ color = Color.White,
+ fontSize = 18.sp,
+ fontWeight = FontWeight.Bold,
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+
+ // 주소
+ Text(
+ text = stringResource(id = R.string.feed_item_address, address),
+ color = Color.White.copy(alpha = 0.8f),
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Normal,
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/chaining/ui/component/FilterOptionSheet.kt b/app/src/main/java/com/example/chaining/ui/component/FilterOptionSheet.kt
new file mode 100644
index 0000000..5a59f7b
--- /dev/null
+++ b/app/src/main/java/com/example/chaining/ui/component/FilterOptionSheet.kt
@@ -0,0 +1,297 @@
+package com.example.chaining.ui.component
+
+import androidx.annotation.DrawableRes
+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.Row
+import androidx.compose.foundation.layout.Spacer
+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.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExposedDropdownMenuBox
+import androidx.compose.material3.ExposedDropdownMenuDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+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.graphics.Color
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.hilt.navigation.compose.hiltViewModel
+import com.example.chaining.R
+import com.example.chaining.data.model.FilterState
+import com.example.chaining.ui.screen.SecondaryTextColor
+import com.example.chaining.viewmodel.AreaViewModel
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Suppress("FunctionName")
+@Composable
+fun FilterOptionsSheet(
+ currentFilterState: FilterState,
+ onApplyFilters: (FilterState) -> Unit,
+ onClose: () -> Unit,
+ areaViewModel: AreaViewModel = hiltViewModel(),
+) {
+ val areaEntities by areaViewModel.areaCodes.collectAsState()
+ var selectedTravelStyle by remember { mutableStateOf(currentFilterState.travelStyle) }
+ var selectedTravelLocation by remember { mutableStateOf(currentFilterState.travelLocation) }
+ var selectedLanguage by remember { mutableStateOf(currentFilterState.language) }
+ var selectedLanguageLevel by remember { mutableStateOf(currentFilterState.languageLevel) }
+ var selectedSortBy by remember { mutableStateOf(currentFilterState.sortBy) }
+
+ // 드롭다운 메뉴 상태
+ var expandedTravelStyle by remember { mutableStateOf(false) }
+ var expandedTravelLocation by remember { mutableStateOf(false) }
+ var expandedLanguage by remember { mutableStateOf(false) }
+ var expandedLanguageLevel by remember { mutableStateOf(false) }
+ var expandedSortBy by remember { mutableStateOf(false) }
+
+ // 드롭다운 옵션 목록
+ val travelStyles =
+ listOf(
+ stringResource(id = R.string.travel_style_mountain),
+ stringResource(id = R.string.travel_style_sea),
+ stringResource(id = R.string.travel_style_city),
+ stringResource(id = R.string.travel_style_activity),
+ stringResource(id = R.string.travel_style_rest),
+ stringResource(id = R.string.travel_style_culture),
+ )
+ val travelLocations =
+ remember(areaEntities) {
+ areaEntities
+ .map { it.regionName }
+ }
+ val languages =
+ listOf(
+ stringResource(id = R.string.language_korean),
+ stringResource(id = R.string.language_english),
+ )
+ val languageLevels = (1..10).toList()
+ val sortByOptions =
+ mapOf(
+ "latest" to stringResource(id = R.string.sort_by_latest),
+ "deadline" to stringResource(id = R.string.sort_by_deadline),
+ // "interest" to "관심순"
+ )
+
+ Column(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .fillMaxHeight()
+ .padding(16.dp)
+ .background(Color(0xFFF3F6FF))
+ // 스크롤 가능하도록 추가
+ .verticalScroll(rememberScrollState()),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ // 헤더 및 닫기 버튼
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Text(
+ text = stringResource(id = R.string.filter_title),
+ fontSize = 22.sp,
+ fontWeight = FontWeight.Bold,
+ color = Color(0xFF4A526A),
+ )
+ IconButton(onClick = onClose) {
+ Icon(Icons.Default.Close, contentDescription = "닫기")
+ }
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // 여행지 스타일 드롭다운
+ FilterDropdown(
+ label = stringResource(id = R.string.filter_placeholder_travel_style),
+ selectedValue = selectedTravelStyle,
+ options = travelStyles,
+ expanded = expandedTravelStyle,
+ onExpandedChange = { expandedTravelStyle = it },
+ onValueChange = { value -> selectedTravelStyle = value },
+ leadingIconRes = R.drawable.global,
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // 여행지 드롭다운
+ FilterDropdown(
+ label = stringResource(id = R.string.filter_placeholder_travel_location),
+ selectedValue = selectedTravelLocation,
+ options = travelLocations,
+ expanded = expandedTravelLocation,
+ onExpandedChange = { expandedTravelLocation = it },
+ onValueChange = { value -> selectedTravelLocation = value },
+ leadingIconRes = R.drawable.country,
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // 언어 드롭다운
+ FilterDropdown(
+ label = stringResource(id = R.string.filter_placeholder_language),
+ selectedValue = selectedLanguage,
+ options = languages,
+ expanded = expandedLanguage,
+ onExpandedChange = { expandedLanguage = it },
+ onValueChange = { value -> selectedLanguage = value },
+ leadingIconRes = R.drawable.language,
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // 언어 레벨 드롭다운
+ FilterDropdown(
+ label = stringResource(id = R.string.filter_placeholder_language_level),
+ // Int? -> String? 변환
+ selectedValue = selectedLanguageLevel?.toString(),
+ // "상관 없음" 추가
+ options = (listOf(stringResource(id = R.string.filter_option_any)) + languageLevels.map { it.toString() }),
+ expanded = expandedLanguageLevel,
+ onExpandedChange = { expandedLanguageLevel = it },
+ onValueChange = { value ->
+ selectedLanguageLevel = if (value == "상관 없음") null else value?.toIntOrNull()
+ },
+ leadingIconRes = R.drawable.level,
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // 정렬 방식 드롭다운
+ FilterDropdown(
+ label = stringResource(id = R.string.filter_placeholder_sort_by),
+ // 맵에서 값 가져오기
+ selectedValue = sortByOptions[selectedSortBy],
+ // 옵션은 표시할 텍스트 리스트
+ options = sortByOptions.values.toList(),
+ expanded = expandedSortBy,
+ onExpandedChange = { expandedSortBy = it },
+ onValueChange = { value ->
+ // 표시된 텍스트(value)로 실제 키를 찾아 저장
+ selectedSortBy = sortByOptions.entries.find { it.value == value }?.key ?: "latest"
+ },
+ leadingIconRes = R.drawable.sort,
+ )
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // 필터 적용 버튼
+ Button(
+ onClick = {
+ onApplyFilters(
+ FilterState(
+ travelStyle = selectedTravelStyle,
+ travelLocation = selectedTravelLocation,
+ language = selectedLanguage,
+ languageLevel = selectedLanguageLevel,
+ sortBy = selectedSortBy,
+ ),
+ )
+ },
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(12.dp),
+ colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF4A526A)),
+ contentPadding = PaddingValues(12.dp),
+ ) {
+ Text(
+ stringResource(id = R.string.filter_apply_button),
+ color = Color.White,
+ fontSize = 18.sp,
+ fontWeight = FontWeight.Bold,
+ )
+ }
+ }
+}
+
+@Suppress("FunctionName")
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun FilterDropdown(
+ label: String,
+ selectedValue: String?,
+ options: List,
+ expanded: Boolean,
+ onExpandedChange: (Boolean) -> Unit,
+ onValueChange: (String?) -> Unit,
+ @DrawableRes leadingIconRes: Int,
+) {
+ Spacer(modifier = Modifier.height(8.dp))
+ ExposedDropdownMenuBox(
+ expanded = expanded,
+ onExpandedChange = onExpandedChange,
+ ) {
+ OutlinedTextField(
+ value = selectedValue ?: stringResource(id = R.string.filter_option_none),
+ onValueChange = {},
+ readOnly = true,
+ label = {
+ Text(
+ text = label,
+ fontSize = 14.sp,
+ )
+ },
+ leadingIcon = {
+ Icon(
+ painter = painterResource(id = leadingIconRes),
+ contentDescription = label,
+ tint = SecondaryTextColor,
+ )
+ },
+ trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
+ modifier =
+ Modifier
+ .menuAnchor()
+ .fillMaxWidth(),
+ colors =
+ ExposedDropdownMenuDefaults.outlinedTextFieldColors(
+ focusedBorderColor = Color(0xFF7282B4),
+ unfocusedBorderColor = Color.Gray.copy(alpha = 0.5f),
+ ),
+ )
+ ExposedDropdownMenu(
+ expanded = expanded,
+ onDismissRequest = { onExpandedChange(false) },
+ modifier =
+ Modifier
+ .exposedDropdownSize()
+ .background(Color.White),
+ ) {
+ DropdownMenuItem(
+ text = { Text(text = stringResource(id = R.string.filter_option_none)) },
+ onClick = {
+ onValueChange(null)
+ onExpandedChange(false)
+ },
+ )
+ options.forEach { option ->
+ DropdownMenuItem(
+ text = { Text(option) },
+ onClick = {
+ onValueChange(option)
+ onExpandedChange(false)
+ },
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/chaining/ui/component/FollowNotificationItem.kt b/app/src/main/java/com/example/chaining/ui/component/FollowNotificationItem.kt
new file mode 100644
index 0000000..dfb477e
--- /dev/null
+++ b/app/src/main/java/com/example/chaining/ui/component/FollowNotificationItem.kt
@@ -0,0 +1,65 @@
+package com.example.chaining.ui.component
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.example.chaining.R
+import com.example.chaining.ui.screen.ProfileImageWithStatus
+
+@Suppress("FunctionName")
+@Composable
+fun FollowNotificationItem(
+ name: String,
+ timestamp: String,
+ imageUrl: String?,
+) {
+ Card(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(top = 8.dp)
+ .padding(horizontal = 24.dp),
+ shape = RoundedCornerShape(16.dp),
+ colors =
+ CardDefaults.cardColors(
+ containerColor = Color.White,
+ ),
+ ) {
+ Row(
+ modifier = Modifier.padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ ProfileImageWithStatus(model = imageUrl, onMyPageClick = {}, isOnline = true)
+
+ Spacer(modifier = Modifier.width(12.dp))
+
+ Column {
+ Text(
+ text = stringResource(id = R.string.follow_message, name),
+ fontWeight = FontWeight.SemiBold,
+ color = Color.Black,
+ )
+ Text(
+ text = timestamp,
+ fontSize = 12.sp,
+ color = Color.Gray,
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/chaining/ui/component/FormatRemainingTime.kt b/app/src/main/java/com/example/chaining/ui/component/FormatRemainingTime.kt
new file mode 100644
index 0000000..17eac90
--- /dev/null
+++ b/app/src/main/java/com/example/chaining/ui/component/FormatRemainingTime.kt
@@ -0,0 +1,41 @@
+package com.example.chaining.ui.component
+
+import android.content.Context
+import com.example.chaining.R
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+fun formatRemainingTime(
+ context: Context,
+ remainingMillis: Long,
+): String {
+ if (remainingMillis <= 0) {
+ return context.getString(R.string.time_closed)
+ }
+ val totalMinutes = remainingMillis / 1000 / 60
+ val days = (totalMinutes / (60 * 24)).toInt()
+ val hours = ((totalMinutes % (60 * 24)) / 60).toInt()
+ val minutes = (totalMinutes % 60).toInt()
+
+ val resources = context.resources
+ val parts = mutableListOf()
+
+ if (days > 0) {
+ parts.add(resources.getQuantityString(R.plurals.time_unit_days, days, days))
+ }
+ if (hours > 0) {
+ parts.add(resources.getQuantityString(R.plurals.time_unit_hours, hours, hours))
+ }
+ if (minutes > 0 || parts.isEmpty()) {
+ parts.add(resources.getQuantityString(R.plurals.time_unit_minutes, minutes, minutes))
+ }
+
+ return parts.joinToString(" ")
+}
+
+fun formatDate(timestamp: Long): String {
+ val date = Date(timestamp)
+ val format = SimpleDateFormat("yyyy.MM.dd", Locale.getDefault())
+ return format.format(date)
+}
diff --git a/app/src/main/java/com/example/chaining/ui/component/OwnerProfile.kt b/app/src/main/java/com/example/chaining/ui/component/OwnerProfile.kt
new file mode 100644
index 0000000..813d8f2
--- /dev/null
+++ b/app/src/main/java/com/example/chaining/ui/component/OwnerProfile.kt
@@ -0,0 +1,126 @@
+package com.example.chaining.ui.component
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+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.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+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.ContentScale
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.hilt.navigation.compose.hiltViewModel
+import coil.compose.AsyncImage
+import com.example.chaining.R
+import com.example.chaining.domain.model.UserSummary
+import com.example.chaining.viewmodel.UserViewModel
+
+@Suppress("FunctionName")
+@Composable
+fun OwnerProfile(
+ owner: UserSummary,
+ // 카드뷰, 모집글 상세보기, 지원서
+ where: String,
+ userViewModel: UserViewModel = hiltViewModel(),
+ // "상세 보기"
+ type: String? = "",
+) {
+ val userState by userViewModel.user.collectAsState()
+ val nicknameInfo =
+ when (where) {
+ "카드뷰" -> 18.sp to 0xFF4A526A
+ "모집글 상세보기" -> 14.sp to 0xFFFFFFFF
+ "지원서" -> 14.sp to 0xFF4A526A
+ else -> 14.sp to 0xFF4A526A
+ }
+
+ val countryInfo =
+ when (where) {
+ "카드뷰" -> 14.sp to 0xFF7282B4
+ "모집글 상세보기" -> 12.sp to 0xCCFFFFFF
+ "지원서" -> 12.sp to 0xFF4A526A
+ else -> 12.sp to 0xFF4A526A
+ }
+
+ val imageSize =
+ when (where) {
+ "카드뷰" -> 50.dp
+ else -> 40.dp
+ }
+
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ AsyncImage(
+ model = if (owner.profileImageUrl.isNotBlank()) owner.profileImageUrl else R.drawable.test_profile,
+ contentDescription = "작성자 프로필 사진",
+ contentScale = ContentScale.Crop,
+ modifier =
+ Modifier
+ .size(imageSize)
+ .clip(RoundedCornerShape(16.dp)),
+ )
+
+ Spacer(modifier = Modifier.width(12.dp))
+
+ Column {
+ Text(
+ text = owner.nickname,
+ fontWeight = FontWeight.SemiBold,
+ fontSize = nicknameInfo.first,
+ color = Color(nicknameInfo.second),
+ )
+ Text(
+ text = owner.country,
+ fontSize = countryInfo.first,
+ color = Color(countryInfo.second),
+ )
+ }
+ if (type == "상세 보기") {
+ Box(
+ modifier =
+ Modifier
+ .size(30.dp)
+ .clip(CircleShape)
+ .background(Color(0xFF3ECDFF))
+ .border(2.dp, Color.White, CircleShape)
+ .padding(3.dp)
+ .clickable {
+ val currentUserSummary =
+ UserSummary(
+ id = userState?.id ?: "",
+ nickname = userState?.nickname ?: "",
+ profileImageUrl = userState?.profileImageUrl ?: "",
+ country = userState?.country ?: "",
+ )
+ userViewModel.toggleFollow(currentUserSummary, owner)
+ },
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ painter = painterResource(id = R.drawable.follow),
+ contentDescription = "친구 추가",
+ tint = Color.White,
+ modifier = Modifier.size(12.dp),
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/chaining/ui/component/SaveButton.kt b/app/src/main/java/com/example/chaining/ui/component/SaveButton.kt
new file mode 100644
index 0000000..4f7ad7d
--- /dev/null
+++ b/app/src/main/java/com/example/chaining/ui/component/SaveButton.kt
@@ -0,0 +1,33 @@
+package com.example.chaining.ui.component
+
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+
+@Suppress("FunctionName")
+@Composable
+fun SaveButton(
+ onSave: () -> Unit,
+ text: String,
+ modifier: Modifier = Modifier,
+) {
+ Button(
+ onClick = onSave,
+ modifier =
+ modifier
+ .fillMaxWidth()
+ .height(45.dp),
+ shape = RoundedCornerShape(30.dp),
+ colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF4285F4)),
+ ) {
+ Text(text = text, fontSize = 16.sp)
+ }
+}
diff --git a/app/src/main/java/com/example/chaining/ui/component/SplashAnimation.kt b/app/src/main/java/com/example/chaining/ui/component/SplashAnimation.kt
new file mode 100644
index 0000000..eb1105c
--- /dev/null
+++ b/app/src/main/java/com/example/chaining/ui/component/SplashAnimation.kt
@@ -0,0 +1,53 @@
+package com.example.chaining.ui.component
+
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.FastOutSlowInEasing
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.size
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.unit.dp
+import com.example.chaining.R
+import kotlinx.coroutines.launch
+
+@Suppress("FunctionName")
+@Composable
+fun SplashAnimation(startAnimation: Boolean) {
+ // 아래 -> 위
+ val offsetY = remember { Animatable(0f) }
+ val alpha = remember { Animatable(0f) }
+
+ LaunchedEffect(startAnimation) {
+ if (startAnimation) {
+ launch {
+ offsetY.animateTo(
+ targetValue = -100f,
+ animationSpec = tween(durationMillis = 400, easing = FastOutSlowInEasing),
+ )
+ }
+ launch { // 투명도 애니메이션 추가
+ alpha.animateTo(
+ targetValue = 1f,
+ animationSpec = tween(durationMillis = 400),
+ )
+ }
+ }
+ }
+
+ Image(
+ painter = painterResource(id = R.drawable.chain),
+ contentDescription = "Chain",
+ modifier =
+ Modifier
+ .size(70.dp)
+ .graphicsLayer {
+ translationY = offsetY.value
+ this.alpha = alpha.value
+ },
+ )
+}
diff --git a/app/src/main/java/com/example/chaining/ui/component/TestButton.kt b/app/src/main/java/com/example/chaining/ui/component/TestButton.kt
new file mode 100644
index 0000000..26b90a1
--- /dev/null
+++ b/app/src/main/java/com/example/chaining/ui/component/TestButton.kt
@@ -0,0 +1,230 @@
+package com.example.chaining.ui.component
+
+import androidx.compose.foundation.BorderStroke
+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.Spacer
+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.shape.RoundedCornerShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Text
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+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.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.example.chaining.R
+import com.example.chaining.domain.model.LanguagePref
+import com.example.chaining.ui.screen.BorderColor
+import kotlinx.coroutines.launch
+
+@Suppress("FunctionName")
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun TestButton(
+ preferredLanguages: Map,
+ onTestClick: (String) -> Unit,
+) {
+ val languageText = stringResource(id = R.string.mypage_quiz_result)
+
+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+ val coroutineScope = rememberCoroutineScope()
+ var isSheetOpen by remember { mutableStateOf(false) }
+
+ Button(
+ onClick = { isSheetOpen = true },
+ shape = RoundedCornerShape(12.dp),
+ border = BorderStroke(width = 1.dp, color = BorderColor),
+ modifier =
+ Modifier
+ .fillMaxWidth(),
+ contentPadding =
+ PaddingValues(
+ vertical = 14.dp,
+ horizontal = 12.dp,
+ ),
+ colors = ButtonDefaults.buttonColors(containerColor = Color.White),
+ ) {
+ Icon(
+ painter = painterResource(id = R.drawable.voice_recognition),
+ contentDescription = null,
+ tint = Color.Unspecified,
+ modifier =
+ Modifier
+ .height(24.dp)
+ .width(24.dp),
+ )
+
+ Spacer(modifier = Modifier.width(8.dp))
+
+ Text(
+ text = languageText,
+ color = Color(0xFF637387),
+ fontSize = 14.sp,
+ modifier =
+ Modifier
+ .weight(1f),
+ textAlign = androidx.compose.ui.text.style.TextAlign.Center,
+ )
+ }
+
+ if (isSheetOpen) {
+ ModalBottomSheet(
+ onDismissRequest = { isSheetOpen = false },
+ sheetState = sheetState,
+ shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
+ containerColor = Color.White,
+ ) {
+ Column(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 20.dp, vertical = 16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ Text(
+ text = stringResource(id = R.string.mypage_quiz_status),
+ style = MaterialTheme.typography.titleMedium,
+ color = Color.Black,
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ val supportedLanguages =
+ listOf(
+ stringResource(id = R.string.mypage_korean),
+ stringResource(id = R.string.mypage_english),
+ )
+
+ supportedLanguages.forEach { language ->
+ val dataLanguage =
+ when (language) {
+ "한국어" -> "KOREAN"
+ "영어" -> "ENGLISH"
+ else -> ""
+ }
+ val pref = preferredLanguages[dataLanguage]
+ LanguageTestItem(
+ language = language,
+ level = pref?.level,
+ onTestClick = {
+ coroutineScope.launch {
+ sheetState.hide()
+ isSheetOpen = false
+ }
+ onTestClick(language)
+ },
+ )
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+ }
+ }
+ }
+}
+
+@Suppress("FunctionName")
+@Composable
+fun LanguageTestItem(
+ language: String,
+ level: Int?,
+ onTestClick: () -> Unit,
+) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Column(
+ modifier = Modifier.weight(1f),
+ ) {
+ Text(
+ text = language,
+ color = Color(0xFF637387),
+ fontSize = 16.sp,
+ )
+
+ Spacer(modifier = Modifier.height(6.dp))
+
+ if (level != null) {
+ LinearProgressIndicator(
+ progress = { level / 10f },
+ modifier =
+ Modifier
+ .fillMaxWidth(0.9f)
+ .height(8.dp)
+ .clip(RoundedCornerShape(4.dp)),
+ color = Color(0xFF637387),
+ trackColor = Color(0xFFE0E0E0),
+ )
+
+ Spacer(modifier = Modifier.height(4.dp))
+
+ Text(
+ text = "Lv.$level / 10",
+ fontSize = 12.sp,
+ color = Color.Gray,
+ )
+ } else {
+ Text(
+ text = stringResource(id = R.string.mypage_quiz_incomplete),
+ fontSize = 12.sp,
+ color = Color.Gray,
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.width(12.dp))
+
+ Button(
+ onClick = onTestClick,
+ shape = RoundedCornerShape(8.dp),
+ colors =
+ ButtonDefaults.buttonColors(
+ containerColor =
+ if (level != null) {
+ Color(0xFF637387)
+ } else {
+ Color(
+ 0xFF4CAF50,
+ )
+ },
+ ),
+ contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
+ ) {
+ Text(
+ text =
+ if (level != null) {
+ stringResource(id = R.string.mypage_quiz_retry)
+ } else {
+ stringResource(
+ id = R.string.mypage_quiz_start,
+ )
+ },
+ color = Color.White,
+ fontSize = 14.sp,
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/chaining/ui/login/LoginScreen.kt b/app/src/main/java/com/example/chaining/ui/login/LoginScreen.kt
new file mode 100644
index 0000000..4788109
--- /dev/null
+++ b/app/src/main/java/com/example/chaining/ui/login/LoginScreen.kt
@@ -0,0 +1,238 @@
+package com.example.chaining.ui.login
+
+import android.app.Activity
+import android.widget.Toast
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.IntentSenderRequest
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+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.draw.shadow
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.hilt.navigation.compose.hiltViewModel
+import com.example.chaining.BuildConfig
+import com.example.chaining.R
+import com.example.chaining.ui.screen.generateRandomNickname
+import com.example.chaining.ui.screen.validateNickname
+import com.example.chaining.viewmodel.UserViewModel
+import com.google.android.gms.auth.api.identity.BeginSignInRequest
+import com.google.android.gms.auth.api.identity.Identity
+import com.google.firebase.Firebase
+import com.google.firebase.auth.GoogleAuthProvider
+import com.google.firebase.auth.auth
+
+@Suppress("FunctionName")
+@Composable
+fun LoginScreen(
+ // 구글 로그인
+ onLoginSuccess: () -> Unit,
+ // 관리자 로그인
+ onAdminLoginClick: () -> Unit,
+ onNavigateToTerms: (uid: String, nickname: String) -> Unit,
+ userViewModel: UserViewModel = hiltViewModel(),
+) {
+ val context = LocalContext.current
+ val signInClient = Identity.getSignInClient(context)
+ var isLoading by remember { mutableStateOf(false) }
+
+ val launcher =
+ rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.StartIntentSenderForResult(),
+ ) { result ->
+ isLoading = false
+ if (result.resultCode == Activity.RESULT_OK) {
+ val intent = result.data
+ if (intent != null) {
+ val credential = signInClient.getSignInCredentialFromIntent(intent)
+ val idToken = credential.googleIdToken
+
+ if (idToken != null) {
+ isLoading = true
+ val firebaseCredential = GoogleAuthProvider.getCredential(idToken, null)
+ Firebase.auth.signInWithCredential(firebaseCredential)
+ .addOnCompleteListener { task ->
+ isLoading = false
+ if (task.isSuccessful) {
+ val firebaseUser = Firebase.auth.currentUser
+ if (firebaseUser != null) {
+ val uid = firebaseUser.uid
+
+ userViewModel.checkUserExists(uid) { exists ->
+ if (exists) {
+ onLoginSuccess()
+ } else {
+ val googleNickname = firebaseUser.displayName ?: ""
+ val isGoogleNicknameValid =
+ validateNickname(googleNickname) == null
+ val finalNickname =
+ if (isGoogleNicknameValid) {
+ googleNickname
+ } else {
+ generateRandomNickname()
+ }
+ onNavigateToTerms(uid, finalNickname)
+ }
+ }
+ }
+ } else {
+ Toast.makeText(context, "로그인 실패", Toast.LENGTH_SHORT).show()
+ }
+ }
+ } else {
+ Toast.makeText(context, "ID 토큰 없음", Toast.LENGTH_SHORT).show()
+ }
+ } else {
+ Toast.makeText(context, "로그인 데이터 없음", Toast.LENGTH_SHORT).show()
+ }
+ } else {
+ Toast.makeText(context, "로그인 취소", Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ Column(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .statusBarsPadding()
+ .navigationBarsPadding()
+ .padding(horizontal = 20.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(24.dp, Alignment.CenterVertically),
+ ) {
+ // 1) 체인 이미지
+ Image(
+ painter = painterResource(id = R.drawable.chain),
+ contentDescription = "Chain",
+ modifier =
+ Modifier
+ .size(90.dp) // 필요하면 조절
+ .padding(bottom = 18.dp),
+ )
+
+ // 2) 체이닝 텍스트
+ Text(
+ text = "Chaining",
+ fontSize = 50.sp,
+ fontWeight = FontWeight.ExtraBold,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(bottom = 150.dp),
+ )
+
+ // 3) 구글 로그인 버튼
+ Button(
+ onClick = {
+ isLoading = true
+ val signInRequest =
+ BeginSignInRequest.builder()
+ .setGoogleIdTokenRequestOptions(
+ BeginSignInRequest.GoogleIdTokenRequestOptions.builder()
+ .setSupported(true)
+ .setServerClientId(BuildConfig.GOOGLE_API_WEB_CLIENT_ID)
+ // 모든 계정 노출
+ .setFilterByAuthorizedAccounts(false)
+ .build(),
+ )
+ // 자동 선택 비활성화 → 계정 선택 보장
+ .setAutoSelectEnabled(false)
+ .build()
+
+ signInClient.beginSignIn(signInRequest)
+ .addOnSuccessListener { result ->
+ val intentSenderRequest =
+ IntentSenderRequest.Builder(result.pendingIntent).build()
+ launcher.launch(intentSenderRequest)
+ }
+ .addOnFailureListener {
+ isLoading = false
+ Toast.makeText(context, "로그인 요청 실패", Toast.LENGTH_SHORT).show()
+ }
+ },
+ colors =
+ ButtonDefaults.buttonColors(
+ containerColor = Color.White,
+ contentColor = Color.Black,
+ disabledContainerColor = Color.White,
+ disabledContentColor = Color.Black,
+ ),
+ enabled = !isLoading,
+ shape = RoundedCornerShape(12.dp),
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .height(52.dp)
+ .shadow(4.dp, RoundedCornerShape(12.dp))
+ .border(0.5.dp, Color.Gray, RoundedCornerShape(12.dp)),
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ runCatching {
+ Image(
+ painter = painterResource(id = R.drawable.google),
+ contentDescription = "Google",
+ modifier = Modifier.size(24.dp),
+ )
+ }
+ Text(
+ text =
+ if (isLoading) {
+ stringResource(id = R.string.login_in_progress)
+ } else {
+ stringResource(id = R.string.login_button)
+ },
+ style = MaterialTheme.typography.labelLarge.copy(fontSize = 18.sp),
+ )
+ }
+ }
+
+ Button(
+ onClick = onAdminLoginClick,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .height(52.dp)
+ .shadow(4.dp, RoundedCornerShape(12.dp)),
+ shape = RoundedCornerShape(12.dp),
+ colors =
+ ButtonDefaults.buttonColors(
+ containerColor = Color(0xFF4285F4),
+ contentColor = Color.White,
+ ),
+ ) {
+ Text(
+ text = "관리자 로그인",
+ fontSize = 18.sp,
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/chaining/ui/login/TermsScreen.kt b/app/src/main/java/com/example/chaining/ui/login/TermsScreen.kt
new file mode 100644
index 0000000..122663d
--- /dev/null
+++ b/app/src/main/java/com/example/chaining/ui/login/TermsScreen.kt
@@ -0,0 +1,249 @@
+package com.example.chaining.ui.login
+
+import android.widget.Toast
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.CheckboxDefaults
+import androidx.compose.material3.Divider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalLifecycleOwner
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import com.example.chaining.R
+import com.example.chaining.domain.model.User
+import com.example.chaining.viewmodel.UserViewModel
+import com.google.firebase.Firebase
+import com.google.firebase.auth.auth
+
+// Custom Colors
+private val PrimaryBlue = Color(0xFF3387E5)
+private val LightGrayBackground = Color(0xFFF3F6FF)
+private val BorderColor = Color(0xFFE0E0E0)
+
+@Suppress("FunctionName")
+@Composable
+fun TermsScreen(
+ uid: String,
+ nickname: String,
+ onSuccess: () -> Unit,
+ onCancel: () -> Unit,
+ userViewModel: UserViewModel = hiltViewModel(),
+) {
+ val context = LocalContext.current
+
+ var allChecked by remember { mutableStateOf(false) }
+ var termsOfServiceChecked by remember { mutableStateOf(false) }
+ var privacyPolicyChecked by remember { mutableStateOf(false) }
+
+ LaunchedEffect(termsOfServiceChecked, privacyPolicyChecked) {
+ if (allChecked != (termsOfServiceChecked && privacyPolicyChecked)) {
+ allChecked = termsOfServiceChecked && privacyPolicyChecked
+ }
+ }
+
+ TermsScreenLifecycleHandler(
+ termsAgreed = termsOfServiceChecked && privacyPolicyChecked,
+ onCancel = onCancel,
+ )
+
+ // 뒤로가기 버튼 처리 (비동의)
+ BackHandler {
+ val user = Firebase.auth.currentUser
+ user?.delete()?.addOnCompleteListener { task ->
+ if (task.isSuccessful) {
+ Toast.makeText(
+ context,
+ context.getString(R.string.terms_agreement_required),
+ Toast.LENGTH_SHORT,
+ ).show()
+ onCancel()
+ } else {
+ Firebase.auth.signOut()
+ Toast.makeText(
+ context,
+ context.getString(R.string.fail_to_sign_up),
+ Toast.LENGTH_SHORT,
+ ).show()
+ onCancel()
+ }
+ } ?: onCancel()
+ }
+
+ Scaffold(
+ bottomBar = {
+ Button(
+ onClick = {
+ userViewModel.addUser(
+ user = User(id = uid, nickname = nickname),
+ onComplete = onSuccess,
+ )
+ },
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(16.dp)
+ .height(52.dp),
+ enabled = termsOfServiceChecked && privacyPolicyChecked,
+ shape = RoundedCornerShape(12.dp),
+ colors =
+ ButtonDefaults.buttonColors(
+ containerColor = PrimaryBlue,
+ contentColor = Color.White,
+ disabledContainerColor = Color.Gray,
+ disabledContentColor = Color.White,
+ ),
+ ) {
+ Text(stringResource(id = R.string.agree_and_start), fontSize = 18.sp)
+ }
+ },
+ ) { paddingValues ->
+ Column(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ .padding(horizontal = 16.dp),
+ ) {
+ Text(
+ text = stringResource(id = R.string.terms_of_service_title),
+ style = MaterialTheme.typography.headlineMedium,
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier.padding(vertical = 24.dp),
+ )
+
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(bottom = 16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Checkbox(
+ checked = allChecked,
+ onCheckedChange = { isChecked ->
+ allChecked = isChecked
+ termsOfServiceChecked = isChecked
+ privacyPolicyChecked = isChecked
+ },
+ colors = CheckboxDefaults.colors(checkedColor = PrimaryBlue),
+ )
+ Text(
+ text = stringResource(id = R.string.agree_all),
+ fontWeight = FontWeight.Bold,
+ fontSize = 18.sp,
+ )
+ }
+
+ Divider()
+
+ CheckboxWithDetails(
+ checked = termsOfServiceChecked,
+ onCheckedChange = { termsOfServiceChecked = it },
+ title = stringResource(id = R.string.agree_terms_of_service_required),
+ content = stringResource(id = R.string.terms_of_service_content),
+ )
+
+ CheckboxWithDetails(
+ checked = privacyPolicyChecked,
+ onCheckedChange = { privacyPolicyChecked = it },
+ title = stringResource(id = R.string.agree_privacy_policy_required),
+ content = stringResource(id = R.string.privacy_policy_content),
+ )
+ }
+ }
+}
+
+@Suppress("FunctionName")
+@Composable
+private fun CheckboxWithDetails(
+ checked: Boolean,
+ onCheckedChange: (Boolean) -> Unit,
+ title: String,
+ content: String,
+) {
+ Column(modifier = Modifier.padding(vertical = 8.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Checkbox(
+ checked = checked,
+ onCheckedChange = onCheckedChange,
+ colors = CheckboxDefaults.colors(checkedColor = PrimaryBlue),
+ )
+ Text(title, modifier = Modifier.weight(1f))
+ }
+ Box(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .height(120.dp)
+ .background(LightGrayBackground, RoundedCornerShape(8.dp))
+ .border(1.dp, BorderColor, RoundedCornerShape(8.dp))
+ .padding(8.dp),
+ ) {
+ Text(
+ text = content,
+ style = MaterialTheme.typography.bodySmall,
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState()),
+ )
+ }
+ }
+}
+
+@Suppress("FunctionName")
+@Composable
+fun TermsScreenLifecycleHandler(
+ termsAgreed: Boolean,
+ onCancel: () -> Unit,
+) {
+ val lifecycleOwner = LocalLifecycleOwner.current
+ val currentTermsAgreed by rememberUpdatedState(newValue = termsAgreed)
+
+ DisposableEffect(lifecycleOwner) {
+ val observer =
+ LifecycleEventObserver { _, event ->
+ if (event == Lifecycle.Event.ON_STOP && !currentTermsAgreed) {
+ val user = Firebase.auth.currentUser
+ user?.delete()?.addOnCompleteListener {
+ onCancel()
+ }
+ }
+ }
+ lifecycleOwner.lifecycle.addObserver(observer)
+ onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
+ }
+}
diff --git a/app/src/main/java/com/example/chaining/ui/navigation/NavGraph.kt b/app/src/main/java/com/example/chaining/ui/navigation/NavGraph.kt
new file mode 100644
index 0000000..70ede60
--- /dev/null
+++ b/app/src/main/java/com/example/chaining/ui/navigation/NavGraph.kt
@@ -0,0 +1,366 @@
+package com.example.chaining.ui.navigation
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.navigation.NavHostController
+import androidx.navigation.NavType
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.navArgument
+import androidx.navigation.navigation
+import com.example.chaining.domain.model.RecruitPost
+import com.example.chaining.ui.login.LoginScreen
+import com.example.chaining.ui.login.TermsScreen
+import com.example.chaining.ui.notification.NotificationScreen
+import com.example.chaining.ui.screen.AdminLoginScreen
+import com.example.chaining.ui.screen.ApplicationsScreen
+import com.example.chaining.ui.screen.ApplyScreen
+import com.example.chaining.ui.screen.CommunityScreen
+import com.example.chaining.ui.screen.CreatePostScreen
+import com.example.chaining.ui.screen.ENQuizScreen
+import com.example.chaining.ui.screen.FeedScreen
+import com.example.chaining.ui.screen.JoinPostScreen
+import com.example.chaining.ui.screen.KRQuizScreen
+import com.example.chaining.ui.screen.MainHomeScreen
+import com.example.chaining.ui.screen.MyPageScreen
+import com.example.chaining.ui.screen.MyPostsScreen
+import com.example.chaining.ui.screen.QuizResultScreen
+import com.example.chaining.ui.screen.SplashScreen
+import com.example.chaining.ui.screen.ViewPostScreen
+import com.example.chaining.viewmodel.QuizViewModel
+import com.google.gson.Gson
+
+@Suppress("FunctionName")
+@Composable
+fun NavGraph(
+ navController: NavHostController,
+ modifier: Modifier = Modifier,
+) {
+ NavHost(
+ navController = navController,
+ startDestination = Screen.Splash.route,
+ modifier = modifier,
+ ) {
+ composable(Screen.Splash.route) {
+ SplashScreen(navController)
+ }
+ composable(Screen.Login.route) {
+ LoginScreen(
+ onLoginSuccess = {
+ navController.navigate(route = Screen.MainHome.route) {
+ popUpTo("login") { inclusive = true }
+ }
+ },
+ // 관리자 로그인 버튼 클릭 시
+ onAdminLoginClick = {
+ navController.navigate("adminLogin")
+ },
+ onNavigateToTerms = { uid, nickname ->
+ navController.navigate("terms/$uid/$nickname")
+ },
+ )
+ }
+
+ composable(
+ route = Screen.Term.route,
+ arguments =
+ listOf(
+ navArgument("uid") { type = NavType.StringType },
+ navArgument("nickname") { type = NavType.StringType },
+ ),
+ ) { backStackEntry ->
+ val uid = backStackEntry.arguments?.getString("uid") ?: ""
+ val nickname = backStackEntry.arguments?.getString("nickname") ?: ""
+
+ TermsScreen(
+ uid = uid,
+ nickname = nickname,
+ onSuccess = {
+ navController.navigate(route = Screen.MainHome.route) {
+ popUpTo(Screen.Login.route) { inclusive = true }
+ }
+ },
+ onCancel = {
+ navController.popBackStack()
+ },
+ )
+ }
+
+ composable(Screen.AdminLogin.route) {
+ AdminLoginScreen(
+ onBackClick = { navController.popBackStack() },
+ onAdminLoginSuccess = {
+ navController.navigate(route = Screen.MainHome.route) {
+ popUpTo(Screen.Login.route) { inclusive = true }
+ }
+ },
+ )
+ }
+
+ composable(Screen.MyPage.route) {
+ MyPageScreen(
+ onKRQuizClick = { navController.navigate("krQuiz") },
+ onENQuizClick = { navController.navigate("enQuiz") },
+ onMyPostsClick = { navController.navigate(route = Screen.MyPosts.route) },
+ onMyApplicationsClick = {
+ navController.navigate(Screen.Applications.createRoute(type = "My"))
+ },
+ onLogout = {
+ navController.navigate("login") {
+ popUpTo(0) { inclusive = true }
+ }
+ },
+ )
+ }
+
+ composable(Screen.MainHome.route) {
+ MainHomeScreen(
+ onMainHomeClick = { navController.navigate("mainHome") },
+ onMyPageClick = { navController.navigate("myPage") },
+ onCommunityClick = { navController.navigate("community") },
+ onFeedClick = { navController.navigate("feed") },
+ onNotificationClick = { navController.navigate(route = Screen.Notification.route) },
+ onViewApplyClick = { applicationId ->
+ navController.navigate(
+ Screen.Apply.createRoute(
+ type = "Owner",
+ applicationId = applicationId,
+ ),
+ )
+ },
+ )
+ }
+ composable(
+ route = Screen.CreatePost.route,
+ arguments =
+ listOf(
+ navArgument("type") {
+ type = NavType.StringType
+ defaultValue = "생성"
+ },
+ navArgument("postId") {
+ type = NavType.StringType
+ defaultValue = ""
+ },
+ ),
+ ) { backStackEntry ->
+ val type = backStackEntry.arguments?.getString("type") ?: "생성"
+ val postId = backStackEntry.arguments?.getString("postId")
+
+ CreatePostScreen(
+ type = type,
+ postId = postId,
+ onBackClick = { navController.popBackStack() },
+ onPostCreated = { navController.popBackStack() },
+ )
+ }
+
+ composable(Screen.Community.route) {
+ CommunityScreen(
+ onBackClick = { navController.navigate("mainHome") },
+ onViewPostClick = { postId ->
+ navController.navigate(Screen.ViewPost.createRoute(postId))
+ },
+ onCreatePostClick = {
+ navController.navigate(
+ Screen.CreatePost.createRoute(type = "생성"),
+ )
+ },
+ )
+ }
+
+ composable(
+ route = Screen.ViewPost.route,
+ arguments = listOf(navArgument("postId") { type = NavType.StringType }),
+ ) {
+ ViewPostScreen(
+ onJoinPostClick = { post ->
+ navController.navigate(Screen.JoinPost.createRoute(post))
+ },
+ onBackClick = { navController.popBackStack() },
+ onEditClick = { postId ->
+ navController.navigate(
+ Screen.CreatePost.createRoute(
+ type = "수정",
+ postId = postId,
+ ),
+ )
+ },
+ onApplicationListClick = { postId ->
+ navController.navigate(
+ Screen.Applications.createRoute(
+ type = "Owner",
+ postId = postId,
+ ),
+ )
+ },
+ onMainHomeClick = { navController.navigate("mainHome") },
+ onCommunityClick = { navController.navigate("community") },
+ onFeedClick = { navController.navigate("feed") },
+ onNotificationClick = { navController.navigate(route = Screen.Notification.route) },
+ )
+ }
+
+ composable(
+ route = Screen.JoinPost.route,
+ arguments = listOf(navArgument("post") { type = NavType.StringType }),
+ ) { backStackEntry ->
+ val json = backStackEntry.arguments?.getString("post")
+ json?.let {
+ val post = remember(it) { Gson().fromJson(it, RecruitPost::class.java) }
+ JoinPostScreen(
+ onBackClick = { navController.popBackStack() },
+ post = post,
+ onSubmitSuccess = {
+ navController.navigate("mainHome")
+ },
+ onViewMyApplications = {
+ navController.navigate(Screen.Applications.createRoute(type = "My"))
+ },
+ )
+ }
+ }
+
+ navigation(
+ // 이 그룹의 시작 화면
+ startDestination = "enQuiz",
+ // 이 그룹의 고유한 이름(경로)
+ route = "quiz_flow",
+ ) {
+ composable("enQuiz") {
+ // 부모 그래프("quiz_flow")의 BackStackEntry를 가져옵니다.
+ val parentEntry = remember(it) { navController.getBackStackEntry("quiz_flow") }
+ // 부모의 ViewModel을 가져와 사용합니다.
+ val quizViewModel: QuizViewModel = hiltViewModel(parentEntry)
+
+ ENQuizScreen(
+ quizViewModel = quizViewModel,
+ onNavigateToResult = {
+ navController.navigate("quizResult") {
+ popUpTo("enQuiz") { inclusive = true }
+ }
+ },
+ )
+ }
+
+ // kr_quiz도 동일하게 수정
+ composable("krQuiz") {
+ val parentEntry = remember(it) { navController.getBackStackEntry("quiz_flow") }
+ val quizViewModel: QuizViewModel = hiltViewModel(parentEntry)
+
+ KRQuizScreen(
+ quizViewModel = quizViewModel,
+ onNavigateToResult = {
+ navController.navigate("quizResult") {
+ // 퀴즈 화면은 뒤로가기 기록에서 제거
+ popUpTo("krQuiz") { inclusive = true }
+ }
+ },
+ )
+ }
+
+ composable("quizResult") {
+ // 퀴즈 화면과 동일한 부모의 ViewModel 인스턴스를 가져옵니다.
+ val parentEntry = remember(it) { navController.getBackStackEntry("quiz_flow") }
+ val quizViewModel: QuizViewModel = hiltViewModel(parentEntry)
+
+ QuizResultScreen(
+ quizViewModel = quizViewModel,
+ onNavigateToMyPage = {
+ navController.navigate(Screen.MyPage.route) {
+ launchSingleTop = true
+ restoreState = true
+ popUpTo("quiz_flow") { inclusive = true }
+ }
+ },
+ )
+ }
+ }
+
+ composable(
+ route = Screen.Apply.route,
+ arguments =
+ listOf(
+ navArgument("type") {
+ type = NavType.StringType
+ defaultValue = "My"
+ },
+ ),
+ ) { backStackEntry ->
+ val type = backStackEntry.arguments?.getString("type") ?: "My"
+ val applicationId =
+ backStackEntry.arguments?.getString("applicationId") ?: return@composable
+
+ ApplyScreen(
+ onBackClick = { navController.popBackStack() },
+ type = type,
+ applicationId = applicationId,
+ )
+ }
+
+ composable("feed") {
+ FeedScreen(
+ onBackClick = { navController.navigate("mainHome") },
+ onMainHomeClick = { navController.navigate("mainHome") },
+ onCommunityClick = { navController.navigate("community") },
+ onFeedClick = { navController.navigate("feed") },
+ onNotificationClick = { navController.navigate(route = Screen.Notification.route) },
+ )
+ }
+
+ composable(route = Screen.MyPosts.route) {
+ MyPostsScreen(
+ onBackClick = { navController.popBackStack() },
+ )
+ }
+ composable(
+ route = Screen.Applications.route,
+ arguments =
+ listOf(
+ navArgument("type") {
+ type = NavType.StringType
+ defaultValue = "My"
+ },
+ navArgument("postId") {
+ type = NavType.StringType
+ // postId는 선택사항이므로 nullable
+ nullable = true
+ // 기본값은 null
+ defaultValue = null
+ },
+ ),
+ ) { backStackEntry ->
+ val type = backStackEntry.arguments?.getString("type") ?: "My"
+ val postId = backStackEntry.arguments?.getString("postId")
+
+ ApplicationsScreen(
+ type = type,
+ postId = postId,
+ onBackClick = { navController.popBackStack() },
+ onViewApplyClick = { applicationId ->
+ navController.navigate(
+ Screen.Apply.createRoute(
+ type = type,
+ applicationId = applicationId,
+ ),
+ )
+ },
+ )
+ }
+
+ composable(route = Screen.Notification.route) {
+ NotificationScreen(
+ onViewApplyClick = { applicationId ->
+ navController.navigate(
+ Screen.Apply.createRoute(
+ type = "Owner",
+ applicationId = applicationId,
+ ),
+ )
+ },
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/chaining/ui/navigation/Screen.kt b/app/src/main/java/com/example/chaining/ui/navigation/Screen.kt
new file mode 100644
index 0000000..6b6f577
--- /dev/null
+++ b/app/src/main/java/com/example/chaining/ui/navigation/Screen.kt
@@ -0,0 +1,60 @@
+package com.example.chaining.ui.navigation
+
+import com.example.chaining.domain.model.RecruitPost
+import com.google.gson.Gson
+
+sealed class Screen(val route: String) {
+ object Splash : Screen("splash")
+ object Login : Screen("login")
+ object AdminLogin : Screen("adminLogin")
+ object MyPage : Screen("myPage")
+ object MainHome : Screen("mainHome")
+ object CreatePost : Screen("createPost?type={type}&postId={postId}") {
+ fun createRoute(
+ type: String,
+ postId: String? = "",
+ ): String {
+ return "createPost?type=$type&postId=${postId ?: ""}"
+ }
+ }
+
+ object JoinPost : Screen("joinPost?post={post}") {
+ fun createRoute(post: RecruitPost): String {
+ val json = Gson().toJson(post)
+ val encodedJson = java.net.URLEncoder.encode(json, "UTF-8")
+ return "joinPost?post=$encodedJson"
+ }
+ }
+
+ object Community : Screen("community")
+ object ViewPost : Screen("viewPost/{postId}") {
+ fun createRoute(postId: String) = "viewPost/$postId"
+ }
+
+ object MyPosts : Screen("myPosts")
+ object Applications : Screen("applications?type={type}&postId={postId}") {
+ fun createRoute(
+ type: String,
+ postId: String? = "",
+ ): String {
+ return "applications?type=$type&postId=${postId ?: ""}"
+ }
+ }
+
+ object KRQuiz : Screen("krQuiz")
+ object ENQuiz : Screen("enQuiz")
+ object QuizResult : Screen("quizResult")
+ object Apply : Screen("apply?type={type}&applicationId={applicationId}") {
+ fun createRoute(
+ type: String,
+ applicationId: String? = "",
+ ): String {
+ return "apply?type=$type&applicationId=${applicationId ?: ""}"
+ }
+ }
+
+ object Feed : Screen("feed")
+ object Term : Screen("terms/{uid}/{nickname}")
+
+ object Notification : Screen("notification")
+}
diff --git a/app/src/main/java/com/example/chaining/ui/screen/AdminLoginScreen.kt b/app/src/main/java/com/example/chaining/ui/screen/AdminLoginScreen.kt
new file mode 100644
index 0000000..50f4ce7
--- /dev/null
+++ b/app/src/main/java/com/example/chaining/ui/screen/AdminLoginScreen.kt
@@ -0,0 +1,256 @@
+package com.example.chaining.ui.screen
+
+import android.widget.Toast
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Arrangement
+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.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextField
+import androidx.compose.material3.TextFieldDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.shadow
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.example.chaining.R
+import com.google.firebase.Firebase
+import com.google.firebase.auth.auth
+
+@Suppress("FunctionName")
+@Composable
+fun AdminLoginScreen(
+ onBackClick: () -> Unit = {},
+ // 로그인 성공 시 호출될 콜백 함수 추가
+ onAdminLoginSuccess: () -> Unit,
+) {
+ // 아이디와 비밀번호 입력을 기억하기 위한 상태 변수
+ var id by remember { mutableStateOf("") }
+ var password by remember { mutableStateOf("") }
+
+ // 로딩 상태를 관리하기 위한 상태 변수
+ var isLoading by remember { mutableStateOf(false) }
+
+ // Toast 메시지를 위한 Context
+ val context = LocalContext.current
+
+ Scaffold(
+ // topBar에 로그인 제목을 넣습니다.
+ topBar = {
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .height(64.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ IconButton(onClick = onBackClick) {
+ Icon(
+ painter = painterResource(id = R.drawable.back_arrow),
+ contentDescription = "뒤로 가기",
+ modifier = Modifier.size(20.dp),
+ tint = Color.Black,
+ )
+ }
+ Text(
+ text = "관리자 로그인",
+ fontSize = 20.sp,
+ color = Color.Black,
+ modifier = Modifier.weight(1f),
+ textAlign = TextAlign.Center,
+ )
+ // 제목을 완벽한 중앙에 맞추기 위한 빈 공간
+ Spacer(modifier = Modifier.width(48.dp))
+ }
+ },
+ ) { innerPadding ->
+ Column(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .padding(innerPadding)
+ .verticalScroll(rememberScrollState()),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Spacer(modifier = Modifier.height(32.dp))
+ Column(
+ modifier =
+ Modifier
+ // 가로 꽉 채움
+ .fillMaxWidth()
+ // 좌우 패딩은 유지
+ .padding(horizontal = 32.dp),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Image(
+ painter = painterResource(id = R.drawable.chain),
+ contentDescription = "Chain",
+ modifier =
+ Modifier
+ // 필요하면 조절
+ .size(90.dp)
+ .padding(bottom = 18.dp),
+ )
+
+ Text(
+ text = "Chaining",
+ fontSize = 50.sp,
+ fontWeight = FontWeight.ExtraBold,
+ textAlign = TextAlign.Center,
+ )
+ }
+ // 로고와 입력창 사이 여백
+ Spacer(modifier = Modifier.height(48.dp))
+ Column(
+ modifier =
+ Modifier
+ // 가로 꽉 채움
+ .fillMaxWidth()
+ // 좌우 패딩은 유지
+ .padding(horizontal = 32.dp),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ // 아이디 입력창
+ TextField(
+ value = id,
+ onValueChange = { id = it },
+ modifier = Modifier.fillMaxWidth(),
+ placeholder = { Text("아이디") },
+ shape = RoundedCornerShape(12.dp),
+ singleLine = true,
+ // 로딩 중에는 입력 비활성화
+ enabled = !isLoading,
+ colors =
+ TextFieldDefaults.colors(
+ focusedContainerColor = Color(0xFFF0F2F5),
+ unfocusedContainerColor = Color(0xFFF0F2F5),
+ // 포커스 시 밑줄 제거
+ focusedIndicatorColor = Color.Transparent,
+ // 포커스 없을 때 밑줄 제거
+ unfocusedIndicatorColor = Color.Transparent,
+ ),
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // 비밀번호 입력창
+ TextField(
+ value = password,
+ onValueChange = { password = it },
+ modifier = Modifier.fillMaxWidth(),
+ placeholder = { Text("비밀번호") },
+ shape = RoundedCornerShape(12.dp),
+ singleLine = true,
+ // 입력된 글자를 '*'로 보이게 함
+ visualTransformation = PasswordVisualTransformation(),
+ // 키보드 타입을 비밀번호용으로 설정
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
+ // 로딩 중에는 입력 비활성화
+ enabled = !isLoading,
+ colors =
+ TextFieldDefaults.colors(
+ focusedContainerColor = Color(0xFFF0F2F5),
+ unfocusedContainerColor = Color(0xFFF0F2F5),
+ focusedIndicatorColor = Color.Transparent,
+ unfocusedIndicatorColor = Color.Transparent,
+ ),
+ )
+ }
+ // 입력창과 버튼 사이 여백
+ Spacer(modifier = Modifier.height(80.dp))
+ Column(
+ modifier =
+ Modifier
+ // 가로 꽉 채움
+ .fillMaxWidth()
+ // 좌우 패딩은 유지
+ .padding(horizontal = 32.dp),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ // 4. 로그인 버튼
+ Button(
+ onClick = {
+ // 입력값 검증
+ if (id.isBlank() || password.isBlank()) {
+ Toast.makeText(context, "아이디와 비밀번호를 모두 입력해주세요.", Toast.LENGTH_SHORT)
+ .show()
+ return@Button
+ }
+ // 로딩 시작
+ isLoading = true
+
+ Firebase.auth.signInWithEmailAndPassword(id.trim(), password)
+ .addOnCompleteListener { task ->
+ // 로딩 종료
+ isLoading = false
+ if (task.isSuccessful) {
+ // 로그인 성공
+ Toast.makeText(context, "관리자 로그인 성공", Toast.LENGTH_SHORT).show()
+ // 성공 콜백 호출
+ onAdminLoginSuccess()
+ } else {
+ // 로그인 실패
+ Toast.makeText(
+ context,
+ "로그인 실패: ${task.exception?.message}",
+ Toast.LENGTH_SHORT,
+ ).show()
+ }
+ }
+ },
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .height(52.dp)
+ .shadow(4.dp, RoundedCornerShape(12.dp)),
+ // 로딩 중에는 버튼 비활성화
+ enabled = !isLoading,
+ shape = RoundedCornerShape(12.dp),
+ colors =
+ ButtonDefaults.buttonColors(
+ containerColor = Color(0xFF4285F4),
+ // 비활성화 시 색상
+ disabledContainerColor = Color.Gray,
+ ),
+ ) {
+ // 로딩 상태에 따라 텍스트 변경
+ Text(
+ text = if (isLoading) "로그인 중..." else "관리자 로그인",
+ fontSize = 18.sp,
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/chaining/ui/screen/ApplicationsScreen.kt b/app/src/main/java/com/example/chaining/ui/screen/ApplicationsScreen.kt
new file mode 100644
index 0000000..bffc580
--- /dev/null
+++ b/app/src/main/java/com/example/chaining/ui/screen/ApplicationsScreen.kt
@@ -0,0 +1,178 @@
+package com.example.chaining.ui.screen
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.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.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.hilt.navigation.compose.hiltViewModel
+import com.example.chaining.R
+import com.example.chaining.domain.model.Application
+import com.example.chaining.ui.component.CardItem
+import com.example.chaining.viewmodel.RecruitPostViewModel
+import com.example.chaining.viewmodel.UserViewModel
+
+@Suppress("FunctionName")
+@Composable
+fun ApplicationsScreen(
+ onBackClick: () -> Unit = {},
+ userViewModel: UserViewModel = hiltViewModel(),
+ postViewModel: RecruitPostViewModel = hiltViewModel(),
+ postId: String?,
+ // "My" or "Owner"
+ type: String,
+ onViewApplyClick: (String) -> Unit,
+) {
+ val userState by userViewModel.user.collectAsState()
+ val myApplications = userState?.applications.orEmpty()
+ val post by postViewModel.post.collectAsState()
+
+ val ownerApplications: Map =
+ if (type == "Owner") {
+ post?.applications ?: emptyMap()
+ } else {
+ emptyMap()
+ }
+
+ var showOnlyFinishedApplications by remember { mutableStateOf(false) }
+
+ val applications: List =
+ if (type == "Owner") {
+ ownerApplications.values.toList()
+ } else {
+ myApplications.values.toList()
+ }
+
+ val filteredApplications: List =
+ if (showOnlyFinishedApplications) {
+ applications.filter { application -> application.status != "PENDING" }
+ } else {
+ applications
+ }
+
+ Scaffold(
+ topBar = {
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .height(64.dp)
+ .clip(RoundedCornerShape(bottomEnd = 20.dp))
+ .background(Color(0xFF4A526A)),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ IconButton(onClick = onBackClick) {
+ Icon(
+ painter = painterResource(id = R.drawable.back_arrow),
+ contentDescription = "뒤로 가기",
+ modifier = Modifier.size(20.dp),
+ tint = Color.White,
+ )
+ }
+
+ // 제목
+ Text(
+ text =
+ if (type == "Owner") {
+ stringResource(id = R.string.post_application)
+ } else {
+ stringResource(
+ id = R.string.myapply_title,
+ )
+ },
+ modifier = Modifier.weight(1f),
+ color = Color.White,
+ fontSize = 20.sp,
+ textAlign = TextAlign.Center,
+ )
+ Spacer(modifier = Modifier.width(48.dp))
+ }
+ },
+ containerColor = Color(0xFFF3F6FF),
+ ) { innerPadding ->
+ Column(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .padding(innerPadding)
+ .padding(16.dp)
+ .verticalScroll(rememberScrollState()),
+ ) {
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 8.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ // 새로 만든 CommunityActionButton 호출
+ ActionButton(
+ modifier = Modifier.weight(1f),
+ iconRes = R.drawable.post,
+ text =
+ if (showOnlyFinishedApplications) {
+ stringResource(id = R.string.myapply_all_post)
+ } else {
+ stringResource(
+ id = R.string.myapply_filter_open,
+ )
+ },
+ onClick = {
+ showOnlyFinishedApplications = !showOnlyFinishedApplications
+ },
+ )
+ }
+ if (filteredApplications.isEmpty()) {
+ // 데이터가 없을 때
+ Text(
+ text = stringResource(id = R.string.myapply_nothing),
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(top = 50.dp),
+ color = Color.Gray,
+ textAlign = TextAlign.Center,
+ )
+ } else {
+ // 모집글 목록 표시
+ filteredApplications.forEach { application ->
+ CardItem(
+ onClick = {
+ onViewApplyClick(application.applicationId)
+ },
+ type = "지원서",
+ application = application,
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/chaining/ui/screen/ApplyScreen.kt b/app/src/main/java/com/example/chaining/ui/screen/ApplyScreen.kt
new file mode 100644
index 0000000..a61c76e
--- /dev/null
+++ b/app/src/main/java/com/example/chaining/ui/screen/ApplyScreen.kt
@@ -0,0 +1,500 @@
+package com.example.chaining.ui.screen
+
+import android.content.Intent
+import android.net.Uri
+import android.widget.Toast
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+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.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Scaffold
+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.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.hilt.navigation.compose.hiltViewModel
+import coil.compose.AsyncImage
+import com.example.chaining.R
+import com.example.chaining.domain.model.UserSummary
+import com.example.chaining.viewmodel.ApplicationViewModel
+import com.example.chaining.viewmodel.RecruitPostViewModel
+import com.example.chaining.viewmodel.UserViewModel
+import kotlinx.coroutines.flow.collectLatest
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Suppress("FunctionName")
+@Composable
+fun ApplyScreen(
+ onBackClick: () -> Unit = {},
+ userViewModel: UserViewModel = hiltViewModel(),
+ // My, Owner
+ type: String,
+ applicationId: String,
+ applicationViewModel: ApplicationViewModel = hiltViewModel(),
+ postViewModel: RecruitPostViewModel = hiltViewModel(),
+ onNavigateHome: () -> Unit? = {},
+) {
+ val userState by userViewModel.user.collectAsState()
+ val application by applicationViewModel.application.collectAsState()
+ val post by postViewModel.post.collectAsState()
+ val context = LocalContext.current
+ var showResultDialog by remember { mutableStateOf(false) }
+
+// 1. applicationId가 변경되면 application 정보를 가져오는 Effect
+ LaunchedEffect(key1 = applicationId) {
+ applicationViewModel.fetchApplication(applicationId)
+ }
+
+// 2. application 정보가 성공적으로 로드되면(null이 아니게 되면) post 정보를 가져오는 Effect
+ LaunchedEffect(key1 = application) {
+ // application이 null이 아니고, 그 안의 postId도 null이 아닐 때만 실행
+ application?.postId?.let { postId ->
+ postViewModel.fetchPost(postId)
+ }
+ }
+
+ LaunchedEffect(key1 = true) {
+ userViewModel.toastEvent.collectLatest { message ->
+ Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ // post가 null이면 로딩 UI 표시
+ if (application == null) {
+ Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ CircularProgressIndicator()
+ }
+ return
+ }
+
+ Scaffold(
+ // 상단바 배경색을 직접 파란색으로 지정
+ topBar = {
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ // 상단바의 기본 높이
+ .height(64.dp)
+ .background(Color(0xFF4285F4)),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ IconButton(onClick = onBackClick) {
+ Icon(
+ painter = painterResource(id = R.drawable.back_arrow),
+ contentDescription = "뒤로 가기",
+ modifier = Modifier.size(20.dp),
+ tint = Color.White,
+ )
+ }
+
+ Text(
+ text =
+ if (type == "Owner") {
+ stringResource(id = R.string.view_application)
+ } else {
+ stringResource(id = R.string.apply_mine)
+ },
+ fontSize = 20.sp,
+ color = Color.White,
+ modifier = Modifier.weight(1f),
+ textAlign = TextAlign.Center,
+ )
+ // 제목을 완벽한 중앙에 맞추기 위한 빈 공간
+ Spacer(modifier = Modifier.width(48.dp))
+ }
+ },
+ // 전체 기본 배경은 흰색으로 둡니다.
+ containerColor = Color.White,
+ ) { innerPadding ->
+ // Box를 사용해 파란 헤더와 흰색 콘텐츠를 겹치게 합니다.
+ Box(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .padding(innerPadding),
+ ) {
+ // 곡선 효과가 있는 파란색 헤더
+ Box(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .height(150.dp)
+ .clip(RoundedCornerShape(bottomEndPercent = 50))
+ .background(Color(0xFF4285F4)),
+ ) {
+ // 타이머 텍스트를 담을 Column 추가
+ Column(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ // 상단바와의 간격
+ .padding(top = 16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(
+ text = "수락/거절까지",
+ color = Color.White.copy(alpha = 0.8f),
+ fontSize = 14.sp,
+ )
+ Text(
+ text = "12시간 30분 남음",
+ color = Color.White,
+ fontSize = 20.sp,
+ fontWeight = FontWeight.Bold,
+ )
+ }
+ }
+
+ // 스크롤되는 흰색 콘텐츠 영역
+ Column(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState()),
+ ) {
+ // 프로필 사진에 내용이 가려지지 않도록 공간 확보
+ Spacer(modifier = Modifier.height(200.dp))
+
+ // 상세 정보 콘텐츠 추가
+ Column(
+ modifier = Modifier.padding(horizontal = 50.dp),
+ horizontalAlignment = Alignment.Start,
+ ) {
+ Text(
+ text =
+ application?.applicant?.nickname
+ ?: stringResource(id = R.string.community_unknown),
+ fontSize = 24.sp,
+ fontWeight = FontWeight.Bold,
+ color = Color(0xFF4A526A),
+ )
+ Text(
+ text =
+ application?.applicant?.country
+ ?: stringResource(id = R.string.community_unknown),
+ fontSize = 16.sp,
+ fontWeight = FontWeight.Bold,
+ color = Color(0xFF7282B4),
+ modifier = Modifier.padding(top = 4.dp),
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // 언어 수준
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ // 이 부분만 왼쪽 정렬
+ horizontalAlignment = Alignment.Start,
+ ) {
+ Text(
+// text = if (type == "Owner") {
+// "${application?.applicationId ?: "알 수 없음"} 수정 필요"
+// } else {
+// "${userState?.preferredLanguages?.get(0)?.language ?: "알 수 없음"} 수준 : ${
+// userState?.preferredLanguages?.get(
+// 0
+// )?.level ?: "알 수 없음"
+// } / 10"
+// },
+ text = stringResource(id = R.string.community_unknown),
+ color = Color(0xFF4A526A),
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+// text = if (type == "Owner") {
+// "${application?.applicationId ?: "알 수 없음"} 수정 필요"
+// } else {
+// "${userState?.preferredLanguages?.get(0)?.language ?: "알 수 없음"} 수준 : ${
+// userState?.preferredLanguages?.get(
+// 0
+// )?.level ?: "알 수 없음"
+// } / 10"
+// },
+ text = stringResource(id = R.string.community_unknown),
+ color = Color(0xFF4A526A),
+ )
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // 자기 소개
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.Start,
+ ) {
+ Text(
+ text = "자기소개:",
+ fontWeight = FontWeight.SemiBold,
+ color = Color(0xFF7282B4),
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text =
+ application?.introduction
+ ?: stringResource(id = R.string.community_unknown),
+ color = Color(0xFF4A526A),
+ )
+ }
+
+ Spacer(modifier = Modifier.height(100.dp))
+
+ if (type == "Owner") {
+ Row {
+ // 수락 버튼
+ Button(
+ onClick = {
+ application?.let { apply ->
+ applicationViewModel.updateStatus(
+ application = apply,
+ value = "승인",
+ )
+ }
+ },
+ modifier =
+ Modifier
+ .weight(1f)
+ .height(50.dp)
+ .width(200.dp),
+ shape = RoundedCornerShape(20.dp),
+ colors =
+ ButtonDefaults.buttonColors(
+ containerColor = Color(0xFF2C80FF),
+ contentColor = Color.White,
+ ),
+ ) {
+ Text("승인", fontSize = 16.sp)
+ }
+
+ // 거절 버튼
+ Button(
+ onClick = {
+ application?.let { apply ->
+ applicationViewModel.updateStatus(
+ application = apply,
+ value = "거절",
+ )
+ }
+ },
+ modifier =
+ Modifier
+ .weight(1f)
+ .height(50.dp)
+ .width(120.dp),
+ shape = RoundedCornerShape(20.dp),
+ colors =
+ ButtonDefaults.buttonColors(
+ containerColor = Color(0xFFF0F2F5),
+ contentColor = Color.DarkGray,
+ ),
+ ) {
+ Text("거절", fontSize = 16.sp)
+ }
+ }
+ } else {
+ // 결과 버튼
+ Button(
+ onClick = { showResultDialog = true },
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .height(50.dp),
+ shape = RoundedCornerShape(20.dp),
+ enabled = application?.status != "PENDING",
+ colors =
+ ButtonDefaults.buttonColors(
+ containerColor =
+ if (application?.status == "PENDING") {
+ Color(
+ 0xFFF0F2F5,
+ )
+ } else {
+ Color(0xFF2C80FF)
+ },
+ contentColor = Color.White,
+ ),
+ ) {
+ Text("결과 보기", fontSize = 16.sp)
+ }
+ }
+
+ // 하단 네비게이션 바와의 간격
+ Spacer(modifier = Modifier.height(24.dp))
+ }
+ }
+ // 결과 모달(Dialog)
+ if (showResultDialog) {
+ AlertDialog(
+ onDismissRequest = { showResultDialog = false },
+ title = {
+ Text(
+ text =
+ when (application?.status) {
+ "승인" -> "축하합니다! 🎉"
+ "거절" -> "아쉽지만 다음 기회에!"
+ else -> "결과 대기 중"
+ },
+ fontWeight = FontWeight.Bold,
+ )
+ },
+ text = {
+ Text(
+ text =
+ when (application?.status) {
+ "승인" -> "지원하신 모집에 합격하셨습니다.\n카카오 오픈채팅으로 바로 이동할 수 있어요."
+ "거절" -> "아쉽게도 이번에는 합격하지 못했어요.\n다른 멋진 모집글을 찾아보세요!"
+ else -> "결과가 아직 나오지 않았습니다."
+ },
+ )
+ },
+ confirmButton = {
+ when (application?.status) {
+ "승인" -> {
+ TextButton(
+ onClick = {
+ showResultDialog = false
+ val chatUrl = post?.kakaoOpenChatUrl
+ println("포포포" + post)
+ println("포포URL" + chatUrl)
+ if (!chatUrl.isNullOrEmpty()) {
+ val intent =
+ Intent(Intent.ACTION_VIEW, Uri.parse(chatUrl))
+ context.startActivity(intent)
+ } else {
+ Toast.makeText(
+ context,
+ "카카오 오픈채팅 URL이 존재하지 않습니다.",
+ Toast.LENGTH_SHORT,
+ ).show()
+ }
+ },
+ ) {
+ Text("카카오톡 오픈채팅으로 이동")
+ }
+ }
+
+ "거절" -> {
+ TextButton(
+ onClick = {
+ showResultDialog = false
+ onNavigateHome()
+ },
+ ) {
+ Text("다른 모집글 보러가기")
+ }
+ }
+
+ else -> {
+ TextButton(onClick = { showResultDialog = false }) {
+ Text("닫기")
+ }
+ }
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { showResultDialog = false }) {
+ Text("닫기")
+ }
+ },
+ )
+ }
+ Row(
+ modifier =
+ Modifier
+ .align(Alignment.TopStart)
+ .padding(top = 100.dp, start = 60.dp),
+ verticalAlignment = Alignment.Bottom,
+ ) {
+ // 프로필 사진
+ AsyncImage(
+ model = application?.applicant?.profileImageUrl,
+ contentDescription = "프로필 사진",
+ contentScale = ContentScale.Crop,
+ modifier =
+ Modifier
+ .size(80.dp)
+ .clip(RoundedCornerShape(20.dp))
+ .border(3.dp, Color.White, RoundedCornerShape(20.dp)),
+ )
+
+ Spacer(modifier = Modifier.width(20.dp))
+
+ // 친구 추가 아이콘
+ Box(
+ modifier =
+ Modifier
+ .size(60.dp)
+ .clip(CircleShape)
+ .background(Color(0xFF3ECDFF))
+ .border(3.dp, Color.White, CircleShape)
+ .padding(4.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ painter = painterResource(id = R.drawable.follow),
+ contentDescription = "친구 추가",
+ tint = Color.White,
+ modifier =
+ Modifier
+ .size(16.dp)
+ .clickable {
+ val currentUser = userState
+ val currentApplication = application
+
+ if (currentUser != null && currentApplication != null) {
+ val myInfo =
+ UserSummary(
+ id = currentUser.id,
+ nickname = currentUser.nickname,
+ profileImageUrl = currentUser.profileImageUrl,
+ country = currentUser.country,
+ )
+ userViewModel.toggleFollow(
+ myInfo,
+ currentApplication.applicant,
+ )
+ }
+ },
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/chaining/ui/screen/CommunityScreen.kt b/app/src/main/java/com/example/chaining/ui/screen/CommunityScreen.kt
new file mode 100644
index 0000000..b2dbe81
--- /dev/null
+++ b/app/src/main/java/com/example/chaining/ui/screen/CommunityScreen.kt
@@ -0,0 +1,246 @@
+package com.example.chaining.ui.screen
+
+import android.util.Log
+import androidx.activity.compose.BackHandler
+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.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.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+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.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.hilt.navigation.compose.hiltViewModel
+import com.example.chaining.R
+import com.example.chaining.data.model.FilterState
+import com.example.chaining.ui.component.CardItem
+import com.example.chaining.ui.component.FilterOptionsSheet
+import com.example.chaining.ui.component.formatRemainingTime
+import com.example.chaining.viewmodel.RecruitPostViewModel
+import com.example.chaining.viewmodel.UserViewModel
+import kotlinx.coroutines.launch
+
+@Suppress("FunctionName")
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun CommunityScreen(
+ postViewModel: RecruitPostViewModel = hiltViewModel(),
+ onBackClick: () -> Unit = {},
+ onViewPostClick: (postId: String) -> Unit = {},
+ userViewModel: UserViewModel = hiltViewModel(),
+ onCreatePostClick: () -> Unit,
+) {
+ val context = LocalContext.current
+ // 1. ViewModel로부터 필터링된 posts와 현재 filterState를 직접 구독
+ val posts by postViewModel.posts.collectAsState()
+ val filterState by postViewModel.filterState.collectAsState()
+ val userState by userViewModel.user.collectAsState()
+
+ // 1. Bottom Sheet의 상태를 제어하기 위한 변수들
+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+ var showBottomSheet by remember { mutableStateOf(false) }
+ val scope = rememberCoroutineScope()
+
+ // 2. '필터 적용' 함수는 ViewModel의 함수를 호출하는 역할만 함
+ val applyFilters: (FilterState) -> Unit = { newFilterState ->
+ postViewModel.applyFilters(newFilterState)
+ scope.launch { sheetState.hide() }.invokeOnCompletion {
+ if (!sheetState.isVisible) {
+ showBottomSheet = false
+ }
+ }
+ }
+
+ // 3. Bottom Sheet UI 구현
+ if (showBottomSheet) {
+ ModalBottomSheet(
+ onDismissRequest = { showBottomSheet = false },
+ sheetState = sheetState,
+ ) {
+ FilterOptionsSheet(
+ currentFilterState = filterState,
+ onApplyFilters = applyFilters,
+ onClose = {
+ scope.launch { sheetState.hide() }.invokeOnCompletion {
+ if (!sheetState.isVisible) {
+ showBottomSheet = false
+ }
+ }
+ },
+ )
+ }
+ }
+ BackHandler(enabled = true) {
+ onBackClick()
+ }
+ Scaffold(
+ topBar = {
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .height(64.dp)
+ .clip(RoundedCornerShape(bottomEnd = 20.dp))
+ .background(Color(0xFF4A526A)),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ // 뒤로가기 버튼
+ IconButton(onClick = onBackClick) {
+ Icon(
+ painter = painterResource(id = R.drawable.back_arrow),
+ contentDescription = "뒤로 가기",
+ modifier = Modifier.size(20.dp),
+ tint = Color.White,
+ )
+ }
+
+ // 제목
+ Text(
+ text = stringResource(id = R.string.community_title),
+ modifier = Modifier.weight(1f),
+ color = Color.White,
+ fontSize = 20.sp,
+ textAlign = TextAlign.Center,
+ )
+
+ IconButton(onClick = { showBottomSheet = true }) {
+ Icon(
+ painter = painterResource(id = R.drawable.filter),
+ contentDescription = "필터",
+ modifier = Modifier.size(20.dp),
+ tint = Color.White,
+ )
+ }
+ }
+ },
+ containerColor = Color(0xFFF3F6FF),
+ ) { innerPadding ->
+ Column(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .padding(innerPadding)
+ .padding(16.dp)
+ .verticalScroll(rememberScrollState()),
+ ) {
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ // 새로 만든 CommunityActionButton 호출
+ CommunityActionButton(
+ modifier = Modifier.weight(1f),
+ iconRes = R.drawable.post,
+ text = stringResource(id = R.string.community_write_post),
+ onClick = onCreatePostClick,
+ )
+
+ // 새로 만든 CommunityActionButton 호출
+ CommunityActionButton(
+ modifier = Modifier.weight(1f),
+ iconRes = R.drawable.reload,
+ text = stringResource(id = R.string.community_refresh),
+ onClick = { postViewModel.refreshPosts() },
+ )
+ }
+ Log.d("hhhh", posts.toString())
+ if (posts.isEmpty()) {
+ // 데이터가 없을 때
+ Text(
+ text = stringResource(id = R.string.community_no_posts),
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(top = 50.dp),
+ color = Color.Gray,
+ textAlign = TextAlign.Center,
+ )
+ } else {
+ posts.forEach { post ->
+ val isLiked = userState?.likedPosts?.get(post.postId) == true
+ CardItem(
+ onClick = { onViewPostClick(post.postId) },
+ type = "모집글",
+ recruitPost = post,
+ isLiked = isLiked,
+ remainingTime =
+ formatRemainingTime(
+ context,
+ post.closeAt - System.currentTimeMillis(),
+ ),
+ onRightButtonClick = { userViewModel.toggleLike(post.postId) },
+ currentUserId = userState?.id,
+ )
+ }
+ }
+ }
+ }
+}
+
+@Suppress("FunctionName")
+@Composable
+fun CommunityActionButton(
+ modifier: Modifier = Modifier,
+ iconRes: Int,
+ text: String,
+ onClick: () -> Unit,
+) {
+ Button(
+ onClick = onClick,
+ modifier = modifier,
+ shape = RoundedCornerShape(30.dp),
+ colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF7282B4)),
+ // 버튼 내부 컨텐츠의 좌우 여백을 조절
+ contentPadding = PaddingValues(horizontal = 8.dp, vertical = 6.dp),
+ ) {
+ Icon(
+ painter = painterResource(id = iconRes),
+ contentDescription = text,
+ tint = Color.White,
+ modifier = Modifier.size(22.dp),
+ )
+ Spacer(modifier = Modifier.width(10.dp))
+ Text(
+ text = text,
+ color = Color.White,
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 16.sp,
+ )
+ }
+}
diff --git a/app/src/main/java/com/example/chaining/ui/screen/CreatePostScreen.kt b/app/src/main/java/com/example/chaining/ui/screen/CreatePostScreen.kt
new file mode 100644
index 0000000..3dbe9a4
--- /dev/null
+++ b/app/src/main/java/com/example/chaining/ui/screen/CreatePostScreen.kt
@@ -0,0 +1,577 @@
+package com.example.chaining.ui.screen
+
+import android.widget.Toast
+import androidx.annotation.DrawableRes
+import androidx.compose.foundation.background
+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.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExposedDropdownMenuBox
+import androidx.compose.material3.ExposedDropdownMenuDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextFieldDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.hilt.navigation.compose.hiltViewModel
+import com.example.chaining.R
+import com.example.chaining.domain.model.RecruitPost
+import com.example.chaining.domain.model.UserSummary
+import com.example.chaining.ui.component.DatePickerFieldToModal
+import com.example.chaining.ui.component.SaveButton
+import com.example.chaining.viewmodel.AreaViewModel
+import com.example.chaining.viewmodel.PostCreationEvent
+import com.example.chaining.viewmodel.RecruitPostViewModel
+import com.example.chaining.viewmodel.UserViewModel
+import kotlinx.coroutines.flow.collectLatest
+
+private const val MAX_TITLE_LENGTH = 20
+private const val MAX_CONTENT_LENGTH = 300
+
+@Suppress("FunctionName")
+@Composable
+fun CreatePostScreen(
+ postId: String? = null,
+ postViewModel: RecruitPostViewModel = hiltViewModel(),
+ onBackClick: () -> Unit = {},
+ onPostCreated: () -> Unit,
+ userViewModel: UserViewModel = hiltViewModel(),
+ // "생성" or "수정"
+ type: String,
+ areaViewModel: AreaViewModel = hiltViewModel(),
+) {
+ val context = LocalContext.current
+ val areaEntities by areaViewModel.areaCodes.collectAsState()
+ val userState by userViewModel.user.collectAsState()
+ val postState by postViewModel.post.collectAsState()
+ val postCreationSuccess by postViewModel.postCreationSuccess.collectAsState()
+
+ LaunchedEffect(key1 = type, key2 = postId) {
+ if (type == "수정" && postId != null) {
+ postViewModel.fetchPost(postId)
+ }
+ }
+
+ // ✅ ViewModel의 이벤트를 구독하고 Toast 메시지를 표시
+ LaunchedEffect(Unit) {
+ postViewModel.postCreationEvent.collectLatest { event ->
+ val message =
+ when (event) {
+ is PostCreationEvent.Success -> {
+ // 성공 시 strings.xml에서 성공 메시지를 가져옴
+ context.getString(R.string.post_creation_success)
+ }
+
+ is PostCreationEvent.Failure -> {
+ // 실패 시 strings.xml에서 실패 메시지 형식을 가져와 조합
+ val errorMessage =
+ event.message ?: context.getString(R.string.unknown_error)
+ context.getString(R.string.post_creation_failed, errorMessage)
+ }
+ }
+ Toast.makeText(context, message, Toast.LENGTH_LONG).show()
+ }
+ }
+
+ var title by remember { mutableStateOf(userState?.nickname ?: "") }
+ var content by remember { mutableStateOf("") }
+ var preferredDestinations by remember { mutableStateOf("") }
+ var preferredLocations by remember { mutableStateOf("") }
+ var preferredLanguages by remember {
+ mutableStateOf(
+ userState?.preferredLanguages ?: emptyMap(),
+ )
+ }
+ var hasCar by remember { mutableStateOf("") }
+ var tourAt by remember { mutableStateOf(null) }
+ var closeAt by remember { mutableStateOf(null) }
+ var kakaoOpenChatUrl by remember { mutableStateOf("") }
+
+ val buttonText =
+ if (type == "생성") {
+ stringResource(id = R.string.post_write_button)
+ } else {
+ stringResource(id = R.string.post_edit_button)
+ }
+
+ val fieldTitleText = stringResource(id = R.string.post_title)
+ val fieldContentText = stringResource(id = R.string.post_content)
+ val fieldTravelStyleText = stringResource(id = R.string.post_travel_style)
+ val fieldLocationText = stringResource(id = R.string.post_location)
+ val fieldTourDateText = stringResource(id = R.string.post_tour_date)
+ val fieldCloseDateText = stringResource(id = R.string.post_close_date)
+ val fieldCarText = stringResource(id = R.string.post_car)
+ val fieldKakaoLinkText = stringResource(id = R.string.post_kakao_link)
+ val validationPleaseEnterText = stringResource(id = R.string.post_please_enter)
+ val validationInvalidKakaoLinkText = stringResource(id = R.string.post_invalid_kakao_link)
+
+ LaunchedEffect(postCreationSuccess) {
+ if (postCreationSuccess) {
+ onPostCreated() // NavGraph에 정의된 화면 이동 로직 실행
+ postViewModel.onPostCreationHandled() // 상태 초기화
+ }
+ }
+
+ LaunchedEffect(userState) {
+ if (type == "생성") {
+ userState?.let { user ->
+ preferredDestinations = user.preferredDestinations
+ preferredLanguages = user.preferredLanguages
+ }
+ }
+ }
+
+ LaunchedEffect(postState) {
+ val currentPost = postState
+ if (type == "수정" && currentPost != null) {
+ title = currentPost.title
+ content = currentPost.content
+ preferredDestinations = currentPost.preferredDestinations
+ preferredLocations = currentPost.preferredLocations
+ preferredLanguages = currentPost.preferredLanguages
+ hasCar = currentPost.hasCar
+ tourAt = currentPost.tourAt
+ closeAt = currentPost.closeAt
+ kakaoOpenChatUrl = currentPost.kakaoOpenChatUrl
+ }
+ }
+
+ Scaffold(
+ topBar = {
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .height(64.dp) // 원하는 높이로 직접 설정
+ .clip(RoundedCornerShape(bottomEnd = 20.dp))
+ .background(Color(0xFF4A526A)),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ // 뒤로가기 아이콘 버튼
+ IconButton(onClick = onBackClick) {
+ Icon(
+ painter = painterResource(id = R.drawable.back_arrow),
+ contentDescription = "뒤로 가기",
+ modifier = Modifier.size(20.dp),
+ tint = Color.White,
+ )
+ }
+
+ // 제목 텍스트
+ Text(
+ text = stringResource(id = R.string.post_write_title),
+ modifier = Modifier.weight(1f),
+ color = Color.White,
+ fontSize = 20.sp,
+ textAlign = TextAlign.Center,
+ )
+
+ // 제목을 완벽한 중앙에 맞추기 위한 빈 공간
+ Spacer(modifier = Modifier.width(48.dp))
+ }
+ },
+ containerColor = Color(0xFFF3F6FF),
+ ) { innerPadding ->
+ Column(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .padding(innerPadding)
+ .padding(16.dp)
+ // 스크롤 가능하게 만듦
+ .verticalScroll(rememberScrollState()),
+ ) {
+ Spacer(modifier = Modifier.height(16.dp))
+
+ OutlinedTextField(
+ value = title,
+ onValueChange = { if (it.length <= MAX_TITLE_LENGTH) title = it },
+ modifier =
+ Modifier
+ .fillMaxWidth(),
+ placeholder = {
+ Text(
+ text = stringResource(id = R.string.post_write_enter_title),
+ modifier = Modifier.padding(start = 14.dp),
+ )
+ },
+ shape = RoundedCornerShape(16.dp),
+ singleLine = true,
+ colors =
+ TextFieldDefaults.colors(
+ focusedContainerColor = Color.White,
+ unfocusedContainerColor = Color.White,
+ disabledContainerColor = Color.White,
+ focusedPlaceholderColor = Color.Gray,
+ unfocusedPlaceholderColor = Color.Gray,
+ focusedIndicatorColor = Color.LightGray,
+ unfocusedIndicatorColor = Color.LightGray,
+ ),
+ supportingText = {
+ Text(
+ text = "${title.length} / $MAX_TITLE_LENGTH",
+ modifier = Modifier.fillMaxWidth(),
+ textAlign = TextAlign.End,
+ )
+ },
+ )
+ Spacer(modifier = Modifier.height(20.dp))
+
+ // 여행지 스타일 드롭다운
+ val travelStyles =
+ listOf(
+ stringResource(id = R.string.travel_style_mountain),
+ stringResource(id = R.string.travel_style_sea),
+ stringResource(id = R.string.travel_style_city),
+ stringResource(id = R.string.travel_style_activity),
+ stringResource(id = R.string.travel_style_rest),
+ stringResource(id = R.string.travel_style_culture),
+ )
+ PreferenceSelector(
+ options = travelStyles,
+ placeholderText = stringResource(id = R.string.post_write_style),
+ selectedOption = preferredDestinations,
+ onOptionSelected = { preferredDestinations = it },
+ leadingIconRes = R.drawable.favorite_spot,
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // 여행 지역 드롭다운
+ val areaNames =
+ remember(areaEntities) {
+ areaEntities
+ .map { it.regionName }
+ }
+ PreferenceSelector(
+ options = areaNames,
+ placeholderText = stringResource(id = R.string.post_write_location),
+ selectedOption = preferredLocations,
+ onOptionSelected = { selectedName ->
+ preferredLocations = selectedName
+ },
+ leadingIconRes = R.drawable.country,
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // 여행 시작일 선택
+ DatePickerFieldToModal(
+ modifier = Modifier.fillMaxWidth(),
+ label = stringResource(id = R.string.post_tour_date),
+ selectedDate = tourAt,
+ onDateSelected = { tourAt = it },
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // 모집 마감일 선택
+ DatePickerFieldToModal(
+ modifier = Modifier.fillMaxWidth(),
+ label = stringResource(id = R.string.post_close_date),
+ selectedDate = closeAt,
+ onDateSelected = { closeAt = it },
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ SingleDropdown(
+ label = stringResource(id = R.string.post_write_car),
+ leadingIconRes = R.drawable.car,
+ options =
+ listOf(
+ stringResource(id = R.string.post_write_car_six),
+ stringResource(id = R.string.post_write_car_four),
+ stringResource(id = R.string.post_write_car_two),
+ stringResource(id = R.string.post_write_no),
+ ),
+ selectedOption = hasCar,
+ onOptionSelected = { hasCar = it },
+ )
+
+ Spacer(modifier = Modifier.height(20.dp))
+ // 오픈 채팅 링크 입력창
+ OutlinedTextField(
+ value = kakaoOpenChatUrl,
+ onValueChange = { kakaoOpenChatUrl = it },
+ modifier =
+ Modifier
+ .fillMaxWidth(),
+ placeholder = {
+ Text(
+ text = stringResource(id = R.string.post_write_kakao),
+ modifier = Modifier.padding(start = 14.dp),
+ )
+ },
+ shape = RoundedCornerShape(16.dp),
+ colors =
+ TextFieldDefaults.colors(
+ focusedContainerColor = Color.White,
+ unfocusedContainerColor = Color.White,
+ focusedPlaceholderColor = Color.Gray,
+ unfocusedPlaceholderColor = Color.Gray,
+ focusedIndicatorColor = Color.LightGray,
+ unfocusedIndicatorColor = Color.LightGray,
+ ),
+ )
+ Spacer(modifier = Modifier.height(30.dp))
+
+ // 내용 입력창
+ OutlinedTextField(
+ value = content,
+ onValueChange = { if (it.length <= MAX_CONTENT_LENGTH) content = it },
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .height(200.dp),
+ placeholder = {
+ Text(
+ text = stringResource(id = R.string.post_write),
+ modifier = Modifier.padding(start = 14.dp),
+ )
+ },
+ shape = RoundedCornerShape(16.dp),
+ supportingText = {
+ Text(
+ text = "${content.length} / $MAX_CONTENT_LENGTH",
+ modifier = Modifier.fillMaxWidth(),
+ textAlign = TextAlign.End,
+ )
+ },
+ colors =
+ TextFieldDefaults.colors(
+ focusedContainerColor = Color.White,
+ unfocusedContainerColor = Color.White,
+ focusedPlaceholderColor = Color.Gray,
+ unfocusedPlaceholderColor = Color.Gray,
+ focusedIndicatorColor = Color.LightGray,
+ unfocusedIndicatorColor = Color.LightGray,
+ ),
+ )
+
+ Spacer(modifier = Modifier.height(20.dp))
+
+ SaveButton(
+ onSave = {
+ val missingFields = mutableListOf()
+ if (title.isBlank()) missingFields.add(fieldTitleText)
+ if (content.isBlank()) missingFields.add(fieldContentText)
+ if (preferredDestinations.isBlank()) missingFields.add(fieldTravelStyleText)
+ if (preferredLocations.isBlank()) missingFields.add(fieldLocationText)
+ if (tourAt == null) missingFields.add(fieldTourDateText)
+ if (closeAt == null) missingFields.add(fieldCloseDateText)
+ if (hasCar.isBlank()) missingFields.add(fieldCarText)
+ val kakaoUrl = kakaoOpenChatUrl.trim()
+ if (kakaoUrl.isBlank()) {
+ missingFields.add(fieldKakaoLinkText)
+ } else if (!kakaoUrl.startsWith("https://open.kakao.com/o/")) {
+ Toast.makeText(
+ context,
+ validationInvalidKakaoLinkText,
+ Toast.LENGTH_LONG,
+ ).show()
+ return@SaveButton
+ }
+
+ if (missingFields.isNotEmpty()) {
+ // 어떤 항목이 비었는지 Toast 또는 Alert
+ val missingFieldsString = missingFields.joinToString(", ")
+ Toast.makeText(
+ context,
+ String.format(validationPleaseEnterText, missingFieldsString),
+ Toast.LENGTH_LONG,
+ ).show()
+ } else {
+ // 모든 항목 유효
+ val newPost =
+ RecruitPost(
+ postId = postId ?: "",
+ title = title,
+ content = content,
+ preferredDestinations = preferredDestinations,
+ preferredLocations = preferredLocations,
+ tourAt = tourAt!!,
+ closeAt = closeAt!!,
+ hasCar = hasCar,
+ preferredLanguages = preferredLanguages,
+ kakaoOpenChatUrl = kakaoOpenChatUrl,
+ createdAt = System.currentTimeMillis(),
+ owner =
+ UserSummary(
+ id = userState?.id ?: "",
+ nickname = userState?.nickname ?: "",
+ profileImageUrl = userState?.profileImageUrl ?: "",
+ ),
+ )
+
+ if (type == "생성") {
+ postViewModel.createPost(newPost)
+ } else {
+ println("키키" + newPost)
+ postViewModel.savePost(newPost)
+ }
+ }
+ },
+ text = buttonText,
+ )
+ }
+ }
+}
+
+@Suppress("FunctionName")
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SingleDropdown(
+ label: String,
+ @DrawableRes leadingIconRes: Int,
+ options: List,
+ selectedOption: String,
+ onOptionSelected: (String) -> Unit,
+) {
+ var expanded by remember { mutableStateOf(false) }
+
+ ExposedDropdownMenuBox(
+ expanded = expanded,
+ onExpandedChange = { expanded = !expanded },
+ ) {
+ OutlinedTextField(
+ value = selectedOption,
+ onValueChange = {},
+ readOnly = true,
+ label = {
+ Text(
+ text = label,
+ fontSize = 14.sp,
+ )
+ },
+ leadingIcon = {
+ Icon(
+ painter = painterResource(id = leadingIconRes),
+ contentDescription = label,
+ tint = Color(0xFF4285F4),
+ )
+ },
+ trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) },
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .menuAnchor(),
+ colors =
+ TextFieldDefaults.colors(
+ focusedContainerColor = Color(0xFFF3F6FF),
+ unfocusedContainerColor = Color(0xFFF3F6FF),
+ focusedPlaceholderColor = Color.Gray,
+ unfocusedPlaceholderColor = Color.LightGray,
+ focusedIndicatorColor = Color.LightGray,
+ unfocusedIndicatorColor = Color.LightGray,
+ ),
+ shape = RoundedCornerShape(4.dp),
+ )
+ ExposedDropdownMenu(
+ expanded = expanded,
+ onDismissRequest = { expanded = false },
+ modifier = Modifier.background(Color.White),
+ ) {
+ options.forEach { option ->
+ DropdownMenuItem(
+ text = { Text(option) },
+ onClick = {
+ onOptionSelected(option)
+ expanded = false
+ },
+ )
+ }
+ }
+ }
+}
+
+@Suppress("FunctionName")
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun PreferenceSelector(
+ options: List,
+ placeholderText: String,
+ selectedOption: String,
+ onOptionSelected: (String) -> Unit,
+ @DrawableRes leadingIconRes: Int,
+) {
+ var isExpanded by remember { mutableStateOf(false) }
+
+ ExposedDropdownMenuBox(
+ expanded = isExpanded,
+ onExpandedChange = { isExpanded = !isExpanded },
+ ) {
+ OutlinedTextField(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .menuAnchor(),
+ readOnly = true,
+ value = selectedOption,
+ onValueChange = {},
+ placeholder = { Text(placeholderText) },
+ label = { Text(placeholderText) },
+ leadingIcon = {
+ Icon(
+ painter = painterResource(id = leadingIconRes),
+ contentDescription = null,
+ tint = Color(0xFF4285F4),
+ )
+ },
+ trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = isExpanded) },
+ colors =
+ TextFieldDefaults.colors(
+ focusedContainerColor = Color(0xFFF3F6FF),
+ unfocusedContainerColor = Color(0xFFF3F6FF),
+ focusedPlaceholderColor = Color.Gray,
+ unfocusedPlaceholderColor = Color.LightGray,
+ focusedIndicatorColor = Color.LightGray,
+ unfocusedIndicatorColor = Color.LightGray,
+ ),
+ shape = RoundedCornerShape(4.dp),
+ )
+ ExposedDropdownMenu(
+ expanded = isExpanded,
+ onDismissRequest = { isExpanded = false },
+ modifier =
+ Modifier
+ .exposedDropdownSize()
+ .background(Color.White),
+ ) {
+ options.forEach { selectionOption ->
+ DropdownMenuItem(
+ text = { Text(selectionOption) },
+ onClick = {
+ onOptionSelected(selectionOption)
+ isExpanded = false
+ },
+ modifier = Modifier.background(Color.White),
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/chaining/ui/screen/ENQuizScreen.kt b/app/src/main/java/com/example/chaining/ui/screen/ENQuizScreen.kt
new file mode 100644
index 0000000..37d2363
--- /dev/null
+++ b/app/src/main/java/com/example/chaining/ui/screen/ENQuizScreen.kt
@@ -0,0 +1,416 @@
+package com.example.chaining.ui.screen
+
+import android.widget.Toast
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+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.Spacer
+import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.wrapContentHeight
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Scaffold
+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.key
+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.platform.LocalContext
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.example.chaining.domain.model.QuizType
+import com.example.chaining.viewmodel.QuizViewModel
+
+@Suppress("FunctionName")
+@Composable
+fun ENQuizScreen(
+ quizViewModel: QuizViewModel = viewModel(),
+ onNavigateToResult: () -> Unit,
+) {
+ val context = LocalContext.current
+ val toastMessage by quizViewModel.toastMessage.collectAsState()
+
+ // toastMessage 상태가 변경될 때마다 실행
+ LaunchedEffect(toastMessage) {
+ toastMessage?.let { message ->
+ Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
+ // Toast를 보여준 후에는 ViewModel의 상태를 다시 null로 초기화
+ quizViewModel.clearToastMessage()
+ }
+ }
+
+ LaunchedEffect(Unit) {
+ quizViewModel.loadQuizzes(context, "ENGLISH")
+ }
+
+ // ViewModel에서 현재 퀴즈 데이터 가져오기
+ val currentQuiz = quizViewModel.currentQuestion.value
+ val wordChips = quizViewModel.wordChips.value
+ val userAnswer = quizViewModel.userAnswerSentence.value
+ val currentIndex = quizViewModel.currentQuestionIndex.value
+ val totalQuestions = quizViewModel.quizSet.value.size
+ val selectedOption = quizViewModel.selectedOption.value
+ val selectedBlankWord = quizViewModel.selectedBlankWord.value
+ val isAnswerSubmitted = quizViewModel.isAnswerSubmitted.value
+ val isQuizFinished = quizViewModel.isQuizFinished.value
+
+ // isQuizFinished 상태가 true로 바뀌면 onNavigateToResult 함수를 호출
+ LaunchedEffect(isQuizFinished) {
+ if (isQuizFinished) {
+ onNavigateToResult()
+ }
+ }
+
+ Scaffold(
+ // 배경색
+ containerColor = Color(0xFFF3F6FF),
+ ) { innerPadding ->
+ Column(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .padding(innerPadding)
+ .padding(24.dp),
+ ) {
+ // 진행률 표시줄 추가
+ QuizProgressIndicator(
+ currentQuestionIndex = quizViewModel.currentQuestionIndex.value,
+ totalQuestions = quizViewModel.quizSet.value.size,
+ )
+
+ // 문제 표시 영역 추가
+ // 현재 퀴즈 데이터가 있을 경우에만 UI를 표시
+ if (currentQuiz != null) {
+ // 퀴즈 유형에 따라 보여줄 텍스트를 결정하는 변수
+ val questionTextToShow =
+ when (currentQuiz.type) {
+ QuizType.MULTIPLE_CHOICE.name -> currentQuiz.problem
+ else -> currentQuiz.translation
+ }
+ // 진행률 표시줄과의 간격
+ Spacer(modifier = Modifier.height(80.dp))
+
+ // 문제(번역문) 텍스트
+ Text(
+ text = questionTextToShow,
+ modifier = Modifier.fillMaxWidth(),
+ textAlign = TextAlign.Center,
+ fontSize = 24.sp,
+ fontWeight = FontWeight.Bold,
+ color = Color.Black,
+ lineHeight = 32.sp,
+ )
+ // 정답 입력 영역을 화면 중앙에 배치하기 위한 Spacer
+ Spacer(modifier = Modifier.weight(1f))
+
+ // 퀴즈 유형에 따라 다른 UI를 보여주는 when 블록
+ when (currentQuiz.type) {
+ QuizType.SENTENCE_ORDER.name -> {
+ SentenceOrderAnswerArea(
+ remainingWords = quizViewModel.remainingWordChips.value,
+ userAnswer = userAnswer,
+ onWordChipClicked = quizViewModel::onWordChipClicked,
+ onAnswerWordClicked = quizViewModel::onAnswerWordClicked,
+ )
+ }
+
+ QuizType.MULTIPLE_CHOICE.name -> {
+ MultipleChoiceAnswerArea(
+ options = currentQuiz.options,
+ selectedOption = selectedOption,
+ onOptionSelected = quizViewModel::onOptionSelected,
+ )
+ }
+
+ QuizType.FILL_IN_THE_BLANK.name -> {
+ FillInTheBlankAnswerArea(
+ problem = currentQuiz.problem,
+ options = currentQuiz.options,
+ selectedWord = selectedBlankWord,
+ onWordSelected = quizViewModel::onBlankWordSelected,
+ )
+ }
+ }
+
+ // 정답 입력 영역을 화면 중앙에 배치하기 위한 Spacer
+ Spacer(modifier = Modifier.weight(1f))
+
+ // '다음' 버튼
+ Button(
+ // 항상 다음 문제로 이동
+ onClick = { quizViewModel.submitAndGoToNext() },
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .height(50.dp),
+ // 사용자가 답을 제출했을 때만 활성화
+ enabled = isAnswerSubmitted,
+ shape = RoundedCornerShape(30.dp),
+ colors =
+ ButtonDefaults.buttonColors(
+ // 버튼의 배경색
+ containerColor = Color(0xFF4285F4),
+ // 버튼 안의 텍스트 색상
+ contentColor = Color.White,
+ ),
+ ) {
+ Text(
+ // 텍스트를 '다음'으로 고정
+ text = "다음",
+ fontSize = 16.sp,
+ )
+ }
+ }
+ }
+ }
+}
+
+/**
+ * 퀴즈 진행률을 보여주는 인디케이터
+ */
+@Suppress("FunctionName")
+@Composable
+fun QuizProgressIndicator(
+ currentQuestionIndex: Int,
+ totalQuestions: Int,
+) {
+ if (totalQuestions > 0) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ // 전체 문제 개수만큼 인디케이터를 만듭니다.
+ for (i in 0 until totalQuestions) {
+ val color = if (i <= currentQuestionIndex) Color(0xFF4285F4) else Color.LightGray
+ Box(
+ modifier =
+ Modifier
+ .weight(1f)
+ .height(4.dp)
+ .clip(RoundedCornerShape(2.dp))
+ .background(color),
+ )
+ }
+ }
+ }
+}
+
+// '문장 순서 맞추기' UI를 위한 별도 Composable 함수
+@Suppress("FunctionName")
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+fun SentenceOrderAnswerArea(
+ remainingWords: List,
+ userAnswer: List,
+ onWordChipClicked: (String) -> Unit,
+ onAnswerWordClicked: (String) -> Unit,
+) {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ // TODO: 사용자가 선택한 단어를 표시할 영역
+
+ Spacer(modifier = Modifier.height(40.dp))
+
+ // 단어 칩들을 표시할 영역
+ FlowRow(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .wrapContentHeight()
+ // 최소 높이 지정
+ .heightIn(min = 50.dp)
+ .background(Color.White, RoundedCornerShape(16.dp))
+ .padding(8.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ userAnswer.forEach { word ->
+ Button(
+ // 클릭 시 선택 해제
+ onClick = { onAnswerWordClicked(word) },
+ shape = CircleShape,
+ colors =
+ ButtonDefaults.buttonColors(
+ containerColor = Color(0xFF4285F4),
+ contentColor = Color.White,
+ ),
+ ) {
+ Text(text = word)
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(40.dp))
+
+ // 2. 선택 가능한 단어 칩들을 표시할 영역
+ FlowRow(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ remainingWords.forEach { word ->
+ key(word) {
+ Button(
+ onClick = { onWordChipClicked(word) },
+ shape = CircleShape,
+ colors =
+ ButtonDefaults.buttonColors(
+ containerColor = Color.White,
+ contentColor = Color.Black,
+ ),
+ ) {
+ Text(text = word)
+ }
+ }
+ }
+ }
+ }
+}
+
+// '객관식' UI를 위한 별도 Composable 함수 추가
+@Suppress("FunctionName")
+@Composable
+fun MultipleChoiceAnswerArea(
+ options: List,
+ selectedOption: String?,
+ onOptionSelected: (String) -> Unit,
+) {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ options.forEach { option ->
+ val isSelected = option == selectedOption
+ OutlinedButton(
+ onClick = { onOptionSelected(option) },
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .defaultMinSize(minHeight = 65.dp),
+ shape = RoundedCornerShape(16.dp),
+ colors =
+ ButtonDefaults.outlinedButtonColors(
+ containerColor = if (isSelected) Color(0xFF4285F4).copy(alpha = 0.1f) else Color.White,
+ contentColor = Color.Black,
+ ),
+ border =
+ BorderStroke(
+ width = if (isSelected) 2.dp else 1.dp,
+ color = if (isSelected) Color(0xFF4285F4) else Color.LightGray,
+ ),
+ ) {
+ Text(
+ text = option,
+ textAlign = TextAlign.Center,
+ )
+ }
+ }
+ }
+}
+
+// '빈칸 채우기' UI를 위한 별도 Composable 함수 추가
+@Suppress("FunctionName")
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+fun FillInTheBlankAnswerArea(
+ problem: String,
+ options: List,
+ selectedWord: String?,
+ onWordSelected: (String) -> Unit,
+) {
+ // 문제 문장을 빈칸("______") 기준으로 나눔
+ val sentenceParts = problem.split("______")
+
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ // 1. 빈칸이 채워지는 문장 UI
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally),
+ ) {
+ // 빈칸 앞부분
+ Text(
+ text = sentenceParts.getOrNull(0) ?: "",
+ fontSize = 18.sp,
+ modifier = Modifier.align(Alignment.CenterVertically),
+ lineHeight = 20.sp,
+ )
+
+ // 빈칸 부분
+ Box(
+ modifier =
+ Modifier
+ .width(100.dp)
+ .height(40.dp)
+ .background(Color.White, RoundedCornerShape(8.dp))
+ .border(1.dp, Color.LightGray, RoundedCornerShape(8.dp))
+ .align(Alignment.CenterVertically),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = selectedWord ?: "",
+ fontWeight = FontWeight.Bold,
+ color = Color(0xFF4285F4),
+ )
+ }
+
+ // 빈칸 뒷부분
+ Text(
+ text = sentenceParts.getOrNull(1) ?: "",
+ fontSize = 18.sp,
+ modifier = Modifier.align(Alignment.CenterVertically),
+ lineHeight = 20.sp,
+ )
+ }
+
+ Spacer(modifier = Modifier.height(40.dp))
+
+ // 2. 선택지 단어 칩 UI
+ FlowRow(
+ modifier = Modifier.width(300.dp),
+ horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ options.forEach { option ->
+ Button(
+ onClick = { onWordSelected(option) },
+ shape = RoundedCornerShape(12.dp),
+ modifier = Modifier.width(140.dp),
+ colors =
+ ButtonDefaults.buttonColors(
+ containerColor = Color.White,
+ contentColor = Color.Black,
+ ),
+ ) {
+ Text(text = option)
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/chaining/ui/screen/FeedScreen.kt b/app/src/main/java/com/example/chaining/ui/screen/FeedScreen.kt
new file mode 100644
index 0000000..980384f
--- /dev/null
+++ b/app/src/main/java/com/example/chaining/ui/screen/FeedScreen.kt
@@ -0,0 +1,145 @@
+package com.example.chaining.ui.screen
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.background
+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.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Scaffold
+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.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.hilt.navigation.compose.hiltViewModel
+import com.example.chaining.R
+import com.example.chaining.ui.component.FeedItem
+import com.example.chaining.viewmodel.FeedViewModel
+
+@Suppress("FunctionName")
+@Composable
+fun FeedScreen(
+ onBackClick: () -> Unit,
+ onMainHomeClick: () -> Unit,
+ onFeedClick: () -> Unit,
+ onCommunityClick: () -> Unit,
+ onNotificationClick: () -> Unit,
+ feedViewModel: FeedViewModel = hiltViewModel(),
+) {
+ // ViewModel의 randomizedFeedItems 상태를 구독하여 UI에 자동 반영
+ val feedItems by feedViewModel.randomizedFeedItems.collectAsState()
+
+ // 화면이 처음 로드될 때 API를 통해 관광 정보를 가져옵니다.
+ LaunchedEffect(Unit) {
+ // 전국 데이터 로드
+ feedViewModel.fetchTourItems()
+ }
+ BackHandler(enabled = true) {
+ onBackClick()
+ }
+ Scaffold(
+ // topBar에 로그인 제목을 넣습니다.
+ topBar = {
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .height(64.dp)
+ .clip(RoundedCornerShape(bottomEnd = 20.dp))
+ .background(Color(0xFF4A526A)),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ IconButton(onClick = onBackClick) {
+ Icon(
+ painter = painterResource(id = R.drawable.back_arrow),
+ contentDescription = "뒤로 가기",
+ modifier = Modifier.size(20.dp),
+ tint = Color.White,
+ )
+ }
+ Text(
+ text = stringResource(id = R.string.feed_title),
+ fontSize = 20.sp,
+ color = Color.White,
+ modifier = Modifier.weight(1f),
+ textAlign = TextAlign.Center,
+ )
+ IconButton(onClick = { feedViewModel.randomizeFeedItems() }) {
+ Icon(
+ painter = painterResource(id = R.drawable.reload),
+ contentDescription = "새로고침",
+ modifier = Modifier.size(20.dp),
+ tint = Color.White,
+ )
+ }
+ }
+ },
+ bottomBar = {
+ AppBottomNavigation(selectedTab = "Feed", onTestClick = { menu ->
+ when (menu) {
+ "Home" -> onMainHomeClick()
+ "Community" -> onCommunityClick()
+ "Notification" -> onNotificationClick()
+ "Feed" -> onFeedClick()
+ }
+ })
+ },
+ containerColor = Color(0xFFF3F6FF),
+ ) { innerPadding ->
+ // feedItems의 상태에 따라 다른 UI 표시
+ if (feedItems.isEmpty()) {
+ // 데이터 로딩 중이거나 데이터가 없을 때
+ Box(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .padding(innerPadding),
+ contentAlignment = Alignment.Center,
+ ) {
+ CircularProgressIndicator() // 로딩 인디케이터
+ }
+ } else {
+ // LazyColumn을 사용하여 랜덤 3개의 피드를 표시
+ LazyColumn(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .padding(innerPadding),
+ contentPadding = PaddingValues(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ items(feedItems) { item ->
+ FeedItem(
+ region = item.address.split(" ").getOrNull(0) ?: "지역",
+ place = item.title,
+ address = item.address,
+ imageUrl =
+ item.imageUrl
+ ?: "https://your-placeholder-image-url.com/default.jpg",
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/chaining/ui/screen/JoinPostScreen.kt b/app/src/main/java/com/example/chaining/ui/screen/JoinPostScreen.kt
new file mode 100644
index 0000000..b4ffec6
--- /dev/null
+++ b/app/src/main/java/com/example/chaining/ui/screen/JoinPostScreen.kt
@@ -0,0 +1,297 @@
+package com.example.chaining.ui.screen
+
+import android.widget.Toast
+import androidx.compose.foundation.background
+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.shape.RoundedCornerShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextFieldDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.hilt.navigation.compose.hiltViewModel
+import com.example.chaining.R
+import com.example.chaining.domain.model.Application
+import com.example.chaining.domain.model.RecruitPost
+import com.example.chaining.domain.model.UserSummary
+import com.example.chaining.ui.component.OwnerProfile
+import com.example.chaining.ui.component.SaveButton
+import com.example.chaining.viewmodel.ApplicationViewModel
+import com.example.chaining.viewmodel.UserViewModel
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
+
+private const val MAX_CONTENT_LENGTH = 300
+
+@Suppress("FunctionName")
+@Composable
+fun JoinPostScreen(
+ applicationViewModel: ApplicationViewModel = hiltViewModel(),
+ onBackClick: () -> Unit = {},
+ onSubmitSuccess: () -> Unit,
+ userViewModel: UserViewModel = hiltViewModel(),
+ post: RecruitPost,
+ onViewMyApplications: () -> Unit,
+) {
+ val context = LocalContext.current
+ val userState by userViewModel.user.collectAsState()
+ var introduction by remember { mutableStateOf("") }
+
+ val isSubmitSuccess by applicationViewModel.isSubmitSuccess.collectAsState()
+
+ LaunchedEffect(Unit) {
+ // 신청 완료 이벤트 처리
+ launch {
+ applicationViewModel.isSubmitSuccess.collectLatest { success ->
+ if (success) {
+ // 성공 시 콜백 호출 (화면 전환)
+ onSubmitSuccess()
+ // 상태 초기화
+ applicationViewModel.resetSubmitStatus()
+ }
+ }
+ }
+ // 토스트 메시지 이벤트 처리
+ launch {
+ applicationViewModel.toastEvent.collectLatest { eventKey ->
+ val message =
+ when (eventKey) {
+ "application_success" -> context.getString(R.string.application_submit_success)
+ "application_failed" -> context.getString(R.string.application_submit_failed)
+ else -> null
+ }
+ message?.let {
+ Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
+ }
+ }
+ }
+ }
+
+ Scaffold(
+ topBar = {
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ // 원하는 높이로 직접 설정
+ .height(64.dp)
+ .clip(RoundedCornerShape(bottomEnd = 20.dp))
+ .background(Color(0xFF4A526A)),
+ // 내부 요소들을 세로 중앙에 정렬
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ // 뒤로가기 아이콘 버튼
+ IconButton(onClick = onBackClick) {
+ Icon(
+ painter = painterResource(id = R.drawable.back_arrow),
+ contentDescription = "뒤로 가기",
+ modifier = Modifier.size(20.dp),
+ tint = Color.White,
+ )
+ }
+
+ // 제목 텍스트
+ Text(
+ text = stringResource(id = R.string.apply_title),
+ // 남는 공간을 모두 차지
+ modifier = Modifier.weight(1f),
+ color = Color.White,
+ fontSize = 20.sp,
+ // 텍스트를 가운데 정렬
+ textAlign = TextAlign.Center,
+ )
+
+ // 제목을 완벽한 중앙에 맞추기 위한 빈 공간
+ Spacer(modifier = Modifier.width(48.dp))
+ }
+ },
+ // 신청서 화면 내 네비게이션 바 보류
+// bottomBar = {
+// // 이전에 만든 하단 네비게이션 바 재사용
+// AppBottomNavigation()
+// },
+ // 전체 배경색
+ containerColor = Color(0xFFF3F6FF),
+ ) { innerPadding ->
+ // 스크롤 영역과 하단 고정 영역을 나누기 위한 부모 Column
+ Column(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .padding(innerPadding)
+ .padding(30.dp),
+ ) {
+ // 게시글 정보 섹션 추가
+ // 상단바와의 간격
+ Spacer(modifier = Modifier.height(30.dp))
+
+ // 게시글 제목
+ Text(
+ text = post.title,
+ fontSize = 20.sp,
+ fontWeight = FontWeight.Bold,
+ color = Color(0xFF4A526A),
+ )
+
+ Spacer(modifier = Modifier.height(20.dp))
+
+ // 작성자 정보
+ OwnerProfile(owner = post.owner, where = "지원서")
+ // 정보와 구분선 사이 간격
+ Spacer(modifier = Modifier.height(24.dp))
+
+ HorizontalDivider(
+ thickness = 1.dp,
+ color = Color.LightGray,
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ Column(
+ // 너비를 꽉 채워 왼쪽 정렬 효과
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(
+ text = stringResource(id = R.string.apply_intro),
+ fontSize = 14.sp,
+ fontWeight = FontWeight.SemiBold,
+ color = Color(0xFF4A526A),
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ OutlinedTextField(
+ value = introduction,
+ onValueChange = { if (it.length <= MAX_CONTENT_LENGTH) introduction = it },
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .height(150.dp),
+ placeholder = {
+ Text(
+ stringResource(id = R.string.apply_write),
+ fontSize = 13.sp,
+ color = Color.Gray,
+ )
+ },
+ shape = RoundedCornerShape(16.dp),
+ supportingText = {
+ Text(
+ text = "${introduction.length} / $MAX_CONTENT_LENGTH",
+ modifier = Modifier.fillMaxWidth(),
+ textAlign = TextAlign.End,
+ )
+ },
+ colors =
+ TextFieldDefaults.colors(
+ focusedContainerColor = Color.White,
+ unfocusedContainerColor = Color.White,
+ focusedIndicatorColor = Color.LightGray,
+ unfocusedIndicatorColor = Color.LightGray,
+ focusedTextColor = Color.Black,
+ unfocusedTextColor = Color.Black,
+ ),
+ )
+ Text(
+ text = stringResource(id = R.string.apply_text_one),
+ modifier =
+ Modifier
+ .padding(top = 8.dp)
+ // 오른쪽 정렬
+ .align(Alignment.End),
+ fontSize = 10.sp,
+ color = Color.Gray,
+ )
+ }
+ Spacer(modifier = Modifier.height(60.dp))
+
+ Text(
+ text = stringResource(id = R.string.apply_text_two),
+ modifier = Modifier.fillMaxWidth(),
+ textAlign = TextAlign.Center,
+ fontSize = 10.sp,
+ color = Color.Gray,
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ // '내 지원서 보기' 버튼 (보조 버튼)
+ Button(
+ onClick = { onViewMyApplications() },
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .height(50.dp),
+ shape = RoundedCornerShape(30.dp),
+ colors =
+ ButtonDefaults.buttonColors(
+ containerColor = Color(0xFFD9DDE9),
+ contentColor = Color(0xFF7282B4),
+ ),
+ ) {
+ Text(stringResource(id = R.string.apply_mine), fontSize = 16.sp)
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // '신청 완료' 버튼 (주요 버튼)
+ SaveButton(onSave = {
+ if (introduction.isBlank()) {
+ Toast.makeText(
+ context,
+ context.getString(R.string.application_intro_blank),
+ Toast.LENGTH_SHORT,
+ ).show()
+ } else {
+ val newApplication =
+ Application(
+ applicationId = "",
+ postId = post.postId,
+ owner = post.owner,
+ recruitPostTitle = post.title,
+ introduction = introduction,
+ applicant =
+ UserSummary(
+ id = userState?.id ?: "",
+ nickname = userState?.nickname ?: "",
+ profileImageUrl = userState?.profileImageUrl ?: "",
+ ),
+ )
+ applicationViewModel.submitApplication(newApplication)
+ }
+ }, text = stringResource(id = R.string.apply_button))
+
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/chaining/ui/screen/KRQuizScreen.kt b/app/src/main/java/com/example/chaining/ui/screen/KRQuizScreen.kt
new file mode 100644
index 0000000..49efd69
--- /dev/null
+++ b/app/src/main/java/com/example/chaining/ui/screen/KRQuizScreen.kt
@@ -0,0 +1,164 @@
+package com.example.chaining.ui.screen
+
+import android.widget.Toast
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+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.shape.RoundedCornerShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Scaffold
+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.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.example.chaining.domain.model.QuizType
+import com.example.chaining.viewmodel.QuizViewModel
+
+@OptIn(ExperimentalLayoutApi::class)
+@Suppress("FunctionName")
+@Composable
+fun KRQuizScreen(
+ quizViewModel: QuizViewModel = viewModel(),
+ onNavigateToResult: () -> Unit,
+) {
+ val context = LocalContext.current
+ val isQuizFinished = quizViewModel.isQuizFinished.value
+ val toastMessage by quizViewModel.toastMessage.collectAsState()
+
+ // toastMessage 상태가 변경될 때마다 실행
+ LaunchedEffect(toastMessage) {
+ toastMessage?.let { message ->
+ Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
+ // Toast를 보여준 후에는 ViewModel의 상태를 다시 null로 초기화
+ quizViewModel.clearToastMessage()
+ }
+ }
+
+ // 화면이 처음 생성될 때 한국어 퀴즈를 불러옵니다.
+ LaunchedEffect(Unit) {
+ // "KOREAN"으로 변경된 부분
+ quizViewModel.loadQuizzes(context, "KOREAN")
+ }
+
+ LaunchedEffect(isQuizFinished) {
+ if (isQuizFinished) {
+ onNavigateToResult()
+ }
+ }
+
+ val currentQuiz = quizViewModel.currentQuestion.value
+ val userAnswerSentence = quizViewModel.userAnswerSentence.value
+ val selectedOption = quizViewModel.selectedOption.value
+ val selectedBlankWord = quizViewModel.selectedBlankWord.value
+ val currentIndex = quizViewModel.currentQuestionIndex.value
+ val totalQuestions = quizViewModel.quizSet.value.size
+ val isAnswerSubmitted = quizViewModel.isAnswerSubmitted.value
+
+ Scaffold(
+ containerColor = Color(0xFFF3F6FF),
+ ) { innerPadding ->
+ Column(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .padding(innerPadding)
+ .padding(24.dp),
+ ) {
+ QuizProgressIndicator(
+ currentQuestionIndex = currentIndex,
+ totalQuestions = totalQuestions,
+ )
+
+ if (currentQuiz != null) {
+ val questionTextToShow =
+ when (currentQuiz.type) {
+ QuizType.MULTIPLE_CHOICE.name -> currentQuiz.problem
+ else -> currentQuiz.translation
+ }
+
+ Spacer(modifier = Modifier.height(60.dp))
+
+ Text(
+ text = questionTextToShow,
+ modifier = Modifier.fillMaxWidth(),
+ textAlign = TextAlign.Center,
+ fontSize = 24.sp,
+ fontWeight = FontWeight.Bold,
+ color = Color.Black,
+ )
+
+ Spacer(modifier = Modifier.weight(1f))
+
+ when (currentQuiz.type) {
+ QuizType.SENTENCE_ORDER.name -> {
+ SentenceOrderAnswerArea(
+ remainingWords = quizViewModel.remainingWordChips.value,
+ userAnswer = userAnswerSentence,
+ onWordChipClicked = quizViewModel::onWordChipClicked,
+ onAnswerWordClicked = quizViewModel::onAnswerWordClicked,
+ )
+ }
+
+ QuizType.MULTIPLE_CHOICE.name -> {
+ MultipleChoiceAnswerArea(
+ options = currentQuiz.options,
+ selectedOption = selectedOption,
+ onOptionSelected = quizViewModel::onOptionSelected,
+ )
+ }
+
+ QuizType.FILL_IN_THE_BLANK.name -> {
+ FillInTheBlankAnswerArea(
+ problem = currentQuiz.problem,
+ options = currentQuiz.options,
+ selectedWord = selectedBlankWord,
+ onWordSelected = quizViewModel::onBlankWordSelected,
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.weight(1f))
+
+ // '다음' 버튼
+ Button(
+ // 항상 다음 문제로 이동
+ onClick = { quizViewModel.submitAndGoToNext() },
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .height(50.dp),
+ // 사용자가 답을 제출했을 때만 활성화
+ enabled = isAnswerSubmitted,
+ shape = RoundedCornerShape(30.dp),
+ colors =
+ ButtonDefaults.buttonColors(
+ // 버튼의 배경색
+ containerColor = Color(0xFF4285F4),
+ // 버튼 안의 텍스트 색상
+ contentColor = Color.White,
+ ),
+ ) {
+ Text(
+ // 텍스트를 '다음'으로 고정
+ text = "다음",
+ fontSize = 16.sp,
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/chaining/ui/screen/MainHomeScreen.kt b/app/src/main/java/com/example/chaining/ui/screen/MainHomeScreen.kt
new file mode 100644
index 0000000..779eae0
--- /dev/null
+++ b/app/src/main/java/com/example/chaining/ui/screen/MainHomeScreen.kt
@@ -0,0 +1,367 @@
+package com.example.chaining.ui.screen
+
+import android.widget.Toast
+import androidx.activity.compose.BackHandler
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.collectIsPressedAsState
+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.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
+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.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.hilt.navigation.compose.hiltViewModel
+import coil.compose.AsyncImage
+import com.example.chaining.R
+import com.example.chaining.domain.model.Notification
+import com.example.chaining.ui.notification.NotificationItem
+import com.example.chaining.viewmodel.NotificationEvent
+import com.example.chaining.viewmodel.NotificationViewModel
+import com.example.chaining.viewmodel.UserViewModel
+
+// OptIn annotation for using experimental Material 3 APIs
+@OptIn(ExperimentalMaterial3Api::class)
+@Suppress("FunctionName")
+@Composable
+fun MainHomeScreen(
+ onMainHomeClick: () -> Unit,
+ notificationViewModel: NotificationViewModel = hiltViewModel(),
+ onMyPageClick: () -> Unit,
+ onCommunityClick: () -> Unit,
+ onFeedClick: () -> Unit,
+ onNotificationClick: () -> Unit,
+ userViewModel: UserViewModel = hiltViewModel(),
+ onViewApplyClick: (String) -> Unit,
+) {
+ var backPressedTime = 0L
+ val eventFlow = notificationViewModel.event
+ val context = LocalContext.current
+ val userState by userViewModel.user.collectAsState()
+ val notifications by notificationViewModel.notifications.collectAsState()
+ val isLoading by notificationViewModel.isLoading.collectAsState()
+
+ LaunchedEffect(Unit) {
+ println("MainHomeScreen userState=${userState?.nickname}, notifications=${notifications.size}")
+ eventFlow.collect { event ->
+ when (event) {
+ is NotificationEvent.NavigateToApplication -> {
+ onViewApplyClick(event.applicationId)
+ }
+
+ is NotificationEvent.ShowToast -> {
+ Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show()
+ }
+
+ NotificationEvent.Refresh -> {
+ // 필요하면 새로고침 처리
+ }
+ }
+ }
+ }
+ BackHandler(enabled = true) {
+ if (System.currentTimeMillis() - backPressedTime <= 2000L) {
+ // 2초 안에 두 번 누르면 앱 종료
+ (context as? android.app.Activity)?.finish()
+ } else {
+ Toast.makeText(context, "한 번 더 누르면 종료됩니다.", Toast.LENGTH_SHORT).show()
+ }
+ backPressedTime = System.currentTimeMillis()
+ }
+
+ val recentApplication: Notification? =
+ notifications
+ .filter { it.type == "application" }
+ .sortedByDescending { it.createdAt }
+ .firstOrNull()
+
+ val recentFollows =
+ notifications
+ .filter { it.type == "follow" }
+ .sortedByDescending { it.createdAt }
+ .take(3)
+
+ Scaffold(
+ topBar = {
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .height(60.dp)
+ .background(Color(0xFFF3F6FF))
+ .padding(top = 4.dp)
+ .padding(horizontal = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Spacer(modifier = Modifier.width(40.dp))
+ Text(
+ text = "Chaining",
+ modifier = Modifier.weight(1f),
+ fontWeight = FontWeight.Bold,
+ fontSize = 20.sp,
+ textAlign = TextAlign.Center,
+ )
+
+ ProfileImageWithStatus(
+ model = userState?.profileImageUrl,
+ isOnline = true,
+ onMyPageClick = onMyPageClick,
+ )
+ }
+ },
+ bottomBar = {
+ AppBottomNavigation(selectedTab = "Home", onTestClick = { menu ->
+ when (menu) {
+ "Home" -> onMainHomeClick()
+ "Community" -> onCommunityClick()
+ "Notification" -> onNotificationClick()
+ "Feed" -> onFeedClick()
+ }
+ })
+ },
+ ) { innerPadding ->
+ // 중앙 콘텐츠 구현 (환영 메시지, 매칭 카드, 팔로우 목록 등)
+ Column(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .padding(innerPadding)
+ .background(Color(0xFFF3F6FF)),
+ ) {
+ Text(
+ text = stringResource(id = R.string.welcome_message, userState?.nickname ?: "체이닝"),
+ fontSize = 22.sp,
+ fontWeight = FontWeight.Bold,
+ modifier =
+ Modifier
+ .padding(top = 16.dp)
+ .padding(horizontal = 32.dp),
+ )
+ Text(
+ text = stringResource(id = R.string.recent_apply),
+ fontSize = 14.sp,
+ fontWeight = FontWeight.SemiBold,
+ color = Color.Gray,
+ modifier =
+ Modifier
+ .padding(top = 24.dp)
+ .padding(horizontal = 32.dp),
+ )
+
+ if (isLoading) {
+ Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
+ CircularProgressIndicator()
+ }
+ } else if (recentApplication == null) {
+ Text(
+ text = stringResource(id = R.string.no_recent_apply),
+ fontSize = 14.sp,
+ color = Color.Gray,
+ modifier =
+ Modifier
+ .padding(horizontal = 32.dp, vertical = 16.dp),
+ )
+ } else {
+ Row(
+ modifier =
+ Modifier
+ .padding(horizontal = 24.dp),
+ ) {
+ NotificationItem(notification = recentApplication)
+ }
+ }
+ Text(
+ text = stringResource(id = R.string.recent_follow),
+ fontSize = 14.sp,
+ fontWeight = FontWeight.SemiBold,
+ color = Color.Gray,
+ modifier =
+ Modifier
+ .padding(top = 24.dp)
+ .padding(horizontal = 32.dp),
+ )
+
+ recentFollows.forEach { notification ->
+ NotificationItem(notification = notification)
+ }
+ }
+ }
+}
+
+@Suppress("FunctionName")
+@Composable
+fun AppBottomNavigation(
+ selectedTab: String,
+ onTestClick: (String) -> Unit,
+) { // "selectedTab" 파라미터 추가
+
+ Surface(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .height(65.dp),
+ shadowElevation = 12.dp,
+ color = Color(0xFFF3F6FF),
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceEvenly,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ // if문을 사용해 선택된 탭에 따라 다른 아이콘을 표시
+ val homeIcon = if (selectedTab == "Home") R.drawable.selected_home else R.drawable.home
+ val peopleIcon =
+ if (selectedTab == "Community") R.drawable.selected_people else R.drawable.people
+ val searchIcon =
+ if (selectedTab == "Feed") R.drawable.selected_search else R.drawable.search
+ val alarmIcon =
+ if (selectedTab == "Notification") R.drawable.selected_alarm else R.drawable.alarm
+
+ CustomIconButton(
+ onClick = { onTestClick("Home") },
+ iconRes = homeIcon,
+ description = "메인 홈",
+ )
+ CustomIconButton(
+ onClick = { onTestClick("Community") },
+ iconRes = peopleIcon,
+ description = "매칭",
+ )
+ CustomIconButton(
+ onClick = { onTestClick("Feed") },
+ iconRes = searchIcon,
+ description = "피드",
+ )
+ CustomIconButton(
+ onClick = { onTestClick("Notification") },
+ iconRes = alarmIcon,
+ description = "알림",
+ )
+ }
+ }
+}
+
+@Suppress("FunctionName")
+@Composable
+private fun CustomIconButton(
+ onClick: () -> Unit,
+ iconRes: Int,
+ description: String,
+) {
+ // 1. 버튼의 상호작용 상태를 추적하기 위한 interactionSource
+ val interactionSource = remember { MutableInteractionSource() }
+
+ // 2. interactionSource를 통해 현재 '눌려있는지' 여부를 Boolean 값으로 가져옴
+ val isPressed by interactionSource.collectIsPressedAsState()
+
+ // 3. isPressed 값에 따라 scale 값을 0.9f 또는 1f로 애니메이션
+ val scale by animateFloatAsState(targetValue = if (isPressed) 0.9f else 1f)
+ Box(
+ modifier =
+ Modifier
+ .clickable(
+ onClick = onClick,
+ // 리플 효과를 없애기 위한 핵심 코드
+ indication = null,
+ interactionSource = interactionSource,
+ )
+ // 버튼의 터치 영역을 적절히 확보하기 위한 패딩
+ .padding(10.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ painter = painterResource(id = iconRes),
+ contentDescription = description,
+ modifier =
+ Modifier
+ .size(30.dp)
+ // 4. 애니메이션으로 변경되는 scale 값을 아이콘에 적용
+ .graphicsLayer {
+ scaleX = scale
+ scaleY = scale
+ },
+ )
+ }
+}
+
+@Suppress("FunctionName")
+@Composable
+fun ProfileImageWithStatus(
+ isOnline: Boolean,
+ modifier: Modifier = Modifier,
+ onMyPageClick: () -> Unit,
+ model: Any? = null,
+) {
+ // Box를 사용해 이미지와 상태 점을 겹치게 만듭니다.
+ Box(
+ modifier =
+ modifier
+ .size(40.dp)
+ .clickable {
+ onMyPageClick()
+ },
+ ) {
+ AsyncImage(
+ model = model,
+ contentDescription = "마이페이지로 이동",
+ contentScale = ContentScale.Crop,
+ placeholder = painterResource(R.drawable.test_profile),
+ error = painterResource(R.drawable.test_profile),
+ modifier =
+ Modifier
+ // 부모(Box) 크기에 맞춤
+ .matchParentSize()
+ // 이미지를 원형으로 자름
+ .clip(RoundedCornerShape(15.dp)),
+ )
+
+ // 온라인 상태를 표시하는 점
+ if (isOnline) {
+ Box(
+ modifier =
+ Modifier
+ .size(12.dp)
+ // 오른쪽 아래에 배치
+ .align(Alignment.BottomEnd)
+ // 초록색 배경
+ .background(Color(0xFF00C853), CircleShape)
+ // 흰색 테두리
+ .border(width = 1.5.dp, color = Color.White, shape = CircleShape),
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/chaining/ui/screen/MyPageScreen.kt b/app/src/main/java/com/example/chaining/ui/screen/MyPageScreen.kt
new file mode 100644
index 0000000..99276a4
--- /dev/null
+++ b/app/src/main/java/com/example/chaining/ui/screen/MyPageScreen.kt
@@ -0,0 +1,688 @@
+package com.example.chaining.ui.screen
+
+import android.content.Context
+import android.net.Uri
+import android.provider.OpenableColumns
+import android.widget.Toast
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+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.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.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExposedDropdownMenuBox
+import androidx.compose.material3.ExposedDropdownMenuDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextField
+import androidx.compose.material3.TextFieldDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.hilt.navigation.compose.hiltViewModel
+import coil.compose.rememberAsyncImagePainter
+import com.example.chaining.R
+import com.example.chaining.ui.component.TestButton
+import com.example.chaining.viewmodel.AreaViewModel
+import com.example.chaining.viewmodel.UserViewModel
+import com.google.firebase.auth.FirebaseAuth
+import com.google.firebase.ktx.Firebase
+import com.google.firebase.storage.ktx.storage
+
+val PrimaryBlue = Color(0xFF3387E5)
+val SecondaryTextColor = Color(0xFF637387)
+val LightGrayBackground = Color(0xFFF3F6FF)
+val BorderColor = Color(0xFFE0E0E0)
+val White = Color(0xFFFEFEFE)
+val Black = Color.Black
+
+@Suppress("FunctionName")
+@Composable
+fun MyPageScreen(
+ userViewModel: UserViewModel = hiltViewModel(),
+ onKRQuizClick: () -> Unit,
+ onENQuizClick: () -> Unit,
+ onMyPostsClick: () -> Unit,
+ onMyApplicationsClick: () -> Unit,
+ onLogout: () -> Unit,
+ areaViewModel: AreaViewModel = hiltViewModel(),
+) {
+ val areaEntities by areaViewModel.areaCodes.collectAsState()
+ val userState by userViewModel.user.collectAsState()
+ val context = LocalContext.current
+ var nickname by remember { mutableStateOf("") }
+ var country by remember { mutableStateOf(userState?.country ?: "") }
+ var residence by remember { mutableStateOf(userState?.residence ?: "") }
+ var preferredDestinations by remember {
+ mutableStateOf(
+ userState?.preferredDestinations ?: "",
+ )
+ }
+
+ val koreanText = stringResource(id = R.string.language_korean)
+ val englishText = stringResource(id = R.string.language_english)
+ val countryFieldText = stringResource(id = R.string.mypage_country)
+ val locationFieldText = stringResource(id = R.string.mypage_location)
+ val prefstyleFieldText = stringResource(id = R.string.mypage_prefstyle)
+ val validationMessageFormat = stringResource(id = R.string.validation_select_fields)
+ val saveSuccessMessage = stringResource(id = R.string.mypage_profile_save_success)
+
+ LaunchedEffect(userState) {
+ userState?.let {
+ nickname = it.nickname
+ country = it.country
+ residence = it.residence
+ preferredDestinations = it.preferredDestinations
+ }
+ }
+
+ Column(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .background(LightGrayBackground),
+ ) {
+ // --- 상단 고정 영역 ---
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(start = 16.dp, end = 16.dp, top = 16.dp),
+ horizontalArrangement = Arrangement.End,
+ ) {
+ Button(
+ onClick = {
+ FirebaseAuth.getInstance().signOut()
+ onLogout()
+ },
+ colors =
+ ButtonDefaults.buttonColors(
+ containerColor = PrimaryBlue,
+ contentColor = White,
+ ),
+ shape = RoundedCornerShape(8.dp),
+ contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
+ ) {
+ Text(
+ stringResource(id = R.string.mypage_logout),
+ style = MaterialTheme.typography.labelLarge,
+ )
+ }
+ }
+
+ // --- 중앙 스크롤 영역 ---
+ Column(
+ modifier =
+ Modifier
+ .weight(1f)
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = 16.dp),
+ ) {
+ Spacer(modifier = Modifier.height(24.dp))
+
+ ProfileSection(
+ nickname = nickname,
+ onNicknameChanged = { newNickname ->
+ nickname = newNickname
+ userState?.let { currentUser ->
+ userViewModel.updateMyUser(currentUser.copy(nickname = newNickname))
+ }
+ },
+ profileImageUrl = userState?.profileImageUrl,
+ userViewModel = userViewModel,
+ )
+
+ Spacer(modifier = Modifier.height(32.dp))
+
+ Text(
+ text = stringResource(id = R.string.mypage_info),
+ style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
+ color = Black,
+ modifier = Modifier.padding(bottom = 12.dp),
+ )
+
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(12.dp),
+ elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
+ colors = CardDefaults.cardColors(containerColor = White),
+ ) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ DropDownField(
+ items =
+ listOf(
+ stringResource(id = R.string.mypage_kr),
+ stringResource(id = R.string.mypage_us),
+ stringResource(id = R.string.mypage_jp),
+ stringResource(id = R.string.mypage_cn),
+ stringResource(id = R.string.mypage_uk),
+ stringResource(id = R.string.mypage_gm),
+ stringResource(id = R.string.mypage_fr),
+ ),
+ selectedItem = country,
+ leadingIconRes = R.drawable.airport,
+ placeholder = stringResource(id = R.string.mypage_country),
+ onItemSelected = { country = it },
+ )
+ Spacer(modifier = Modifier.height(12.dp))
+ val areaNames =
+ remember(areaEntities) {
+ areaEntities
+ .map { it.regionName }
+ }
+ DropDownField(
+ items = areaNames,
+ selectedItem = residence,
+ leadingIconRes = R.drawable.country,
+ placeholder = stringResource(id = R.string.mypage_location),
+ onItemSelected = { residence = it },
+ )
+ Spacer(modifier = Modifier.height(12.dp))
+ DropDownField(
+ items =
+ listOf(
+ stringResource(id = R.string.travel_style_mountain),
+ stringResource(id = R.string.travel_style_sea),
+ stringResource(id = R.string.travel_style_city),
+ stringResource(id = R.string.travel_style_activity),
+ stringResource(id = R.string.travel_style_rest),
+ stringResource(id = R.string.travel_style_culture),
+ ),
+ selectedItem = preferredDestinations,
+ leadingIconRes = R.drawable.forest_path,
+ placeholder = stringResource(id = R.string.mypage_prefstyle),
+ onItemSelected = { preferredDestinations = it },
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ userState?.preferredLanguages?.let {
+ Text(
+ text = stringResource(id = R.string.mypage_quiz),
+ style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
+ color = Black,
+ modifier = Modifier.padding(bottom = 12.dp),
+ )
+ TestButton(
+ preferredLanguages = it,
+ onTestClick = { language ->
+ when (language) {
+ koreanText -> onKRQuizClick()
+ englishText -> onENQuizClick()
+ }
+ },
+ )
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // 모집 현황, 지원 현황 버튼
+ Button(
+ onClick = { onMyPostsClick() },
+ modifier = Modifier.fillMaxWidth(),
+ contentPadding = PaddingValues(vertical = 14.dp),
+ colors =
+ ButtonDefaults.buttonColors(
+ containerColor = White,
+ contentColor = SecondaryTextColor,
+ ),
+ shape = RoundedCornerShape(12.dp),
+ border = BorderStroke(width = 1.dp, color = BorderColor),
+ ) {
+ Text(
+ text = stringResource(id = R.string.mypage_post),
+ style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold),
+ )
+ }
+ Spacer(modifier = Modifier.height(12.dp))
+ Button(
+ onClick = { onMyApplicationsClick() },
+ modifier = Modifier.fillMaxWidth(),
+ contentPadding = PaddingValues(vertical = 14.dp),
+ colors =
+ ButtonDefaults.buttonColors(
+ containerColor = White,
+ contentColor = SecondaryTextColor,
+ ),
+ shape = RoundedCornerShape(12.dp),
+ border = BorderStroke(width = 1.dp, color = BorderColor),
+ ) {
+ Text(
+ text = stringResource(id = R.string.mypage_apply),
+ style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold),
+ )
+ }
+ Spacer(modifier = Modifier.height(24.dp)) // 스크롤 영역 하단 여백
+ }
+
+ // --- 하단 고정 영역 ---
+ Button(
+ onClick = {
+ val unselectedFields = mutableListOf()
+ if (country.isEmpty()) unselectedFields.add(countryFieldText)
+ if (residence.isEmpty()) unselectedFields.add(locationFieldText)
+ if (preferredDestinations.isEmpty()) unselectedFields.add(prefstyleFieldText)
+
+ if (unselectedFields.isNotEmpty()) {
+ val message =
+ context.getString(
+ R.string.validation_select_fields,
+ unselectedFields.joinToString(", "),
+ )
+ Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
+ } else {
+ userState?.let { currentUser ->
+ val updatedUser =
+ currentUser.copy(
+ nickname = nickname,
+ country = country,
+ residence = residence,
+ preferredDestinations = preferredDestinations,
+ )
+ userViewModel.updateMyUser(updatedUser)
+ Toast.makeText(
+ context,
+ context.getString(R.string.mypage_profile_save_success),
+ Toast.LENGTH_SHORT,
+ ).show()
+ }
+ }
+ },
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ contentPadding = PaddingValues(vertical = 16.dp),
+ colors =
+ ButtonDefaults.buttonColors(
+ containerColor = PrimaryBlue,
+ contentColor = White,
+ ),
+ shape = RoundedCornerShape(12.dp),
+ ) {
+ Text(
+ stringResource(id = R.string.mypage_profile_save),
+ style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
+ )
+ }
+ }
+}
+
+@Suppress("FunctionName")
+@Composable
+fun ProfileSection(
+ nickname: String,
+ onNicknameChanged: (String) -> Unit,
+ profileImageUrl: String?,
+ userViewModel: UserViewModel,
+) {
+ val context = LocalContext.current
+ var showDialog by remember { mutableStateOf(false) }
+ var tempNickname by remember { mutableStateOf(nickname) }
+
+ val galleryLauncher =
+ rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.GetContent(),
+ ) { uri ->
+ uri?.let {
+ val size = getFileSize(context, uri)
+ if (size > 2 * 1024 * 1024) {
+ Toast.makeText(
+ context,
+ context.getString(R.string.mypage_select_image_under_2mb),
+ Toast.LENGTH_SHORT,
+ ).show()
+ return@let
+ }
+
+ val uid =
+ FirebaseAuth.getInstance().currentUser?.uid ?: run {
+ Toast.makeText(
+ context,
+ context.getString(R.string.mypage_login_required),
+ Toast.LENGTH_SHORT,
+ ).show()
+ return@let
+ }
+ val storageRef = Firebase.storage.reference.child("profileImages/$uid.jpg")
+
+ storageRef.putFile(uri)
+ .addOnSuccessListener {
+ storageRef.downloadUrl.addOnSuccessListener { downloadUrl ->
+ userViewModel.updateProfileImage(downloadUrl.toString())
+ Toast.makeText(
+ context,
+ context.getString(R.string.mypage_profile_image_changed),
+ Toast.LENGTH_SHORT,
+ ).show()
+ }
+ }
+ .addOnFailureListener { e ->
+ Toast.makeText(
+ context,
+ context.getString(R.string.mypage_image_upload_failed, e.message),
+ Toast.LENGTH_SHORT,
+ ).show()
+ }
+ }
+ }
+
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Box(modifier = Modifier.clickable { galleryLauncher.launch("image/*") }) {
+ Image(
+ painter =
+ rememberAsyncImagePainter(
+ model =
+ profileImageUrl.takeIf { !it.isNullOrEmpty() }
+ ?: R.drawable.test_profile,
+ ),
+ contentDescription = "프로필 이미지",
+ contentScale = ContentScale.Crop,
+ modifier =
+ Modifier
+ .size(100.dp)
+ .clip(CircleShape)
+ .border(2.dp, PrimaryBlue, CircleShape),
+ )
+ Icon(
+ painter = painterResource(id = R.drawable.change),
+ contentDescription = "프로필 변경",
+ tint = Color.Unspecified,
+ modifier =
+ Modifier
+ .align(Alignment.BottomEnd)
+ .offset(x = (-8).dp, y = (-8).dp)
+ .size(20.dp),
+ )
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Center,
+ modifier =
+ Modifier.clickable {
+ tempNickname = nickname
+ showDialog = true
+ },
+ ) {
+ Text(
+ text = nickname.ifEmpty { stringResource(id = R.string.mypage_no_nickname) },
+ style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.Bold),
+ color = Black,
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Icon(
+ painter = painterResource(id = R.drawable.pen_squared),
+ contentDescription = stringResource(id = R.string.mypage_edit_nickname),
+ tint = SecondaryTextColor,
+ modifier = Modifier.size(24.dp),
+ )
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = stringResource(id = R.string.mypage_follower_info, "203", "106"),
+ style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium),
+ color = Color.Gray,
+ )
+ }
+
+ if (showDialog) {
+ var tempNickname by remember(nickname) { mutableStateOf(nickname) }
+ var nicknameErrorResId by remember { mutableStateOf(null) }
+
+ LaunchedEffect(tempNickname) {
+ // validateNickname으로부터 이제 String이 아닌 Int? (리소스 ID)를 받음
+ nicknameErrorResId = validateNickname(tempNickname)
+ }
+
+ AlertDialog(
+ onDismissRequest = { showDialog = false },
+ title = {
+ Text(
+ stringResource(id = R.string.mypage_nick_change),
+ style = MaterialTheme.typography.titleLarge,
+ )
+ },
+ text = {
+ Column {
+ TextField(
+ value = tempNickname,
+ onValueChange = {
+ tempNickname = it
+ },
+ label = {
+ Text(
+ stringResource(id = R.string.mypage_new_nick),
+ color = SecondaryTextColor,
+ )
+ },
+ singleLine = true,
+ isError = nicknameErrorResId != null,
+ colors =
+ TextFieldDefaults.colors(
+ focusedContainerColor = LightGrayBackground,
+ unfocusedContainerColor = LightGrayBackground,
+ focusedIndicatorColor = PrimaryBlue,
+ unfocusedIndicatorColor = BorderColor,
+ errorIndicatorColor = MaterialTheme.colorScheme.error,
+ ),
+ modifier = Modifier.fillMaxWidth(),
+ )
+ nicknameErrorResId?.let { resId ->
+ Text(
+ // stringResource를 사용해 ID를 실제 텍스트로 변환
+ text = stringResource(id = resId),
+ color = MaterialTheme.colorScheme.error,
+ style = MaterialTheme.typography.bodySmall,
+ modifier = Modifier.padding(start = 16.dp, top = 4.dp),
+ )
+ }
+ }
+ },
+ confirmButton = {
+ Button(
+ onClick = {
+ if (validateNickname(tempNickname) == null) {
+ onNicknameChanged(tempNickname)
+ showDialog = false
+ }
+ },
+ enabled = validateNickname(tempNickname) == null,
+ colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue),
+ ) {
+ Text(stringResource(id = R.string.mypage_change), color = White)
+ }
+ },
+ dismissButton = {
+ Button(
+ onClick = { showDialog = false },
+ colors =
+ ButtonDefaults.buttonColors(
+ containerColor = LightGrayBackground,
+ contentColor = Black,
+ ),
+ ) {
+ Text(stringResource(id = R.string.mypage_cancel))
+ }
+ },
+ )
+ }
+}
+
+@Suppress("FunctionName")
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun DropDownField(
+ items: List,
+ selectedItem: String,
+ leadingIconRes: Int,
+ placeholder: String,
+ onItemSelected: (String) -> Unit,
+) {
+ var expanded by remember { mutableStateOf(false) }
+ // FilterDropdown의 구조와 로직을 가져와 스타일만 MyPage에 맞게 수정
+ ExposedDropdownMenuBox(
+ expanded = expanded,
+ onExpandedChange = { expanded = !expanded },
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .background(Color.White),
+ ) {
+ OutlinedTextField(
+ value = if (selectedItem.isEmpty()) placeholder else selectedItem,
+ onValueChange = {},
+ readOnly = true,
+ textStyle =
+ MaterialTheme.typography.bodyLarge.copy(
+ fontWeight = FontWeight.Medium,
+ color = if (selectedItem.isEmpty()) Color.Gray else SecondaryTextColor,
+ ),
+ label = {
+ Text(
+ placeholder,
+ style = MaterialTheme.typography.labelSmall,
+ fontSize = 14.sp,
+ )
+ },
+ leadingIcon = {
+ Icon(
+ painter = painterResource(id = leadingIconRes),
+ contentDescription = null,
+ tint = SecondaryTextColor,
+ modifier = Modifier.size(24.dp),
+ )
+ },
+ trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
+ colors =
+ ExposedDropdownMenuDefaults.outlinedTextFieldColors(
+ // 배경색은 투명하게 유지
+ focusedContainerColor = White,
+ unfocusedContainerColor = White,
+ // 테두리 색상 지정
+ focusedBorderColor = PrimaryBlue,
+ unfocusedBorderColor = BorderColor,
+ ),
+ shape = RoundedCornerShape(4.dp),
+ modifier =
+ Modifier
+ .menuAnchor()
+ .fillMaxWidth(),
+ )
+
+ ExposedDropdownMenu(
+ expanded = expanded,
+ onDismissRequest = { expanded = false },
+ modifier = Modifier.background(White),
+ ) {
+ // "선택 안 함" 옵션 추가 (값을 비우는 기능)
+ DropdownMenuItem(
+ text = { Text(placeholder, color = Color.Gray) },
+ onClick = {
+ onItemSelected("") // 빈 문자열을 전달하여 선택 해제
+ expanded = false
+ },
+ )
+ items.forEach { item ->
+ DropdownMenuItem(
+ text = {
+ Text(
+ text = item,
+ fontWeight = if (item == selectedItem) FontWeight.Bold else FontWeight.Normal,
+ )
+ },
+ onClick = {
+ onItemSelected(item)
+ expanded = false
+ },
+ )
+ }
+ }
+ }
+}
+
+private fun getFileSize(
+ context: Context,
+ uri: Uri,
+): Long {
+ val cursor = context.contentResolver.query(uri, null, null, null, null)
+ val sizeIndex = cursor?.getColumnIndex(OpenableColumns.SIZE) ?: -1
+ cursor?.moveToFirst()
+ val size = if (sizeIndex >= 0) cursor?.getLong(sizeIndex) else 0L
+ cursor?.close()
+ return size ?: 0L
+}
+
+fun validateNickname(nickname: String): Int? {
+ if (nickname.isBlank()) {
+ return R.string.error_nickname_blank
+ }
+ val pattern = Regex("^[a-zA-Z0-9가-힣]*$")
+ if (!pattern.matches(nickname)) {
+ return R.string.error_nickname_pattern
+ }
+ var weightedLength = 0
+ for (char in nickname) {
+ weightedLength += if (char in '가'..'힣') 2 else 1
+ }
+ if (weightedLength > 12) {
+ return R.string.error_nickname_length
+ }
+ return null
+}
+
+fun generateRandomNickname(): String {
+ val adjectives = listOf("행복한", "즐거운", "용감한", "신나는", "총명한", "빛나는")
+ val nouns = listOf("여행가", "탐험가", "모험가", "항해사", "개척자", "방랑자", "별빛")
+ // 공백 없이 두 단어를 조합
+ return "${adjectives.random()}${nouns.random()}"
+}
diff --git a/app/src/main/java/com/example/chaining/ui/screen/MyPostsScreen.kt b/app/src/main/java/com/example/chaining/ui/screen/MyPostsScreen.kt
new file mode 100644
index 0000000..e167076
--- /dev/null
+++ b/app/src/main/java/com/example/chaining/ui/screen/MyPostsScreen.kt
@@ -0,0 +1,190 @@
+package com.example.chaining.ui.screen
+
+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.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.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.hilt.navigation.compose.hiltViewModel
+import com.example.chaining.R
+import com.example.chaining.ui.component.CardItem
+import com.example.chaining.ui.component.formatRemainingTime
+import com.example.chaining.viewmodel.UserViewModel
+
+@Suppress("FunctionName")
+@Composable
+fun MyPostsScreen(
+ onBackClick: () -> Unit = {},
+ onViewPostClick: (postId: String) -> Unit = {},
+ userViewModel: UserViewModel = hiltViewModel(),
+) {
+ val context = LocalContext.current
+ val userState by userViewModel.user.collectAsState()
+
+ val myPosts = userState?.recruitPosts.orEmpty()
+
+ var showOnlyOpenPosts by remember { mutableStateOf(false) }
+
+ val filteredPosts =
+ if (showOnlyOpenPosts) {
+ myPosts.filter { post -> !post.value.isDeleted && post.value.closeAt > System.currentTimeMillis() }
+ } else {
+ myPosts.filter { post -> !post.value.isDeleted } // 삭제된 글은 항상 제외
+ }
+
+ Scaffold(
+ topBar = {
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .height(64.dp)
+ .clip(RoundedCornerShape(bottomEnd = 20.dp))
+ .background(Color(0xFF4A526A)),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ IconButton(onClick = onBackClick) {
+ Icon(
+ painter = painterResource(id = R.drawable.back_arrow),
+ contentDescription = "뒤로 가기",
+ modifier = Modifier.size(20.dp),
+ tint = Color.White,
+ )
+ }
+
+ // 제목
+ Text(
+ text = stringResource(id = R.string.mypost_title),
+ modifier = Modifier.weight(1f),
+ color = Color.White,
+ fontSize = 20.sp,
+ textAlign = TextAlign.Center,
+ )
+ Spacer(modifier = Modifier.width(48.dp))
+ }
+ },
+ containerColor = Color(0xFFF3F6FF),
+ ) { innerPadding ->
+ Column(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .padding(innerPadding)
+ .padding(16.dp)
+ .verticalScroll(rememberScrollState()),
+ ) {
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 8.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ // 새로 만든 CommunityActionButton 호출
+ ActionButton(
+ modifier = Modifier.weight(1f),
+ iconRes = R.drawable.post,
+ text =
+ if (showOnlyOpenPosts) {
+ stringResource(id = R.string.mypost_all_post)
+ } else {
+ stringResource(id = R.string.mypost_filter_open)
+ },
+ onClick = {
+ showOnlyOpenPosts = !showOnlyOpenPosts
+ },
+ )
+ }
+ if (filteredPosts.isEmpty()) {
+ // 데이터가 없을 때
+ Text(
+ text = stringResource(id = R.string.mypost_nothing),
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(top = 50.dp),
+ color = Color.Gray,
+ textAlign = TextAlign.Center,
+ )
+ } else {
+ // 모집글 목록 표시
+ filteredPosts.forEach { post ->
+ CardItem(
+ onClick = { onViewPostClick(post.value.postId) },
+ type = "모집글",
+ recruitPost = post.value,
+ remainingTime =
+ formatRemainingTime(
+ context,
+ post.value.closeAt - System.currentTimeMillis(),
+ ),
+ )
+ }
+ }
+ }
+ }
+}
+
+@Suppress("FunctionName")
+@Composable
+fun ActionButton(
+ modifier: Modifier = Modifier,
+ iconRes: Int,
+ text: String,
+ onClick: () -> Unit,
+) {
+ Button(
+ onClick = onClick,
+ modifier = modifier,
+ shape = RoundedCornerShape(30.dp),
+ colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF7282B4)),
+ contentPadding = PaddingValues(horizontal = 8.dp, vertical = 6.dp),
+ ) {
+ Icon(
+ painter = painterResource(id = iconRes),
+ contentDescription = text,
+ tint = Color.White,
+ modifier = Modifier.size(22.dp),
+ )
+ Spacer(modifier = Modifier.width(10.dp))
+ Text(
+ text = text,
+ color = Color.White,
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 16.sp,
+ )
+ }
+}
diff --git a/app/src/main/java/com/example/chaining/ui/screen/NotificationScreen.kt b/app/src/main/java/com/example/chaining/ui/screen/NotificationScreen.kt
new file mode 100644
index 0000000..87dd617
--- /dev/null
+++ b/app/src/main/java/com/example/chaining/ui/screen/NotificationScreen.kt
@@ -0,0 +1,321 @@
+package com.example.chaining.ui.notification
+
+import android.widget.Toast
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.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.shape.RoundedCornerShape
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Tab
+import androidx.compose.material3.TabRow
+import androidx.compose.material3.TabRowDefaults
+import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset
+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.mutableIntStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.hilt.navigation.compose.hiltViewModel
+import com.example.chaining.R
+import com.example.chaining.domain.model.Notification
+import com.example.chaining.ui.component.CardItem
+import com.example.chaining.ui.component.FollowNotificationItem
+import com.example.chaining.ui.component.formatRemainingTime
+import com.example.chaining.ui.screen.LightGrayBackground
+import com.example.chaining.ui.screen.PrimaryBlue
+import com.example.chaining.viewmodel.ApplicationViewModel
+import com.example.chaining.viewmodel.NotificationEvent
+import com.example.chaining.viewmodel.NotificationViewModel
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+@Suppress("FunctionName")
+@Composable
+fun NotificationScreen(
+ viewModel: NotificationViewModel = hiltViewModel(),
+ onViewApplyClick: (String) -> Unit,
+) {
+ val notifications by viewModel.notifications.collectAsState()
+ val isLoading by viewModel.isLoading.collectAsState()
+ val errorMessage by viewModel.errorMessage.collectAsState()
+
+ val eventFlow = viewModel.event
+ val context = LocalContext.current
+
+ var selectedTabIndex by remember { mutableIntStateOf(0) }
+ val tabTitles =
+ listOf(
+ stringResource(id = R.string.alarm_follow),
+ stringResource(id = R.string.alarm_apply),
+ )
+
+ LaunchedEffect(Unit) {
+ eventFlow.collect { event ->
+ when (event) {
+ is NotificationEvent.NavigateToApplication -> {
+ onViewApplyClick(event.applicationId)
+ }
+
+ is NotificationEvent.ShowToast -> {
+ Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show()
+ }
+
+ NotificationEvent.Refresh -> {
+ // 필요하면 새로고침 처리
+ }
+ }
+ }
+ }
+
+ // 알림 타입별 필터링
+ val filteredNotifications =
+ when (selectedTabIndex) {
+ 0 -> notifications.filter { it.type.equals("follow", ignoreCase = true) }
+ 1 -> notifications.filter { it.type.equals("application", ignoreCase = true) }
+ else -> emptyList()
+ }
+
+ Scaffold(
+ containerColor = LightGrayBackground,
+ topBar = {
+ Column(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .background(LightGrayBackground),
+ ) {
+ Text(
+ text = stringResource(id = R.string.alarm_title),
+ fontSize = 18.sp,
+ fontWeight = FontWeight.Bold,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ textAlign = TextAlign.Center,
+ )
+
+ TabRow(
+ selectedTabIndex = selectedTabIndex,
+ containerColor = LightGrayBackground,
+ contentColor = PrimaryBlue,
+ modifier = Modifier.fillMaxWidth(),
+ indicator = { tabPositions ->
+ TabRowDefaults.Indicator(
+ Modifier.tabIndicatorOffset(tabPositions[selectedTabIndex]),
+ color = PrimaryBlue,
+ )
+ },
+ ) {
+ tabTitles.forEachIndexed { index, title ->
+ Tab(
+ selected = selectedTabIndex == index,
+ onClick = { selectedTabIndex = index },
+ text = { Text(title, fontSize = 14.sp) },
+ selectedContentColor = PrimaryBlue,
+ unselectedContentColor = Color.Gray,
+ )
+ }
+ }
+ }
+ },
+ ) { innerPadding ->
+ Box(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .padding(innerPadding)
+ .background(LightGrayBackground),
+ ) {
+ when {
+ isLoading -> {
+ CircularProgressIndicator(
+ modifier = Modifier.align(Alignment.Center),
+ )
+ }
+
+ errorMessage != null -> {
+ Text(
+ text = "오류 발생: $errorMessage",
+ color = Color.Red,
+ modifier = Modifier.align(Alignment.Center),
+ )
+ }
+
+ filteredNotifications.isEmpty() -> {
+ Text(
+ text = stringResource(id = R.string.alarm_apply_text_two),
+ modifier = Modifier.align(Alignment.Center),
+ fontSize = 16.sp,
+ )
+ }
+
+ else -> {
+ LazyColumn(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ ) {
+ items(filteredNotifications) { notification ->
+ NotificationItem(
+ notification = notification,
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Suppress("FunctionName")
+@Composable
+fun NotificationItem(
+ notification: Notification,
+ viewModel: NotificationViewModel = hiltViewModel(),
+ applicationViewModel: ApplicationViewModel = hiltViewModel(),
+) {
+ val context = LocalContext.current
+ val formattedDate =
+ remember(notification.createdAt) {
+ val date = Date(notification.createdAt)
+ SimpleDateFormat("yyyy.MM.dd HH:mm", Locale.getDefault()).format(date)
+ }
+
+ when (notification.type) {
+ "follow" -> {
+ FollowNotificationItem(
+ name =
+ notification.sender?.nickname
+ ?: stringResource(id = R.string.community_unknown),
+ timestamp = formattedDate,
+ imageUrl = notification.sender?.profileImageUrl?.takeIf { it.isNotEmpty() } ?: "",
+ )
+ }
+
+ "application" -> {
+ // Application 데이터를 StateFlow로 구독
+ val application by applicationViewModel.application.collectAsState()
+
+ // notification.applicationId로 데이터 로드
+ LaunchedEffect(notification.applicationId) {
+ notification.applicationId?.let { applicationViewModel.fetchApplication(it) }
+ }
+
+ CardItem(
+ onClick = {
+ notification.applicationId?.let { id ->
+ viewModel.onApplicationClick(id)
+ }
+ },
+ type = "지원서",
+ // Notification -> Application 매핑 필요
+ application = application,
+ remainingTime =
+ formatRemainingTime(
+ context,
+ notification.closeAt?.minus(System.currentTimeMillis()) ?: 0L,
+ ),
+ onLeftButtonClick = {
+ application?.let { apply ->
+ applicationViewModel.updateStatus(
+ application = apply,
+ value = "승인",
+ )
+ }
+ },
+ onRightButtonClick = {
+ application?.let { apply ->
+ applicationViewModel.updateStatus(
+ application = apply,
+ value = "거절",
+ )
+ }
+ },
+ )
+ }
+
+ else -> {
+ // 기타 알림 처리
+ Card(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(vertical = 6.dp),
+ shape = RoundedCornerShape(12.dp),
+ colors =
+ CardDefaults.cardColors(
+ containerColor =
+ if (notification.isRead) {
+ MaterialTheme.colorScheme.surface
+ } else {
+ MaterialTheme.colorScheme.primary.copy(alpha = 0.08f)
+ },
+ ),
+ elevation = CardDefaults.cardElevation(2.dp),
+ ) {
+ Column(modifier = Modifier.padding(14.dp)) {
+ Text(
+ text = "알림",
+ fontWeight = FontWeight.Bold,
+ fontSize = 16.sp,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = "알림을 확인하세요.",
+ fontSize = 14.sp,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Spacer(modifier = Modifier.height(6.dp))
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Text(
+ text = "${stringResource(id = R.string.post_writer)}: ${
+ notification.sender ?: stringResource(
+ id = R.string.community_unknown,
+ )
+ }",
+ fontSize = 12.sp,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Text(
+ text = formattedDate,
+ fontSize = 12.sp,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/chaining/ui/screen/QuizResultScreen.kt b/app/src/main/java/com/example/chaining/ui/screen/QuizResultScreen.kt
new file mode 100644
index 0000000..fbdd472
--- /dev/null
+++ b/app/src/main/java/com/example/chaining/ui/screen/QuizResultScreen.kt
@@ -0,0 +1,171 @@
+package com.example.chaining.ui.screen
+
+import androidx.compose.animation.core.FastOutSlowInEasing
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.background
+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.shape.RoundedCornerShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.Scaffold
+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.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.hilt.navigation.compose.hiltViewModel
+import com.example.chaining.viewmodel.QuizViewModel
+import com.example.chaining.viewmodel.UserViewModel
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Suppress("FunctionName")
+@Composable
+fun QuizResultScreen(
+ // 공유되는 ViewModel
+ quizViewModel: QuizViewModel = hiltViewModel(),
+ onNavigateToMyPage: () -> Unit,
+ userViewModel: UserViewModel = hiltViewModel(),
+) {
+ val userState by userViewModel.user.collectAsState()
+ val finalLevel by quizViewModel.finalLevel
+ val totalScore by quizViewModel.totalScore
+ val language by quizViewModel.currentLanguage
+ val isKoreanQuiz = language == "KOREAN"
+ val topBarTitle = if (isKoreanQuiz) "Quiz Result" else "퀴즈 결과"
+ val mainTitle =
+ if (isKoreanQuiz) "${userState?.nickname}'s Korean Level" else "${userState?.nickname}님의 영어 레벨"
+ val scoreLabel = if (isKoreanQuiz) "Total Score" else "총점"
+ val scoreUnit = if (isKoreanQuiz) "pts" else "점"
+ val descriptionText =
+ if (isKoreanQuiz) "The quiz consists of 3 types from LV 1 to 5." else "LV 1~5, 3가지 유형으로 출제되었습니다."
+ val confirmButtonText = if (isKoreanQuiz) "Confirm" else "확인"
+
+ // 프로그레스 바 애니메이션을 위한 상태
+ var animationPlayed by remember { mutableStateOf(false) }
+ val progress by animateFloatAsState(
+ targetValue = if (animationPlayed) (finalLevel / 10f) else 0f,
+ animationSpec =
+ tween(
+ durationMillis = 2000,
+ delayMillis = 500,
+ easing = FastOutSlowInEasing,
+ ),
+ )
+
+ LaunchedEffect(Unit) {
+ animationPlayed = true
+ }
+
+ Scaffold(
+ topBar = {
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .height(64.dp)
+ .clip(RoundedCornerShape(bottomEnd = 20.dp))
+ .background(Color(0xFF4A526A)),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = "$topBarTitle",
+ modifier = Modifier.weight(1f),
+ color = Color.White,
+ fontSize = 20.sp,
+ textAlign = TextAlign.Center,
+ )
+ }
+ },
+ containerColor = Color(0xFFF3F6FF),
+ ) { innerPadding ->
+ Column(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .padding(innerPadding)
+ .padding(32.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Spacer(modifier = Modifier.height(24.dp))
+
+ Text(mainTitle, fontSize = 20.sp, fontWeight = FontWeight.SemiBold)
+ Spacer(modifier = Modifier.weight(1f))
+
+ // 레벨 표시
+ Text(
+ "LV. $finalLevel",
+ fontSize = 40.sp,
+ fontWeight = FontWeight.Bold,
+ color = Color(0xFF4285F4),
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // 프로그레스 바
+ LinearProgressIndicator(
+ progress = { progress },
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .height(30.dp)
+ .clip(RoundedCornerShape(15.dp)),
+ color = Color(0xFF4285F4),
+ trackColor = Color.LightGray,
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ Text(
+ "$scoreLabel: $totalScore / 45 $scoreUnit",
+ fontSize = 20.sp,
+ color = Color.Gray,
+ )
+
+ Spacer(modifier = Modifier.weight(1f))
+
+ Text(
+ descriptionText,
+ fontSize = 12.sp,
+ color = Color.Gray,
+ )
+
+ Spacer(modifier = Modifier.weight(1f))
+
+ Button(
+ onClick = {
+ quizViewModel.saveTestResult()
+ onNavigateToMyPage()
+ },
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(20.dp)
+ .height(50.dp),
+ shape = RoundedCornerShape(30.dp),
+ colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF4285F4)),
+ ) {
+ Text(confirmButtonText, fontSize = 16.sp)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/chaining/ui/screen/SplashScreen.kt b/app/src/main/java/com/example/chaining/ui/screen/SplashScreen.kt
new file mode 100644
index 0000000..ebee2af
--- /dev/null
+++ b/app/src/main/java/com/example/chaining/ui/screen/SplashScreen.kt
@@ -0,0 +1,114 @@
+package com.example.chaining.ui.screen
+
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.FastOutSlowInEasing
+import androidx.compose.animation.core.LinearOutSlowInEasing
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+import androidx.navigation.NavController
+import com.example.chaining.ui.component.SplashAnimation
+import com.example.chaining.ui.navigation.Screen
+import com.google.firebase.Firebase
+import com.google.firebase.auth.auth
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+
+@Suppress("FunctionName")
+@Composable
+fun SplashScreen(navController: NavController) {
+ // X축 슬라이드인 용도
+ val offsetX = remember { Animatable(300f) }
+ // 페이드 인
+ val alpha = remember { Animatable(0f) }
+ // Y축 슬라이드인 용도
+ val offsetY = remember { Animatable(0f) }
+ // 체인 이미지 용도
+ val chainVisible = remember { mutableStateOf(false) }
+ // 2단계 시작 용도
+ val textSlideDownStart = remember { mutableStateOf(false) }
+
+ LaunchedEffect(Unit) {
+ // 1단계: 오른쪽 -> 중앙 슬라이드 + 페이드인
+ launch {
+ offsetX.animateTo(
+ targetValue = 0f,
+ animationSpec = tween(durationMillis = 1000, easing = FastOutSlowInEasing),
+ )
+ }
+ launch {
+ alpha.animateTo(
+ targetValue = 1f,
+ animationSpec = tween(durationMillis = 1500, easing = FastOutSlowInEasing),
+ )
+ }
+
+ delay(800)
+
+ // 2단계: 중앙 -> 아래쪽 슬라이드
+ textSlideDownStart.value = true
+ launch {
+ offsetY.animateTo(
+ 70f,
+ animationSpec = tween(400, easing = LinearOutSlowInEasing),
+ )
+ }
+ delay(50)
+ chainVisible.value = true
+
+ delay(1500)
+
+ val isLoggedIn = Firebase.auth.currentUser != null
+ if (isLoggedIn) {
+ navController.navigate(route = Screen.MainHome.route) {
+ popUpTo("splash") { inclusive = true }
+ }
+ } else {
+ navController.navigate("login") {
+ popUpTo("splash") { inclusive = true }
+ }
+ }
+ }
+
+ Box(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .background(Color.White),
+ contentAlignment = Alignment.Center,
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ SplashAnimation(startAnimation = chainVisible.value)
+ Text(
+ text = "Chaining",
+ fontSize = 50.sp,
+ fontWeight = FontWeight.Bold,
+ color = Color.Black,
+ modifier =
+ Modifier
+ .graphicsLayer {
+ translationX = offsetX.value
+ translationY = offsetY.value
+ this.alpha = alpha.value
+ },
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/chaining/ui/screen/ViewPostScreen.kt b/app/src/main/java/com/example/chaining/ui/screen/ViewPostScreen.kt
new file mode 100644
index 0000000..76b317a
--- /dev/null
+++ b/app/src/main/java/com/example/chaining/ui/screen/ViewPostScreen.kt
@@ -0,0 +1,291 @@
+package com.example.chaining.ui.screen
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.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.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+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.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.hilt.navigation.compose.hiltViewModel
+import com.example.chaining.R
+import com.example.chaining.domain.model.RecruitPost
+import com.example.chaining.ui.component.OwnerProfile
+import com.example.chaining.ui.component.SaveButton
+import com.example.chaining.ui.component.formatDate
+import com.example.chaining.viewmodel.RecruitPostViewModel
+import com.example.chaining.viewmodel.UserViewModel
+
+@Suppress("FunctionName")
+@Composable
+fun ViewPostScreen(
+ userViewModel: UserViewModel = hiltViewModel(),
+ postViewModel: RecruitPostViewModel = hiltViewModel(),
+ onBackClick: () -> Unit = {},
+ onJoinPostClick: (post: RecruitPost) -> Unit = {},
+ onEditClick: (postId: String) -> Unit = {},
+ onApplicationListClick: (postId: String) -> Unit = {},
+ onMainHomeClick: () -> Unit,
+ onCommunityClick: () -> Unit,
+ onFeedClick: () -> Unit,
+ onNotificationClick: () -> Unit,
+) {
+ val userState by userViewModel.user.collectAsState()
+ val post by postViewModel.post.collectAsState()
+ val currentPost = post
+
+ // post가 null이면 로딩 UI 표시
+ if (currentPost == null) {
+ Column(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .padding(30.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ Text("Loading...", fontSize = 18.sp)
+ }
+ return
+ }
+
+ Scaffold(
+ topBar = {
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .height(64.dp)
+ .clip(RoundedCornerShape(bottomEnd = 20.dp))
+ .background(Color(0xFF4A526A)),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ IconButton(onClick = onBackClick) {
+ Icon(
+ painter = painterResource(id = R.drawable.back_arrow),
+ contentDescription = "뒤로 가기",
+ modifier = Modifier.size(20.dp),
+ tint = Color.White,
+ )
+ }
+
+ OwnerProfile(owner = currentPost.owner, where = "모집글 상세보기", type = "상세 보기")
+
+ // 제목을 완벽한 중앙에 맞추기 위한 빈 공간
+ Spacer(modifier = Modifier.width(48.dp))
+ }
+ },
+ bottomBar = {
+ AppBottomNavigation(selectedTab = "Community", onTestClick = { menu ->
+ when (menu) {
+ "Home" -> onMainHomeClick()
+ "Community" -> onCommunityClick()
+ "Notification" -> onNotificationClick()
+ "Feed" -> onFeedClick()
+ }
+ })
+ },
+ containerColor = Color(0xFFF3F6FF),
+ ) { innerPadding ->
+ Column(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .padding(innerPadding)
+ .padding(start = 10.dp, end = 10.dp),
+ ) {
+ // 스크롤이 필요한 콘텐츠 영역 (Card)
+ Card(
+ // weight(1f)를 주어 남는 공간을 모두 차지하게 함
+ modifier =
+ Modifier
+ .padding(12.dp)
+ .weight(1f),
+ colors = CardDefaults.cardColors(containerColor = Color(0xFFF3F6FF)),
+ ) {
+ // 카드 내부는 이전과 동일하게 스크롤 가능
+ Column(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(16.dp)
+ .verticalScroll(rememberScrollState()),
+ ) {
+ Spacer(modifier = Modifier.height(30.dp))
+
+ Text(
+ text = currentPost.title,
+ fontSize = 20.sp,
+ fontWeight = FontWeight.Bold,
+ color = Color(0xFF4A526A),
+ )
+
+ Spacer(modifier = Modifier.height(20.dp))
+
+ HorizontalDivider(
+ thickness = 1.dp,
+ color = Color.LightGray,
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ SetInfo(
+ icon = R.drawable.global,
+ title = stringResource(id = R.string.post_style),
+ content = currentPost.preferredDestinations,
+ )
+
+ SetInfo(
+ icon = R.drawable.calendar,
+ title = stringResource(id = R.string.post_date),
+ content = formatDate(currentPost.tourAt),
+ )
+
+ SetInfo(
+ icon = R.drawable.car,
+ title = stringResource(id = R.string.post_car),
+ content = currentPost.hasCar,
+ )
+
+ SetInfo(
+ icon = R.drawable.timer,
+ title = stringResource(id = R.string.post_finish),
+ content = formatDate(currentPost.closeAt),
+ )
+
+ SetInfo(
+ icon = R.drawable.language,
+ title = stringResource(id = R.string.post_lang),
+ content = currentPost.preferredLanguages.values.joinToString { it.language },
+ )
+
+ SetInfo(
+ icon = R.drawable.level,
+ title = stringResource(id = R.string.post_lang_level),
+ content = currentPost.preferredLanguages.values.joinToString { it.level.toString() },
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ HorizontalDivider(
+ thickness = 1.dp,
+ color = Color.LightGray,
+ )
+
+ Spacer(modifier = Modifier.height(20.dp))
+
+ Text(
+ text = currentPost.content,
+ fontSize = 16.sp,
+ lineHeight = 22.sp,
+ color = Color(0xFF4A526A),
+ )
+ }
+ }
+
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(bottom = 10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ if (userState?.id == currentPost.owner.id) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ SaveButton(
+ onSave = { onEditClick(currentPost.postId) },
+ text = stringResource(id = R.string.post_edit),
+ modifier = Modifier.weight(1f),
+ )
+ SaveButton(
+ onSave = { onApplicationListClick(currentPost.postId) },
+ text = stringResource(id = R.string.post_look),
+ modifier = Modifier.weight(1f),
+ )
+ }
+
+ // SaveButton(onSave = { /*TODO*/ }, text = "삭제")
+ } else {
+ SaveButton(
+ onSave = { onJoinPostClick(currentPost) },
+ text = stringResource(id = R.string.post_button),
+ )
+ // SaveButton(onSave = { /*TODO*/ }, text = "숨김")
+ }
+ }
+ }
+ }
+}
+
+@Suppress("FunctionName")
+@Composable
+fun SetInfo(
+ icon: Int,
+ title: String,
+ content: String,
+) {
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .height(48.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ painter = painterResource(id = icon),
+ contentDescription = "정보 아이콘",
+ modifier = Modifier.size(25.dp),
+ tint = Color(0xFF4A526A),
+ )
+
+ Spacer(modifier = Modifier.width(12.dp))
+
+ Text(
+ text = title,
+ modifier = Modifier.weight(2f),
+ color = Color(0xFF4A526A),
+ fontSize = 14.sp,
+ textAlign = TextAlign.Center,
+ )
+
+ Spacer(modifier = Modifier.width(12.dp))
+
+ Text(
+ text = content,
+ modifier = Modifier.weight(1f),
+ color = Color(0xFF4A526A),
+ fontSize = 14.sp,
+ textAlign = TextAlign.Center,
+ )
+ }
+}
diff --git a/app/src/main/java/com/example/chaining/ui/theme/Theme.kt b/app/src/main/java/com/example/chaining/ui/theme/Theme.kt
index aac1cff..c014f73 100644
--- a/app/src/main/java/com/example/chaining/ui/theme/Theme.kt
+++ b/app/src/main/java/com/example/chaining/ui/theme/Theme.kt
@@ -8,6 +8,7 @@ import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
private val DarkColorScheme =
@@ -22,22 +23,20 @@ private val LightColorScheme =
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40,
- /* Other default colors to override
- background = Color(0xFFFFFBFE),
- surface = Color(0xFFFFFBFE),
- onPrimary = Color.White,
- onSecondary = Color.White,
- onTertiary = Color.White,
- onBackground = Color(0xFF1C1B1F),
- onSurface = Color(0xFF1C1B1F),
- */
+ // 원하는 색상 (#FFFFBF)
+ background = Color.White,
+ // Scaffold, Surface 등 표면도 같은 색상
+ surface = Color.White,
+ onBackground = Color.Black,
+ onSurface = Color.Black,
)
+@Suppress("FunctionName")
@Composable
-fun chainingTheme(
+fun ChainingTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
- dynamicColor: Boolean = true,
+ dynamicColor: Boolean = false,
content: @Composable () -> Unit,
) {
val colorScheme =
diff --git a/app/src/main/java/com/example/chaining/viewmodel/ApplicationViewModel.kt b/app/src/main/java/com/example/chaining/viewmodel/ApplicationViewModel.kt
new file mode 100644
index 0000000..d2aaee0
--- /dev/null
+++ b/app/src/main/java/com/example/chaining/viewmodel/ApplicationViewModel.kt
@@ -0,0 +1,100 @@
+@file:Suppress("ktlint:standard:property-naming")
+
+package com.example.chaining.viewmodel
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.example.chaining.data.repository.ApplicationRepository
+import com.example.chaining.domain.model.Application
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class ApplicationViewModel
+ @Inject
+ constructor(
+ private val repo: ApplicationRepository,
+ ) : ViewModel() {
+ private val _application = MutableStateFlow(null)
+ val application: StateFlow = _application
+
+ private val _applications = MutableStateFlow>(emptyList())
+ val applications: StateFlow> = _applications
+
+ private val _statusUpdates = MutableStateFlow>(emptyList())
+ val statusUpdates: StateFlow> = _statusUpdates
+
+ // 1. 신청 완료 이벤트를 UI에 알리기 위한 StateFlow 추가
+ private val _isSubmitSuccess = MutableStateFlow(false)
+ val isSubmitSuccess: StateFlow = _isSubmitSuccess
+
+ private val _toastEvent = MutableSharedFlow()
+ val toastEvent = _toastEvent.asSharedFlow()
+
+ init {
+ // 기존 목록 조회
+// fetchAllApplications()
+ // 상태 변경 구독
+// observeStatusUpdates()
+ }
+
+ private fun observeStatusUpdates() =
+ viewModelScope.launch {
+ repo.observeMyApplicationStatus().collect { apps ->
+ _statusUpdates.value = apps
+ }
+ }
+
+ fun submitApplication(application: Application) =
+ viewModelScope.launch {
+ val result = repo.submitApplication(application)
+ result.onSuccess { returnedApplicationId ->
+ val updatedList = _applications.value.toMutableList()
+ val newApplicationForUi =
+ application.copy(
+ applicationId = returnedApplicationId,
+ createdAt = System.currentTimeMillis(),
+ )
+ updatedList.add(newApplicationForUi)
+ _applications.value = updatedList
+ _toastEvent.emit("application_success")
+ _isSubmitSuccess.value = true
+ }.onFailure { exception ->
+ _toastEvent.emit("application_failed")
+ }
+ }
+
+ fun resetSubmitStatus() {
+ _isSubmitSuccess.value = false
+ }
+
+ fun fetchApplication(applicationId: String) =
+ viewModelScope.launch {
+ _application.value = repo.getApplication(applicationId)
+ }
+
+ /** Update - 전체 User 객체 저장 */
+ fun updateStatus(
+ application: Application,
+ value: String,
+ ) = viewModelScope.launch {
+ repo.updateStatus(application, value)
+ }
+
+ fun fetchAllApplications() =
+ viewModelScope.launch {
+ _applications.value = repo.getMyApplications()
+ }
+
+ fun deleteApply() =
+ viewModelScope.launch {
+ _application.value?.applicationId?.let { aid ->
+ repo.deleteApplication(aid)
+ }
+ }
+ }
diff --git a/app/src/main/java/com/example/chaining/viewmodel/AreaViewModel.kt b/app/src/main/java/com/example/chaining/viewmodel/AreaViewModel.kt
new file mode 100644
index 0000000..1e23d0e
--- /dev/null
+++ b/app/src/main/java/com/example/chaining/viewmodel/AreaViewModel.kt
@@ -0,0 +1,35 @@
+@file:Suppress("ktlint:standard:property-naming")
+
+package com.example.chaining.viewmodel
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.example.chaining.data.local.entity.AreaEntity
+import com.example.chaining.data.repository.AreaRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class AreaViewModel
+ @Inject
+ constructor(
+ private val repository: AreaRepository,
+ ) : ViewModel() {
+ val areaCodes: StateFlow> =
+ repository.allAreas
+ .stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(5000),
+ initialValue = emptyList(),
+ )
+
+ init {
+ viewModelScope.launch {
+ repository.refreshAreasIfNeeded()
+ }
+ }
+ }
diff --git a/app/src/main/java/com/example/chaining/viewmodel/FeedViewModel.kt b/app/src/main/java/com/example/chaining/viewmodel/FeedViewModel.kt
new file mode 100644
index 0000000..59364be
--- /dev/null
+++ b/app/src/main/java/com/example/chaining/viewmodel/FeedViewModel.kt
@@ -0,0 +1,50 @@
+@file:Suppress("ktlint:standard:property-naming")
+
+package com.example.chaining.viewmodel
+
+import android.util.Log
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.example.chaining.data.model.TourItem
+import com.example.chaining.data.repository.FeedRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class FeedViewModel
+ @Inject
+ constructor(
+ // TourRepository를 주입받음
+ private val repository: FeedRepository,
+ ) : ViewModel() {
+ // API로 가져온 전체 관광지 리스트 (비공개)
+ private val _tourItems = MutableStateFlow>(emptyList())
+ val tourItems: StateFlow> = _tourItems
+
+ // UI에 보여줄 랜덤 3개의 관광지 리스트 (공개)
+ private val _randomizedFeedItems = MutableStateFlow>(emptyList())
+ val randomizedFeedItems: StateFlow> = _randomizedFeedItems
+
+ fun fetchTourItems(areaCode: Int? = null) {
+ viewModelScope.launch {
+ try {
+ val items = repository.getTourItems(areaCode)
+ _tourItems.value = items
+ // 데이터를 성공적으로 불러온 후, 바로 랜덤 아이템 선택 함수 호출
+ randomizeFeedItems()
+ } catch (e: Exception) {
+ Log.e("FeedViewModel", "Failed to fetch tour items", e)
+ }
+ }
+ }
+
+ // 전체 리스트에서 3개의 아이템을 랜덤으로 선택하여 상태를 업데이트하는 함수
+ fun randomizeFeedItems() {
+ if (_tourItems.value.isNotEmpty()) {
+ _randomizedFeedItems.value = _tourItems.value.shuffled().take(3)
+ }
+ }
+ }
diff --git a/app/src/main/java/com/example/chaining/viewmodel/NotificationViewModel.kt b/app/src/main/java/com/example/chaining/viewmodel/NotificationViewModel.kt
new file mode 100644
index 0000000..daef6e9
--- /dev/null
+++ b/app/src/main/java/com/example/chaining/viewmodel/NotificationViewModel.kt
@@ -0,0 +1,85 @@
+@file:Suppress("ktlint:standard:property-naming")
+
+package com.example.chaining.viewmodel
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.example.chaining.data.repository.NotificationRepository
+import com.example.chaining.domain.model.Notification
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class NotificationViewModel
+ @Inject
+ constructor(
+ private val repository: NotificationRepository,
+ ) : ViewModel() {
+ private val _notifications = MutableStateFlow>(emptyList())
+ val notifications: StateFlow> = _notifications.asStateFlow()
+
+ private val _isLoading = MutableStateFlow(false)
+ val isLoading: StateFlow = _isLoading.asStateFlow()
+
+ private val _errorMessage = MutableStateFlow(null)
+ val errorMessage: StateFlow = _errorMessage.asStateFlow()
+
+ private val _event = MutableSharedFlow()
+ val event: SharedFlow = _event
+
+ init {
+ fetchNotifications()
+ }
+
+ fun onApplicationClick(id: String) {
+ viewModelScope.launch {
+ _event.emit(NotificationEvent.NavigateToApplication(id))
+ }
+ }
+
+ /** 알림 실시간 구독 시작 */
+ private fun fetchNotifications() {
+ viewModelScope.launch {
+ _isLoading.value = true
+ repository.observeNotifications()
+ .catch { e ->
+ _errorMessage.value = e.message
+ _isLoading.value = false
+ }
+ .collect { list ->
+ _notifications.value = list
+ _isLoading.value = false
+ }
+ }
+ }
+
+ /** 알림 읽음 상태 업데이트 (옵션) */
+ fun markNotificationAsRead(notificationId: String) {
+ viewModelScope.launch {
+ val updatedList =
+ _notifications.value.map {
+ if (it.id == notificationId) it.copy(isRead = true) else it
+ }
+ _notifications.value = updatedList
+ }
+ }
+
+ /** 에러 메시지 초기화 */
+ fun clearError() {
+ _errorMessage.value = null
+ }
+ }
+
+sealed class NotificationEvent {
+ data class NavigateToApplication(val applicationId: String) : NotificationEvent()
+
+ data class ShowToast(val message: String) : NotificationEvent()
+ object Refresh : NotificationEvent()
+}
diff --git a/app/src/main/java/com/example/chaining/viewmodel/QuizViewModel.kt b/app/src/main/java/com/example/chaining/viewmodel/QuizViewModel.kt
new file mode 100644
index 0000000..35f1d65
--- /dev/null
+++ b/app/src/main/java/com/example/chaining/viewmodel/QuizViewModel.kt
@@ -0,0 +1,298 @@
+@file:Suppress("ktlint:standard:property-naming")
+
+package com.example.chaining.viewmodel
+
+import android.content.Context
+import androidx.compose.runtime.State
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.example.chaining.data.repository.UserRepository
+import com.example.chaining.domain.model.QuizItem // 이전에 만든 QuizItem 데이터 클래스 import
+import com.example.chaining.domain.model.QuizType
+import com.google.gson.Gson
+import com.google.gson.reflect.TypeToken
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class QuizViewModel
+ @Inject
+ constructor(
+ private val userRepository: UserRepository,
+ ) : ViewModel() {
+ // 전체 퀴즈 목록 (비공개)
+ private var allQuizzes: List = emptyList()
+
+ // UI가 구독할 최종 15문제 퀴즈 리스트 (공개)
+ private val _quizSet = mutableStateOf>(emptyList())
+ val quizSet: State> = _quizSet
+
+ // 현재 몇 번째 문제를 풀고 있는지 추적
+ private val _currentQuestionIndex = mutableIntStateOf(0)
+ val currentQuestionIndex: State = _currentQuestionIndex
+
+ // 현재 문제 State (읽기 전용)
+ val currentQuestion: State =
+ derivedStateOf {
+ _quizSet.value.getOrNull(_currentQuestionIndex.value)
+ }
+
+ // '순서 맞추기' 유형을 위한 단어 묶음 (shuffled)
+ val wordChips =
+ derivedStateOf {
+ currentQuestion.value?.takeIf { it.type == QuizType.SENTENCE_ORDER.name }
+ ?.answer?.split(" ")?.shuffled() ?: emptyList()
+ }
+
+ // '순서 맞추기' 유형을 위한 '한 번만 섞인' 단어 목록 (상태로 관리)
+ @Suppress("PropertyName")
+ private val _shuffledWordChips = mutableStateOf>(emptyList())
+
+ // '선택하고 남은 단어 칩'은 이제 _shuffledWordChips를 기준으로 계산
+ val remainingWordChips: State> =
+ derivedStateOf {
+ _shuffledWordChips.value - _userAnswerSentence.value.toSet()
+ }
+
+ // 사용자가 구성한 정답 문장을 저장하는 State
+ private val _userAnswerSentence = mutableStateOf>(emptyList())
+ val userAnswerSentence: State> = _userAnswerSentence
+
+ // '객관식' 유형을 위한 사용자 선택 답안 저장 State
+ private val _selectedOption = mutableStateOf(null)
+ val selectedOption: State = _selectedOption
+
+ // '빈칸 채우기' 유형을 위한 사용자 선택 답안 저장 State
+ private val _selectedBlankWord = mutableStateOf(null)
+ val selectedBlankWord: State = _selectedBlankWord
+
+ // 사용자의 답변을 기록할 Map (Key: 문제 ID, Value: 사용자 답변)
+ private val _userAnswersMap = mutableStateOf