diff --git a/.kotlin/errors/errors-1758100641387.log b/.kotlin/errors/errors-1758100641387.log new file mode 100644 index 0000000..b3a87a6 --- /dev/null +++ b/.kotlin/errors/errors-1758100641387.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 1151de4..de3b61c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -30,8 +30,8 @@ android { applicationId = "com.example.chaining" minSdk = 24 targetSdk = 35 - versionCode = 1 - versionName = "1.0" + versionCode = 2 + versionName = "1.0.2" buildConfigField("String", "DATA_OPEN_API_KEY", properties["DATA_OPEN_API_KEY"].toString()) diff --git a/app/release/app-release.aab b/app/release/app-release.aab index 8fef108..29c7241 100644 Binary files a/app/release/app-release.aab and b/app/release/app-release.aab differ diff --git a/app/src/main/assets/korean_quizzes.json b/app/src/main/assets/korean_quizzes.json index cc436e4..578628c 100644 --- a/app/src/main/assets/korean_quizzes.json +++ b/app/src/main/assets/korean_quizzes.json @@ -47,7 +47,7 @@ "problem": "화장실은 ______에 있어요?", "translation": "Where is the restroom?", "options": ["어디", "언제", "누구", "무엇"], - "answer": "어디 (Where)" + "answer": "어디" }, { "id": "kor_lv1_fb_02", @@ -57,7 +57,7 @@ "problem": "이거 ______예요?", "translation": "How much is this?", "options": ["얼마", "왜", "어떻게", "몇"], - "answer": "얼마 (How much)" + "answer": "얼마" }, { "id": "kor_lv2_so_01", @@ -107,7 +107,7 @@ "problem": "이 버스, 시청에 ______?", "translation": "Does this bus go to the city hall?", "options": ["가요", "와요", "먹어요", "자요"], - "answer": "가요 (go)" + "answer": "가요" }, { "id": "kor_lv2_fb_02", @@ -117,7 +117,7 @@ "problem": "계산서 좀 ______.", "translation": "The bill, please.", "options": ["앉으세요", "주세요", "입으세요", "읽으세요"], - "answer": "주세요 (give me)" + "answer": "주세요" }, { "id": "kor_lv3_so_01", @@ -167,7 +167,7 @@ "problem": "창가 쪽 자리로 ______.", "translation": "I'd like a window seat, please.", "options": ["부탁드립니다", "괜찮습니다", "모릅니다", "없습니다"], - "answer": "부탁드립니다 (I'd like to request)" + "answer": "부탁드립니다" }, { "id": "kor_lv3_fb_02", @@ -177,7 +177,7 @@ "problem": "와이파이 비밀번호가 ______?", "translation": "What is the Wi-Fi password?", "options": ["뭐예요", "예뻐요", "비싸요", "멀어요"], - "answer": "뭐예요 (what is)" + "answer": "뭐예요" }, { "id": "kor_lv4_so_01", @@ -227,7 +227,7 @@ "problem": "이 자리에 다른 분이 ______?", "translation": "Is this seat taken? (Did someone else sit here?)", "options": ["앉으셨어요", "서셨어요", "주무셨어요", "오셨어요"], - "answer": "앉으셨어요 (sat down)" + "answer": "앉으셨어요" }, { "id": "kor_lv4_fb_02", @@ -237,7 +237,7 @@ "problem": "제가 한국 드라마를 좋아해서 한국어를 ______ 됐어요.", "translation": "I like K-dramas, so I came to learn Korean.", "options": ["배우게", "가르치게", "만들게", "보게"], - "answer": "배우게 (came to learn)" + "answer": "배우게" }, { "id": "kor_lv5_so_01", @@ -287,7 +287,7 @@ "problem": "그 친구는 약속을 밥 ______ 어겨서 믿을 수가 없어.", "translation": "I can't trust that friend because they break promises as habitually as they eat meals.", "options": ["먹듯이", "보듯이", "자듯이", "입듯이"], - "answer": "먹듯이 (like eating)" + "answer": "먹듯이" }, { "id": "kor_lv5_fb_02", @@ -297,6 +297,6 @@ "problem": "한국의 배달 문화는 ______ 감탄스러울 정도예요.", "translation": "Korea's delivery culture is, once again, impressive.", "options": ["새삼", "감히", "차마", "결코"], - "answer": "새삼 (newly, once again)" + "answer": "새삼" } ] \ No newline at end of file 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 index c9b43f5..d239135 100644 --- a/app/src/main/java/com/example/chaining/data/repository/ApplicationRepository.kt +++ b/app/src/main/java/com/example/chaining/data/repository/ApplicationRepository.kt @@ -55,11 +55,9 @@ class ApplicationRepository ) val postOwnerId = application.owner.id - val newNotificationKey = rootRef.child("notifications") .child(postOwnerId).push().key ?: error("알림 ID 생성 실패") - val notification = Notification( id = newNotificationKey, @@ -76,6 +74,7 @@ class ApplicationRepository createdAt = System.currentTimeMillis(), isRead = false, uid = postOwnerId, + closeAt = application.closeAt, ) updates["/notifications/$postOwnerId/$newNotificationKey"] = notification @@ -175,7 +174,6 @@ class ApplicationRepository return Result.failure(Exception("이미 처리된 지원서입니다.")) } // 멀티패스 업데이트 경로 구성 - println("호시기" + application.applicant.id + application.applicationId) val updates = hashMapOf( // 1. applications 노드에 지원서 저장 @@ -205,6 +203,7 @@ class ApplicationRepository status = value, createdAt = System.currentTimeMillis(), isRead = false, + closeAt = application.closeAt, ) updates["/notifications/${application.applicant.id}/$newNotificationKey"] = notification // 원자적 업데이트 수행 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 index ee05697..552a978 100644 --- a/app/src/main/java/com/example/chaining/data/repository/UserRepository.kt +++ b/app/src/main/java/com/example/chaining/data/repository/UserRepository.kt @@ -106,43 +106,6 @@ class UserRepository 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( @@ -180,13 +143,112 @@ class UserRepository /** 프로필 사진 변경 */ suspend fun updateProfileImage(newUrl: String) { val uid = uidOrThrow() - usersRef().child(uid).child("profileImageUrl").setValue(newUrl).await() +// usersRef().child(uid).child("profileImageUrl").setValue(newUrl).await() + + // 1. users/{uid}/profileImageUrl 업데이트 + val updates = + hashMapOf( + "/users/$uid/profileImageUrl" to newUrl, + ) + + val myFollowingSnapshot = usersRef().child(uid).child("following").get().await() + for (followingSnap in myFollowingSnapshot.children) { + val followedUid = followingSnap.key ?: continue + + updates["/users/$followedUid/follower/$uid/profileImageUrl"] = newUrl + } + + // 2. 내가 작성한 posts (owner) + val myPosts = usersRef().child(uid).child("recruitPosts").get().await() + for (postSnap in myPosts.children) { + val postId = postSnap.key ?: continue + updates["/posts/$postId/owner/profileImageUrl"] = newUrl + updates["/users/$uid/posts/$postId/owner/profileImageUrl"] = newUrl + } + + // 3. 내가 작성한 applications (applicant) + val myApplications = usersRef().child(uid).child("applications").get().await() + for (appSnap in myApplications.children) { + val applicationId = appSnap.key ?: continue + updates["/applications/$applicationId/applicant/profileImageUrl"] = newUrl + updates["/users/$uid/applications/$applicationId/applicant/profileImageUrl"] = newUrl + } + + // 4. notifications에서 sender.id == uid 인 것들 갱신 + val notifications = rootRef.child("notifications").get().await() + for (userSnap in notifications.children) { + val targetUid = userSnap.key ?: continue + for (notiSnap in userSnap.children) { + val senderId = notiSnap.child("sender/id").getValue(String::class.java) + val notiId = notiSnap.key ?: continue + if (senderId == uid) { + updates["/notifications/$targetUid/$notiId/sender/profileImageUrl"] = newUrl + } + } + } + + // 5. 원자적 업데이트 실행 + rootRef.updateChildren(updates).await() val current = userDao.getUser(uid).firstOrNull() ?: return val updatedEntity = current.copy(profileImageUrl = newUrl) userDao.updateUser(updatedEntity) } + /** 닉네임 변경 */ + suspend fun updateNickname(newNickname: String) { + val uid = uidOrThrow() + + // 1. users/{uid}/profileImageUrl 업데이트 + val updates = + hashMapOf( + "/users/$uid/nickname" to newNickname, + ) + + val myFollowingSnapshot = usersRef().child(uid).child("following").get().await() + for (followingSnap in myFollowingSnapshot.children) { + val followedUid = followingSnap.key ?: continue + + updates["/users/$followedUid/follower/$uid/nickname"] = newNickname + } + + // 2. 내가 작성한 posts (owner) + val myPosts = usersRef().child(uid).child("recruitPosts").get().await() + for (postSnap in myPosts.children) { + val postId = postSnap.key ?: continue + updates["/posts/$postId/owner/nickname"] = newNickname + updates["/users/$uid/posts/$postId/owner/nickname"] = newNickname + } + + // 3. 내가 작성한 applications (applicant) + val myApplications = usersRef().child(uid).child("applications").get().await() + for (appSnap in myApplications.children) { + val applicationId = appSnap.key ?: continue + updates["/applications/$applicationId/applicant/nickname"] = newNickname + updates["/users/$uid/applications/$applicationId/applicant/nickname"] = newNickname + } + + // 4. notifications에서 sender.id == uid 인 것들 갱신 + val notifications = rootRef.child("notifications").get().await() + for (userSnap in notifications.children) { + val targetUid = userSnap.key ?: continue + for (notiSnap in userSnap.children) { + val senderId = notiSnap.child("sender/id").getValue(String::class.java) + val notiId = notiSnap.key ?: continue + if (senderId == uid) { + updates["/notifications/$targetUid/$notiId/sender/nickname"] = newNickname + } + } + } + + // 5. 원자적 업데이트 실행 + rootRef.updateChildren(updates).await() + + val current = userDao.getUser(uid).firstOrNull() ?: return + val updatedEntity = current.copy(nickname = newNickname) + userDao.updateUser(updatedEntity) + } + /** 테스트 결과 변경 */ suspend fun updateTestResult(languagePref: LanguagePref) { val uid = uidOrThrow() 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 index 1183847..22589fc 100644 --- a/app/src/main/java/com/example/chaining/domain/model/Application.kt +++ b/app/src/main/java/com/example/chaining/domain/model/Application.kt @@ -20,4 +20,5 @@ data class Application( // Soft Delete 플래그 추가 @get:PropertyName("isDeleted") val isDeleted: Boolean = false, + val closeAt: Long = 0L, ) 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 index 7629eb5..1236ec5 100644 --- a/app/src/main/java/com/example/chaining/domain/model/QuizItem.kt +++ b/app/src/main/java/com/example/chaining/domain/model/QuizItem.kt @@ -1,5 +1,7 @@ package com.example.chaining.domain.model +import java.util.UUID + data class QuizItem( // 고유 ID val id: String = "", @@ -29,3 +31,9 @@ enum class QuizType { // 문장 빈칸 채우기 FILL_IN_THE_BLANK, } + +data class WordChip( + val text: String, + // 각 단어에 고유한 ID를 자동으로 부여 + val id: UUID = UUID.randomUUID(), +) 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 index ffcc61c..2e2e940 100644 --- a/app/src/main/java/com/example/chaining/ui/component/CardItem.kt +++ b/app/src/main/java/com/example/chaining/ui/component/CardItem.kt @@ -33,6 +33,7 @@ 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.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil.compose.AsyncImage @@ -45,7 +46,7 @@ import com.example.chaining.domain.model.UserSummary @Composable fun CardItem( onClick: () -> Unit, - // "모집글" or "지원서" + // "모집글" or "지원서" or "결과" type: String, recruitPost: RecruitPost? = null, application: Application? = null, @@ -55,6 +56,7 @@ fun CardItem( currentUserId: String? = "", isLiked: Boolean? = false, hasApplied: Boolean = false, + hasStatus: Boolean = false, ) { val title = when (type) { @@ -63,17 +65,40 @@ fun CardItem( application?.recruitPostTitle ?: stringResource(id = R.string.community_no_title) + "결과" -> + application?.recruitPostTitle + ?: stringResource(id = R.string.community_no_title) + else -> stringResource(id = R.string.community_no_title) }.replace("+", " ") val remainingTimeText = remainingTime ?: stringResource(id = R.string.community_unknown) - val isAuthor = recruitPost?.owner?.id == currentUserId + val isAuthor = + (recruitPost?.owner?.id == currentUserId) || (application?.applicant?.id == currentUserId) val timeText = - when (type) { - "모집글" -> stringResource(id = R.string.time_left_recruit, remainingTimeText) - "지원서" -> stringResource(id = R.string.time_left_application, remainingTimeText) - else -> "" + if (type == "결과") { + stringResource(id = R.string.application_result_available) + } else { + when (type) { + "모집글" -> { + if (remainingTimeText == stringResource(id = R.string.time_closed)) { + stringResource(id = R.string.time_closed) + } else { + stringResource(id = R.string.time_left_recruit, remainingTimeText) + } + } + + "지원서" -> { + if (remainingTimeText == stringResource(id = R.string.time_closed)) { + stringResource(id = R.string.time_closed) + } else { + stringResource(id = R.string.time_left_application, remainingTimeText) + } + } + + else -> "" + } } // val profile = when (type) { @@ -188,7 +213,12 @@ fun CardItem( Spacer(modifier = Modifier.width(10.dp)) Text( - text = timeText, + text = + if (type == "결과") { + stringResource(id = R.string.application_result_available) + } else { + timeText + }, color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.Bold, @@ -254,51 +284,69 @@ fun CardItem( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { - // 왼쪽 버튼 - Button( - onClick = onLeftButtonClick, - modifier = Modifier.weight(3f), - shape = RoundedCornerShape(20.dp), - enabled = !isAuthor && !hasApplied, - colors = - ButtonDefaults.buttonColors( - containerColor = Color(0xFF4285F4), - contentColor = Color.White, - ), - ) { - Text(text = leftButtonText) - } - - // 오른쪽 버튼 - if (type == "모집글") { - Button( - onClick = onRightButtonClick, + if (type == "결과") { + Text( + text = stringResource(id = R.string.application_check_result), + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = Color.Gray, modifier = - Modifier - .weight(2f) - .scale(scale.value), - shape = RoundedCornerShape(20.dp), - enabled = !isAuthor, - colors = - ButtonDefaults.buttonColors( - containerColor = buttonColor, - contentColor = if (isLiked == true) Color.White else Color.Gray, - ), - ) { - Text(text = rightButtonText) - } + if (type == "결과") { + Modifier + .fillMaxWidth() + .padding(12.dp) + } else { + Modifier.padding(12.dp) + }, + textAlign = if (type == "결과") TextAlign.Center else TextAlign.Start, + ) } else { + // 왼쪽 버튼 Button( - onClick = onRightButtonClick, - modifier = Modifier.weight(2f), + onClick = onLeftButtonClick, + modifier = Modifier.weight(3f), shape = RoundedCornerShape(20.dp), + enabled = !isAuthor && !hasApplied && !hasStatus, colors = ButtonDefaults.buttonColors( - containerColor = Color(0xFFEBEFFA), - contentColor = Color.Gray, + containerColor = Color(0xFF4285F4), + contentColor = Color.White, ), ) { - Text(text = rightButtonText) + Text(text = leftButtonText) + } + // 오른쪽 버튼 + if (type == "모집글") { + Button( + onClick = onRightButtonClick, + modifier = + Modifier + .weight(2f) + .scale(scale.value), + shape = RoundedCornerShape(20.dp), + enabled = !isAuthor, + 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), + enabled = !isAuthor && !hasStatus, + colors = + ButtonDefaults.buttonColors( + containerColor = Color(0xFFEBEFFA), + contentColor = Color.Gray, + ), + ) { + Text(text = rightButtonText) + } } } } 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 index 5be5ba5..38231c2 100644 --- a/app/src/main/java/com/example/chaining/ui/component/FeedItem.kt +++ b/app/src/main/java/com/example/chaining/ui/component/FeedItem.kt @@ -1,6 +1,7 @@ package com.example.chaining.ui.component import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -30,9 +31,10 @@ fun FeedItem( place: String, address: String, imageUrl: String, + onClick: () -> Unit, ) { Card( - modifier = modifier.fillMaxWidth(), + modifier = modifier.fillMaxWidth().clickable(onClick = onClick), shape = RoundedCornerShape(16.dp), colors = CardDefaults.cardColors( 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 index 17eac90..4750d07 100644 --- a/app/src/main/java/com/example/chaining/ui/component/FormatRemainingTime.kt +++ b/app/src/main/java/com/example/chaining/ui/component/FormatRemainingTime.kt @@ -13,10 +13,10 @@ fun formatRemainingTime( 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 totalSeconds = remainingMillis / 1000 + val days = (totalSeconds / (24 * 3600)).toInt() + val hours = ((totalSeconds % (24 * 3600)) / 3600).toInt() + val minutes = ((totalSeconds % 3600) / 60).toInt() val resources = context.resources val parts = mutableListOf() 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 index 4f7ad7d..047a939 100644 --- a/app/src/main/java/com/example/chaining/ui/component/SaveButton.kt +++ b/app/src/main/java/com/example/chaining/ui/component/SaveButton.kt @@ -18,9 +18,11 @@ fun SaveButton( onSave: () -> Unit, text: String, modifier: Modifier = Modifier, + enable: Boolean? = true, ) { Button( onClick = onSave, + enabled = enable ?: true, modifier = modifier .fillMaxWidth() 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 index eb1105c..3d9c579 100644 --- a/app/src/main/java/com/example/chaining/ui/component/SplashAnimation.kt +++ b/app/src/main/java/com/example/chaining/ui/component/SplashAnimation.kt @@ -26,7 +26,7 @@ fun SplashAnimation(startAnimation: Boolean) { if (startAnimation) { launch { offsetY.animateTo( - targetValue = -100f, + targetValue = -50f, animationSpec = tween(durationMillis = 400, easing = FastOutSlowInEasing), ) } @@ -44,7 +44,7 @@ fun SplashAnimation(startAnimation: Boolean) { contentDescription = "Chain", modifier = Modifier - .size(70.dp) + .size(90.dp) .graphicsLayer { translationY = offsetY.value this.alpha = alpha.value 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 index 3798e44..37b4f29 100644 --- a/app/src/main/java/com/example/chaining/ui/navigation/NavGraph.kt +++ b/app/src/main/java/com/example/chaining/ui/navigation/NavGraph.kt @@ -396,13 +396,14 @@ fun NavGraph( composable(route = Screen.Notification.route) { NotificationScreen( - onViewApplyClick = { applicationId -> + onBackClick = { navController.navigate("mainHome") }, + onViewApplyClick = { applicationId, type, introduction, closeAt -> navController.navigate( Screen.Apply.createRoute( - type = "Owner", + type = type, applicationId = applicationId, - introduction = "", - closeAt = 0L, + introduction = introduction ?: "", + closeAt = closeAt, ), ) }, 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 index 4bc7b8f..f1bdbae 100644 --- a/app/src/main/java/com/example/chaining/ui/screen/ApplicationsScreen.kt +++ b/app/src/main/java/com/example/chaining/ui/screen/ApplicationsScreen.kt @@ -1,5 +1,6 @@ package com.example.chaining.ui.screen +import android.widget.Toast import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -28,6 +29,7 @@ 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 @@ -37,6 +39,7 @@ 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.ui.component.formatRemainingTime import com.example.chaining.viewmodel.ApplicationViewModel import com.example.chaining.viewmodel.RecruitPostViewModel import com.example.chaining.viewmodel.UserViewModel @@ -51,11 +54,12 @@ fun ApplicationsScreen( postId: String?, // "My" or "Owner" type: String, - onViewApplyClick: (String) -> Unit, + onViewApplyClick: (applicationId: String) -> Unit, ) { val userState by userViewModel.user.collectAsState() val myApplications = userState?.applications.orEmpty() val post by postViewModel.post.collectAsState() + val context = LocalContext.current val ownerApplications: Map = if (type == "Owner") { @@ -166,11 +170,20 @@ fun ApplicationsScreen( } else { // 모집글 목록 표시 filteredApplications.forEach { application -> + val hasStatus = + application.status != "PENDING" CardItem( + hasStatus = hasStatus, + remainingTime = + formatRemainingTime( + context, + application.closeAt.minus(System.currentTimeMillis()), + ), onClick = { onViewApplyClick(application.applicationId) }, type = "지원서", + currentUserId = userState?.id, application = application, onLeftButtonClick = { application.let { apply -> @@ -178,14 +191,24 @@ fun ApplicationsScreen( application = apply, value = "APPROVED", ) + Toast.makeText( + context, + context.getString(R.string.toast_approved), + Toast.LENGTH_SHORT, + ).show() } }, onRightButtonClick = { application.let { apply -> applicationViewModel.updateStatus( application = apply, - value = "APPROVED", + value = "REJECTED", ) + Toast.makeText( + context, + context.getString(R.string.toast_rejected), + Toast.LENGTH_SHORT, + ).show() } }, ) 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 index 0a0d945..c64c5e5 100644 --- a/app/src/main/java/com/example/chaining/ui/screen/ApplyScreen.kt +++ b/app/src/main/java/com/example/chaining/ui/screen/ApplyScreen.kt @@ -3,6 +3,7 @@ package com.example.chaining.ui.screen import android.content.Intent import android.net.Uri import android.widget.Toast +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -83,8 +84,11 @@ fun ApplyScreen( // 1. applicationId가 변경되면 application 정보를 가져오는 Effect LaunchedEffect(key1 = applicationId) { - if (type != "My" && applicationId != null) { - applicationViewModel.fetchApplication(applicationId) + applicationId?.let { id -> + val fetchedApplication = applicationViewModel.fetchApplication(id) + if (fetchedApplication != null) { + applicationViewModel.setApplication(fetchedApplication) + } } } @@ -100,6 +104,10 @@ fun ApplyScreen( userViewModel.toastEvent.collectLatest { message -> Toast.makeText(context, message, Toast.LENGTH_SHORT).show() } + + applicationViewModel.toastEvent.collectLatest { message -> + Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + } } // post가 null이면 로딩 UI 표시 @@ -178,13 +186,14 @@ fun ApplyScreen( horizontalAlignment = Alignment.CenterHorizontally, ) { Text( - text = "수락/거절까지", + stringResource(id = R.string.accept_or_reject), color = Color.White.copy(alpha = 0.8f), fontSize = 14.sp, ) - val remainingMillis = closeAt - System.currentTimeMillis() + val remainingMillis = closeAt.minus(System.currentTimeMillis()) + val remainingTimeText = formatRemainingTime(context, remainingMillis) Text( - text = "${formatRemainingTime(context, remainingMillis)} 남음", + text = stringResource(id = R.string.time_left, remainingTimeText), color = Color.White, fontSize = 20.sp, fontWeight = FontWeight.Bold, @@ -249,14 +258,14 @@ fun ApplyScreen( if (korean != null) { "${korean.language} 수준 : ${korean.level} / 10" } else { - "알 수 없음" + stringResource(id = R.string.unknown) } } else { val korean = userState?.preferredLanguages?.get("KOREAN") if (korean != null) { "${korean.language} 수준 : ${korean.level} / 10" } else { - "알 수 없음" + stringResource(id = R.string.unknown) } }, // text = stringResource(id = R.string.community_unknown), @@ -271,14 +280,14 @@ fun ApplyScreen( if (english != null) { "${english.language} 수준 : ${english.level} / 10" } else { - "알 수 없음" + stringResource(id = R.string.unknown) } } else { val english = userState?.preferredLanguages?.get("ENGLISH") if (english != null) { "${english.language} 수준 : ${english.level} / 10" } else { - "알 수 없음" + stringResource(id = R.string.unknown) } }, // text = stringResource(id = R.string.community_unknown), @@ -299,9 +308,10 @@ fun ApplyScreen( color = Color(0xFF7282B4), ) Spacer(modifier = Modifier.height(4.dp)) + Text( text = - if (type == "Owner") { + if (type == "Owner" || (type == "My" && applicationId != null)) { application?.introduction ?: stringResource(id = R.string.community_unknown) } else { @@ -315,6 +325,9 @@ fun ApplyScreen( Spacer(modifier = Modifier.height(100.dp)) if (type == "Owner") { + val hasStatus = + application?.status != "PENDING" + val isAuthor = application?.applicant?.id == userState?.id Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { // 수락 버튼 Button( @@ -326,6 +339,7 @@ fun ApplyScreen( ) } }, + enabled = !hasStatus && !isAuthor, modifier = Modifier .weight(1.5f) @@ -353,6 +367,7 @@ fun ApplyScreen( ) } }, + enabled = !hasStatus && !isAuthor, modifier = Modifier .weight(1f) @@ -371,32 +386,36 @@ fun ApplyScreen( } } } 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( - text = stringResource(id = R.string.myapply_filter_open), - fontSize = 16.sp, - ) + if (applicationId != null) { + // 결과 버튼 + 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( + text = stringResource(id = R.string.myapply_filter_open), + fontSize = 16.sp, + ) + } + } else { + // 비어있음 } } @@ -412,9 +431,9 @@ fun ApplyScreen( Text( text = when (application?.status) { - "APPROVED" -> "축하합니다! 🎉" - "REJECTED" -> "아쉽지만 다음 기회에!" - else -> "결과 대기 중" + "APPROVED" -> stringResource(id = R.string.result_approved_title) + "REJECTED" -> stringResource(id = R.string.result_rejected_title) + else -> stringResource(id = R.string.result_pending_title) }, fontWeight = FontWeight.Bold, ) @@ -423,9 +442,9 @@ fun ApplyScreen( Text( text = when (application?.status) { - "APPROVED" -> "지원하신 모집에 합격하셨습니다.\n카카오 오픈채팅으로 바로 이동할 수 있어요." - "REJECTED" -> "아쉽게도 이번에는 합격하지 못했어요.\n다른 멋진 모집글을 찾아보세요!" - else -> "결과가 아직 나오지 않았습니다." + "APPROVED" -> stringResource(id = R.string.result_approved_message) + "REJECTED" -> stringResource(id = R.string.result_rejected_message) + else -> stringResource(id = R.string.result_pending_message) }, ) }, @@ -436,8 +455,6 @@ fun ApplyScreen( onClick = { showResultDialog = false val chatUrl = post?.kakaoOpenChatUrl - println("포포포" + post) - println("포포URL" + chatUrl) if (!chatUrl.isNullOrEmpty()) { val intent = Intent(Intent.ACTION_VIEW, Uri.parse(chatUrl)) @@ -445,13 +462,13 @@ fun ApplyScreen( } else { Toast.makeText( context, - "카카오 오픈채팅 URL이 존재하지 않습니다.", + context.getString(R.string.kakao_chat_url_not_exist), Toast.LENGTH_SHORT, ).show() } }, ) { - Text("카카오톡 오픈채팅으로 이동") + Text(stringResource(id = R.string.move_to_kakao_chat)) } } @@ -462,13 +479,13 @@ fun ApplyScreen( onNavigateHome() }, ) { - Text("다른 모집글 보러가기") + Text(stringResource(id = R.string.go_to_other_posts)) } } else -> { TextButton(onClick = { showResultDialog = false }) { - Text("닫기") + Text(stringResource(id = R.string.close)) } } } @@ -495,33 +512,43 @@ fun ApplyScreen( .background(Color.White) .border(3.dp, Color.White, RoundedCornerShape(20.dp)), ) { - // 프로필 사진 - AsyncImage( - model = - if (type == "Owner") { - application?.applicant?.profileImageUrl - ?: "" - } else { - userState?.profileImageUrl ?: "" - }, - placeholder = painterResource(id = R.drawable.chain), - error = painterResource(id = R.drawable.chain), - contentDescription = "프로필 사진", - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize(), - ) + val imageUrl = + if (type == "Owner") { + application?.applicant?.profileImageUrl ?: "" + } else { + userState?.profileImageUrl ?: "" + } + + if (imageUrl.isBlank()) { + Image( + painter = painterResource(id = R.drawable.test_profile), + contentDescription = "기본 프로필", + modifier = Modifier.size(48.dp), + ) + } else { + AsyncImage( + model = imageUrl, + contentDescription = "프로필 사진", + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + error = painterResource(id = R.drawable.test_profile), + ) + } } Spacer(modifier = Modifier.width(20.dp)) // 친구 추가 아이콘 if (type == "Owner") { + val isFollowing = + userState?.following?.contains(application?.applicant?.id) == true + Box( modifier = Modifier .size(60.dp) .clip(CircleShape) - .background(Color(0xFF3ECDFF)) + .background(if (isFollowing) Color.Gray else Color(0xFF3ECDFF)) .border(3.dp, Color.White, CircleShape) .padding(4.dp) .clickable { @@ -544,17 +571,28 @@ fun ApplyScreen( country = currentApplication.applicant.country, ), ) + + val toastText = + if (isFollowing) { + context.getString(R.string.toast_unfollowed) + } else { + context.getString(R.string.toast_followed) + } + Toast + .makeText(context, toastText, Toast.LENGTH_SHORT) + .show() } }, contentAlignment = Alignment.Center, ) { Icon( - painter = painterResource(id = R.drawable.follow), - contentDescription = "친구 추가", + painter = + painterResource( + id = if (isFollowing) R.drawable.un_follow else R.drawable.follow, + ), + contentDescription = if (isFollowing) "팔로우 취소" else "팔로우", tint = Color.White, - modifier = - Modifier - .size(16.dp), + modifier = Modifier.size(16.dp), ) } } 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 index 6323393..0e9ab78 100644 --- a/app/src/main/java/com/example/chaining/ui/screen/CommunityScreen.kt +++ b/app/src/main/java/com/example/chaining/ui/screen/CommunityScreen.kt @@ -164,7 +164,7 @@ fun CommunityScreen( modifier = Modifier .fillMaxSize() - .padding(top = innerPadding.calculateTopPadding()) // TopBar 아래 여백 + .padding(top = innerPadding.calculateTopPadding()) .padding(bottom = 0.dp) .padding(horizontal = horizontalPaddingValue) .verticalScroll(rememberScrollState()), @@ -191,7 +191,6 @@ fun CommunityScreen( onClick = { postViewModel.refreshPosts() }, ) } - Log.d("hhhh", posts.toString()) if (posts.isEmpty()) { // 데이터가 없을 때 Text( @@ -208,6 +207,7 @@ fun CommunityScreen( val isLiked = userState?.likedPosts?.get(post.postId) == true val hasApplied = userState?.applications?.values?.any { it.postId == post.postId } == true + Log.d("CardItemCheck", "게시글 ID: ${post.postId}, 작성자 정보: ${post.owner}") CardItem( onClick = { onViewPostClick(post.postId) }, type = "모집글", 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 index 47ba757..f7f25b3 100644 --- a/app/src/main/java/com/example/chaining/ui/screen/CreatePostScreen.kt +++ b/app/src/main/java/com/example/chaining/ui/screen/CreatePostScreen.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBars @@ -182,7 +183,10 @@ fun CreatePostScreen( } Scaffold( - contentWindowInsets = WindowInsets.systemBars, + modifier = + Modifier + .fillMaxSize() + .imePadding(), topBar = { Row( modifier = @@ -231,7 +235,6 @@ fun CreatePostScreen( // 스크롤 가능하게 만듦 .padding(top = innerPadding.calculateTopPadding()) .padding(bottom = 0.dp) - .padding(horizontal = horizontalPaddingValue) .verticalScroll(rememberScrollState()) .pointerInput(Unit) { detectTapGestures(onTap = { @@ -240,172 +243,181 @@ fun CreatePostScreen( }) }, ) { - Spacer(modifier = Modifier.height(16.dp)) - - OutlinedTextField( - value = title, - onValueChange = { if (it.length <= MAX_TITLE_LENGTH) title = it }, + Column( 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), + .weight(1f) // 남는 공간을 모두 차지 + .padding(horizontal = 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, + ) + }, ) - 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)) + Spacer(modifier = Modifier.height(20.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 = + // 여행지 스타일 드롭다운 + val travelStyles = 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), + 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), ) - }, - 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)) + 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)) - // 내용 입력창 - 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, - ), - ) + // 여행 지역 드롭다운 + 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)) + Spacer(modifier = Modifier.height(20.dp)) + } SaveButton( + modifier = Modifier.padding(horizontal = 16.dp), onSave = { val missingFields = mutableListOf() if (title.isBlank()) missingFields.add(fieldTitleText) 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 index 37d2363..5c32476 100644 --- a/app/src/main/java/com/example/chaining/ui/screen/ENQuizScreen.kt +++ b/app/src/main/java/com/example/chaining/ui/screen/ENQuizScreen.kt @@ -42,6 +42,7 @@ 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.domain.model.WordChip import com.example.chaining.viewmodel.QuizViewModel @Suppress("FunctionName") @@ -223,10 +224,10 @@ fun QuizProgressIndicator( @OptIn(ExperimentalLayoutApi::class) @Composable fun SentenceOrderAnswerArea( - remainingWords: List, - userAnswer: List, - onWordChipClicked: (String) -> Unit, - onAnswerWordClicked: (String) -> Unit, + remainingWords: List, + userAnswer: List, + onWordChipClicked: (WordChip) -> Unit, + onAnswerWordClicked: (WordChip) -> Unit, ) { Column( modifier = Modifier.fillMaxWidth(), @@ -249,18 +250,20 @@ fun SentenceOrderAnswerArea( 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) + userAnswer.forEach { chip -> + key(chip.id) { + Button( + // 클릭 시 선택 해제 + onClick = { onAnswerWordClicked(chip) }, + shape = CircleShape, + colors = + ButtonDefaults.buttonColors( + containerColor = Color(0xFF4285F4), + contentColor = Color.White, + ), + ) { + Text(text = chip.text) + } } } } @@ -273,10 +276,10 @@ fun SentenceOrderAnswerArea( horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - remainingWords.forEach { word -> - key(word) { + remainingWords.forEach { chip -> + key(chip.id) { Button( - onClick = { onWordChipClicked(word) }, + onClick = { onWordChipClicked(chip) }, shape = CircleShape, colors = ButtonDefaults.buttonColors( @@ -284,7 +287,7 @@ fun SentenceOrderAnswerArea( contentColor = Color.Black, ), ) { - Text(text = word) + Text(text = chip.text) } } } 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 index 980384f..375deab 100644 --- a/app/src/main/java/com/example/chaining/ui/screen/FeedScreen.kt +++ b/app/src/main/java/com/example/chaining/ui/screen/FeedScreen.kt @@ -1,5 +1,6 @@ package com.example.chaining.ui.screen +import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -27,8 +28,11 @@ 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.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -49,6 +53,8 @@ fun FeedScreen( ) { // ViewModel의 randomizedFeedItems 상태를 구독하여 UI에 자동 반영 val feedItems by feedViewModel.randomizedFeedItems.collectAsState() + val clipboardManager = LocalClipboardManager.current + val context = LocalContext.current // 화면이 처음 로드될 때 API를 통해 관광 정보를 가져옵니다. LaunchedEffect(Unit) { @@ -137,6 +143,10 @@ fun FeedScreen( imageUrl = item.imageUrl ?: "https://your-placeholder-image-url.com/default.jpg", + onClick = { + clipboardManager.setText(AnnotatedString(item.address)) + Toast.makeText(context, context.getString(R.string.feed_copy_address), Toast.LENGTH_SHORT).show() + }, ) } } 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 index bbb8cb1..cfb647e 100644 --- a/app/src/main/java/com/example/chaining/ui/screen/JoinPostScreen.kt +++ b/app/src/main/java/com/example/chaining/ui/screen/JoinPostScreen.kt @@ -289,6 +289,7 @@ fun JoinPostScreen( recruitPostTitle = post.title, introduction = introduction, applicant = it, + closeAt = post.closeAt, ) } if (newApplication != null) { 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 index 779eae0..c290cf1 100644 --- a/app/src/main/java/com/example/chaining/ui/screen/MainHomeScreen.kt +++ b/app/src/main/java/com/example/chaining/ui/screen/MainHomeScreen.kt @@ -4,7 +4,6 @@ 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 @@ -19,7 +18,6 @@ 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 @@ -98,7 +96,11 @@ fun MainHomeScreen( // 2초 안에 두 번 누르면 앱 종료 (context as? android.app.Activity)?.finish() } else { - Toast.makeText(context, "한 번 더 누르면 종료됩니다.", Toast.LENGTH_SHORT).show() + Toast.makeText( + context, + context.getString(R.string.toast_exit_again), + Toast.LENGTH_SHORT, + ).show() } backPressedTime = System.currentTimeMillis() } @@ -350,18 +352,18 @@ fun ProfileImageWithStatus( ) // 온라인 상태를 표시하는 점 - 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), - ) - } +// 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 index a8238f1..3e3ac8c 100644 --- a/app/src/main/java/com/example/chaining/ui/screen/MyPageScreen.kt +++ b/app/src/main/java/com/example/chaining/ui/screen/MyPageScreen.kt @@ -165,10 +165,7 @@ fun MyPageScreen( ProfileSection( nickname = nickname, onNicknameChanged = { newNickname -> - nickname = newNickname - userState?.let { currentUser -> - userViewModel.updateMyUser(currentUser.copy(nickname = newNickname)) - } + userViewModel.updateNickname(newNickname = newNickname) }, profileImageUrl = userState?.profileImageUrl, userViewModel = userViewModel, @@ -361,6 +358,7 @@ fun ProfileSection( userViewModel: UserViewModel, ) { val context = LocalContext.current + val userState by userViewModel.user.collectAsState() var showDialog by remember { mutableStateOf(false) } var tempNickname by remember { mutableStateOf(nickname) } @@ -390,6 +388,12 @@ fun ProfileSection( } val storageRef = Firebase.storage.reference.child("profileImages/$uid.jpg") + Toast.makeText( + context, + context.getString((R.string.mypage_profile_image_upload)), + Toast.LENGTH_SHORT, + ).show() + storageRef.putFile(uri) .addOnSuccessListener { storageRef.downloadUrl.addOnSuccessListener { downloadUrl -> @@ -469,7 +473,12 @@ fun ProfileSection( Spacer(modifier = Modifier.height(8.dp)) Text( - text = stringResource(id = R.string.mypage_follower_info, "203", "106"), + text = + stringResource( + id = R.string.mypage_follower_info, + userState?.follower?.count() ?: 0, + userState?.following?.count() ?: 0, + ), style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium), color = Color.Gray, ) 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 index 9ef917b..f36122a 100644 --- a/app/src/main/java/com/example/chaining/ui/screen/NotificationScreen.kt +++ b/app/src/main/java/com/example/chaining/ui/screen/NotificationScreen.kt @@ -1,6 +1,7 @@ package com.example.chaining.ui.notification import android.widget.Toast +import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -11,12 +12,16 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Tab @@ -29,12 +34,15 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf +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 @@ -42,6 +50,7 @@ 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.Notification import com.example.chaining.ui.component.CardItem import com.example.chaining.ui.component.FollowNotificationItem @@ -51,6 +60,7 @@ 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 com.example.chaining.viewmodel.UserViewModel import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -58,8 +68,14 @@ import java.util.Locale @Suppress("FunctionName") @Composable fun NotificationScreen( + onBackClick: () -> Unit = {}, viewModel: NotificationViewModel = hiltViewModel(), - onViewApplyClick: (String) -> Unit, + onViewApplyClick: ( + applicationId: String, + type: String, + introduction: String, + closeAt: Long, + ) -> Unit, ) { val notifications by viewModel.notifications.collectAsState() val isLoading by viewModel.isLoading.collectAsState() @@ -79,7 +95,12 @@ fun NotificationScreen( eventFlow.collect { event -> when (event) { is NotificationEvent.NavigateToApplication -> { - onViewApplyClick(event.applicationId) + onViewApplyClick( + event.applicationId, + event.type, + event.introduction, + event.closeAt, + ) } is NotificationEvent.ShowToast -> { @@ -97,10 +118,19 @@ fun NotificationScreen( val filteredNotifications = when (selectedTabIndex) { 0 -> notifications.filter { it.type.equals("follow", ignoreCase = true) } - 1 -> notifications.filter { it.type.equals("application", ignoreCase = true) } + 1 -> + notifications.filter { + it.type.equals("application", ignoreCase = true) || + it.type.equals("status_update", ignoreCase = true) + } + else -> emptyList() } + BackHandler(enabled = true) { + onBackClick() + } + Scaffold( containerColor = LightGrayBackground, topBar = { @@ -110,17 +140,40 @@ fun NotificationScreen( .fillMaxWidth() .background(LightGrayBackground), ) { - Text( - text = stringResource(id = R.string.alarm_title), - fontSize = 18.sp, - fontWeight = FontWeight.Bold, + // 상단바 + Row( modifier = Modifier .fillMaxWidth() - .padding(16.dp), - textAlign = TextAlign.Center, - ) + .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.alarm_title), + modifier = Modifier.weight(1f), + color = Color.White, + fontSize = 20.sp, + textAlign = TextAlign.Center, + ) + + // 오른쪽에 비워둠 (필요시 버튼 추가 가능) + Spacer(modifier = Modifier.width(48.dp)) + } + + // 탭 TabRow( selectedTabIndex = selectedTabIndex, containerColor = LightGrayBackground, @@ -150,7 +203,7 @@ fun NotificationScreen( modifier = Modifier .fillMaxSize() - .padding(innerPadding) + .padding(top = innerPadding.calculateTopPadding()) .background(LightGrayBackground), ) { when { @@ -201,8 +254,10 @@ fun NotificationItem( notification: Notification, viewModel: NotificationViewModel = hiltViewModel(), applicationViewModel: ApplicationViewModel = hiltViewModel(), + userViewModel: UserViewModel = hiltViewModel(), ) { val context = LocalContext.current + val userState by userViewModel.user.collectAsState() val formattedDate = remember(notification.createdAt) { val date = Date(notification.createdAt) @@ -222,26 +277,39 @@ fun NotificationItem( "application" -> { // Application 데이터를 StateFlow로 구독 - val application by applicationViewModel.application.collectAsState() + val applicationState = remember { mutableStateOf(null) } // notification.applicationId로 데이터 로드 LaunchedEffect(notification.applicationId) { - notification.applicationId?.let { applicationViewModel.fetchApplication(it) } + notification.applicationId?.let { id -> + applicationState.value = applicationViewModel.fetchApplication(id) + } } + val application = applicationState.value + + val hasStatus = + application?.status != "PENDING" CardItem( onClick = { - notification.applicationId?.let { id -> - viewModel.onApplicationClick(id) + if (application != null) { + viewModel.onApplicationClick( + applicationId = application.applicationId, + screenType = "Owner", + introduction = application.introduction, + closeAt = application.closeAt, + ) } }, + hasStatus = hasStatus, type = "지원서", // Notification -> Application 매핑 필요 application = application, + currentUserId = userState?.id, remainingTime = formatRemainingTime( context, - notification.closeAt?.minus(System.currentTimeMillis()) ?: 0L, + application?.closeAt?.minus(System.currentTimeMillis()) ?: 0L, ), onLeftButtonClick = { application?.let { apply -> @@ -249,6 +317,11 @@ fun NotificationItem( application = apply, value = "APPROVED", ) + Toast.makeText( + context, + context.getString(R.string.toast_approved), + Toast.LENGTH_SHORT, + ).show() } }, onRightButtonClick = { @@ -257,11 +330,55 @@ fun NotificationItem( application = apply, value = "REJECTED", ) + Toast.makeText( + context, + context.getString(R.string.toast_rejected), + Toast.LENGTH_SHORT, + ).show() } }, ) } + "status_update" -> { + // status_update 알림은 결과 확인용으로 표시 + val applicationState = remember { mutableStateOf(null) } + + LaunchedEffect(notification.applicationId) { + notification.applicationId?.let { id -> + val app = applicationViewModel.fetchApplication(id) + applicationState.value = app + } + } + + val application = applicationState.value + CardItem( + onClick = { +// notification.applicationId?.let { id -> +// viewModel.onApplicationClick(id) +// } + if (application != null && userState != null) { + val screenType = + if (application.applicant.id == userState?.id) { + "My" + } else { + "Owner" + } + + viewModel.onApplicationClick( + application.applicationId, + screenType, + application.introduction, + application.closeAt, + ) + } + }, + type = "결과", + currentUserId = userState?.id, + application = application, + ) + } + else -> { // 기타 알림 처리 Card( 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 index 697ca92..60366c7 100644 --- a/app/src/main/java/com/example/chaining/ui/screen/ViewPostScreen.kt +++ b/app/src/main/java/com/example/chaining/ui/screen/ViewPostScreen.kt @@ -61,6 +61,9 @@ fun ViewPostScreen( val post by postViewModel.post.collectAsState() val currentPost = post + val hasApplied = + userState?.applications?.values?.any { it.postId == currentPost?.postId } == true + // post가 null이면 로딩 UI 표시 if (currentPost == null) { Column( @@ -236,9 +239,16 @@ fun ViewPostScreen( // SaveButton(onSave = { /*TODO*/ }, text = "삭제") } else { + val buttonText = + if (hasApplied == true) { + stringResource(id = R.string.community_application_complete) + } else { + stringResource(id = R.string.community_apply_button) + } SaveButton( onSave = { onJoinPostClick(currentPost) }, - text = stringResource(id = R.string.post_button), + text = buttonText, + enable = !hasApplied, ) // SaveButton(onSave = { /*TODO*/ }, text = "숨김") } diff --git a/app/src/main/java/com/example/chaining/viewmodel/ApplicationViewModel.kt b/app/src/main/java/com/example/chaining/viewmodel/ApplicationViewModel.kt index fb6f730..0c8dbc6 100644 --- a/app/src/main/java/com/example/chaining/viewmodel/ApplicationViewModel.kt +++ b/app/src/main/java/com/example/chaining/viewmodel/ApplicationViewModel.kt @@ -50,6 +50,10 @@ class ApplicationViewModel } } + fun setApplication(app: Application) { + _application.value = app + } + fun submitApplication(application: Application) = viewModelScope.launch { val result = repo.submitApplication(application) @@ -59,6 +63,7 @@ class ApplicationViewModel application.copy( applicationId = returnedApplicationId, createdAt = System.currentTimeMillis(), + closeAt = application.closeAt, ) updatedList.add(newApplicationForUi) _applications.value = updatedList @@ -73,10 +78,14 @@ class ApplicationViewModel _isSubmitSuccess.value = false } - fun fetchApplication(applicationId: String) = - viewModelScope.launch { - _application.value = repo.getApplication(applicationId) - } +// fun fetchApplication(applicationId: String) = +// viewModelScope.launch { +// _application.value = repo.getApplication(applicationId) +// } + + suspend fun fetchApplication(applicationId: String): Application? { + return repo.getApplication(applicationId) + } /** Update - 전체 User 객체 저장 */ fun updateStatus( @@ -84,6 +93,14 @@ class ApplicationViewModel value: String, ) = viewModelScope.launch { repo.updateStatus(application, value) + + val message = + when (value) { + "APPROVED" -> "수락 처리 되었습니다" + "REJECTED" -> "거절 처리 되었습니다" + else -> "" + } + if (message.isNotEmpty()) _toastEvent.emit(message) } fun fetchAllApplications() = diff --git a/app/src/main/java/com/example/chaining/viewmodel/NotificationViewModel.kt b/app/src/main/java/com/example/chaining/viewmodel/NotificationViewModel.kt index daef6e9..18bcad9 100644 --- a/app/src/main/java/com/example/chaining/viewmodel/NotificationViewModel.kt +++ b/app/src/main/java/com/example/chaining/viewmodel/NotificationViewModel.kt @@ -38,9 +38,21 @@ class NotificationViewModel fetchNotifications() } - fun onApplicationClick(id: String) { + fun onApplicationClick( + applicationId: String, + screenType: String, + introduction: String?, + closeAt: Long?, + ) { viewModelScope.launch { - _event.emit(NotificationEvent.NavigateToApplication(id)) + _event.emit( + NotificationEvent.NavigateToApplication( + applicationId = applicationId, + type = screenType, + introduction = introduction ?: "", + closeAt = closeAt ?: 0L, + ), + ) } } @@ -78,7 +90,12 @@ class NotificationViewModel } sealed class NotificationEvent { - data class NavigateToApplication(val applicationId: String) : NotificationEvent() + data class NavigateToApplication( + val applicationId: String, + val type: String, + val introduction: String, + val closeAt: Long, + ) : 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 index 35f1d65..a57dd7a 100644 --- a/app/src/main/java/com/example/chaining/viewmodel/QuizViewModel.kt +++ b/app/src/main/java/com/example/chaining/viewmodel/QuizViewModel.kt @@ -3,6 +3,7 @@ package com.example.chaining.viewmodel import android.content.Context +import android.util.Log import androidx.compose.runtime.State import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.mutableIntStateOf @@ -12,6 +13,7 @@ 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.example.chaining.domain.model.WordChip import com.google.gson.Gson import com.google.gson.reflect.TypeToken import dagger.hilt.android.lifecycle.HiltViewModel @@ -52,17 +54,17 @@ class QuizViewModel // '순서 맞추기' 유형을 위한 '한 번만 섞인' 단어 목록 (상태로 관리) @Suppress("PropertyName") - private val _shuffledWordChips = mutableStateOf>(emptyList()) + private val _shuffledWordChips = mutableStateOf>(emptyList()) // '선택하고 남은 단어 칩'은 이제 _shuffledWordChips를 기준으로 계산 - val remainingWordChips: State> = + val remainingWordChips: State> = derivedStateOf { - _shuffledWordChips.value - _userAnswerSentence.value.toSet() + _shuffledWordChips.value - _userAnswerSentence.value } // 사용자가 구성한 정답 문장을 저장하는 State - private val _userAnswerSentence = mutableStateOf>(emptyList()) - val userAnswerSentence: State> = _userAnswerSentence + private val _userAnswerSentence = mutableStateOf>(emptyList()) + val userAnswerSentence: State> = _userAnswerSentence // '객관식' 유형을 위한 사용자 선택 답안 저장 State private val _selectedOption = mutableStateOf(null) @@ -124,7 +126,7 @@ class QuizViewModel // 1. Assets에서 파일 스트림 열기 val inputStream = context.assets.open(fileName) // 2. 텍스트 읽기 - val jsonString = inputStream.bufferedReader().use { it.readText() } + val jsonString = inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() } // 3. Gson을 사용해 JSON 텍스트를 List으로 변환 val listType = object : TypeToken>() {}.type allQuizzes = Gson().fromJson(jsonString, listType) @@ -162,20 +164,20 @@ class QuizViewModel private fun prepareSentenceOrderChips() { val quiz = currentQuestion.value if (quiz != null && quiz.type == QuizType.SENTENCE_ORDER.name) { - _shuffledWordChips.value = quiz.answer.split(" ").shuffled() + _shuffledWordChips.value = quiz.answer.split(" ").map { WordChip(text = it) }.shuffled() } else { _shuffledWordChips.value = emptyList() } } // 단어 칩을 클릭했을 때 호출될 함수 - fun onWordChipClicked(word: String) { - _userAnswerSentence.value = _userAnswerSentence.value + word + fun onWordChipClicked(chip: WordChip) { + _userAnswerSentence.value = _userAnswerSentence.value + chip } // '내가 만든 문장'의 단어를 클릭했을 때 (선택 해제) - fun onAnswerWordClicked(word: String) { - _userAnswerSentence.value = _userAnswerSentence.value - word + fun onAnswerWordClicked(chip: WordChip) { + _userAnswerSentence.value = _userAnswerSentence.value - chip } // '객관식' 선택지를 클릭했을 때 @@ -211,7 +213,7 @@ class QuizViewModel // 현재 문제 유형에 맞는 사용자 답변 가져오기 val userAnswer = when (quiz.type) { - QuizType.SENTENCE_ORDER.name -> _userAnswerSentence.value.joinToString(" ") + QuizType.SENTENCE_ORDER.name -> _userAnswerSentence.value.joinToString(" ") { it.text } QuizType.MULTIPLE_CHOICE.name -> _selectedOption.value QuizType.FILL_IN_THE_BLANK.name -> _selectedBlankWord.value else -> null @@ -251,12 +253,24 @@ class QuizViewModel private fun calculateScore() { var score = 0 + Log.d("QuizScoreCheck", "===== 채점 시작 =====") _quizSet.value.forEach { quizItem -> val userAnswer = _userAnswersMap.value[quizItem.id] + val correctAnswer = quizItem.answer + + Log.d("QuizScoreCheck", "문제 ID: ${quizItem.id} | 문제: ${quizItem.problem}") + Log.d("QuizScoreCheck", " -> 제출한 답: '$userAnswer'") + Log.d("QuizScoreCheck", " -> 실제 정답: '$correctAnswer'") + + val isCorrect = userAnswer?.trim() == correctAnswer.trim() if (userAnswer == quizItem.answer) { score += quizItem.level // 정답이면 레벨만큼 점수 추가 } + Log.d("QuizScoreCheck", " -> 채점 결과: ${if (isCorrect) "정답 (점수 +${quizItem.level})" else "오답"}") + Log.d("QuizScoreCheck", "---------------------------------") } + Log.d("QuizScoreCheck", "최종 점수: $score") + Log.d("QuizScoreCheck", "===== 채점 종료 =====") _totalScore.value = score _finalLevel.value = mapScoreToLevel(score) } diff --git a/app/src/main/java/com/example/chaining/viewmodel/UserViewModel.kt b/app/src/main/java/com/example/chaining/viewmodel/UserViewModel.kt index 7342436..a1d11e7 100644 --- a/app/src/main/java/com/example/chaining/viewmodel/UserViewModel.kt +++ b/app/src/main/java/com/example/chaining/viewmodel/UserViewModel.kt @@ -64,6 +64,17 @@ class UserViewModel } } + fun updateNickname(newNickname: String) { + viewModelScope.launch { + try { + repo.updateNickname(newNickname) + _user.value = _user.value?.copy(nickname = newNickname) + } catch (e: Exception) { + Log.e("UserViewModel", "닉네임 업데이트 실패", e) + } + } + } + /** Create - 최초 회원가입 시 User 등록 */ fun addUser( user: User, @@ -109,7 +120,6 @@ class UserViewModel other: UserSummary, ) = viewModelScope.launch { val result = repo.toggleFollow(user, other) - println("호시기 누름") result.onFailure { exception -> _toastEvent.emit(exception.message ?: "작업에 실패했습니다.") } diff --git a/app/src/main/res/drawable/test_profile.png b/app/src/main/res/drawable/test_profile.png index 62630c4..27f870d 100644 Binary files a/app/src/main/res/drawable/test_profile.png and b/app/src/main/res/drawable/test_profile.png differ diff --git a/app/src/main/res/drawable/un_follow.png b/app/src/main/res/drawable/un_follow.png new file mode 100644 index 0000000..3f03fff Binary files /dev/null and b/app/src/main/res/drawable/un_follow.png differ diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index e5d0dfd..2521fb0 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -182,6 +182,7 @@ Edit Nickname Please select an image under 2MB. Login required. + Uploading Images... Profile image has been changed. Image upload failed: %1$s Profile saved successfully. @@ -202,6 +203,9 @@ Show the Apply Results You haven\'t created any applications yet. + You are now following %1$s. + You unfollowed %1$s. + You must agree to the terms to use the service. Agree and Start Terms of Service @@ -210,6 +214,8 @@ [Required] Agree to Privacy Policy Failed to cancel membership. + Address Copied. + Article 1 (Purpose)\n @@ -236,4 +242,25 @@ 3. Period of Retention and Use of Personal Information\n As a general rule, the user\'s personal information shall be destroyed without delay once the purposes of its collection and use have been achieved. + + Until accept/reject + %1$s left + Unknown + Congratulations! 🎉 + Better luck next time! + Waiting for result + You have been accepted!\nYou can move to Kakao Open Chat directly. + Unfortunately, you did not pass this time.\nCheck out other great posts! + Go to Kakao Open Chat + See other posts + Close + Result is not available yet. + Kakao Open Chat URL does not exist. + Accepted successfully + Rejected successfully + Press again to exit + Followed + Unfollowed + Your application result is now available. + Check your result! \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a6c14a0..76597ab 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -179,6 +179,7 @@ 닉네임 수정 2MB 이하 이미지를 선택해주세요. 로그인이 필요합니다. + 이미지 업로드 중... 프로필 이미지가 변경되었습니다. 이미지 업로드 실패: %1$s 프로필이 저장되었습니다. @@ -199,6 +200,8 @@ 지원서 결과 보기 등록된 지원서가 없습니다. + %1$s님을 팔로우합니다. + %1$s님 팔로우를 취소했습니다. 서비스 이용을 위해 약관 동의가 필요합니다. 동의하고 시작하기 @@ -208,6 +211,7 @@ [필수] 개인정보 처리방침 동의 회원가입 취소에 실패했습니다. + 주소가 복사되었습니다. @@ -240,4 +244,25 @@ 3. 개인정보의 보유 및 이용기간 이용자의 개인정보는 원칙적으로 개인정보의 수집 및 이용목적이 달성되면 지체 없이 파기합니다. + + 수락/거절까지 + %1$s 남음 + 알 수 없음 + 축하합니다! 🎉 + 아쉽지만 다음 기회에! + 결과 대기 중 + 지원하신 모집에 합격하셨습니다.\n카카오 오픈채팅으로 바로 이동할 수 있어요. + 아쉽게도 이번에는 합격하지 못했어요.\n다른 멋진 모집글을 찾아보세요! + 카카오톡 오픈채팅으로 이동 + 다른 모집글 보러가기 + 닫기 + 결과가 아직 나오지 않았습니다. + 카카오 오픈채팅 URL이 존재하지 않습니다. + 수락 처리 되었습니다 + 거절 처리 되었습니다 + 한 번 더 누르면 종료됩니다. + 팔로우 완료 + 팔로우 취소 + 지원자님의 지원 결과가 나왔습니다. + 결과를 확인하세요! \ No newline at end of file