diff --git a/app/src/main/java/com/afternote/afternote_fe/navigation/AppNavigationActions.kt b/app/src/main/java/com/afternote/afternote_fe/navigation/AppNavigationActions.kt index 29ade94ed6..f76a648607 100644 --- a/app/src/main/java/com/afternote/afternote_fe/navigation/AppNavigationActions.kt +++ b/app/src/main/java/com/afternote/afternote_fe/navigation/AppNavigationActions.kt @@ -356,6 +356,22 @@ fun rememberSettingNavActions(appState: AppState): SettingNavActions = override fun onNoticeBack() { appState.navController.popBackStack() } + + override fun onNavigateToInquiry() { + appState.navController.navigate(SettingRoute.InquiryListRoute) + } + + override fun onInquiryBack() { + appState.navController.popBackStack() + } + + override fun onNavigateToInquiryDetail(inquiryId: Long) { + appState.navController.navigate(SettingRoute.InquiryDetailRoute(inquiryId)) + } + + override fun onNavigateToInquiryWrite() { + appState.navController.navigate(SettingRoute.InquiryWriteRoute) + } } } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/model/InquiryUiModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/model/InquiryUiModel.kt new file mode 100644 index 0000000000..98bfed89da --- /dev/null +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/model/InquiryUiModel.kt @@ -0,0 +1,12 @@ +package com.afternote.feature.setting.presentation.model + +enum class InquiryStatus { RECEIVED, ANSWERED } + +data class InquiryUiModel( + val id: Long, + val status: InquiryStatus, + val date: String, + val title: String, + val content: String, + val answer: String?, +) diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingNavActions.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingNavActions.kt index 44bef11258..c6bdeb019d 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingNavActions.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingNavActions.kt @@ -64,4 +64,12 @@ interface SettingNavActions { fun onNavigateToNotice() fun onNoticeBack() + + fun onNavigateToInquiry() + + fun onInquiryBack() + + fun onNavigateToInquiryDetail(inquiryId: Long) + + fun onNavigateToInquiryWrite() } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingNavGraph.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingNavGraph.kt index c47d60b995..3934782bbb 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingNavGraph.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingNavGraph.kt @@ -14,6 +14,9 @@ import com.afternote.feature.setting.presentation.component.PinSetupStep import com.afternote.feature.setting.presentation.screen.AppLockSetupScreen import com.afternote.feature.setting.presentation.screen.ConnectedAccountsScreen import com.afternote.feature.setting.presentation.screen.DeliveryConditionScreen +import com.afternote.feature.setting.presentation.screen.InquiryDetailScreen +import com.afternote.feature.setting.presentation.screen.InquiryListScreen +import com.afternote.feature.setting.presentation.screen.InquiryWriteScreen import com.afternote.feature.setting.presentation.screen.NoticeListScreen import com.afternote.feature.setting.presentation.screen.PassKeyListScreen import com.afternote.feature.setting.presentation.screen.PassKeyMakingScreen @@ -52,7 +55,7 @@ fun NavGraphBuilder.settingNavGraph( onPasskeyClick = actions::onNavigateToPasskey, onAppLockClick = actions::onNavigateToAppLock, onFaqClick = {}, - onInquiryClick = {}, + onInquiryClick = actions::onNavigateToInquiry, onNoticeClick = actions::onNavigateToNotice, onTermsClick = {}, onPrivacyClick = {}, @@ -187,5 +190,26 @@ fun NavGraphBuilder.settingNavGraph( onBackClick = actions::onNoticeBack, ) } + + composable { + InquiryListScreen( + inquiries = emptyList(), + onBackClick = actions::onInquiryBack, + onInquiryClick = actions::onNavigateToInquiryDetail, + onNewInquiryClick = actions::onNavigateToInquiryWrite, + ) + } + + composable { + // 문의 조회 API 가 아직 없다 (Afternote-BE#246). 실제 데이터 소스가 연결되기 전까지는 + // 지어낸 문의를 보여주는 대신 "찾을 수 없음" 상태를 그린다. + InquiryDetailScreen(inquiry = null, onBackClick = actions::onInquiryBack) + } + + composable { + InquiryWriteScreen( + onBackClick = actions::onInquiryBack, + ) + } } } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingRoute.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingRoute.kt index 898cb15077..26dae13e3e 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingRoute.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingRoute.kt @@ -53,4 +53,15 @@ sealed interface SettingRoute { @Serializable data object NoticeRoute : SettingRoute + + @Serializable + data object InquiryListRoute : SettingRoute + + @Serializable + data class InquiryDetailRoute( + val inquiryId: Long, + ) : SettingRoute + + @Serializable + data object InquiryWriteRoute : SettingRoute } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/InquiryScreens.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/InquiryScreens.kt new file mode 100644 index 0000000000..f50c780ea0 --- /dev/null +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/InquiryScreens.kt @@ -0,0 +1,443 @@ +package com.afternote.feature.setting.presentation.screen + +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.afternote.core.ui.button.AfternoteButton +import com.afternote.core.ui.button.AfternoteButtonType +import com.afternote.core.ui.theme.AfternoteDesign +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.core.ui.topbar.DetailTopBar +import com.afternote.feature.setting.presentation.R +import com.afternote.feature.setting.presentation.model.InquiryStatus +import com.afternote.feature.setting.presentation.model.InquiryUiModel +import kotlinx.coroutines.launch + +@Composable +fun InquiryListScreen( + inquiries: List, + onBackClick: () -> Unit, + onInquiryClick: (Long) -> Unit, + onNewInquiryClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier, + topBar = { InquiryTopBar(onBackClick) }, + bottomBar = { + AfternoteButton( + text = stringResource(R.string.inquiry_new), + onClick = onNewInquiryClick, + modifier = Modifier.navigationBarsPadding().padding(horizontal = 20.dp, vertical = 16.dp), + ) + }, + containerColor = AfternoteDesign.colors.gray1, + ) { padding -> + if (inquiries.isEmpty()) { + InquiryEmptyState(modifier = Modifier.fillMaxSize().padding(padding)) + } else { + LazyColumn( + modifier = Modifier.fillMaxSize().padding(padding).padding(horizontal = 20.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + item { Spacer(Modifier.height(2.dp)) } + items(inquiries, key = { it.id }) { inquiry -> + InquiryListItem(inquiry, onClick = { onInquiryClick(inquiry.id) }) + } + } + } + } +} + +@Composable +private fun InquiryEmptyState( + modifier: Modifier = Modifier, + message: String = stringResource(R.string.inquiry_list_empty), +) { + Box( + modifier = modifier.padding(horizontal = 20.dp).padding(top = 24.dp), + ) { + Text( + text = message, + style = AfternoteDesign.typography.bodyLargeR, + color = AfternoteDesign.colors.gray8, + ) + } +} + +@Composable +private fun InquiryListItem( + inquiry: InquiryUiModel, + onClick: () -> Unit, +) { + Surface( + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), + shape = RoundedCornerShape(6.dp), + color = AfternoteDesign.colors.white, + border = BorderStroke(1.dp, AfternoteDesign.colors.gray2), + ) { + Column(Modifier.padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Surface( + shape = RoundedCornerShape(18.dp), + color = if (inquiry.status == InquiryStatus.ANSWERED) Color(0xFFDCEDE3) else AfternoteDesign.colors.gray2, + ) { + Text( + text = + stringResource( + if (inquiry.status == + InquiryStatus.ANSWERED + ) { + R.string.inquiry_answered + } else { + R.string.inquiry_received + }, + ), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + style = AfternoteDesign.typography.captionLargeB, + ) + } + Spacer(Modifier.weight(1f)) + Text(inquiry.date, style = AfternoteDesign.typography.footnoteCaption, color = AfternoteDesign.colors.gray9) + } + Spacer(Modifier.height(12.dp)) + Text(inquiry.title, style = AfternoteDesign.typography.bodyBase, color = AfternoteDesign.colors.gray9) + Spacer(Modifier.height(6.dp)) + Text( + text = inquiry.content.replace('\n', ' '), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = AfternoteDesign.typography.bodySmallR, + color = AfternoteDesign.colors.gray6, + ) + } + } +} + +@Composable +fun InquiryDetailScreen( + inquiry: InquiryUiModel?, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier, + topBar = { InquiryTopBar(onBackClick) }, + containerColor = AfternoteDesign.colors.gray1, + ) { padding -> + if (inquiry == null) { + InquiryEmptyState( + modifier = Modifier.fillMaxSize().padding(padding), + message = stringResource(R.string.inquiry_detail_not_found), + ) + return@Scaffold + } + Column(Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState())) { + Column(Modifier.padding(horizontal = 20.dp, vertical = 24.dp)) { + Text(inquiry.date, style = AfternoteDesign.typography.footnoteCaption, color = AfternoteDesign.colors.gray8) + Spacer(Modifier.height(10.dp)) + Text(inquiry.title, style = AfternoteDesign.typography.h2, color = AfternoteDesign.colors.gray9) + } + HorizontalDivider(color = AfternoteDesign.colors.gray2) + Column(Modifier.padding(horizontal = 20.dp, vertical = 24.dp)) { + Text(inquiry.content, style = AfternoteDesign.typography.bodySmallR, color = AfternoteDesign.colors.gray9) + inquiry.answer?.let { answer -> + HorizontalDivider(Modifier.padding(vertical = 28.dp), color = AfternoteDesign.colors.gray3) + Text( + stringResource(R.string.inquiry_answer), + style = AfternoteDesign.typography.captionLargeR, + color = AfternoteDesign.colors.gray6, + ) + Spacer(Modifier.height(10.dp)) + Surface( + shape = RoundedCornerShape(6.dp), + color = AfternoteDesign.colors.white, + border = BorderStroke(1.dp, AfternoteDesign.colors.gray2), + ) { + Text( + answer, + Modifier.padding(24.dp), + style = AfternoteDesign.typography.bodySmallR, + color = AfternoteDesign.colors.gray9, + ) + } + } + } + } + } +} + +@Composable +fun InquiryWriteScreen( + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + var type by rememberSaveable { mutableStateOf("\ud0c0\uc784\ub808\ud130") } + var title by rememberSaveable { mutableStateOf("") } + var content by rememberSaveable { mutableStateOf("") } + var attachments by remember { mutableStateOf>(emptyList()) } + val onAddScreenshots: () -> Unit = + if (LocalInspectionMode.current) { + ({}) + } else { + val picker = + rememberLauncherForActivityResult(ActivityResultContracts.GetMultipleContents()) { uris -> + attachments = (attachments + uris).distinct().take(MAX_INQUIRY_IMAGES) + } + ({ picker.launch("image/*") }) + } + val canSubmit = title.isNotBlank() && content.isNotBlank() + val snackbarHostState = remember { SnackbarHostState() } + val coroutineScope = rememberCoroutineScope() + val submitNotSupportedMessage = stringResource(R.string.inquiry_submit_not_supported) + + Scaffold( + modifier = modifier.imePadding(), + topBar = { InquiryTopBar(onBackClick) }, + bottomBar = { + AfternoteButton( + text = stringResource(R.string.inquiry_submit), + onClick = { + // \uc811\uc218 API \uac00 \uc544\uc9c1 \uc5c6\ub2e4 (Afternote-BE#246). \uc811\uc218\ub41c \uac83\ucc98\ub7fc \ub4a4\ub85c \uac00\uc9c0 \uc54a\uace0 + // \uc544\uc9c1 \uc9c0\uc6d0\ud558\uc9c0 \uc54a\ub294 \uae30\ub2a5\uc784\uc744 \uadf8\ub300\ub85c \uc54c\ub9b0\ub2e4 \u2014 \uc131\uacf5\ud55c \ucc99 \ub2eb\uc9c0 \uc54a\ub294\ub2e4 (#1629 \uc640 \ub3d9\uc77c\ud55c \uc6d0\uce59). + coroutineScope.launch { snackbarHostState.showSnackbar(submitNotSupportedMessage) } + }, + type = if (canSubmit) AfternoteButtonType.Default else AfternoteButtonType.Un, + modifier = Modifier.navigationBarsPadding().padding(horizontal = 20.dp, vertical = 16.dp), + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + containerColor = AfternoteDesign.colors.gray1, + ) { padding -> + Column( + Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp, vertical = 16.dp), + ) { + InquiryTypeDropdown(type, onSelected = { type = it }) + Spacer(Modifier.height(22.dp)) + InquiryFieldLabel(stringResource(R.string.inquiry_title)) + OutlinedTextField( + value = title, + onValueChange = { title = it }, + modifier = Modifier.fillMaxWidth(), + placeholder = { + Text( + text = stringResource(R.string.inquiry_title_text_field), + color = AfternoteDesign.colors.gray4, + ) + }, + singleLine = true, + shape = RoundedCornerShape(8.dp), + ) + Spacer(Modifier.height(18.dp)) + InquiryFieldLabel(stringResource(R.string.inquiry_content)) + OutlinedTextField( + value = content, + onValueChange = { content = it }, + modifier = Modifier.fillMaxWidth().height(180.dp), + placeholder = { + Text( + text = stringResource(R.string.inquiry_content_text_field), + color = AfternoteDesign.colors.gray4, + ) + }, + shape = RoundedCornerShape(8.dp), + ) + Spacer(Modifier.height(20.dp)) + Surface( + modifier = + Modifier + .fillMaxWidth() + .clickable( + enabled = attachments.size < MAX_INQUIRY_IMAGES, + onClick = onAddScreenshots, + ), + shape = RoundedCornerShape(8.dp), + color = AfternoteDesign.colors.white, + border = BorderStroke(1.dp, AfternoteDesign.colors.gray2), + ) { + Row(Modifier.padding(horizontal = 16.dp, vertical = 18.dp), verticalAlignment = Alignment.CenterVertically) { + Icon( + painter = painterResource(com.afternote.core.ui.R.drawable.core_ui_ic_image), + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = AfternoteDesign.colors.gray7, + ) + Spacer(Modifier.size(12.dp)) + Text( + stringResource(R.string.inquiry_add_screenshot, attachments.size, MAX_INQUIRY_IMAGES), + style = AfternoteDesign.typography.bodySmallR, + ) + } + } + } + } +} + +@Composable +private fun InquiryTypeDropdown( + selected: String, + onSelected: (String) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + InquiryFieldLabel(stringResource(R.string.inquiry_type)) + Box { + Row( + Modifier.fillMaxWidth().clickable { expanded = true }.padding(vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(selected, Modifier.weight(1f), style = AfternoteDesign.typography.bodySmallR) + Text("\u2304", style = AfternoteDesign.typography.bodyLargeR) + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + listOf( + "\ud0c0\uc784\ub808\ud130", + "\ub9c8\uc74c \uae30\ub85d", + "\uc560\ud504\ud130\ub178\ud2b8", + "\uae30\ud0c0", + ).forEach { item -> + DropdownMenuItem(text = { Text(item) }, onClick = { + onSelected(item) + expanded = false + }) + } + } + } + HorizontalDivider(color = AfternoteDesign.colors.gray2) +} + +@Composable +private fun InquiryFieldLabel(text: String) { + Text(text, style = AfternoteDesign.typography.bodySmallR, color = AfternoteDesign.colors.gray7) + Spacer(Modifier.height(8.dp)) +} + +@Composable +private fun InquiryTopBar(onBackClick: () -> Unit) { + DetailTopBar(stringResource(R.string.inquiry_title_bar), onBackClick = onBackClick) +} + +@Preview(name = "Inquiry list", showBackground = true) +@Composable +private fun InquiryListScreenPreview() { + AfternoteTheme { + InquiryListScreen( + inquiries = sampleInquiries, + onBackClick = {}, + onInquiryClick = {}, + onNewInquiryClick = {}, + ) + } +} + +@Preview(name = "Inquiry list - empty", showBackground = true) +@Composable +private fun InquiryListScreenEmptyPreview() { + AfternoteTheme { + InquiryListScreen( + inquiries = emptyList(), + onBackClick = {}, + onInquiryClick = {}, + onNewInquiryClick = {}, + ) + } +} + +@Preview(name = "Inquiry detail - answered", showBackground = true) +@Composable +private fun InquiryDetailScreenPreview() { + AfternoteTheme { + InquiryDetailScreen( + inquiry = sampleInquiries.first(), + onBackClick = {}, + ) + } +} + +// 프리뷰 전용 표본 데이터 — 프로덕션 라우트(SettingNavGraph)에서는 사용하지 않는다. +private val sampleInquiries = + listOf( + InquiryUiModel( + id = 1L, + status = InquiryStatus.ANSWERED, + date = "2025.08.09.", + title = "타임레터 발송일 변경이 안 돼요", + content = + "타임레터를 작성한 뒤 발송일을 변경하려고 하는데 날짜를 선택해도 기존 날짜로 계속 표시됩니다. " + + "앱을 종료했다가 다시 실행해도 동일하고, 수정 버튼을 눌러 저장해도 변경사항이 반영되지 않아요.\n" + + "현재 발송 예정일은 8월 20일로 설정되어 있고, 9월 15일로 변경하려고 합니다. 확인 부탁드립니다.", + answer = + "안녕하세요 애프터노트입니다.\n\n타임레터 발신인은 마이페이지에서 변경하실 수 있습니다.\n" + + "마이페이지 > 타임레터 관리에서 변경을 원하시는 타임레터를 선택한 후, 발송예정일을 변경해 주세요.\n" + + "변경된 날짜는 저장 후 정상적으로 반영됩니다.\n\n" + + "추가로 이용에 어려움이 있으실 경우 언제든지 문의해 주세요.\n\n감사합니다.", + ), + InquiryUiModel( + id = 2L, + status = InquiryStatus.RECEIVED, + date = "2025.08.09.", + title = "타임레터 발송일 변경이 안 돼요", + content = "발송 예약일은 마이페이지에서 변경이 가능한지 문의드립니다.", + answer = null, + ), + ) + +@Preview(name = "Inquiry write", showBackground = true) +@Composable +private fun InquiryWriteScreenPreview() { + AfternoteTheme { + InquiryWriteScreen( + onBackClick = {}, + ) + } +} + +private const val MAX_INQUIRY_IMAGES = 3 diff --git a/feature/setting/presentation/src/main/res/values/strings.xml b/feature/setting/presentation/src/main/res/values/strings.xml index 5bef489d95..f1c231ea88 100644 --- a/feature/setting/presentation/src/main/res/values/strings.xml +++ b/feature/setting/presentation/src/main/res/values/strings.xml @@ -22,6 +22,21 @@ FAQ 고객센터 + 1:1 문의 + 새 문의 접수하기 + 접수 중 + 답변 완료 + 답변 + 문의 유형 + 제목 + 내용 + 제목을 입력해 주세요. + 내용을 입력해 주세요. + 스크린샷 추가하기 (최대 3장) + 문의 접수하기 + 등록된 문의가 없어요. + 문의 내역을 찾을 수 없어요. + 문의 접수 기능은 아직 준비 중이에요. 조금만 기다려 주세요. 공지사항 등록된 공지사항이 없어요. 이용약관