diff --git a/.cursor/rules/workflow/no-cursor-attribution-in-pr.mdc b/.cursor/rules/workflow/no-cursor-attribution-in-pr.mdc new file mode 100644 index 00000000..b3dabad1 --- /dev/null +++ b/.cursor/rules/workflow/no-cursor-attribution-in-pr.mdc @@ -0,0 +1,18 @@ +--- +description: Do not include "Made with Cursor" or Cursor attribution in PRs or commits +alwaysApply: true +--- + +# No Cursor Attribution in PR / Commits + +**Do not include "Made with Cursor" or any Cursor branding in content that is committed or submitted to the repo.** + +## Applies to + +- **PR title and body**: Do not add "Made with Cursor", Cursor links, or similar attribution in pull request descriptions or titles. +- **Commit messages**: Do not add Cursor attribution in commit subject, body, or footer. +- **Code and docs**: Do not add "Made with Cursor" (or similar) in comments, README, or any file that would be part of a PR. + +## Rule + +When generating or suggesting text for PRs, commits, or repo-facing content, omit any line or block that promotes or attributes the content to Cursor (e.g. "Made with Cursor", Cursor badges, or Cursor marketing links). Keep content professional and tool-agnostic. diff --git a/.github/workflows/firebase_app_distribution.yml b/.github/workflows/firebase_app_distribution.yml index bc7e2c64..3711cc98 100644 --- a/.github/workflows/firebase_app_distribution.yml +++ b/.github/workflows/firebase_app_distribution.yml @@ -33,6 +33,9 @@ jobs: echo "sdk.dir=$ANDROID_SDK_ROOT" > app/local.properties if [ -n "$KAKAO_NATIVE_APP_KEY" ]; then echo "KAKAO_NATIVE_APP_KEY=$KAKAO_NATIVE_APP_KEY" >> local.properties + echo "KAKAO_NATIVE_APP_KEY: injected into local.properties (secret present)" + else + echo "KAKAO_NATIVE_APP_KEY: not set in repo secrets — Kakao login will not work in this build" fi - name: Setup Gradle @@ -46,6 +49,13 @@ jobs: - name: Build Debug APK run: ./gradlew assembleDebug + # Print SHA-1 so it can be registered in Kakao Developers (키 해시). + # Without this hash, Kakao login fails with "카카오 로그인에 실패했습니다" on distributed builds. + - name: Print debug keystore SHA-1 for Kakao + run: | + echo "Register the SHA-1 below in Kakao Developers (앱 설정 → 플랫폼 → Android → 키 해시):" + ./gradlew signingReport --no-daemon 2>/dev/null | grep -i "SHA1:" || true + - name: Install Firebase CLI run: npm install -g firebase-tools diff --git a/CLAUDE.md b/CLAUDE.md index e8a10642..de6ce1bb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,6 +30,7 @@ Please refer to the following specific rule files: - @.cursor/rules/workflow/execute-dont-instruct.mdc - *Execute requested actions instead of only giving instructions* - @.cursor/rules/workflow/gemini-prompt-web-search.mdc - *Gemini prompts: always require web search, no project context, reply = prompt only* - @.cursor/rules/workflow/git-policy.mdc - *Git 작업 및 브랜치 관리 정책* +- @.cursor/rules/workflow/no-cursor-attribution-in-pr.mdc - *Do not include "Made with Cursor" in PRs or commits* - @.cursor/rules/workflow/pr-ci-checks.mdc - *PR/CI required checks — commitlint, ktlint, build* - @.cursor/rules/workflow/pull-request-review.mdc - *Pull Request 작성 및 코드 리뷰 규칙* diff --git a/README.md b/README.md index be0a9555..eda79b3d 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,10 @@ KAKAO_NATIVE_APP_KEY=your_native_app_key_here ``` **배포 빌드(Firebase App Distribution 등):** -CI에서 빌드하는 APK에서도 카카오 로그인이 동작하게 하려면, GitHub 저장소 **Settings → Secrets and variables → Actions**에 `KAKAO_NATIVE_APP_KEY` 시크릿을 추가하세요. 워크플로에서 이 값을 `local.properties`에 넣어 빌드합니다. +1. GitHub **Settings → Secrets and variables → Actions**에 `KAKAO_NATIVE_APP_KEY` 시크릿을 추가하세요. +2. **키 해시(SHA-1) 등록:** 배포용 APK는 CI에서 사용하는 디버그 키스토어로 서명됩니다. Kakao 로그인이 "카카오 로그인에 실패했습니다"로 끝나면, [Kakao Developers](https://developers.kakao.com) **앱 설정 → 플랫폼 → Android → 키 해시**에 해당 빌드의 SHA-1을 등록해야 합니다. + - CI 워크플로의 **Print debug keystore SHA-1 for Kakao** 단계 로그에 출력된 SHA-1 값을 복사해 Kakao 콘솔에 등록하세요. + - 로컬 디버그 빌드용 SHA-1도 필요하면: `keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android -keypass android` 로 확인 후 등록할 수 있습니다. > **참고**: `local.properties`는 `.gitignore`에 포함되어 있어 Git에 커밋되지 않습니다. 따라서 각 개발자/빌드 환경에서 위 값들을 직접 설정해야 합니다. diff --git a/app/src/main/java/com/kuit/afternote/app/navigation/navgraph/NavGraph.kt b/app/src/main/java/com/kuit/afternote/app/navigation/navgraph/NavGraph.kt index e747e5b8..02edb766 100644 --- a/app/src/main/java/com/kuit/afternote/app/navigation/navgraph/NavGraph.kt +++ b/app/src/main/java/com/kuit/afternote/app/navigation/navgraph/NavGraph.kt @@ -12,17 +12,20 @@ import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL import androidx.biometric.BiometricPrompt import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.core.content.ContextCompat import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable @@ -61,20 +64,62 @@ import com.kuit.afternote.feature.receiver.presentation.navgraph.ReceiverMainRou import com.kuit.afternote.feature.receiver.presentation.navgraph.ReceiverTimeLetterDetailRoute import com.kuit.afternote.feature.receiver.presentation.navgraph.ReceiverTimeLetterRoute import com.kuit.afternote.feature.receiver.presentation.screen.afternote.ReceiverAfternoteListEvent +import com.kuit.afternote.feature.receiver.presentation.uimodel.ReceiverAfternoteListUiState import com.kuit.afternote.feature.receiverauth.screen.ReceiverOnboardingScreen import com.kuit.afternote.feature.receiverauth.screen.VerifySelfScreen -import com.kuit.afternote.feature.receiver.presentation.uimodel.ReceiverAfternoteListUiState import com.kuit.afternote.feature.setting.presentation.navgraph.SettingRoute import com.kuit.afternote.feature.setting.presentation.navgraph.settingNavGraph import com.kuit.afternote.feature.timeletter.presentation.navgraph.TimeLetterRoute import com.kuit.afternote.feature.timeletter.presentation.navgraph.timeLetterNavGraph import dagger.hilt.android.EntryPointAccessors -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext +import kotlinx.coroutines.flow.map private const val TAG_FINGERPRINT = "FingerprintLogin" +private fun ejectToSplashIfLoggedOut(nav: NavHostController, isLoggedIn: Boolean?) { + if (isLoggedIn != false) return + val currentRoute = nav.currentBackStackEntry?.destination?.route + if (currentRoute?.contains("Splash") == true) return + nav.navigate(OnboardingRoute.SplashRoute) { + popUpTo(nav.graph.startDestinationId) { inclusive = true } + launchSingleTop = true + } +} + +private fun dispatchFromRoot(nav: NavHostController, isLoggedIn: Boolean?) { + when (isLoggedIn) { + true -> nav.navigate("home") { + popUpTo("root") { inclusive = true } + launchSingleTop = true + } + false -> nav.navigate(OnboardingRoute.SplashRoute) { + popUpTo("root") { inclusive = true } + launchSingleTop = true + } + null -> Unit + } +} + +private fun redirectFromHomeIfLoggedOut(nav: NavHostController, isLoggedIn: Boolean?) { + if (isLoggedIn != false) return + nav.navigate(OnboardingRoute.SplashRoute) { + launchSingleTop = true + popUpTo("home") { inclusive = true } + } +} + +private fun createBottomNavTabSelectedHandler(nav: NavHostController): (BottomNavItem) -> Unit = + { item -> + when (item) { + BottomNavItem.HOME -> nav.navigate("home") { launchSingleTop = true } + BottomNavItem.AFTERNOTE -> + nav.navigate(AfternoteRoute.FingerprintLoginRoute) { launchSingleTop = true } + BottomNavItem.RECORD -> nav.navigate("record_main") { launchSingleTop = true } + BottomNavItem.TIME_LETTER -> + nav.navigate(TimeLetterRoute.TimeLetterMainRoute) { launchSingleTop = true } + } + } + @Composable private fun FingerprintLoginRouteContent( navHostController: NavHostController, @@ -273,20 +318,13 @@ fun NavGraph(navHostController: NavHostController) { ReceiverAuthSessionEntryPoint::class.java ).receiverAuthSessionHolder() } - val scope = rememberCoroutineScope() - val navigateToUserMode: () -> Unit = { - scope.launch { - val token = tokenManager.getAccessToken() - withContext(Dispatchers.Main.immediate) { - if (!token.isNullOrEmpty()) { - navHostController.navigate("home") { - launchSingleTop = true - } - } else { - navHostController.navigate(OnboardingRoute.SplashRoute) - } - } - } + // null = still loading from DataStore, true/false = resolved + val isLoggedIn by remember(tokenManager) { + tokenManager.isLoggedInFlow.map { it } + }.collectAsStateWithLifecycle(initialValue = null) + + LaunchedEffect(isLoggedIn) { + ejectToSplashIfLoggedOut(navHostController, isLoggedIn) } val afternoteProvider = DataProviderLocals.LocalAfternoteEditDataProvider.current @@ -296,26 +334,7 @@ fun NavGraph(navHostController: NavHostController) { val playlistStateHolder = remember { MemorialPlaylistStateHolder() } var listRefreshRequested by remember { mutableStateOf(false) } - val onBottomNavTabSelected: (BottomNavItem) -> Unit = { item -> - when (item) { - BottomNavItem.HOME -> - navHostController.navigate("home") { - launchSingleTop = true - } - BottomNavItem.AFTERNOTE -> - navHostController.navigate(AfternoteRoute.FingerprintLoginRoute) { - launchSingleTop = true - } - BottomNavItem.RECORD -> - navHostController.navigate("record_main") { - launchSingleTop = true - } - BottomNavItem.TIME_LETTER -> - navHostController.navigate(TimeLetterRoute.TimeLetterMainRoute) { - launchSingleTop = true - } - } - } + val onBottomNavTabSelected = createBottomNavTabSelectedHandler(navHostController) NavHost( navController = navHostController, @@ -328,44 +347,42 @@ fun NavGraph(navHostController: NavHostController) { popExitTransition = { ExitTransition.None } ) { composable("root") { - LaunchedEffect(Unit) { - val token = tokenManager.getAccessToken() - withContext(Dispatchers.Main.immediate) { - if (!token.isNullOrEmpty()) { - navHostController.navigate("home") { - launchSingleTop = true - } - } else { - navHostController.navigate(OnboardingRoute.SplashRoute) - } - } + LaunchedEffect(isLoggedIn) { + dispatchFromRoot(navHostController, isLoggedIn) } } composable("home") { - HomeScreenContent( - onBottomNavTabSelected = onBottomNavTabSelected, - onSettingsClick = { - navHostController.navigate(SettingRoute.SettingMainRoute) { - launchSingleTop = true - } - }, - onDailyQuestionCtaClick = { - navHostController.navigate("record_main") { - launchSingleTop = true - } - }, - onAfternoteClick = { - navHostController.navigate(AfternoteRoute.FingerprintLoginRoute) { - launchSingleTop = true - } - }, - onTImeLetterClick = { - navHostController.navigate(TimeLetterRoute.TimeLetterMainRoute) { - launchSingleTop = true + LaunchedEffect(isLoggedIn) { + redirectFromHomeIfLoggedOut(navHostController, isLoggedIn) + } + if (isLoggedIn == true) { + HomeScreenContent( + onBottomNavTabSelected = onBottomNavTabSelected, + onSettingsClick = { + navHostController.navigate(SettingRoute.SettingMainRoute) { + launchSingleTop = true + } + }, + onDailyQuestionCtaClick = { + navHostController.navigate("record_main") { + launchSingleTop = true + } + }, + onAfternoteClick = { + navHostController.navigate(AfternoteRoute.FingerprintLoginRoute) { + launchSingleTop = true + } + }, + onTImeLetterClick = { + navHostController.navigate(TimeLetterRoute.TimeLetterMainRoute) { + launchSingleTop = true + } } - } - ) + ) + } else { + Box(Modifier.fillMaxSize()) + } } onboardingNavGraph( @@ -402,7 +419,10 @@ fun NavGraph(navHostController: NavHostController) { navController = navHostController, onNavItemSelected = onBottomNavTabSelected ) - settingNavGraph(navController = navHostController) + settingNavGraph( + navController = navHostController, + onBottomNavTabSelected = onBottomNavTabSelected + ) composable("receiver_main/{receiverId}") { backStackEntry -> val receiverId = backStackEntry.arguments?.getString("receiverId") ?: "1" diff --git a/app/src/main/java/com/kuit/afternote/core/ui/component/content/MemorialGuidelineContent.kt b/app/src/main/java/com/kuit/afternote/core/ui/component/content/MemorialGuidelineContent.kt index 9b0dca77..3faf4f2a 100644 --- a/app/src/main/java/com/kuit/afternote/core/ui/component/content/MemorialGuidelineContent.kt +++ b/app/src/main/java/com/kuit/afternote/core/ui/component/content/MemorialGuidelineContent.kt @@ -20,6 +20,7 @@ data class MemorialGuidelineSlots( val photoContent: @Composable () -> Unit, val playlistContent: @Composable () -> Unit, val lastWishContent: @Composable () -> Unit, + val recipientContent: @Composable () -> Unit = {}, val videoContent: @Composable () -> Unit ) @@ -48,6 +49,8 @@ fun MemorialGuidelineContent( Spacer(modifier = Modifier.height(sectionSpacing)) slots.lastWishContent() Spacer(modifier = Modifier.height(sectionSpacing)) + slots.recipientContent() + Spacer(modifier = Modifier.height(sectionSpacing)) slots.videoContent() if (trailingSpacerHeight > 0.dp) { Spacer(modifier = Modifier.height(trailingSpacerHeight)) @@ -65,6 +68,7 @@ private fun MemorialGuidelineContentPreview() { photoContent = { }, playlistContent = { }, lastWishContent = { }, + recipientContent = { }, videoContent = { } ) ) diff --git a/app/src/main/java/com/kuit/afternote/core/ui/screen/afternotedetail/GalleryDetailScreen.kt b/app/src/main/java/com/kuit/afternote/core/ui/screen/afternotedetail/GalleryDetailScreen.kt index e60fb6bf..feb7d7e8 100644 --- a/app/src/main/java/com/kuit/afternote/core/ui/screen/afternotedetail/GalleryDetailScreen.kt +++ b/app/src/main/java/com/kuit/afternote/core/ui/screen/afternotedetail/GalleryDetailScreen.kt @@ -1,9 +1,6 @@ package com.kuit.afternote.core.ui.screen.afternotedetail import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -21,11 +18,9 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable -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.res.painterResource import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextStyle @@ -36,7 +31,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.kuit.afternote.R -import com.kuit.afternote.core.ui.component.detail.DeleteConfirmDialogContent +import com.kuit.afternote.core.ui.component.detail.DeleteConfirmDialog import com.kuit.afternote.core.ui.component.detail.EditDropdownMenu import com.kuit.afternote.core.ui.component.detail.InfoCard import com.kuit.afternote.core.ui.component.detail.ProcessingMethodItem @@ -175,60 +170,14 @@ private fun GalleryDetailContent( ) } - GalleryDetailDeleteDialog( - showDialog = uiState.showDeleteDialog, - serviceName = detailState.serviceName, - onDismiss = uiState::hideDeleteDialog, - onConfirm = { - uiState.hideDeleteDialog() - callbacks.onDeleteConfirm() - } - ) - } - } -} - -@Composable -private fun GalleryDetailDeleteDialog( - showDialog: Boolean, - serviceName: String, - onDismiss: () -> Unit, - onConfirm: () -> Unit -) { - if (!showDialog) return - - Box( - modifier = Modifier.fillMaxSize() - ) { - // 배경 오버레이 (반투명 검은색) - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.5f)) - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() } - ) { onDismiss() } - ) - - // 다이얼로그 컨텐츠 - 피그마 비율에 맞게 weight 사용 - Column( - modifier = Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - ) { - // 피그마 기준: 네비게이션 바로부터 247dp 비율에 맞게 weight 적용 - Spacer(modifier = Modifier.weight(1f)) - - Box( - modifier = Modifier - .padding(horizontal = 20.dp) - .fillMaxWidth() - ) { - DeleteConfirmDialogContent( - serviceName = serviceName, - onDismiss = onDismiss, - onConfirm = onConfirm + if (uiState.showDeleteDialog) { + DeleteConfirmDialog( + serviceName = detailState.serviceName, + onDismiss = uiState::hideDeleteDialog, + onConfirm = { + uiState.hideDeleteDialog() + callbacks.onDeleteConfirm() + } ) } } diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/content/GalleryAndFileEditContent.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/content/GalleryAndFileEditContent.kt index d6ac7fe7..c909589d 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/content/GalleryAndFileEditContent.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/content/GalleryAndFileEditContent.kt @@ -19,14 +19,10 @@ import com.kuit.afternote.core.ui.component.Label import com.kuit.afternote.core.ui.component.LabelStyle import com.kuit.afternote.core.ui.component.Multiline import com.kuit.afternote.core.ui.component.OutlineTextField -import com.kuit.afternote.feature.afternote.presentation.component.edit.afternoteeditreceiver.AfternoteEditReceiverList -import com.kuit.afternote.feature.afternote.presentation.component.edit.model.InfoMethodSection -import com.kuit.afternote.feature.afternote.presentation.component.edit.model.InformationProcessingMethod import com.kuit.afternote.feature.afternote.presentation.component.edit.model.AfternoteEditReceiverSection +import com.kuit.afternote.feature.afternote.presentation.component.edit.model.ProcessingMethodSection import com.kuit.afternote.feature.afternote.presentation.component.edit.processingmethod.ProcessingMethodList import com.kuit.afternote.feature.afternote.presentation.component.edit.processingmethod.ProcessingMethodListParams -import com.kuit.afternote.core.ui.component.SelectableRadioCard -import com.kuit.afternote.feature.afternote.presentation.component.edit.processingmethod.OptionRadioCardContent import com.kuit.afternote.app.compositionlocal.DataProviderLocals import com.kuit.afternote.data.provider.FakeAfternoteEditDataProvider import com.kuit.afternote.ui.theme.AfternoteTheme @@ -65,63 +61,7 @@ private fun GalleryAndFileEditContentBody( spacerHeight: Dp ) { Column(modifier = modifier.fillMaxWidth()) { - // 정보 처리 방법 섹션 - Label( - text = "정보 처리 방법", - isRequired = true, - style = LabelStyle(requiredDotOffsetY = 4.dp) - ) - - Spacer(modifier = Modifier.height(16.dp)) - - SelectableRadioCard( - selected = params.infoMethodSection.selectedMethod == InformationProcessingMethod.TRANSFER_TO_AFTERNOTE_EDIT_RECEIVER, - onClick = { params.infoMethodSection.onMethodSelected(InformationProcessingMethod.TRANSFER_TO_AFTERNOTE_EDIT_RECEIVER) }, - modifier = Modifier.fillMaxWidth(), - content = { - OptionRadioCardContent( - option = InformationProcessingMethod.TRANSFER_TO_AFTERNOTE_EDIT_RECEIVER, - selected = params.infoMethodSection.selectedMethod == InformationProcessingMethod.TRANSFER_TO_AFTERNOTE_EDIT_RECEIVER - ) - } - ) - - Spacer(modifier = Modifier.height(8.dp)) - - SelectableRadioCard( - selected = params.infoMethodSection.selectedMethod == InformationProcessingMethod.TRANSFER_TO_ADDITIONAL_AFTERNOTE_EDIT_RECEIVER, - onClick = { - params.infoMethodSection.onMethodSelected( - InformationProcessingMethod.TRANSFER_TO_ADDITIONAL_AFTERNOTE_EDIT_RECEIVER - ) - }, - modifier = Modifier.fillMaxWidth(), - content = { - OptionRadioCardContent( - option = InformationProcessingMethod.TRANSFER_TO_ADDITIONAL_AFTERNOTE_EDIT_RECEIVER, - selected = params.infoMethodSection.selectedMethod == InformationProcessingMethod.TRANSFER_TO_ADDITIONAL_AFTERNOTE_EDIT_RECEIVER - ) - } - ) - - // 추가 수신자에게 정보 전달 선택 시 수신자 추가 섹션 표시 - params.afternoteEditReceiverSection?.let { afternoteEditReceiverSection -> - Spacer(modifier = Modifier.height(32.dp)) - - // 수신자 추가 섹션 제목 - Label( - text = "수신자 추가", - isRequired = true, - style = LabelStyle(requiredDotOffsetY = 3.dp) - ) - - Spacer(modifier = Modifier.height(9.dp)) - - AfternoteEditReceiverList( - afternoteEditReceivers = afternoteEditReceiverSection.afternoteEditReceivers, - events = afternoteEditReceiverSection.callbacks - ) - } + RecipientDesignationSection(section = params.recipientSection) Spacer(modifier = Modifier.height(32.dp)) @@ -173,10 +113,8 @@ private fun GalleryAndFileEditContentPreview() { bottomPadding = PaddingValues(bottom = 88.dp), params = GalleryAndFileEditContentParams( messageState = rememberTextFieldState(), - infoMethodSection = InfoMethodSection( - selectedMethod = InformationProcessingMethod.TRANSFER_TO_AFTERNOTE_EDIT_RECEIVER, - onMethodSelected = {} - ) + recipientSection = AfternoteEditReceiverSection(), + processingMethodSection = ProcessingMethodSection() ) ) } @@ -200,11 +138,7 @@ private fun GalleryAndFileEditContentWithAfternoteEditReceiversPreview() { bottomPadding = PaddingValues(bottom = 88.dp), params = GalleryAndFileEditContentParams( messageState = rememberTextFieldState(), - infoMethodSection = InfoMethodSection( - selectedMethod = InformationProcessingMethod.TRANSFER_TO_ADDITIONAL_AFTERNOTE_EDIT_RECEIVER, - onMethodSelected = {} - ), - afternoteEditReceiverSection = AfternoteEditReceiverSection( + recipientSection = AfternoteEditReceiverSection( afternoteEditReceivers = provider.getAfternoteEditReceivers() ) ) diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/content/GalleryAndFileEditContentParams.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/content/GalleryAndFileEditContentParams.kt index 0cdde9a3..69db5aa7 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/content/GalleryAndFileEditContentParams.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/content/GalleryAndFileEditContentParams.kt @@ -2,7 +2,6 @@ package com.kuit.afternote.feature.afternote.presentation.component.edit.content import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.Immutable -import com.kuit.afternote.feature.afternote.presentation.component.edit.model.InfoMethodSection import com.kuit.afternote.feature.afternote.presentation.component.edit.model.AfternoteEditReceiverSection import com.kuit.afternote.feature.afternote.presentation.component.edit.model.ProcessingMethodSection @@ -12,7 +11,6 @@ import com.kuit.afternote.feature.afternote.presentation.component.edit.model.Pr @Immutable data class GalleryAndFileEditContentParams( val messageState: TextFieldState, - val infoMethodSection: InfoMethodSection, - val afternoteEditReceiverSection: AfternoteEditReceiverSection? = null, + val recipientSection: AfternoteEditReceiverSection, val processingMethodSection: ProcessingMethodSection = ProcessingMethodSection() ) diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/content/MemorialGuidelineEditContent.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/content/MemorialGuidelineEditContent.kt index ab2941c7..c3aea871 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/content/MemorialGuidelineEditContent.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/content/MemorialGuidelineEditContent.kt @@ -64,6 +64,9 @@ fun MemorialGuidelineEditContent( ) ) }, + recipientContent = { + params.recipientSection?.let { RecipientDesignationSection(section = it) } + }, videoContent = { FuneralVideoUpload( videoUrl = params.funeralVideoUrl, diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/content/MemorialGuidelineEditContentParams.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/content/MemorialGuidelineEditContentParams.kt index 81505025..b49c7d6b 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/content/MemorialGuidelineEditContentParams.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/content/MemorialGuidelineEditContentParams.kt @@ -3,6 +3,7 @@ package com.kuit.afternote.feature.afternote.presentation.component.edit.content import androidx.compose.runtime.Immutable import com.kuit.afternote.core.ui.component.LastWishOption import com.kuit.afternote.core.ui.component.list.AlbumCover +import com.kuit.afternote.feature.afternote.presentation.component.edit.model.AfternoteEditReceiverSection /** * 추모 가이드라인 콘텐츠 파라미터 @@ -17,6 +18,7 @@ data class MemorialGuidelineEditContentParams( val funeralVideoUrl: String?, val funeralThumbnailUrl: String? = null, val customLastWishText: String, + val recipientSection: AfternoteEditReceiverSection? = null, val onSongAddClick: () -> Unit, val onLastWishSelected: (String) -> Unit, val onCustomLastWishChanged: (String) -> Unit, diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/content/RecipientDesignationSection.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/content/RecipientDesignationSection.kt new file mode 100644 index 00000000..34544268 --- /dev/null +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/content/RecipientDesignationSection.kt @@ -0,0 +1,52 @@ +package com.kuit.afternote.feature.afternote.presentation.component.edit.content + +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.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.kuit.afternote.core.ui.component.Label +import com.kuit.afternote.core.ui.component.LabelStyle +import com.kuit.afternote.feature.afternote.presentation.component.edit.afternoteeditreceiver.AfternoteEditReceiverList +import com.kuit.afternote.feature.afternote.presentation.component.edit.model.AfternoteEditReceiverCallbacks +import com.kuit.afternote.feature.afternote.presentation.component.edit.model.AfternoteEditReceiverSection +import com.kuit.afternote.ui.theme.AfternoteTheme + +/** + * 수신자 지정 섹션 (Figma 수신자 지정 컴포넌트). + * 라벨 "수신자 지정"과 수신자 리스트를 표시합니다. + */ +@Composable +fun RecipientDesignationSection( + modifier: Modifier = Modifier, + section: AfternoteEditReceiverSection +) { + Column(modifier = modifier.fillMaxWidth()) { + Label( + text = "수신자 지정", + isRequired = true, + style = LabelStyle(requiredDotOffsetY = 3.dp) + ) + Spacer(modifier = Modifier.height(9.dp)) + AfternoteEditReceiverList( + afternoteEditReceivers = section.afternoteEditReceivers, + events = section.callbacks + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun RecipientDesignationSectionPreview() { + AfternoteTheme { + RecipientDesignationSection( + section = AfternoteEditReceiverSection( + afternoteEditReceivers = emptyList(), + callbacks = AfternoteEditReceiverCallbacks() + ) + ) + } +} diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/content/SocialNetworkEditContent.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/content/SocialNetworkEditContent.kt index 679ba914..20396552 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/content/SocialNetworkEditContent.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/content/SocialNetworkEditContent.kt @@ -139,6 +139,11 @@ private fun SocialNetworkEditContentContent( Spacer(modifier = Modifier.height(32.dp)) + params.recipientSection?.let { section -> + RecipientDesignationSection(section = section) + Spacer(modifier = Modifier.height(32.dp)) + } + // 처리 방법 리스트 섹션 Label( text = "처리 방법 리스트", diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/content/SocialNetworkEditContentParams.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/content/SocialNetworkEditContentParams.kt index 1da9f0f2..9648017d 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/content/SocialNetworkEditContentParams.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/content/SocialNetworkEditContentParams.kt @@ -3,6 +3,7 @@ package com.kuit.afternote.feature.afternote.presentation.component.edit.content import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.Immutable import com.kuit.afternote.feature.afternote.presentation.component.edit.model.AccountSection +import com.kuit.afternote.feature.afternote.presentation.component.edit.model.AfternoteEditReceiverSection import com.kuit.afternote.feature.afternote.presentation.component.edit.model.ProcessingMethodSection /** @@ -12,5 +13,6 @@ import com.kuit.afternote.feature.afternote.presentation.component.edit.model.Pr data class SocialNetworkEditContentParams( val messageState: TextFieldState, val accountSection: AccountSection, + val recipientSection: AfternoteEditReceiverSection? = null, val processingMethodSection: ProcessingMethodSection = ProcessingMethodSection() ) diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/AfternoteEditScreen.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/AfternoteEditScreen.kt index c4859305..536beb0e 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/AfternoteEditScreen.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/screen/AfternoteEditScreen.kt @@ -44,8 +44,6 @@ import com.kuit.afternote.feature.afternote.presentation.component.edit.afternot import com.kuit.afternote.feature.afternote.presentation.component.edit.afternoteeditreceiver.AddAfternoteEditReceiverDialogCallbacks import com.kuit.afternote.feature.afternote.presentation.component.edit.afternoteeditreceiver.AddAfternoteEditReceiverDialogParams import com.kuit.afternote.feature.afternote.presentation.component.edit.model.AccountSection -import com.kuit.afternote.feature.afternote.presentation.component.edit.model.InfoMethodSection -import com.kuit.afternote.feature.afternote.presentation.component.edit.model.InformationProcessingMethod import com.kuit.afternote.feature.afternote.presentation.component.edit.model.AfternoteEditReceiverSection import com.kuit.afternote.feature.afternote.presentation.component.edit.model.ProcessingMethodItem import com.kuit.afternote.feature.afternote.presentation.component.edit.model.ProcessingMethodSection @@ -379,6 +377,10 @@ private fun CategoryContent( funeralVideoUrl = state.funeralVideoUrl, funeralThumbnailUrl = state.funeralThumbnailUrl, customLastWishText = state.customLastWishText, + recipientSection = AfternoteEditReceiverSection( + afternoteEditReceivers = state.afternoteEditReceivers, + callbacks = state.galleryAfternoteEditReceiverCallbacks + ), onSongAddClick = onNavigateToAddSong, onLastWishSelected = state::onLastWishSelected, onCustomLastWishChanged = state::onCustomLastWishChanged, @@ -394,21 +396,10 @@ private fun CategoryContent( bottomPadding = bottomPadding, params = GalleryAndFileEditContentParams( messageState = state.messageState, - infoMethodSection = InfoMethodSection( - selectedMethod = state.selectedInformationProcessingMethod, - onMethodSelected = state::onInformationProcessingMethodSelected + recipientSection = AfternoteEditReceiverSection( + afternoteEditReceivers = state.afternoteEditReceivers, + callbacks = state.galleryAfternoteEditReceiverCallbacks ), - afternoteEditReceiverSection = if ( - state.selectedInformationProcessingMethod == - InformationProcessingMethod.TRANSFER_TO_ADDITIONAL_AFTERNOTE_EDIT_RECEIVER - ) { - AfternoteEditReceiverSection( - afternoteEditReceivers = state.afternoteEditReceivers, - callbacks = state.galleryAfternoteEditReceiverCallbacks - ) - } else { - null - }, processingMethodSection = ProcessingMethodSection( items = state.galleryProcessingMethods, callbacks = state.galleryProcessingCallbacks @@ -428,6 +419,10 @@ private fun CategoryContent( selectedMethod = state.selectedProcessingMethod, onMethodSelected = state::onProcessingMethodSelected ), + recipientSection = AfternoteEditReceiverSection( + afternoteEditReceivers = state.afternoteEditReceivers, + callbacks = state.galleryAfternoteEditReceiverCallbacks + ), processingMethodSection = ProcessingMethodSection( items = state.processingMethods, callbacks = state.socialProcessingCallbacks diff --git a/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/component/RecordCalendarSort.kt b/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/component/RecordCalendarSort.kt index d02b1651..6c345aff 100644 --- a/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/component/RecordCalendarSort.kt +++ b/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/component/RecordCalendarSort.kt @@ -36,13 +36,98 @@ import com.kuit.afternote.R import com.kuit.afternote.ui.theme.White import java.time.LocalDate import java.time.YearMonth -import java.time.format.DateTimeFormatter -import java.util.Locale + +private fun parseMarkedDaysInMonth(markedDates: Set, year: Int, month: Int): Set = + markedDates.mapNotNull { dateStr -> + runCatching { + val parts = dateStr.split("-") + if (parts.size == 3 && + parts[0].toIntOrNull() == year && + parts[1].toIntOrNull() == month + ) { + parts[2].toIntOrNull() + } else null + }.getOrNull() + }.filterNotNull().toSet() + +@Composable +private fun CalendarDayCell( + modifier: Modifier, + day: Int, + isSelected: Boolean, + isMarked: Boolean, + onClick: () -> Unit +) { + Box( + modifier = modifier + .aspectRatio(1f) + .padding(4.dp) + .clip(CircleShape) + .clickable(onClick = onClick) + .background( + if (isSelected) Color(0xFF328BFF) else Color.Transparent + ) + .then( + if (isMarked) { + Modifier.border( + width = 2.dp, + color = Color(0xFF328BFF), + shape = CircleShape + ) + } else { + Modifier + } + ), + contentAlignment = Alignment.Center + ) { + Text( + text = day.toString(), + fontSize = 18.sp, + color = if (isSelected) Color.White else Color.Black + ) + } +} + +@Composable +private fun CalendarGrid( + year: Int, + month: Int, + markedDaysInMonth: Set, + selectedDate: Int, + onDateSelected: (Int) -> Unit +) { + val daysInMonth = YearMonth.of(year, month).lengthOfMonth() + val startOffset = LocalDate.of(year, month, 1).dayOfWeek.value + val totalCells = startOffset + daysInMonth + val rows = (totalCells / 7) + if (totalCells % 7 != 0) 1 else 0 + + for (row in 0 until rows) { + Row { + for (col in 0..6) { + val day = row * 7 + col - startOffset + 1 + if (day in 1..daysInMonth) { + CalendarDayCell( + modifier = Modifier.weight(1f), + day = day, + isSelected = day == selectedDate, + isMarked = day in markedDaysInMonth, + onClick = { onDateSelected(day) } + ) + } else { + Spacer( + modifier = Modifier + .weight(1f) + .aspectRatio(1f) + ) + } + } + } + } +} /** * 기록들 정렬할 때 쓰이는 컴포넌트 */ - @RequiresApi(Build.VERSION_CODES.O) @Composable fun RecordCalendarSort( @@ -52,18 +137,7 @@ fun RecordCalendarSort( ) { val year = today.year val month = today.monthValue - val formatter = DateTimeFormatter.ofPattern("yyyy년 mm월", Locale.KOREA) - val markedDaysInMonth = markedDates.mapNotNull { dateStr -> - runCatching { - val parts = dateStr.split("-") - if (parts.size == 3 && - parts[0].toIntOrNull() == year && - parts[1].toIntOrNull() == month - ) { - parts[2].toIntOrNull() - } else null - }.getOrNull() - }.filterNotNull().toSet() + val markedDaysInMonth = parseMarkedDaysInMonth(markedDates, year, month) var selectedDate by remember { mutableStateOf(today.dayOfMonth) } Box( @@ -80,11 +154,11 @@ fun RecordCalendarSort( .size(120.dp, 34.dp) .background(color = White) .border( - width = 1.dp, // 두께 - color = Color(0xFF328BFF), // 색상 - shape = RoundedCornerShape(20.dp) // 모양 (선택) + width = 1.dp, + color = Color(0xFF328BFF), + shape = RoundedCornerShape(20.dp) ), - contentAlignment = Alignment.Center // 가운데 정렬 + contentAlignment = Alignment.Center ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -99,8 +173,7 @@ fun RecordCalendarSort( Image( painter = painterResource(R.drawable.ic_under), contentDescription = null, - modifier = Modifier - .size(24.dp), + modifier = Modifier.size(24.dp), colorFilter = ColorFilter.tint(Color(0xFF328BFF)) ) } @@ -110,65 +183,13 @@ fun RecordCalendarSort( .padding(top = 16.dp) .padding(horizontal = 16.dp) ) { - // 요일 헤더 -// Row(horizontalArrangement = Arrangement.SpaceBetween) { -// listOf("일", "월", "화", "수", "목", "금", "토").forEach { -// Text(text = it, modifier = Modifier.weight(1f), textAlign = TextAlign.Center) -// } -// } - - // 날짜 그리드 (예: 1일부터 30일까지) - val daysInMonth = YearMonth.of(year, month).lengthOfMonth() - val startOffset = LocalDate.of(year, month, 1).dayOfWeek.value - val totalCells = startOffset + daysInMonth - val rows = (totalCells / 7) + if (totalCells % 7 != 0) 1 else 0 - - for (row in 0 until rows) { - Row { - for (col in 0..6) { - val day = row * 7 + col - startOffset + 1 - if (day in 1..daysInMonth) { - Box( - modifier = Modifier - .weight(1f) - .aspectRatio(1f) - .padding(4.dp) - .clip(CircleShape) - .clickable(onClick = { selectedDate = day }) - .background( - when { - day == selectedDate -> Color(0xFF328BFF) - else -> Color.Transparent - } - ) .then( - if (day in markedDaysInMonth) { - Modifier.border( - width = 2.dp, - color = Color(0xFF328BFF), - shape = CircleShape - ) - } else { - Modifier - } - ), - contentAlignment = Alignment.Center - ) { - Text( - text = day.toString(), - fontSize = 18.sp, - color = if (day == selectedDate) Color.White else Color.Black - ) - } - } else { - Spacer( - modifier = Modifier - .weight(1f) - .aspectRatio(1f) - ) - } - } - } - } + CalendarGrid( + year = year, + month = month, + markedDaysInMonth = markedDaysInMonth, + selectedDate = selectedDate, + onDateSelected = { selectedDate = it } + ) } } } diff --git a/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/component/RecordDiaryContentItem.kt b/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/component/RecordDiaryContentItem.kt index c1cd5c21..181481f9 100644 --- a/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/component/RecordDiaryContentItem.kt +++ b/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/component/RecordDiaryContentItem.kt @@ -19,10 +19,9 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -32,10 +31,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip 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.Font -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.DpOffset @@ -54,194 +50,238 @@ import java.time.format.DateTimeFormatter @Composable fun RecordDiaryContentItem( modifier: Modifier = Modifier, - standard: String, - onDateSelected: (Int, Int, Int) -> Unit, - title: String, - onTitleChange: (String) -> Unit, - content: String, - onContentChange: (String) -> Unit, - - //작성 날짜 - onDateClick: () -> Unit, - sendDate: String, - showDatePicker: Boolean = false, - onDatePickerDismiss: () -> Unit, - - // 깊은 생각 기록 시 카테고리 (기록 주제) - selectedCategory: String = "나의 가치관", - onCategoryChange: (String) -> Unit = {}, - showCategoryDropdown: Boolean = false, - onCategoryClick: () -> Unit = {}, - onCategoryDropdownDismiss: () -> Unit = {} + params: RecordDiaryContentItemParams ) { - // 오늘 날짜 기준으로 받아오기 val today = LocalDate.now() - val formatter = DateTimeFormatter.ofPattern("yyyy년 MM월 dd일") - val formattedDate = today.format(formatter) + val formattedDate = today.format(DateTimeFormatter.ofPattern("yyyy년 MM월 dd일")) + val sectionLabel = if (params.standard == "일기 기록하기") "작성 날짜" else "기록 주제" + val date = params.dateSection + val category = params.categorySection - var s = " " - if (standard == "일기 기록하기") { - s = "작성 날짜" - } else { - s = "기록 주제" - } Column( modifier = Modifier .padding(top = 24.dp, start = 20.dp, end = 20.dp) ) { Text( - text = s, + text = sectionLabel, color = Black, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, fontFamily = Sansneo ) - Box( - modifier = Modifier - .padding(all = 8.dp) + RecordDiarySectionRow( + sectionLabel = sectionLabel, + formattedDate = formattedDate, + dateSection = date, + categorySection = category, + onDateOrCategoryClick = { + if (sectionLabel == "기록 주제") category.onCategoryClick() else date.onDateClick() + } + ) + + RecordDiaryTitleField( + title = params.title, + onTitleChange = params.onTitleChange + ) + + RecordDiaryDatePickerOverlay( + showDatePicker = date.showDatePicker, + onDateSelected = params.onDateSelected, + onDatePickerDismiss = date.onDatePickerDismiss + ) + + HorizontalDivider( + color = Color.LightGray, + thickness = 0.8.dp + ) + + RecordDiaryContentField( + content = params.content, + onContentChange = params.onContentChange + ) + } +} + +@Composable +private fun RecordDiarySectionRow( + sectionLabel: String, + formattedDate: String, + dateSection: RecordDiaryDateSectionParams, + categorySection: RecordDiaryCategorySectionParams, + onDateOrCategoryClick: () -> Unit +) { + Box(modifier = Modifier.padding(all = 8.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - if (s == "작성 날짜") { - Text( - text = if (sendDate.isNotEmpty()) sendDate else formattedDate, - fontSize = 12.sp, - modifier = Modifier.padding(vertical = 8.dp) - ) - } else if (s == "기록 주제") { - Box { + if (sectionLabel == "작성 날짜") { + Text( + text = if (dateSection.sendDate.isNotEmpty()) dateSection.sendDate else formattedDate, + fontSize = 12.sp, + modifier = Modifier.padding(vertical = 8.dp) + ) + } else { + RecordDiaryCategoryDropdown( + categorySection = categorySection + ) + } + Image( + painter = painterResource(id = R.drawable.ic_under), + contentDescription = "밑 화살표", + modifier = Modifier + .size(24.dp) + .clickable(onClick = onDateOrCategoryClick) + ) + } + HorizontalDivider( + color = Color.LightGray, + thickness = 0.8.dp + ) + } +} + +@Composable +private fun RecordDiaryCategoryDropdown( + categorySection: RecordDiaryCategorySectionParams +) { + Box { + Text( + text = categorySection.selectedCategory, + fontSize = 12.sp, + modifier = Modifier.padding(vertical = 8.dp) + ) + DropdownMenu( + expanded = categorySection.showCategoryDropdown, + onDismissRequest = categorySection.onCategoryDropdownDismiss, + offset = DpOffset(x = 0.dp, y = 0.dp), + modifier = Modifier.width(350.dp) + ) { + listOf( + "나의 가치관", + "오늘 떠올린 생각", + "인생을 되돌아 보며" + ).forEach { option -> + DropdownMenuItem( + text = { Text( - text = selectedCategory, - fontSize = 12.sp, - modifier = Modifier.padding(vertical = 8.dp) + text = option, + fontSize = 14.sp, + fontFamily = Sansneo ) - DropdownMenu( - expanded = showCategoryDropdown, - onDismissRequest = onCategoryDropdownDismiss, - offset = DpOffset(x = 0.dp, y = 0.dp), - modifier = Modifier.width(350.dp) - ) { - listOf( - "나의 가치관", - "오늘 떠올린 생각", - "인생을 되돌아 보며" - ).forEach { option -> - DropdownMenuItem( - text = { - Text( - text = option, - fontSize = 14.sp, - fontFamily = Sansneo - ) - }, - onClick = { - onCategoryChange(option) - onCategoryDropdownDismiss() - } - ) - } - } + }, + onClick = { + categorySection.onCategoryChange(option) + categorySection.onCategoryDropdownDismiss() } - } - - Image( - painter = painterResource(id = R.drawable.ic_under), - contentDescription = "밑 화살표", - modifier = Modifier - .size(24.dp) - .clickable { - if (s == "기록 주제") onCategoryClick() else onDateClick() - } ) - if (sendDate.isNotEmpty()) { - val sendDate = DateTimeFormatter.ofPattern("yyyy년 MM월 dd일") - } } - Divider(color = Color.LightGray, thickness = 0.8.dp) } - BasicTextField( - value = title, - onValueChange = onTitleChange, - singleLine = true, - modifier = Modifier - .fillMaxWidth() - .padding(start = 8.dp, top = 16.dp, bottom = 8.dp), - textStyle = androidx.compose.ui.text.TextStyle( - fontSize = 18.sp, - color = Black - ), - decorationBox = { innerTextField -> - if (title.isEmpty()) { - Text( - text = "제목", - color = Gray5, - fontSize = 16.sp - ) - } - innerTextField() - } - ) - if (showDatePicker) { - var selectedDate by remember { mutableStateOf(LocalDate.now()) } - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.3f)) - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() } - ) { - onDateSelected(selectedDate.year, selectedDate.monthValue, selectedDate.dayOfMonth) - onDatePickerDismiss() - } - .zIndex(1f) - ) + } +} - Box( - modifier = Modifier - .zIndex(2f) - .shadow(elevation = 8.dp, shape = RoundedCornerShape(12.dp)) - .clip(RoundedCornerShape(12.dp)) - .background(Color.White) - .padding(vertical = 16.dp) - .align(Alignment.CenterHorizontally) - ) { - DateWheelPicker( - modifier = Modifier.width(DateWheelPickerDefaults.ContainerWidth), - currentDate = selectedDate, - onDateChanged = { date -> selectedDate = date }, - minDate = LocalDate.now() +@Composable +private fun RecordDiaryTitleField( + title: String, + onTitleChange: (String) -> Unit +) { + BasicTextField( + value = title, + onValueChange = onTitleChange, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .padding(start = 8.dp, top = 16.dp, bottom = 8.dp), + textStyle = androidx.compose.ui.text.TextStyle( + fontSize = 18.sp, + color = Black + ), + decorationBox = { innerTextField -> + if (title.isEmpty()) { + Text( + text = "제목", + color = Gray5, + fontSize = 16.sp ) } + innerTextField() } - Divider(color = Color.LightGray, thickness = 0.8.dp) + ) +} - BasicTextField( - value = content, - onValueChange = onContentChange, - singleLine = true, +@Composable +private fun RecordDiaryDatePickerOverlay( + showDatePicker: Boolean, + onDateSelected: (Int, Int, Int) -> Unit, + onDatePickerDismiss: () -> Unit +) { + if (!showDatePicker) return + var selectedDate by remember { mutableStateOf(LocalDate.now()) } + Box(modifier = Modifier.fillMaxSize()) { + Box( modifier = Modifier - .fillMaxWidth() - .padding(top = 40.dp), - textStyle = androidx.compose.ui.text.TextStyle( - fontSize = 18.sp, - color = Black - ), - decorationBox = { innerTextField -> - if (content.isEmpty()) { - Text( - text = "당신의 오늘을 기록해보세요", - color = Gray5, - fontSize = 16.sp, - fontWeight = FontWeight.Medium + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.3f)) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { + onDateSelected( + selectedDate.year, + selectedDate.monthValue, + selectedDate.dayOfMonth ) + onDatePickerDismiss() } - innerTextField() - } + .zIndex(1f) ) + Box( + modifier = Modifier + .zIndex(2f) + .shadow(elevation = 8.dp, shape = RoundedCornerShape(12.dp)) + .clip(RoundedCornerShape(12.dp)) + .background(Color.White) + .padding(vertical = 16.dp) + .align(Alignment.Center) + ) { + DateWheelPicker( + modifier = Modifier.width(DateWheelPickerDefaults.ContainerWidth), + currentDate = selectedDate, + onDateChanged = { selectedDate = it }, + minDate = LocalDate.now() + ) + } } } + +@Composable +private fun RecordDiaryContentField( + content: String, + onContentChange: (String) -> Unit +) { + BasicTextField( + value = content, + onValueChange = onContentChange, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .padding(top = 40.dp), + textStyle = androidx.compose.ui.text.TextStyle( + fontSize = 18.sp, + color = Black + ), + decorationBox = { innerTextField -> + if (content.isEmpty()) { + Text( + text = "당신의 오늘을 기록해보세요", + color = Gray5, + fontSize = 16.sp, + fontWeight = FontWeight.Medium + ) + } + innerTextField() + } + ) +} diff --git a/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/component/RecordDiaryContentItemParams.kt b/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/component/RecordDiaryContentItemParams.kt new file mode 100644 index 00000000..15cfd91d --- /dev/null +++ b/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/component/RecordDiaryContentItemParams.kt @@ -0,0 +1,41 @@ +package com.kuit.afternote.feature.dailyrecord.presentation.component + +import androidx.compose.runtime.Immutable + +/** + * 파라미터 그룹: RecordDiaryContentItem 날짜 섹션 + */ +@Immutable +data class RecordDiaryDateSectionParams( + val onDateClick: () -> Unit, + val sendDate: String, + val showDatePicker: Boolean = false, + val onDatePickerDismiss: () -> Unit +) + +/** + * 파라미터 그룹: RecordDiaryContentItem 카테고리(기록 주제) 섹션 + */ +@Immutable +data class RecordDiaryCategorySectionParams( + val selectedCategory: String = "나의 가치관", + val onCategoryChange: (String) -> Unit = {}, + val showCategoryDropdown: Boolean = false, + val onCategoryClick: () -> Unit = {}, + val onCategoryDropdownDismiss: () -> Unit = {} +) + +/** + * RecordDiaryContentItem 단일 파라미터 (S107 준수) + */ +@Immutable +data class RecordDiaryContentItemParams( + val standard: String, + val onDateSelected: (Int, Int, Int) -> Unit, + val title: String, + val onTitleChange: (String) -> Unit, + val content: String, + val onContentChange: (String) -> Unit, + val dateSection: RecordDiaryDateSectionParams, + val categorySection: RecordDiaryCategorySectionParams = RecordDiaryCategorySectionParams() +) diff --git a/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/component/RecordListItem.kt b/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/component/RecordListItem.kt index f872e015..156f1882 100644 --- a/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/component/RecordListItem.kt +++ b/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/component/RecordListItem.kt @@ -1,7 +1,6 @@ package com.kuit.afternote.feature.dailyrecord.presentation.component import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.size @@ -18,17 +17,14 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.compose.viewModel import com.kuit.afternote.R import com.kuit.afternote.core.ui.component.list.RecordQnAListItem import com.kuit.afternote.feature.dailyrecord.presentation.uimodel.MindRecordUiModel import com.kuit.afternote.ui.theme.Gray5 import com.kuit.afternote.ui.theme.Sansneo import com.kuit.afternote.ui.theme.White -import java.time.LocalDate /** * 미리보기 창 diff --git a/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/component/RecordListSort.kt b/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/component/RecordListSort.kt index a8349cd2..c6e703da 100644 --- a/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/component/RecordListSort.kt +++ b/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/component/RecordListSort.kt @@ -34,8 +34,6 @@ import com.kuit.afternote.R import com.kuit.afternote.ui.theme.Gray2 import com.kuit.afternote.ui.theme.Gray5 import java.time.LocalDate -import java.time.format.DateTimeFormatter -import java.util.Locale @RequiresApi(Build.VERSION_CODES.O) @Composable @@ -45,9 +43,6 @@ fun RecordListSort( markedDates: Set = emptySet() ) { var isClicked by remember { mutableStateOf(true) } - val year = today.year - val month = today.monthValue - val formatter = DateTimeFormatter.ofPattern("yyyy.mm", Locale.KOREA) Row( modifier = Modifier diff --git a/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/navigiation/RecordNavgraph.kt b/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/navigiation/RecordNavgraph.kt index f35bf9a2..fc9a513d 100644 --- a/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/navigiation/RecordNavgraph.kt +++ b/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/navigiation/RecordNavgraph.kt @@ -13,16 +13,20 @@ import androidx.navigation.compose.composable import androidx.navigation.toRoute import com.kuit.afternote.core.ui.component.navigation.BottomNavItem import com.kuit.afternote.feature.dailyrecord.presentation.navigiation.RecordRoute +import com.kuit.afternote.feature.dailyrecord.presentation.screen.RecordCategoryState import com.kuit.afternote.feature.dailyrecord.presentation.screen.RecordDailyQuestionListScreen import com.kuit.afternote.feature.dailyrecord.presentation.screen.RecordDeepMindScreen +import com.kuit.afternote.feature.dailyrecord.presentation.screen.RecordDeepMindScreenParams +import com.kuit.afternote.feature.dailyrecord.presentation.screen.RecordDiaryContentState +import com.kuit.afternote.feature.dailyrecord.presentation.screen.RecordDiaryDateState import com.kuit.afternote.feature.dailyrecord.presentation.screen.RecordDiaryScreen +import com.kuit.afternote.feature.dailyrecord.presentation.screen.RecordDiaryScreenParams import com.kuit.afternote.feature.dailyrecord.presentation.screen.RecordFirstDiaryListScreen import com.kuit.afternote.feature.dailyrecord.presentation.screen.RecordMainScreen import com.kuit.afternote.feature.dailyrecord.presentation.screen.RecordQuestionScreen import com.kuit.afternote.feature.dailyrecord.presentation.screen.RecordWeekendReportScreen import com.kuit.afternote.feature.dailyrecord.presentation.viewmodel.MindRecordViewModel import com.kuit.afternote.feature.dailyrecord.presentation.viewmodel.MindRecordWriterViewModel -import com.kuit.afternote.feature.timeletter.presentation.viewmodel.TimeLetterWriterViewModel import java.time.LocalDate import java.time.format.DateTimeFormatter @@ -89,22 +93,32 @@ fun NavGraphBuilder.recordNavGraph( val uiState by viewModel.uiState.collectAsStateWithLifecycle() RecordDiaryScreen( - onLeftClick = { navController.popBackStack() }, - title = uiState.title, - content = uiState.content, - onTitleChange = viewModel::updateTitle, - onContentChange = viewModel::updateContent, - sendDate = uiState.sendDate, - showDatePicker = uiState.showDatePicker, - onDateClick = viewModel::showDatePicker, - onDatePickerDismiss = viewModel::hideDatePicker, - onDateSelected = { year, month, day -> - val formatter = DateTimeFormatter.ofPattern("yyyy년 MM월 dd일") - viewModel.updateSendDate(LocalDate.of(year, month, day).format(formatter)) - }, - onRegisterClick = { - viewModel.registerWithPopUpThenSave(onSuccess = { navController.popBackStack() }) - } + params = RecordDiaryScreenParams( + contentState = RecordDiaryContentState( + title = uiState.title, + content = uiState.content, + onTitleChange = viewModel::updateTitle, + onContentChange = viewModel::updateContent + ), + dateState = RecordDiaryDateState( + sendDate = uiState.sendDate, + showDatePicker = uiState.showDatePicker, + onDateClick = viewModel::showDatePicker, + onDatePickerDismiss = viewModel::hideDatePicker, + onDateSelected = { year, month, day -> + val formatter = DateTimeFormatter.ofPattern("yyyy년 MM월 dd일") + viewModel.updateSendDate( + LocalDate.of(year, month, day).format(formatter) + ) + } + ), + onLeftClick = { navController.popBackStack() }, + onRegisterClick = { + viewModel.registerWithPopUpThenSave( + onSuccess = { navController.popBackStack() } + ) + } + ) ) } composable { @@ -122,28 +136,36 @@ fun NavGraphBuilder.recordNavGraph( val uiState by viewModel.uiState.collectAsStateWithLifecycle() RecordDeepMindScreen( - onLeftClick = { navController.popBackStack() }, - recordId = null, - title = uiState.title, - content = uiState.content, - onTitleChange = viewModel::updateTitle, - onContentChange = viewModel::updateContent, - sendDate = uiState.sendDate, - showDatePicker = uiState.showDatePicker, - onDateClick = viewModel::showDatePicker, - onDatePickerDismiss = viewModel::hideDatePicker, - selectedCategory = uiState.selectedCategory, - onCategoryChange = viewModel::updateCategory, - showCategoryDropdown = uiState.showCategoryDropdown, - onCategoryClick = viewModel::showCategoryDropdown, - onCategoryDropdownDismiss = viewModel::hideCategoryDropdown, - onRegisterClick = { - viewModel.registerWithPopUpThenSave( - recordType = "DEEP_THOUGHT", - category = uiState.selectedCategory, - onSuccess = { navController.popBackStack() } - ) - } + params = RecordDeepMindScreenParams( + contentState = RecordDiaryContentState( + title = uiState.title, + content = uiState.content, + onTitleChange = viewModel::updateTitle, + onContentChange = viewModel::updateContent + ), + dateState = RecordDiaryDateState( + sendDate = uiState.sendDate, + showDatePicker = uiState.showDatePicker, + onDateClick = viewModel::showDatePicker, + onDatePickerDismiss = viewModel::hideDatePicker, + onDateSelected = { _, _, _ -> } + ), + categoryState = RecordCategoryState( + selectedCategory = uiState.selectedCategory, + onCategoryChange = viewModel::updateCategory, + showCategoryDropdown = uiState.showCategoryDropdown, + onCategoryClick = viewModel::showCategoryDropdown, + onCategoryDropdownDismiss = viewModel::hideCategoryDropdown + ), + onLeftClick = { navController.popBackStack() }, + onRegisterClick = { + viewModel.registerWithPopUpThenSave( + recordType = "DEEP_THOUGHT", + category = uiState.selectedCategory, + onSuccess = { navController.popBackStack() } + ) + } + ) ) } diff --git a/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/screen/RecordDailyQuestionListScreen.kt b/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/screen/RecordDailyQuestionListScreen.kt index b2c1ef95..2b19cb98 100644 --- a/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/screen/RecordDailyQuestionListScreen.kt +++ b/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/screen/RecordDailyQuestionListScreen.kt @@ -13,8 +13,8 @@ import androidx.compose.foundation.lazy.items import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.kuit.afternote.core.ui.component.button.AddFloatingActionButton @@ -38,7 +38,7 @@ fun RecordDailyQuestionListScreen( viewModel: MindRecordViewModel // ViewModel 주입 ) { val today = LocalDate.now() - val records by viewModel.records.collectAsState() // UIModel 구독 + val records by viewModel.records.collectAsStateWithLifecycle() LaunchedEffect(Unit) { viewModel.loadRecords("DAILY_QUESTION") diff --git a/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/screen/RecordDeepMindScreen.kt b/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/screen/RecordDeepMindScreen.kt index 65d0f208..27df1e24 100644 --- a/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/screen/RecordDeepMindScreen.kt +++ b/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/screen/RecordDeepMindScreen.kt @@ -11,61 +11,58 @@ import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview +import com.kuit.afternote.feature.dailyrecord.presentation.component.RecordDiaryCategorySectionParams import com.kuit.afternote.feature.dailyrecord.presentation.component.RecordDiaryContentItem +import com.kuit.afternote.feature.dailyrecord.presentation.component.RecordDiaryContentItemParams +import com.kuit.afternote.feature.dailyrecord.presentation.component.RecordDiaryDateSectionParams import com.kuit.afternote.feature.dailyrecord.presentation.component.RecordSubTopbar @RequiresApi(Build.VERSION_CODES.O) @Composable fun RecordDeepMindScreen( modifier: Modifier = Modifier, - onLeftClick: () -> Unit, - recordId: Long?, - title: String, - content: String, - onTitleChange: (String) -> Unit, - onContentChange: (String) -> Unit, - onDateClick: () -> Unit, - sendDate: String, - showDatePicker: Boolean, - onDatePickerDismiss: () -> Unit, - selectedCategory: String, - onCategoryChange: (String) -> Unit, - showCategoryDropdown: Boolean, - onCategoryClick: () -> Unit, - onCategoryDropdownDismiss: () -> Unit, - onRegisterClick: () -> Unit + params: RecordDeepMindScreenParams ) { - Scaffold { paddingValues -> + val content = params.contentState + val date = params.dateState + val category = params.categoryState + Scaffold { _ -> LazyColumn( modifier = Modifier .fillMaxWidth() - .windowInsetsPadding(WindowInsets.statusBars) // 상태바 만큼 패딩을 줘서 겹치지 않도록 + .windowInsetsPadding(WindowInsets.statusBars) ) { item { RecordSubTopbar( text = "깊은 생각 기록하기", - onLeftClock = onLeftClick, - onRightClick = onRegisterClick + onLeftClock = params.onLeftClick, + onRightClick = params.onRegisterClick ) } item { RecordDiaryContentItem( - standard = "깊은 생각 기록하기", - onDateSelected = { _, _, _ -> }, - title = title, - onTitleChange = onTitleChange, - content = content, - onContentChange = onContentChange, - onDateClick = {}, - sendDate = sendDate, - showDatePicker = showDatePicker, - onDatePickerDismiss = onDatePickerDismiss, - selectedCategory = selectedCategory, - onCategoryChange = onCategoryChange, - showCategoryDropdown = showCategoryDropdown, - onCategoryClick = onCategoryClick, - onCategoryDropdownDismiss = onCategoryDropdownDismiss + params = RecordDiaryContentItemParams( + standard = "깊은 생각 기록하기", + onDateSelected = { _, _, _ -> }, + title = content.title, + onTitleChange = content.onTitleChange, + content = content.content, + onContentChange = content.onContentChange, + dateSection = RecordDiaryDateSectionParams( + onDateClick = {}, + sendDate = date.sendDate, + showDatePicker = date.showDatePicker, + onDatePickerDismiss = date.onDatePickerDismiss + ), + categorySection = RecordDiaryCategorySectionParams( + selectedCategory = category.selectedCategory, + onCategoryChange = category.onCategoryChange, + showCategoryDropdown = category.showCategoryDropdown, + onCategoryClick = category.onCategoryClick, + onCategoryDropdownDismiss = category.onCategoryDropdownDismiss + ) + ) ) } } diff --git a/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/screen/RecordDeepMindScreenParams.kt b/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/screen/RecordDeepMindScreenParams.kt new file mode 100644 index 00000000..e6f2e1e0 --- /dev/null +++ b/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/screen/RecordDeepMindScreenParams.kt @@ -0,0 +1,27 @@ +package com.kuit.afternote.feature.dailyrecord.presentation.screen + +import androidx.compose.runtime.Immutable + +/** + * 파라미터 그룹: 깊은 생각 화면 카테고리(기록 주제) 상태 및 콜백 + */ +@Immutable +data class RecordCategoryState( + val selectedCategory: String, + val onCategoryChange: (String) -> Unit, + val showCategoryDropdown: Boolean, + val onCategoryClick: () -> Unit, + val onCategoryDropdownDismiss: () -> Unit +) + +/** + * RecordDeepMindScreen 단일 파라미터 (S107 준수) + */ +@Immutable +data class RecordDeepMindScreenParams( + val contentState: RecordDiaryContentState, + val dateState: RecordDiaryDateState, + val categoryState: RecordCategoryState, + val onLeftClick: () -> Unit, + val onRegisterClick: () -> Unit +) diff --git a/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/screen/RecordDiaryScreen.kt b/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/screen/RecordDiaryScreen.kt index 9521c28f..6825781a 100644 --- a/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/screen/RecordDiaryScreen.kt +++ b/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/screen/RecordDiaryScreen.kt @@ -12,6 +12,8 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import com.kuit.afternote.feature.dailyrecord.presentation.component.RecordDiaryContentItem +import com.kuit.afternote.feature.dailyrecord.presentation.component.RecordDiaryContentItemParams +import com.kuit.afternote.feature.dailyrecord.presentation.component.RecordDiaryDateSectionParams import com.kuit.afternote.feature.dailyrecord.presentation.component.RecordSubTopbar /** @@ -26,51 +28,40 @@ import com.kuit.afternote.feature.dailyrecord.presentation.component.RecordSubTo @Composable fun RecordDiaryScreen( modifier: Modifier = Modifier, - onLeftClick: () -> Unit, - - title: String, - content: String, - onTitleChange: (String) -> Unit, - onContentChange: (String) -> Unit, - - //발송시간 - onDateClick: () -> Unit, - sendDate: String, - showDatePicker: Boolean, - onDatePickerDismiss: () -> Unit, - - //등록 버튼 - onRegisterClick: () -> Unit, - onDateSelected: (Int, Int, Int) -> Unit = { _, _, _ -> }, + params: RecordDiaryScreenParams ) { - Scaffold { paddingValues -> + val content = params.contentState + val date = params.dateState + Scaffold { _ -> LazyColumn( modifier = Modifier .fillMaxWidth() - .windowInsetsPadding(WindowInsets.statusBars) // 상태바 만큼 패딩을 줘서 겹치지 않도록 + .windowInsetsPadding(WindowInsets.statusBars) ) { item { RecordSubTopbar( text = "일기 기록하기", - onLeftClock = onLeftClick, - onRightClick = onRegisterClick + onLeftClock = params.onLeftClick, + onRightClick = params.onRegisterClick ) } item { RecordDiaryContentItem( - standard = "일기 기록하기", - onDateSelected = { year, month, day -> - onDateSelected(year, month, day) - }, - title = title, - onTitleChange = onTitleChange, - content = content, - onContentChange = onContentChange, - onDateClick = onDateClick, - sendDate = sendDate, - showDatePicker = showDatePicker, - onDatePickerDismiss = onDatePickerDismiss, + params = RecordDiaryContentItemParams( + standard = "일기 기록하기", + onDateSelected = date.onDateSelected, + title = content.title, + onTitleChange = content.onTitleChange, + content = content.content, + onContentChange = content.onContentChange, + dateSection = RecordDiaryDateSectionParams( + onDateClick = date.onDateClick, + sendDate = date.sendDate, + showDatePicker = date.showDatePicker, + onDatePickerDismiss = date.onDatePickerDismiss + ) + ) ) } } diff --git a/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/screen/RecordDiaryScreenParams.kt b/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/screen/RecordDiaryScreenParams.kt new file mode 100644 index 00000000..b5840702 --- /dev/null +++ b/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/screen/RecordDiaryScreenParams.kt @@ -0,0 +1,37 @@ +package com.kuit.afternote.feature.dailyrecord.presentation.screen + +import androidx.compose.runtime.Immutable + +/** + * 파라미터 그룹: 일기 화면 제목/내용 및 변경 콜백 + */ +@Immutable +data class RecordDiaryContentState( + val title: String, + val content: String, + val onTitleChange: (String) -> Unit, + val onContentChange: (String) -> Unit +) + +/** + * 파라미터 그룹: 일기 화면 날짜 선택 상태 및 콜백 + */ +@Immutable +data class RecordDiaryDateState( + val sendDate: String, + val showDatePicker: Boolean, + val onDateClick: () -> Unit, + val onDatePickerDismiss: () -> Unit, + val onDateSelected: (Int, Int, Int) -> Unit +) + +/** + * RecordDiaryScreen 단일 파라미터 (S107 준수) + */ +@Immutable +data class RecordDiaryScreenParams( + val contentState: RecordDiaryContentState, + val dateState: RecordDiaryDateState, + val onLeftClick: () -> Unit, + val onRegisterClick: () -> Unit +) diff --git a/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/screen/RecordFirstDiaryListScreen.kt b/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/screen/RecordFirstDiaryListScreen.kt index 772f9881..2061d452 100644 --- a/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/screen/RecordFirstDiaryListScreen.kt +++ b/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/screen/RecordFirstDiaryListScreen.kt @@ -21,13 +21,13 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.kuit.afternote.core.ui.component.navigation.BottomNavItem import com.kuit.afternote.core.ui.component.navigation.BottomNavigationBar import com.kuit.afternote.core.ui.component.navigation.TopBar diff --git a/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/screen/RecordQuestionScreen.kt b/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/screen/RecordQuestionScreen.kt index 9d2eadb4..881e0d91 100644 --- a/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/screen/RecordQuestionScreen.kt +++ b/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/screen/RecordQuestionScreen.kt @@ -52,7 +52,35 @@ fun RecordQuestionScreen( } } - Scaffold { paddingValues -> + fun onSaveClick() { + if (record != null) { + viewModel.editRecord( + params = EditRecordParams( + recordId = record.id, + title = title.ifBlank { questionText }, + content = content, + date = record.originalDate, + type = record.type ?: "DAILY_QUESTION", + category = record.category, + isDraft = false + ), + onSuccess = onLeftClick + ) + } else { + viewModel.onCreateRecord( + params = CreateRecordParams( + type = "DAILY_QUESTION", + title = title.ifBlank { questionText }, + content = content, + date = LocalDate.now().toString(), + isDraft = false + ), + onSuccess = onLeftClick + ) + } + } + + Scaffold { _ -> LazyColumn( modifier = Modifier .fillMaxWidth() @@ -62,39 +90,13 @@ fun RecordQuestionScreen( RecordSubTopbar( text = "데일리 질문 답변", onLeftClock = onLeftClick, - onRightClick = { - if (record != null) { - viewModel.editRecord( - params = EditRecordParams( - recordId = record.id, - title = title.ifBlank { questionText }, - content = content, - date = record.originalDate, - type = record.type ?: "DAILY_QUESTION", - category = record.category, - isDraft = false - ), - onSuccess = onLeftClick - ) - } else { - viewModel.onCreateRecord( - params = CreateRecordParams( - type = "DAILY_QUESTION", - title = title.ifBlank { questionText }, - content = content, - date = LocalDate.now().toString(), - isDraft = false - ), - onSuccess = onLeftClick - ) - } - } + onRightClick = { onSaveClick() } ) } item { RecordDiaryQuestionContentItem( - questionText = if (record != null) (record.title.ifBlank { questionText }) else questionText, + questionText = record?.title?.ifBlank { questionText } ?: questionText, title = title, onTitleChange = { title = it }, content = content, diff --git a/app/src/main/java/com/kuit/afternote/feature/receiver/presentation/viewmodel/ReceiverMemorialPlaylistViewModel.kt b/app/src/main/java/com/kuit/afternote/feature/receiver/presentation/viewmodel/ReceiverMemorialPlaylistViewModel.kt index b1b399e3..79b949c9 100644 --- a/app/src/main/java/com/kuit/afternote/feature/receiver/presentation/viewmodel/ReceiverMemorialPlaylistViewModel.kt +++ b/app/src/main/java/com/kuit/afternote/feature/receiver/presentation/viewmodel/ReceiverMemorialPlaylistViewModel.kt @@ -34,7 +34,7 @@ class ReceiverMemorialPlaylistViewModel val uiState: StateFlow = _uiState.asStateFlow() init { - val afternoteId = savedStateHandle.get("afternoteId")?.toLongOrNull() + val afternoteId = (savedStateHandle["afternoteId"] as? String)?.toLongOrNull() val authCode = receiverAuthSessionHolder.getAuthCode() when { diff --git a/app/src/main/java/com/kuit/afternote/feature/setting/presentation/navgraph/SettingNavGraph.kt b/app/src/main/java/com/kuit/afternote/feature/setting/presentation/navgraph/SettingNavGraph.kt index c2adc3e8..c832df02 100644 --- a/app/src/main/java/com/kuit/afternote/feature/setting/presentation/navgraph/SettingNavGraph.kt +++ b/app/src/main/java/com/kuit/afternote/feature/setting/presentation/navgraph/SettingNavGraph.kt @@ -12,12 +12,14 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavBackStackEntry import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable import androidx.navigation.toRoute import com.kuit.afternote.R import com.kuit.afternote.core.ui.component.ConfirmationPopup +import com.kuit.afternote.core.ui.component.navigation.BottomNavItem import com.kuit.afternote.core.ui.util.getAfternoteDisplayRes import com.kuit.afternote.core.uimodel.AfternoteListDisplayItem import com.kuit.afternote.feature.afternote.presentation.component.edit.model.AfternoteEditReceiver @@ -49,7 +51,9 @@ import com.kuit.afternote.feature.setting.presentation.screen.receiver.ReceiverD import com.kuit.afternote.feature.setting.presentation.screen.receiver.ReceiverDetailScreen import com.kuit.afternote.feature.setting.presentation.screen.receiver.ReceiverDetailScreenParams import com.kuit.afternote.feature.setting.presentation.screen.receiver.ReceiverManagementScreen -import com.kuit.afternote.feature.setting.presentation.screen.receiver.ReceiverRegisterScreen +import com.kuit.afternote.feature.setting.presentation.screen.receiver.ReceiverFormCallbacks +import com.kuit.afternote.feature.setting.presentation.screen.receiver.ReceiverFormInitialState +import com.kuit.afternote.feature.setting.presentation.screen.receiver.ReceiverFormScreen import com.kuit.afternote.feature.setting.presentation.screen.receiver.ReceiverTimeLetterListScreen import com.kuit.afternote.feature.setting.presentation.viewmodel.WithdrawalPasswordViewModel import com.kuit.afternote.feature.timeletter.presentation.component.LetterTheme @@ -57,12 +61,19 @@ import com.kuit.afternote.feature.timeletter.presentation.uimodel.TimeLetterItem import com.kuit.afternote.feature.user.presentation.viewmodel.ProfileViewModel import com.kuit.afternote.feature.user.presentation.viewmodel.ReceiverDailyQuestionsViewModel import com.kuit.afternote.feature.user.presentation.viewmodel.ReceiverDetailViewModel +import com.kuit.afternote.feature.user.presentation.viewmodel.EditReceiverViewModel import com.kuit.afternote.feature.user.presentation.viewmodel.ReceiverListViewModel import com.kuit.afternote.feature.user.presentation.viewmodel.RegisterReceiverViewModel -fun NavGraphBuilder.settingNavGraph(navController: NavController) { +fun NavGraphBuilder.settingNavGraph( + navController: NavController, + onBottomNavTabSelected: (BottomNavItem) -> Unit = {} +) { composable { - SettingMainRouteContent(navController) + SettingMainRouteContent( + navController = navController, + onBottomNavTabSelected = onBottomNavTabSelected + ) } composable { ConnectedAccountsScreen(onBackClick = { navController.popBackStack() }) @@ -83,7 +94,11 @@ fun NavGraphBuilder.settingNavGraph(navController: NavController) { PasswordModifyScreen(onBackClick = { navController.popBackStack() }) } composable { backStackEntry -> - ReceiverDetailRouteContent(navController, backStackEntry.toRoute()) + ReceiverDetailRouteContent( + navController = navController, + backStackEntry = backStackEntry, + route = backStackEntry.toRoute() + ) } composable { ProfileEditScreen( @@ -103,6 +118,12 @@ fun NavGraphBuilder.settingNavGraph(navController: NavController) { composable { ReceiverRegisterRouteContent(navController) } + composable { backStackEntry -> + ReceiverEditRouteContent( + navController = navController, + backStackEntry = backStackEntry + ) + } composable { PostDeliveryConditionScreen( onBackClick = { navController.popBackStack() } @@ -192,7 +213,6 @@ private fun WithdrawalPasswordRouteContent(navController: NavController) { onBackClick = { navController.popBackStack() }, onWithdrawClick = { password -> withdrawalPasswordViewModel.submitWithdrawal(password) - // TODO: API 연동 후 실제 탈퇴 처리; 성공 시 withdrawalComplete = true로 설정 Log.d(TAG_SETTING_NAV, "Withdrawal password confirmed") }, onGoBackClick = { navController.popBackStack() }, @@ -212,7 +232,10 @@ private fun sampleNoticeItems(): List = List(10) { index -> private const val TAG_SETTING_NAV = "SettingNavGraph" @Composable -private fun SettingMainRouteContent(navController: NavController) { +private fun SettingMainRouteContent( + navController: NavController, + onBottomNavTabSelected: (BottomNavItem) -> Unit +) { val context = LocalContext.current val unhandledMessage = stringResource(R.string.setting_menu_not_connected) val logoutConfirmMessage = stringResource(R.string.setting_logout_confirm_message) @@ -242,6 +265,7 @@ private fun SettingMainRouteContent(navController: NavController) { } SettingMainScreen( + onBottomNavTabSelected = onBottomNavTabSelected, onClick = { title -> when (title) { "프로필 수정" -> navController.navigate(SettingRoute.ProfileEditRoute) @@ -268,9 +292,10 @@ private fun SettingMainRouteContent(navController: NavController) { @Composable private fun ReceiverDetailRouteContent( navController: NavController, + backStackEntry: NavBackStackEntry, route: SettingRoute.ReceiverDetailRoute ) { - val detailViewModel: ReceiverDetailViewModel = hiltViewModel() + val detailViewModel: ReceiverDetailViewModel = hiltViewModel(backStackEntry) val detailState by detailViewModel.uiState.collectAsStateWithLifecycle() val phoneNumberState = rememberTextFieldState() @@ -294,7 +319,11 @@ private fun ReceiverDetailRouteContent( ), callbacks = ReceiverDetailEditCallbacks( onBackClick = { navController.popBackStack() }, - onEditClick = { }, + onEditClick = { + navController.navigate( + SettingRoute.ReceiverEditRoute(receiverId = route.receiverId) + ) + }, onReceiverDetailImageClick = { }, onDailyQuestionClick = { navController.navigate( @@ -352,10 +381,48 @@ private fun ReceiverRegisterRouteContent(navController: NavController) { navController.popBackStack() } } - ReceiverRegisterScreen( - onBackClick = { navController.popBackStack() }, - onRegisterClick = { navController.popBackStack() }, - registerViewModel = registerViewModel + ReceiverFormScreen( + callbacks = ReceiverFormCallbacks( + onBackClick = { navController.popBackStack() }, + onActionClick = { navController.popBackStack() }, + onAddProfileImageClick = {} + ), + registerViewModel = registerViewModel, + screenTitle = "수신자 등록", + actionText = "등록" + ) +} + +@Composable +private fun ReceiverEditRouteContent( + navController: NavController, + backStackEntry: NavBackStackEntry +) { + val editViewModel: EditReceiverViewModel = hiltViewModel(backStackEntry) + val editState by editViewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(editState.updateSuccess) { + if (editState.updateSuccess) { + editViewModel.clearUpdateSuccess() + navController.popBackStack() + } + } + + ReceiverFormScreen( + callbacks = ReceiverFormCallbacks( + onBackClick = { navController.popBackStack() }, + onActionClick = {}, + onAddProfileImageClick = {} + ), + editViewModel = editViewModel, + screenTitle = "수신자 수정", + actionText = "수정", + initialFormState = ReceiverFormInitialState( + name = editState.name, + phone = editState.phone, + email = editState.email, + relation = editState.relation + ) ) } diff --git a/app/src/main/java/com/kuit/afternote/feature/setting/presentation/navgraph/SettingRoute.kt b/app/src/main/java/com/kuit/afternote/feature/setting/presentation/navgraph/SettingRoute.kt index 994f4fca..c71a1e23 100644 --- a/app/src/main/java/com/kuit/afternote/feature/setting/presentation/navgraph/SettingRoute.kt +++ b/app/src/main/java/com/kuit/afternote/feature/setting/presentation/navgraph/SettingRoute.kt @@ -44,6 +44,11 @@ sealed interface SettingRoute { @Serializable data object ReceiverRegisterRoute : SettingRoute + @Serializable + data class ReceiverEditRoute( + val receiverId: String = "" + ) : SettingRoute + @Serializable data object PostDeliveryConditionRoute : SettingRoute diff --git a/app/src/main/java/com/kuit/afternote/feature/setting/presentation/screen/main/SettingMainScreen.kt b/app/src/main/java/com/kuit/afternote/feature/setting/presentation/screen/main/SettingMainScreen.kt index 89cb63bc..1daeb16a 100644 --- a/app/src/main/java/com/kuit/afternote/feature/setting/presentation/screen/main/SettingMainScreen.kt +++ b/app/src/main/java/com/kuit/afternote/feature/setting/presentation/screen/main/SettingMainScreen.kt @@ -27,6 +27,7 @@ import com.kuit.afternote.ui.theme.Sansneo @Composable fun SettingMainScreen( modifier: Modifier = Modifier, + onBottomNavTabSelected: (BottomNavItem) -> Unit = {}, onClick: (String) -> Unit ) { val sections = listOf( @@ -71,7 +72,12 @@ fun SettingMainScreen( topBar = { TopBar(title = "설정") }, - bottomBar = { BottomNavigationBar(selectedItem = BottomNavItem.HOME) { } } + bottomBar = { + BottomNavigationBar( + selectedItem = BottomNavItem.HOME, + onItemSelected = onBottomNavTabSelected + ) + } ) { paddingValues -> LazyColumn( modifier = modifier diff --git a/app/src/main/java/com/kuit/afternote/feature/setting/presentation/screen/receiver/ReceiverRegisterScreen.kt b/app/src/main/java/com/kuit/afternote/feature/setting/presentation/screen/receiver/ReceiverFormScreen.kt similarity index 70% rename from app/src/main/java/com/kuit/afternote/feature/setting/presentation/screen/receiver/ReceiverRegisterScreen.kt rename to app/src/main/java/com/kuit/afternote/feature/setting/presentation/screen/receiver/ReceiverFormScreen.kt index c0b8240a..71a7eccc 100644 --- a/app/src/main/java/com/kuit/afternote/feature/setting/presentation/screen/receiver/ReceiverRegisterScreen.kt +++ b/app/src/main/java/com/kuit/afternote/feature/setting/presentation/screen/receiver/ReceiverFormScreen.kt @@ -17,6 +17,7 @@ import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -38,18 +39,40 @@ import com.kuit.afternote.feature.afternote.presentation.component.edit.dropdown import com.kuit.afternote.feature.afternote.presentation.component.edit.dropdown.SelectionDropdown import com.kuit.afternote.feature.afternote.presentation.component.edit.dropdown.SelectionDropdownLabelParams import com.kuit.afternote.feature.afternote.presentation.component.edit.dropdown.rememberSelectionDropdownState +import com.kuit.afternote.feature.user.presentation.viewmodel.EditReceiverViewModel import com.kuit.afternote.feature.user.presentation.viewmodel.RegisterReceiverViewModel import com.kuit.afternote.ui.theme.AfternoteTheme import com.kuit.afternote.ui.theme.Gray1 import com.kuit.afternote.ui.theme.White +/** + * Callbacks for the receiver form screen (register / edit). + */ +data class ReceiverFormCallbacks( + val onBackClick: () -> Unit = {}, + val onActionClick: () -> Unit = {}, + val onAddProfileImageClick: () -> Unit = {} +) + +/** + * Pre-filled values for the receiver form (edit mode). + */ +data class ReceiverFormInitialState( + val name: String = "", + val phone: String = "", + val email: String = "", + val relation: String = "" +) + @Composable -fun ReceiverRegisterScreen( +fun ReceiverFormScreen( modifier: Modifier = Modifier, - onBackClick: () -> Unit, - onRegisterClick: () -> Unit = {}, - onAddProfileImageClick: () -> Unit = {}, - registerViewModel: RegisterReceiverViewModel? = null + callbacks: ReceiverFormCallbacks = ReceiverFormCallbacks(), + registerViewModel: RegisterReceiverViewModel? = null, + editViewModel: EditReceiverViewModel? = null, + screenTitle: String = "수신자 등록", + actionText: String = "등록", + initialFormState: ReceiverFormInitialState = ReceiverFormInitialState() ) { val nameState = rememberTextFieldState() val phoneNumberState = rememberTextFieldState() @@ -57,29 +80,52 @@ fun ReceiverRegisterScreen( var relationshipSelectedValue by remember { mutableStateOf("딸") } val relationshipOptions = listOf("딸", "아들", "친구", "가족", "연인", "동료", "기타") val dropdownState = rememberSelectionDropdownState() + var initialApplied by remember { mutableStateOf(false) } + + LaunchedEffect( + initialFormState.name, + initialFormState.phone, + initialFormState.email, + initialFormState.relation + ) { + if (!initialApplied && initialFormState.name.isNotEmpty()) { + nameState.edit { replace(0, length, initialFormState.name) } + phoneNumberState.edit { replace(0, length, initialFormState.phone) } + emailState.edit { replace(0, length, initialFormState.email) } + relationshipSelectedValue = + if (initialFormState.relation in relationshipOptions) initialFormState.relation + else "딸" + initialApplied = true + } + } - val onRegisterAction: () -> Unit = { - if (registerViewModel != null) { - registerViewModel.registerReceiver( + val onPrimaryAction: () -> Unit = { + when { + editViewModel != null -> editViewModel.updateReceiver( name = nameState.text.toString().trim(), relation = relationshipSelectedValue, phone = phoneNumberState.text.toString().trim().takeIf { it.isNotBlank() }, email = emailState.text.toString().trim().takeIf { it.isNotBlank() } ) - } else { - onRegisterClick() + registerViewModel != null -> registerViewModel.registerReceiver( + name = nameState.text.toString().trim(), + relation = relationshipSelectedValue, + phone = phoneNumberState.text.toString().trim().takeIf { it.isNotBlank() }, + email = emailState.text.toString().trim().takeIf { it.isNotBlank() } + ) + else -> callbacks.onActionClick() } } - BackHandler(onBack = onBackClick) + BackHandler(onBack = callbacks.onBackClick) Scaffold( containerColor = Gray1, topBar = { TopBar( - title = "수신자 등록", - onBackClick = onBackClick, - onActionClick = onRegisterAction, - actionText = "등록" + title = screenTitle, + onBackClick = callbacks.onBackClick, + onActionClick = onPrimaryAction, + actionText = actionText ) } ) { paddingValues -> @@ -94,7 +140,7 @@ fun ReceiverRegisterScreen( // Profile Image with Add Button ProfileImageWithAddButton( - onAddClick = onAddProfileImageClick + onAddClick = callbacks.onAddProfileImageClick ) Spacer(modifier = Modifier.height(16.dp)) @@ -205,12 +251,30 @@ private fun ProfileImageWithAddButton( @Preview(showBackground = true) @Composable -private fun ReceiverRegisterScreenPreview() { +private fun ReceiverFormScreenRegisterPreview() { + AfternoteTheme { + ReceiverFormScreen( + callbacks = ReceiverFormCallbacks( + onBackClick = {}, + onActionClick = {}, + onAddProfileImageClick = {} + ) + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun ReceiverFormScreenEditPreview() { AfternoteTheme { - ReceiverRegisterScreen( - onBackClick = {}, - onRegisterClick = {}, - onAddProfileImageClick = {} + ReceiverFormScreen( + callbacks = ReceiverFormCallbacks( + onBackClick = {}, + onActionClick = {}, + onAddProfileImageClick = {} + ), + screenTitle = "수신자 수정", + actionText = "수정" ) } } diff --git a/app/src/main/java/com/kuit/afternote/feature/setting/presentation/screen/receiver/ReceiverTimeLetterListScreen.kt b/app/src/main/java/com/kuit/afternote/feature/setting/presentation/screen/receiver/ReceiverTimeLetterListScreen.kt index 3bfa8fb7..1ccc7c25 100644 --- a/app/src/main/java/com/kuit/afternote/feature/setting/presentation/screen/receiver/ReceiverTimeLetterListScreen.kt +++ b/app/src/main/java/com/kuit/afternote/feature/setting/presentation/screen/receiver/ReceiverTimeLetterListScreen.kt @@ -52,11 +52,11 @@ fun ReceiverTimeLetterListScreen( onBackClick = onBackClick ) } - ) { paddingValues -> + ) { contentPadding -> LazyColumn( modifier = modifier .fillMaxSize() - .padding(paddingValues), + .padding(contentPadding), contentPadding = PaddingValues(horizontal = 20.dp), horizontalAlignment = Alignment.CenterHorizontally ) { diff --git a/app/src/main/java/com/kuit/afternote/feature/user/data/api/UserApiService.kt b/app/src/main/java/com/kuit/afternote/feature/user/data/api/UserApiService.kt index 3445e53e..f4a163c4 100644 --- a/app/src/main/java/com/kuit/afternote/feature/user/data/api/UserApiService.kt +++ b/app/src/main/java/com/kuit/afternote/feature/user/data/api/UserApiService.kt @@ -84,10 +84,19 @@ interface UserApiService { */ @GET("users/receivers/{receiverId}") suspend fun getReceiverDetail( - @Query("userId") userId: Long, @Path("receiverId") receiverId: Long ): ApiResponse + /** + * 수신인 수정. + * PATCH /users/receivers/{receiverId} — 특정 수신인 정보를 수정합니다. + */ + @PATCH("users/receivers/{receiverId}") + suspend fun updateReceiver( + @Path("receiverId") receiverId: Long, + @Body body: RegisterReceiverRequestDto + ): ApiResponse + /** * 수신인별 데일리 질문 답변 목록 조회 (페이지네이션). * GET /users/receivers/{receiverId}/daily-questions diff --git a/app/src/main/java/com/kuit/afternote/feature/user/data/dto/UserDto.kt b/app/src/main/java/com/kuit/afternote/feature/user/data/dto/UserDto.kt index aea500e8..0ff44cbf 100644 --- a/app/src/main/java/com/kuit/afternote/feature/user/data/dto/UserDto.kt +++ b/app/src/main/java/com/kuit/afternote/feature/user/data/dto/UserDto.kt @@ -72,9 +72,9 @@ data class ReceiverDetailResponseDto( val relation: String, val phone: String? = null, val email: String? = null, - val dailyQuestionCount: Int, - val timeLetterCount: Int, - val afterNoteCount: Int + val dailyQuestionCount: Int = 0, + val timeLetterCount: Int = 0, + val afterNoteCount: Int = 0 ) // --- GET /users/receivers/{receiverId}/daily-questions (time-letters, after-notes are in Received API) --- diff --git a/app/src/main/java/com/kuit/afternote/feature/user/data/repository/UserRepositoryImpl.kt b/app/src/main/java/com/kuit/afternote/feature/user/data/repository/UserRepositoryImpl.kt index c021f727..cd248927 100644 --- a/app/src/main/java/com/kuit/afternote/feature/user/data/repository/UserRepositoryImpl.kt +++ b/app/src/main/java/com/kuit/afternote/feature/user/data/repository/UserRepositoryImpl.kt @@ -126,14 +126,43 @@ class UserRepositoryImpl data.receiverId } - override suspend fun getReceiverDetail(userId: Long, receiverId: Long): Result = + override suspend fun getReceiverDetail(receiverId: Long): Result = runCatching { - Log.d(TAG, "getReceiverDetail: userId=$userId, receiverId=$receiverId") - val response = api.getReceiverDetail(userId = userId, receiverId = receiverId) + Log.d(TAG, "getReceiverDetail: receiverId=$receiverId") + val response = api.getReceiverDetail(receiverId = receiverId) Log.d(TAG, "getReceiverDetail: response=$response") UserMapper.toReceiverDetail(response.requireData()) } + override suspend fun updateReceiver( + receiverId: Long, + name: String, + relation: String, + phone: String?, + email: String? + ): Result = + runCatching { + Log.d(TAG, "updateReceiver: receiverId=$receiverId, name=$name") + val response = api.updateReceiver( + receiverId = receiverId, + body = RegisterReceiverRequestDto( + name = name, + relation = relation, + phone = phone, + email = email + ) + ) + Log.d(TAG, "updateReceiver: response=$response") + if (response.status !in 200..299) { + throw ApiException( + status = response.status, + code = response.code, + message = response.message + ) + } + Unit + } + override suspend fun getReceiverDailyQuestions( receiverId: Long, page: Int, diff --git a/app/src/main/java/com/kuit/afternote/feature/user/domain/repository/UserRepository.kt b/app/src/main/java/com/kuit/afternote/feature/user/domain/repository/UserRepository.kt index d0b98db0..70d31e73 100644 --- a/app/src/main/java/com/kuit/afternote/feature/user/domain/repository/UserRepository.kt +++ b/app/src/main/java/com/kuit/afternote/feature/user/domain/repository/UserRepository.kt @@ -41,7 +41,15 @@ interface UserRepository { email: String? ): Result - suspend fun getReceiverDetail(userId: Long, receiverId: Long): Result + suspend fun getReceiverDetail(receiverId: Long): Result + + suspend fun updateReceiver( + receiverId: Long, + name: String, + relation: String, + phone: String?, + email: String? + ): Result suspend fun getReceiverDailyQuestions( receiverId: Long, diff --git a/app/src/main/java/com/kuit/afternote/feature/user/domain/usecase/GetReceiverDetailUseCase.kt b/app/src/main/java/com/kuit/afternote/feature/user/domain/usecase/GetReceiverDetailUseCase.kt index 00305ebf..764e97ef 100644 --- a/app/src/main/java/com/kuit/afternote/feature/user/domain/usecase/GetReceiverDetailUseCase.kt +++ b/app/src/main/java/com/kuit/afternote/feature/user/domain/usecase/GetReceiverDetailUseCase.kt @@ -14,9 +14,8 @@ class GetReceiverDetailUseCase private val userRepository: UserRepository ) { /** - * @param userId 사용자 ID (query, required) * @param receiverId 수신인 식별자 (path, required) */ - suspend operator fun invoke(userId: Long, receiverId: Long): Result = - userRepository.getReceiverDetail(userId = userId, receiverId = receiverId) + suspend operator fun invoke(receiverId: Long): Result = + userRepository.getReceiverDetail(receiverId = receiverId) } diff --git a/app/src/main/java/com/kuit/afternote/feature/user/domain/usecase/UpdateReceiverUseCase.kt b/app/src/main/java/com/kuit/afternote/feature/user/domain/usecase/UpdateReceiverUseCase.kt new file mode 100644 index 00000000..2cd021e9 --- /dev/null +++ b/app/src/main/java/com/kuit/afternote/feature/user/domain/usecase/UpdateReceiverUseCase.kt @@ -0,0 +1,29 @@ +package com.kuit.afternote.feature.user.domain.usecase + +import com.kuit.afternote.feature.user.domain.repository.UserRepository +import javax.inject.Inject + +/** + * 수신자 수정 UseCase. + * PATCH /users/receivers/{receiverId} + */ +class UpdateReceiverUseCase + @Inject + constructor( + private val userRepository: UserRepository + ) { + suspend operator fun invoke( + receiverId: Long, + name: String, + relation: String, + phone: String? = null, + email: String? = null + ): Result = + userRepository.updateReceiver( + receiverId = receiverId, + name = name, + relation = relation, + phone = phone, + email = email + ) + } diff --git a/app/src/main/java/com/kuit/afternote/feature/user/presentation/uimodel/UserUiState.kt b/app/src/main/java/com/kuit/afternote/feature/user/presentation/uimodel/UserUiState.kt index f2454004..a57c10e4 100644 --- a/app/src/main/java/com/kuit/afternote/feature/user/presentation/uimodel/UserUiState.kt +++ b/app/src/main/java/com/kuit/afternote/feature/user/presentation/uimodel/UserUiState.kt @@ -57,6 +57,17 @@ data class ReceiverDetailUiState( val errorMessage: String? = null ) +data class EditReceiverUiState( + val receiverId: Long = 0L, + val name: String = "", + val relation: String = "", + val phone: String = "", + val email: String = "", + val isLoading: Boolean = false, + val errorMessage: String? = null, + val updateSuccess: Boolean = false +) + data class DailyQuestionAnswerItemUi( val dailyQuestionAnswerId: Long, val question: String, diff --git a/app/src/main/java/com/kuit/afternote/feature/user/presentation/viewmodel/EditReceiverViewModel.kt b/app/src/main/java/com/kuit/afternote/feature/user/presentation/viewmodel/EditReceiverViewModel.kt new file mode 100644 index 00000000..a9d2e483 --- /dev/null +++ b/app/src/main/java/com/kuit/afternote/feature/user/presentation/viewmodel/EditReceiverViewModel.kt @@ -0,0 +1,114 @@ +package com.kuit.afternote.feature.user.presentation.viewmodel + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.navigation.toRoute +import com.kuit.afternote.feature.setting.presentation.navgraph.SettingRoute +import com.kuit.afternote.feature.user.domain.usecase.GetReceiverDetailUseCase +import com.kuit.afternote.feature.user.domain.usecase.UpdateReceiverUseCase +import com.kuit.afternote.feature.user.presentation.uimodel.EditReceiverUiState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * 수신자 수정 화면 ViewModel. + * GET /users/receivers/{receiverId} 로 상세 조회, PATCH /users/receivers/{receiverId} 로 수정. + */ +@HiltViewModel +class EditReceiverViewModel + @Inject + constructor( + savedStateHandle: SavedStateHandle, + private val getReceiverDetailUseCase: GetReceiverDetailUseCase, + private val updateReceiverUseCase: UpdateReceiverUseCase + ) : ViewModel() { + private val _uiState = MutableStateFlow(EditReceiverUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val receiverId: Long? = savedStateHandle.toRoute() + .receiverId.toLongOrNull() + + init { + receiverId?.let { loadReceiverDetail(it) } + } + + fun loadReceiverDetail(receiverId: Long) { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + getReceiverDetailUseCase(receiverId = receiverId) + .onSuccess { detail -> + _uiState.update { + it.copy( + receiverId = detail.receiverId, + name = detail.name, + relation = detail.relation, + phone = detail.phone.orEmpty(), + email = detail.email.orEmpty(), + isLoading = false, + errorMessage = null + ) + } + } + .onFailure { e -> + _uiState.update { + it.copy( + isLoading = false, + errorMessage = e.message ?: "수신자 정보를 불러오지 못했습니다." + ) + } + } + } + } + + fun updateReceiver( + name: String, + relation: String, + phone: String?, + email: String? + ) { + val id = _uiState.value.receiverId + if (id == 0L) return + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, errorMessage = null, updateSuccess = false) } + updateReceiverUseCase( + receiverId = id, + name = name, + relation = relation, + phone = phone?.takeIf { it.isNotBlank() }, + email = email?.takeIf { it.isNotBlank() } + ) + .onSuccess { + _uiState.update { + it.copy( + isLoading = false, + errorMessage = null, + updateSuccess = true + ) + } + } + .onFailure { e -> + _uiState.update { + it.copy( + isLoading = false, + errorMessage = e.message ?: "수신자 수정에 실패했습니다.", + updateSuccess = false + ) + } + } + } + } + + fun clearUpdateSuccess() { + _uiState.update { it.copy(updateSuccess = false) } + } + + fun clearError() { + _uiState.update { it.copy(errorMessage = null) } + } + } diff --git a/app/src/main/java/com/kuit/afternote/feature/user/presentation/viewmodel/ReceiverDetailViewModel.kt b/app/src/main/java/com/kuit/afternote/feature/user/presentation/viewmodel/ReceiverDetailViewModel.kt index bbfefb41..b2de61b0 100644 --- a/app/src/main/java/com/kuit/afternote/feature/user/presentation/viewmodel/ReceiverDetailViewModel.kt +++ b/app/src/main/java/com/kuit/afternote/feature/user/presentation/viewmodel/ReceiverDetailViewModel.kt @@ -6,7 +6,6 @@ import androidx.lifecycle.viewModelScope import androidx.navigation.toRoute import com.kuit.afternote.feature.setting.presentation.navgraph.SettingRoute import com.kuit.afternote.feature.user.domain.usecase.GetReceiverDetailUseCase -import com.kuit.afternote.feature.user.domain.usecase.GetUserIdUseCase import com.kuit.afternote.feature.user.presentation.uimodel.ReceiverDetailUiState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow @@ -25,8 +24,7 @@ class ReceiverDetailViewModel @Inject constructor( savedStateHandle: SavedStateHandle, - private val getReceiverDetailUseCase: GetReceiverDetailUseCase, - private val getUserIdUseCase: GetUserIdUseCase + private val getReceiverDetailUseCase: GetReceiverDetailUseCase ) : ViewModel() { private val _uiState = MutableStateFlow(ReceiverDetailUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -46,18 +44,8 @@ class ReceiverDetailViewModel fun loadReceiverDetail(receiverId: Long) { viewModelScope.launch { - val userId = getUserIdUseCase() - if (userId == null) { - _uiState.update { - it.copy( - isLoading = false, - errorMessage = "사용자 정보를 가져올 수 없습니다. 다시 로그인해주세요." - ) - } - return@launch - } _uiState.update { it.copy(isLoading = true, errorMessage = null) } - getReceiverDetailUseCase(userId = userId, receiverId = receiverId) + getReceiverDetailUseCase(receiverId = receiverId) .onSuccess { detail -> _uiState.update { it.copy( diff --git a/app/src/test/java/com/kuit/afternote/feature/user/presentation/viewmodel/ReceiverDetailViewModelTest.kt b/app/src/test/java/com/kuit/afternote/feature/user/presentation/viewmodel/ReceiverDetailViewModelTest.kt index 612455a1..1aae000e 100644 --- a/app/src/test/java/com/kuit/afternote/feature/user/presentation/viewmodel/ReceiverDetailViewModelTest.kt +++ b/app/src/test/java/com/kuit/afternote/feature/user/presentation/viewmodel/ReceiverDetailViewModelTest.kt @@ -3,7 +3,6 @@ package com.kuit.afternote.feature.user.presentation.viewmodel import androidx.lifecycle.SavedStateHandle import com.kuit.afternote.feature.user.domain.model.ReceiverDetail import com.kuit.afternote.feature.user.domain.usecase.GetReceiverDetailUseCase -import com.kuit.afternote.feature.user.domain.usecase.GetUserIdUseCase import com.kuit.afternote.util.MainCoroutineRule import io.mockk.coEvery import io.mockk.mockk @@ -31,13 +30,10 @@ class ReceiverDetailViewModelTest { val mainRule = MainCoroutineRule() private lateinit var getReceiverDetailUseCase: GetReceiverDetailUseCase - private lateinit var getUserIdUseCase: GetUserIdUseCase @Before fun setUp() { getReceiverDetailUseCase = mockk() - getUserIdUseCase = mockk() - coEvery { getUserIdUseCase() } returns 1L } private fun savedStateHandle(receiverId: String = ""): SavedStateHandle = @@ -49,8 +45,7 @@ class ReceiverDetailViewModelTest { val savedStateHandle = savedStateHandle(receiverId = "") val viewModel = ReceiverDetailViewModel( savedStateHandle, - getReceiverDetailUseCase, - getUserIdUseCase + getReceiverDetailUseCase ) advanceUntilIdle() @@ -71,12 +66,11 @@ class ReceiverDetailViewModelTest { timeLetterCount = 12, afterNoteCount = 4 ) - coEvery { getReceiverDetailUseCase(userId = 1L, receiverId = 1L) } returns Result.success(detail) + coEvery { getReceiverDetailUseCase(receiverId = 1L) } returns Result.success(detail) val savedStateHandle = savedStateHandle(receiverId = "1") val viewModel = ReceiverDetailViewModel( savedStateHandle, - getReceiverDetailUseCase, - getUserIdUseCase + getReceiverDetailUseCase ) viewModel.loadReceiverDetail(receiverId = 1L) advanceUntilIdle() @@ -94,12 +88,11 @@ class ReceiverDetailViewModelTest { val errorBody = """{"status":401,"code":401,"message":"Unauthorized"}""" .toResponseBody("application/json".toMediaType()) val httpException = HttpException(Response.error(401, errorBody)) - coEvery { getReceiverDetailUseCase(userId = any(), receiverId = any()) } returns Result.failure(httpException) + coEvery { getReceiverDetailUseCase(receiverId = any()) } returns Result.failure(httpException) val savedStateHandle = savedStateHandle(receiverId = "1") val viewModel = ReceiverDetailViewModel( savedStateHandle, - getReceiverDetailUseCase, - getUserIdUseCase + getReceiverDetailUseCase ) viewModel.loadReceiverDetail(receiverId = 1L) advanceUntilIdle() @@ -114,12 +107,11 @@ class ReceiverDetailViewModelTest { val errorBody = """{"status":404,"code":404,"message":"Receiver not found"}""" .toResponseBody("application/json".toMediaType()) val httpException = HttpException(Response.error(404, errorBody)) - coEvery { getReceiverDetailUseCase(userId = any(), receiverId = any()) } returns Result.failure(httpException) + coEvery { getReceiverDetailUseCase(receiverId = any()) } returns Result.failure(httpException) val savedStateHandle = savedStateHandle(receiverId = "1") val viewModel = ReceiverDetailViewModel( savedStateHandle, - getReceiverDetailUseCase, - getUserIdUseCase + getReceiverDetailUseCase ) viewModel.loadReceiverDetail(receiverId = 999L) advanceUntilIdle() @@ -134,12 +126,11 @@ class ReceiverDetailViewModelTest { val errorBody = """{"status":500,"code":500,"message":"Server error"}""" .toResponseBody("application/json".toMediaType()) val httpException = HttpException(Response.error(500, errorBody)) - coEvery { getReceiverDetailUseCase(userId = any(), receiverId = any()) } returns Result.failure(httpException) + coEvery { getReceiverDetailUseCase(receiverId = any()) } returns Result.failure(httpException) val savedStateHandle = savedStateHandle(receiverId = "1") val viewModel = ReceiverDetailViewModel( savedStateHandle, - getReceiverDetailUseCase, - getUserIdUseCase + getReceiverDetailUseCase ) viewModel.loadReceiverDetail(receiverId = 1L) advanceUntilIdle() @@ -151,14 +142,13 @@ class ReceiverDetailViewModelTest { @Test fun loadReceiverDetail_whenNetworkError_setsErrorMessage() = runTest { - coEvery { getReceiverDetailUseCase(userId = any(), receiverId = any()) } returns Result.failure( + coEvery { getReceiverDetailUseCase(receiverId = any()) } returns Result.failure( java.io.IOException("Network unavailable") ) val savedStateHandle = savedStateHandle(receiverId = "1") val viewModel = ReceiverDetailViewModel( savedStateHandle, - getReceiverDetailUseCase, - getUserIdUseCase + getReceiverDetailUseCase ) viewModel.loadReceiverDetail(receiverId = 1L) advanceUntilIdle()