From d17f5fd6d1a48e8efafa08f2cac247c9840d832b Mon Sep 17 00:00:00 2001 From: dgddgd314 Date: Wed, 24 Jun 2026 18:46:14 +0900 Subject: [PATCH 1/4] feat: add tag into event detail page --- .../data/network/model/UpdateMemoRequest.kt | 4 +++- .../hangsha_android/data/repository/MemoRepository.kt | 8 ++++++-- .../ui/view/eventdetail/EventDetailMemoSection.kt | 1 - .../ui/view/eventdetail/EventDetailViewModel.kt | 11 ++++++----- 4 files changed, 15 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/example/hangsha_android/data/network/model/UpdateMemoRequest.kt b/app/src/main/java/com/example/hangsha_android/data/network/model/UpdateMemoRequest.kt index 84cd9ee..a74dcd6 100644 --- a/app/src/main/java/com/example/hangsha_android/data/network/model/UpdateMemoRequest.kt +++ b/app/src/main/java/com/example/hangsha_android/data/network/model/UpdateMemoRequest.kt @@ -4,5 +4,7 @@ import com.google.gson.annotations.SerializedName data class UpdateMemoRequest( @SerializedName("content") - val content: String + val content: String? = null, + @SerializedName("tagNames") + val tagNames: List? = null ) diff --git a/app/src/main/java/com/example/hangsha_android/data/repository/MemoRepository.kt b/app/src/main/java/com/example/hangsha_android/data/repository/MemoRepository.kt index 1e97454..7db170f 100644 --- a/app/src/main/java/com/example/hangsha_android/data/repository/MemoRepository.kt +++ b/app/src/main/java/com/example/hangsha_android/data/repository/MemoRepository.kt @@ -31,11 +31,15 @@ class MemoRepository @Inject constructor( suspend fun updateMemo( memoId: Long, - content: String + content: String? = null, + tagNames: List? = null ): Response { return memoApi.updateMemo( memoId = memoId, - request = UpdateMemoRequest(content = content) + request = UpdateMemoRequest( + content = content, + tagNames = tagNames + ) ) } } diff --git a/app/src/main/java/com/example/hangsha_android/ui/view/eventdetail/EventDetailMemoSection.kt b/app/src/main/java/com/example/hangsha_android/ui/view/eventdetail/EventDetailMemoSection.kt index 24e41c8..c949ec2 100644 --- a/app/src/main/java/com/example/hangsha_android/ui/view/eventdetail/EventDetailMemoSection.kt +++ b/app/src/main/java/com/example/hangsha_android/ui/view/eventdetail/EventDetailMemoSection.kt @@ -118,7 +118,6 @@ fun EventDetailMemoSection( ) } - // TODO: Connect tag creation/update API for existing memos when the backend contract is ready. Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), diff --git a/app/src/main/java/com/example/hangsha_android/ui/view/eventdetail/EventDetailViewModel.kt b/app/src/main/java/com/example/hangsha_android/ui/view/eventdetail/EventDetailViewModel.kt index b86f571..e14894c 100644 --- a/app/src/main/java/com/example/hangsha_android/ui/view/eventdetail/EventDetailViewModel.kt +++ b/app/src/main/java/com/example/hangsha_android/ui/view/eventdetail/EventDetailViewModel.kt @@ -139,7 +139,10 @@ class EventDetailViewModel @Inject constructor( val currentState = _uiState.value val currentItem = currentState.item ?: return val content = currentState.memoContent.trim() - if (content.isBlank()) { + val tagNames = (currentState.memoTagNames + currentState.memoTagInput.trim()) + .filter { it.isNotBlank() } + .distinct() + if (currentState.savedMemo == null && content.isBlank()) { _uiState.update { it.copy(memoSaveMessage = "메모를 입력해주세요.") } @@ -158,9 +161,6 @@ class EventDetailViewModel @Inject constructor( runCatching { val savedMemo = currentState.savedMemo if (savedMemo == null) { - val tagNames = (currentState.memoTagNames + currentState.memoTagInput.trim()) - .filter { it.isNotBlank() } - .distinct() memoRepository.createMemo( eventId = currentItem.id, content = content, @@ -169,7 +169,8 @@ class EventDetailViewModel @Inject constructor( } else { memoRepository.updateMemo( memoId = savedMemo.id, - content = content + content = content, + tagNames = tagNames ).requireBody("Memo response was empty.") } }.fold( From 6ec51e06e04af0ff620ec67ce4dcef742a54a0f1 Mon Sep 17 00:00:00 2001 From: dgddgd314 Date: Wed, 24 Jun 2026 19:46:13 +0900 Subject: [PATCH 2/4] mymemo page impl --- .../data/network/api/MemoApi.kt | 6 + .../data/repository/MemoRepository.kt | 6 + .../ui/navigation/HangshaNavHost.kt | 26 +- .../ui/view/mymemos/MyMemosScreen.kt | 303 ++++++++++++++++++ .../ui/view/mymemos/MyMemosUiState.kt | 23 ++ .../ui/view/mymemos/MyMemosViewModel.kt | 196 +++++++++++ 6 files changed, 559 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/example/hangsha_android/ui/view/mymemos/MyMemosScreen.kt create mode 100644 app/src/main/java/com/example/hangsha_android/ui/view/mymemos/MyMemosUiState.kt create mode 100644 app/src/main/java/com/example/hangsha_android/ui/view/mymemos/MyMemosViewModel.kt diff --git a/app/src/main/java/com/example/hangsha_android/data/network/api/MemoApi.kt b/app/src/main/java/com/example/hangsha_android/data/network/api/MemoApi.kt index 0739b7b..fa478b6 100644 --- a/app/src/main/java/com/example/hangsha_android/data/network/api/MemoApi.kt +++ b/app/src/main/java/com/example/hangsha_android/data/network/api/MemoApi.kt @@ -6,6 +6,7 @@ import com.example.hangsha_android.data.network.model.MemoResponse import com.example.hangsha_android.data.network.model.UpdateMemoRequest import retrofit2.Response import retrofit2.http.Body +import retrofit2.http.DELETE import retrofit2.http.GET import retrofit2.http.PATCH import retrofit2.http.Path @@ -25,4 +26,9 @@ interface MemoApi { @Path("memoId") memoId: Long, @Body request: UpdateMemoRequest ): Response + + @DELETE("api/v1/memos/{memoId}") + suspend fun deleteMemo( + @Path("memoId") memoId: Long + ): Response } diff --git a/app/src/main/java/com/example/hangsha_android/data/repository/MemoRepository.kt b/app/src/main/java/com/example/hangsha_android/data/repository/MemoRepository.kt index 7db170f..11ce02a 100644 --- a/app/src/main/java/com/example/hangsha_android/data/repository/MemoRepository.kt +++ b/app/src/main/java/com/example/hangsha_android/data/repository/MemoRepository.kt @@ -42,4 +42,10 @@ class MemoRepository @Inject constructor( ) ) } + + suspend fun deleteMemo( + memoId: Long + ): Response { + return memoApi.deleteMemo(memoId = memoId) + } } diff --git a/app/src/main/java/com/example/hangsha_android/ui/navigation/HangshaNavHost.kt b/app/src/main/java/com/example/hangsha_android/ui/navigation/HangshaNavHost.kt index 97a36c0..3849404 100644 --- a/app/src/main/java/com/example/hangsha_android/ui/navigation/HangshaNavHost.kt +++ b/app/src/main/java/com/example/hangsha_android/ui/navigation/HangshaNavHost.kt @@ -49,6 +49,8 @@ import com.example.hangsha_android.ui.view.interestpriority.InterestPriorityScre import com.example.hangsha_android.ui.view.interestpriority.InterestPriorityViewModel import com.example.hangsha_android.ui.view.mypage.MyPageScreen import com.example.hangsha_android.ui.view.mypage.MyPageViewModel +import com.example.hangsha_android.ui.view.mymemos.MyMemosScreen +import com.example.hangsha_android.ui.view.mymemos.MyMemosViewModel import com.example.hangsha_android.ui.view.serverhealth.ServerHealthViewModel import com.example.hangsha_android.ui.view.signup.SignUpScreen import com.example.hangsha_android.ui.view.signup.SignUpViewModel @@ -64,6 +66,7 @@ sealed class HangshaDestinations(val route: String) { data object Main : HangshaDestinations("main") data object InterestPriority : HangshaDestinations("interest_priority") data object MyBookmarks : HangshaDestinations("my_bookmarks") + data object MyMemos : HangshaDestinations("my_memos") data object DailyEvents : HangshaDestinations("daily_events/{date}") { const val baseRoute = "daily_events" const val dateArg = "date" @@ -444,6 +447,27 @@ fun NavGraphBuilder.mainGraph(navController: NavHostController) { } ) } + composable(HangshaDestinations.MyMemos.route) { + val myMemosViewModel: MyMemosViewModel = hiltViewModel() + val myMemosUiState by myMemosViewModel.uiState.collectAsState() + val context = LocalContext.current + + LaunchedEffect(myMemosUiState.toastMessage) { + val message = myMemosUiState.toastMessage ?: return@LaunchedEffect + Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + myMemosViewModel.onToastMessageConsumed() + } + + MyMemosScreen( + uiState = myMemosUiState, + onNavigateBack = { navController.popBackStack() }, + onMemoClick = { eventId -> + navController.navigate(HangshaDestinations.EventDetail.createRoute(eventId)) + }, + onDeleteMemoClick = { memoId -> myMemosViewModel.deleteMemo(memoId) }, + onRetryClick = { myMemosViewModel.loadMemos() } + ) + } composable(HangshaDestinations.InterestPriority.route) { val interestPriorityViewModel: InterestPriorityViewModel = hiltViewModel() val interestPriorityUiState by interestPriorityViewModel.uiState.collectAsState() @@ -549,7 +573,7 @@ fun NavGraphBuilder.mainGraph(navController: NavHostController) { navController.navigate(HangshaDestinations.EventDetail.createRoute(eventId)) }, onMemoListClick = { - // TODO: Navigate to the memo detail/list page when that screen is implemented. + navController.navigate(HangshaDestinations.MyMemos.route) }, onMemoEventClick = { eventId -> navController.navigate(HangshaDestinations.EventDetail.createRoute(eventId)) diff --git a/app/src/main/java/com/example/hangsha_android/ui/view/mymemos/MyMemosScreen.kt b/app/src/main/java/com/example/hangsha_android/ui/view/mymemos/MyMemosScreen.kt new file mode 100644 index 0000000..bc68c23 --- /dev/null +++ b/app/src/main/java/com/example/hangsha_android/ui/view/mymemos/MyMemosScreen.kt @@ -0,0 +1,303 @@ +package com.example.hangsha_android.ui.view.mymemos + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.Edit +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.example.hangsha_android.ui.theme.Ink60 +import com.example.hangsha_android.ui.theme.Ink90 +import com.example.hangsha_android.ui.theme.Ink100 +import com.example.hangsha_android.ui.theme.PureWhite + +private val MemoCardBorderColor = Color(0xFFE8E8E8) +private val MemoCardShadowColor = Color(0x11000000) +private val MemoTagBackground = Color(0xFFE8E8E8) +private val MemoIconColor = Color(0xFF777777) + +@Composable +fun MyMemosScreen( + uiState: MyMemosUiState, + onNavigateBack: () -> Unit, + onMemoClick: (Long) -> Unit, + onDeleteMemoClick: (Long) -> Unit, + onRetryClick: () -> Unit +) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 10.dp) + ) { + MyMemosTopBar(onNavigateBack = onNavigateBack) + + when { + uiState.isLoading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + + uiState.errorMessage != null -> { + MyMemosErrorState( + message = uiState.errorMessage, + onRetryClick = onRetryClick + ) + } + + uiState.groupedMemos.isEmpty() -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = "아직 작성한 메모가 없습니다.", + style = MaterialTheme.typography.bodyMedium, + color = Ink60, + fontSize = 13.sp + ) + } + } + + else -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = androidx.compose.foundation.layout.PaddingValues( + top = 27.dp, + bottom = 32.dp + ), + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + uiState.groupedMemos.forEach { group -> + item(key = "group-${group.dateDisplay}") { + Column( + verticalArrangement = Arrangement.spacedBy(9.dp) + ) { + Text( + text = group.dateDisplay, + style = MaterialTheme.typography.bodyMedium, + color = Ink60, + fontSize = 15.sp + ) + group.memos.forEach { memo -> + MyMemoCard( + memo = memo, + onClick = { onMemoClick(memo.eventId) }, + isDeleting = uiState.deletingMemoId == memo.id, + onDeleteClick = { onDeleteMemoClick(memo.id) }, + onEditClick = { + // TODO: Connect memo edit flow. + } + ) + } + } + } + } + } + } + } + } + } +} + +@Composable +private fun MyMemosTopBar( + onNavigateBack: () -> Unit +) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(58.dp), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = "뒤로 가기", + tint = Color(0xFFB0B0B0), + modifier = Modifier + .align(Alignment.CenterStart) + .padding(start = 24.dp) + .size(26.dp) + .clickable(onClick = onNavigateBack) + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "내 메모 목록", + style = MaterialTheme.typography.bodyMedium, + color = Ink100, + fontSize = 17.sp, + fontWeight = FontWeight.Bold + ) + Icon( + imageVector = Icons.Rounded.Edit, + contentDescription = null, + tint = Color(0xFFB0B0B0), + modifier = Modifier.size(18.dp) + ) + } + } +} + +@Composable +private fun MyMemoCard( + memo: MyMemoItem, + isDeleting: Boolean, + onClick: () -> Unit, + onDeleteClick: () -> Unit, + onEditClick: () -> Unit +) { + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + shape = RoundedCornerShape(14.dp), + color = PureWhite, + border = androidx.compose.foundation.BorderStroke(1.dp, MemoCardBorderColor), + shadowElevation = 2.dp, + tonalElevation = 0.dp + ) { + Column( + modifier = Modifier.padding(horizontal = 21.dp, vertical = 19.dp) + ) { + Text( + text = memo.content, + style = MaterialTheme.typography.bodyMedium, + color = Ink100, + fontSize = 15.sp, + lineHeight = 20.sp, + fontWeight = FontWeight.Bold, + maxLines = 3, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.height(53.dp)) + Text( + text = memo.eventTitle, + style = MaterialTheme.typography.bodyMedium, + color = Ink60, + fontSize = 14.sp, + lineHeight = 17.sp, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Bottom + ) { + FlowRow( + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.spacedBy(7.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + memo.tagNames.take(4).forEach { tagName -> + MyMemoTagChip(text = tagName) + } + } + Row( + horizontalArrangement = Arrangement.spacedBy(9.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Rounded.Delete, + contentDescription = "메모 삭제", + tint = MemoIconColor, + modifier = Modifier + .size(24.dp) + .clickable(enabled = !isDeleting, onClick = onDeleteClick) + ) + Icon( + imageVector = Icons.Rounded.Edit, + contentDescription = "메모 수정", + tint = MemoIconColor, + modifier = Modifier + .size(24.dp) + .clickable(onClick = onEditClick) + ) + } + } + } + } +} + +@Composable +private fun MyMemoTagChip(text: String) { + Surface( + shape = RoundedCornerShape(6.dp), + color = MemoTagBackground + ) { + Text( + text = text, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp), + style = MaterialTheme.typography.bodyMedium, + color = Ink60, + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + +@Composable +private fun MyMemosErrorState( + message: String, + onRetryClick: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = Ink90, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = onRetryClick) { + Text(text = "다시 시도") + } + } +} diff --git a/app/src/main/java/com/example/hangsha_android/ui/view/mymemos/MyMemosUiState.kt b/app/src/main/java/com/example/hangsha_android/ui/view/mymemos/MyMemosUiState.kt new file mode 100644 index 0000000..c8ed060 --- /dev/null +++ b/app/src/main/java/com/example/hangsha_android/ui/view/mymemos/MyMemosUiState.kt @@ -0,0 +1,23 @@ +package com.example.hangsha_android.ui.view.mymemos + +data class MyMemosUiState( + val isLoading: Boolean = true, + val deletingMemoId: Long? = null, + val errorMessage: String? = null, + val toastMessage: String? = null, + val groupedMemos: List = emptyList() +) + +data class MyMemoDateGroup( + val dateDisplay: String, + val memos: List +) + +data class MyMemoItem( + val id: Long, + val eventId: Long, + val eventTitle: String, + val content: String, + val tagNames: List, + val dateDisplay: String +) diff --git a/app/src/main/java/com/example/hangsha_android/ui/view/mymemos/MyMemosViewModel.kt b/app/src/main/java/com/example/hangsha_android/ui/view/mymemos/MyMemosViewModel.kt new file mode 100644 index 0000000..9b3f41b --- /dev/null +++ b/app/src/main/java/com/example/hangsha_android/ui/view/mymemos/MyMemosViewModel.kt @@ -0,0 +1,196 @@ +package com.example.hangsha_android.ui.view.mymemos + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.hangsha_android.data.network.model.MemoResponse +import com.example.hangsha_android.data.repository.MemoRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import java.io.IOException +import java.net.SocketTimeoutException +import java.net.UnknownHostException +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.OffsetDateTime +import java.time.format.DateTimeFormatter +import java.util.Locale +import javax.inject.Inject +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 retrofit2.HttpException + +@HiltViewModel +class MyMemosViewModel @Inject constructor( + private val memoRepository: MemoRepository +) : ViewModel() { + private val _uiState = MutableStateFlow(MyMemosUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + loadMemos() + } + + fun loadMemos() { + viewModelScope.launch { + _uiState.update { + it.copy(isLoading = true, errorMessage = null) + } + + runCatching { + val response = memoRepository.getMemos() + if (!response.isSuccessful) { + throw HttpException(response) + } + + response.body() ?: throw IllegalStateException("Memos response was empty.") + }.fold( + onSuccess = { body -> + _uiState.update { + it.copy( + isLoading = false, + errorMessage = null, + groupedMemos = body.items.toMyMemoDateGroups() + ) + } + }, + onFailure = { error -> + _uiState.update { + it.copy( + isLoading = false, + errorMessage = mapErrorMessage(error) + ) + } + } + ) + } + } + + fun deleteMemo(memoId: Long) { + if (_uiState.value.deletingMemoId != null) { + return + } + + viewModelScope.launch { + _uiState.update { + it.copy(deletingMemoId = memoId, toastMessage = null) + } + + runCatching { + val response = memoRepository.deleteMemo(memoId) + if (!response.isSuccessful) { + throw HttpException(response) + } + }.fold( + onSuccess = { + _uiState.update { + it.copy( + deletingMemoId = null, + groupedMemos = it.groupedMemos.removeMemo(memoId), + toastMessage = "메모가 삭제되었습니다." + ) + } + }, + onFailure = { error -> + _uiState.update { + it.copy( + deletingMemoId = null, + toastMessage = mapDeleteErrorMessage(error) + ) + } + } + ) + } + } + + fun onToastMessageConsumed() { + _uiState.update { + it.copy(toastMessage = null) + } + } + + private fun mapErrorMessage(error: Throwable): String { + return when (error) { + is HttpException -> when (error.code()) { + 401 -> "로그인이 필요합니다." + in 500..599 -> "서버 오류가 발생했습니다. 잠시 후 다시 시도해 주세요." + else -> "메모 목록을 불러오지 못했습니다. (${error.code()})" + } + is UnknownHostException -> "인터넷 연결을 확인해 주세요." + is SocketTimeoutException -> "요청 시간이 초과되었습니다. 다시 시도해 주세요." + is IOException -> "네트워크 오류가 발생했습니다. 다시 시도해 주세요." + else -> error.message ?: "메모 목록을 불러오지 못했습니다." + } + } + + private fun mapDeleteErrorMessage(error: Throwable): String { + return when (error) { + is HttpException -> when (error.code()) { + 401 -> "로그인이 필요합니다." + 403 -> "메모 삭제 권한이 없습니다." + 404 -> "이미 삭제되었거나 찾을 수 없는 메모입니다." + in 500..599 -> "서버 오류가 발생했습니다. 잠시 후 다시 시도해 주세요." + else -> "메모 삭제에 실패했습니다. (${error.code()})" + } + is UnknownHostException -> "인터넷 연결을 확인해 주세요." + is SocketTimeoutException -> "요청 시간이 초과되었습니다. 다시 시도해 주세요." + is IOException -> "네트워크 오류가 발생했습니다. 다시 시도해 주세요." + else -> error.message ?: "메모 삭제에 실패했습니다." + } + } +} + +private fun List.removeMemo(memoId: Long): List { + return mapNotNull { group -> + val updatedMemos = group.memos.filterNot { memo -> memo.id == memoId } + if (updatedMemos.isEmpty()) { + null + } else { + group.copy(memos = updatedMemos) + } + } +} + +private fun List.toMyMemoDateGroups(): List { + return map { memo -> memo.toMyMemoItem() } + .groupBy { memo -> memo.dateDisplay } + .map { (dateDisplay, memos) -> + MyMemoDateGroup( + dateDisplay = dateDisplay, + memos = memos + ) + } +} + +private fun MemoResponse.toMyMemoItem(): MyMemoItem { + val dateDisplay = formatMemoDate(createdAt) + ?: "-" + + return MyMemoItem( + id = id, + eventId = eventId, + eventTitle = eventTitle, + content = content, + tagNames = tags.map { tag -> tag.name }, + dateDisplay = dateDisplay + ) +} + +private fun formatMemoDate(value: String?): String? { + return parseDate(value)?.format(MemoDateFormatter) +} + +private fun parseDate(value: String?): LocalDate? { + if (value.isNullOrBlank()) { + return null + } + + return runCatching { OffsetDateTime.parse(value).toLocalDate() }.getOrElse { + runCatching { LocalDateTime.parse(value).toLocalDate() }.getOrElse { + runCatching { LocalDate.parse(value) }.getOrNull() + } + } +} + +private val MemoDateFormatter = DateTimeFormatter.ofPattern("yyyy.MM.dd", Locale.KOREA) From b2ffbcae2b78a386c8b2acec924d1211623d454b Mon Sep 17 00:00:00 2001 From: dgddgd314 Date: Wed, 24 Jun 2026 22:55:56 +0900 Subject: [PATCH 3/4] feat: complete impl my memo --- .../ui/navigation/HangshaNavHost.kt | 7 + .../ui/view/mymemos/MyMemosScreen.kt | 335 +++++++++++++++--- .../ui/view/mymemos/MyMemosUiState.kt | 6 + .../ui/view/mymemos/MyMemosViewModel.kt | 162 +++++++++ .../ui/view/mypage/MyPageScreen.kt | 58 +-- 5 files changed, 463 insertions(+), 105 deletions(-) diff --git a/app/src/main/java/com/example/hangsha_android/ui/navigation/HangshaNavHost.kt b/app/src/main/java/com/example/hangsha_android/ui/navigation/HangshaNavHost.kt index 3849404..b67ba7d 100644 --- a/app/src/main/java/com/example/hangsha_android/ui/navigation/HangshaNavHost.kt +++ b/app/src/main/java/com/example/hangsha_android/ui/navigation/HangshaNavHost.kt @@ -465,6 +465,13 @@ fun NavGraphBuilder.mainGraph(navController: NavHostController) { navController.navigate(HangshaDestinations.EventDetail.createRoute(eventId)) }, onDeleteMemoClick = { memoId -> myMemosViewModel.deleteMemo(memoId) }, + onStartEditMemo = { memo -> myMemosViewModel.startEditMemo(memo) }, + onEditContentChanged = { value -> myMemosViewModel.onEditContentChanged(value) }, + onStartAddingTag = { myMemosViewModel.startAddingTag() }, + onEditTagInputChanged = { value -> myMemosViewModel.onEditTagInputChanged(value) }, + onAddEditTag = { myMemosViewModel.addEditTag() }, + onRemoveEditTag = { tagName -> myMemosViewModel.removeEditTag(tagName) }, + onSaveEditedMemo = { myMemosViewModel.saveEditedMemo() }, onRetryClick = { myMemosViewModel.loadMemos() } ) } diff --git a/app/src/main/java/com/example/hangsha_android/ui/view/mymemos/MyMemosScreen.kt b/app/src/main/java/com/example/hangsha_android/ui/view/mymemos/MyMemosScreen.kt index bc68c23..9d181a9 100644 --- a/app/src/main/java/com/example/hangsha_android/ui/view/mymemos/MyMemosScreen.kt +++ b/app/src/main/java/com/example/hangsha_android/ui/view/mymemos/MyMemosScreen.kt @@ -2,10 +2,11 @@ package com.example.hangsha_android.ui.view.mymemos import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -13,22 +14,33 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Edit +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -50,8 +62,17 @@ fun MyMemosScreen( onNavigateBack: () -> Unit, onMemoClick: (Long) -> Unit, onDeleteMemoClick: (Long) -> Unit, + onStartEditMemo: (MyMemoItem) -> Unit, + onEditContentChanged: (String) -> Unit, + onStartAddingTag: () -> Unit, + onEditTagInputChanged: (String) -> Unit, + onAddEditTag: () -> Unit, + onRemoveEditTag: (String) -> Unit, + onSaveEditedMemo: () -> Unit, onRetryClick: () -> Unit ) { + var pendingDeleteMemoId by remember { mutableStateOf(null) } + Box( modifier = Modifier .fillMaxSize() @@ -116,14 +137,25 @@ fun MyMemosScreen( fontSize = 15.sp ) group.memos.forEach { memo -> + val isEditing = uiState.editingMemoId == memo.id MyMemoCard( memo = memo, + isEditing = isEditing, + editContent = uiState.editContent, + editTagNames = uiState.editTagNames, + isAddingTag = uiState.isAddingTag, + editTagInput = uiState.editTagInput, + isSaving = uiState.savingMemoId == memo.id, onClick = { onMemoClick(memo.eventId) }, isDeleting = uiState.deletingMemoId == memo.id, - onDeleteClick = { onDeleteMemoClick(memo.id) }, - onEditClick = { - // TODO: Connect memo edit flow. - } + onDeleteClick = { pendingDeleteMemoId = memo.id }, + onEditClick = { onStartEditMemo(memo) }, + onEditContentChanged = onEditContentChanged, + onStartAddingTag = onStartAddingTag, + onEditTagInputChanged = onEditTagInputChanged, + onAddEditTag = onAddEditTag, + onRemoveEditTag = onRemoveEditTag, + onSaveEditClick = onSaveEditedMemo ) } } @@ -134,6 +166,42 @@ fun MyMemosScreen( } } } + + pendingDeleteMemoId?.let { memoId -> + ConfirmDeleteMemoDialog( + onDismiss = { pendingDeleteMemoId = null }, + onConfirm = { + pendingDeleteMemoId = null + onDeleteMemoClick(memoId) + } + ) + } +} + +@Composable +private fun ConfirmDeleteMemoDialog( + onDismiss: () -> Unit, + onConfirm: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text(text = "메모 삭제") + }, + text = { + Text(text = "정말로 삭제하시겠습니까?") + }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text(text = "삭제") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(text = "취소") + } + } + ) } @Composable @@ -180,15 +248,33 @@ private fun MyMemosTopBar( @Composable private fun MyMemoCard( memo: MyMemoItem, + isEditing: Boolean, + editContent: String, + editTagNames: List, + isAddingTag: Boolean, + editTagInput: String, + isSaving: Boolean, isDeleting: Boolean, onClick: () -> Unit, onDeleteClick: () -> Unit, - onEditClick: () -> Unit + onEditClick: () -> Unit, + onEditContentChanged: (String) -> Unit, + onStartAddingTag: () -> Unit, + onEditTagInputChanged: (String) -> Unit, + onAddEditTag: () -> Unit, + onRemoveEditTag: (String) -> Unit, + onSaveEditClick: () -> Unit ) { Surface( modifier = Modifier .fillMaxWidth() - .clickable(onClick = onClick), + .then( + if (isEditing) { + Modifier + } else { + Modifier.clickable(onClick = onClick) + } + ), shape = RoundedCornerShape(14.dp), color = PureWhite, border = androidx.compose.foundation.BorderStroke(1.dp, MemoCardBorderColor), @@ -196,19 +282,26 @@ private fun MyMemoCard( tonalElevation = 0.dp ) { Column( - modifier = Modifier.padding(horizontal = 21.dp, vertical = 19.dp) + modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp) ) { - Text( - text = memo.content, - style = MaterialTheme.typography.bodyMedium, - color = Ink100, - fontSize = 15.sp, - lineHeight = 20.sp, - fontWeight = FontWeight.Bold, - maxLines = 3, - overflow = TextOverflow.Ellipsis - ) - Spacer(modifier = Modifier.height(53.dp)) + if (isEditing) { + MemoEditTextField( + value = editContent, + onValueChange = onEditContentChanged + ) + } else { + Text( + text = memo.content, + style = MaterialTheme.typography.bodyMedium, + color = Ink100, + fontSize = 15.sp, + lineHeight = 20.sp, + fontWeight = FontWeight.Bold, + maxLines = 3, + overflow = TextOverflow.Ellipsis + ) + } + Spacer(modifier = Modifier.height(if (isEditing) 16.dp else 45.dp)) Text( text = memo.eventTitle, style = MaterialTheme.typography.bodyMedium, @@ -220,39 +313,80 @@ private fun MyMemoCard( overflow = TextOverflow.Ellipsis ) Spacer(modifier = Modifier.height(8.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.Bottom - ) { - FlowRow( - modifier = Modifier.weight(1f), + if (isEditing) { + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(7.dp), - verticalArrangement = Arrangement.spacedBy(6.dp) + verticalAlignment = Alignment.CenterVertically ) { - memo.tagNames.take(4).forEach { tagName -> - MyMemoTagChip(text = tagName) + editTagNames.forEach { tagName -> + MyMemoTagChip( + text = tagName, + onRemoveClick = { onRemoveEditTag(tagName) } + ) + } + if (isAddingTag) { + MemoTagInputChip( + value = editTagInput, + onValueChange = onEditTagInputChanged, + onConfirmClick = onAddEditTag + ) } + MemoAddTagChip(onClick = onStartAddingTag) } Row( - horizontalArrangement = Arrangement.spacedBy(9.dp), + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically ) { Icon( - imageVector = Icons.Rounded.Delete, - contentDescription = "메모 삭제", + imageVector = Icons.Rounded.Check, + contentDescription = "메모 저장", tint = MemoIconColor, modifier = Modifier - .size(24.dp) - .clickable(enabled = !isDeleting, onClick = onDeleteClick) + .size(28.dp) + .clickable(enabled = !isSaving, onClick = onSaveEditClick) ) - Icon( - imageVector = Icons.Rounded.Edit, - contentDescription = "메모 수정", - tint = MemoIconColor, + } + } else { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Bottom + ) { + Row( modifier = Modifier - .size(24.dp) - .clickable(onClick = onEditClick) - ) + .weight(1f) + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(7.dp), + verticalAlignment = Alignment.CenterVertically + ) { + memo.tagNames.forEach { tagName -> + MyMemoTagChip(text = tagName) + } + } + Row( + horizontalArrangement = Arrangement.spacedBy(9.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Rounded.Delete, + contentDescription = "메모 삭제", + tint = MemoIconColor, + modifier = Modifier + .size(24.dp) + .clickable(enabled = !isDeleting, onClick = onDeleteClick) + ) + Icon( + imageVector = Icons.Rounded.Edit, + contentDescription = "메모 수정", + tint = MemoIconColor, + modifier = Modifier + .size(24.dp) + .clickable(onClick = onEditClick) + ) + } } } } @@ -260,23 +394,126 @@ private fun MyMemoCard( } @Composable -private fun MyMemoTagChip(text: String) { +private fun MemoEditTextField( + value: String, + onValueChange: (String) -> Unit +) { + Surface( + modifier = Modifier + .fillMaxWidth() + .height(58.dp), + shape = RoundedCornerShape(7.dp), + color = PureWhite, + border = BorderStroke(1.dp, Color(0xFFC9C9C9)) + ) { + BasicTextField( + value = value, + onValueChange = onValueChange, + textStyle = MaterialTheme.typography.bodyMedium.copy( + color = Ink100, + fontSize = 15.sp, + lineHeight = 20.sp, + fontWeight = FontWeight.Bold + ), + cursorBrush = SolidColor(Ink100), + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 10.dp, vertical = 9.dp) + ) + } +} + +@Composable +private fun MyMemoTagChip( + text: String, + onRemoveClick: (() -> Unit)? = null +) { Surface( shape = RoundedCornerShape(6.dp), color = MemoTagBackground ) { - Text( - text = text, - modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp), - style = MaterialTheme.typography.bodyMedium, - color = Ink60, - fontSize = 12.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis + Row( + modifier = Modifier.padding(horizontal = 9.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = Ink60, + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (onRemoveClick != null) { + Text( + text = "x", + modifier = Modifier.clickable(onClick = onRemoveClick), + style = MaterialTheme.typography.bodyMedium, + color = Ink60, + fontSize = 12.sp + ) + } + } + } +} + +@Composable +private fun MemoAddTagChip(onClick: () -> Unit) { + Surface( + onClick = onClick, + shape = RoundedCornerShape(100.dp), + color = MemoTagBackground + ) { + Icon( + imageVector = Icons.Rounded.Add, + contentDescription = "태그 추가", + tint = MemoIconColor, + modifier = Modifier + .padding(5.dp) + .size(17.dp) ) } } +@Composable +private fun MemoTagInputChip( + value: String, + onValueChange: (String) -> Unit, + onConfirmClick: () -> Unit +) { + Surface( + shape = RoundedCornerShape(6.dp), + color = MemoTagBackground + ) { + Row( + modifier = Modifier.padding(horizontal = 9.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(5.dp), + verticalAlignment = Alignment.CenterVertically + ) { + BasicTextField( + value = value, + onValueChange = onValueChange, + singleLine = true, + textStyle = MaterialTheme.typography.bodyMedium.copy( + color = Ink100, + fontSize = 12.sp + ), + cursorBrush = SolidColor(Ink100), + modifier = Modifier.size(width = 58.dp, height = 18.dp) + ) + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = "태그 추가 완료", + tint = MemoIconColor, + modifier = Modifier + .size(15.dp) + .clickable(onClick = onConfirmClick) + ) + } + } +} + @Composable private fun MyMemosErrorState( message: String, diff --git a/app/src/main/java/com/example/hangsha_android/ui/view/mymemos/MyMemosUiState.kt b/app/src/main/java/com/example/hangsha_android/ui/view/mymemos/MyMemosUiState.kt index c8ed060..0273b76 100644 --- a/app/src/main/java/com/example/hangsha_android/ui/view/mymemos/MyMemosUiState.kt +++ b/app/src/main/java/com/example/hangsha_android/ui/view/mymemos/MyMemosUiState.kt @@ -3,6 +3,12 @@ package com.example.hangsha_android.ui.view.mymemos data class MyMemosUiState( val isLoading: Boolean = true, val deletingMemoId: Long? = null, + val savingMemoId: Long? = null, + val editingMemoId: Long? = null, + val editContent: String = "", + val editTagNames: List = emptyList(), + val isAddingTag: Boolean = false, + val editTagInput: String = "", val errorMessage: String? = null, val toastMessage: String? = null, val groupedMemos: List = emptyList() diff --git a/app/src/main/java/com/example/hangsha_android/ui/view/mymemos/MyMemosViewModel.kt b/app/src/main/java/com/example/hangsha_android/ui/view/mymemos/MyMemosViewModel.kt index 9b3f41b..1a789f8 100644 --- a/app/src/main/java/com/example/hangsha_android/ui/view/mymemos/MyMemosViewModel.kt +++ b/app/src/main/java/com/example/hangsha_android/ui/view/mymemos/MyMemosViewModel.kt @@ -104,6 +104,130 @@ class MyMemosViewModel @Inject constructor( } } + fun startEditMemo(memo: MyMemoItem) { + _uiState.update { + it.copy( + editingMemoId = memo.id, + editContent = memo.content, + editTagNames = memo.tagNames, + isAddingTag = false, + editTagInput = "", + toastMessage = null + ) + } + } + + fun onEditContentChanged(value: String) { + _uiState.update { + it.copy(editContent = value) + } + } + + fun startAddingTag() { + _uiState.update { + it.copy(isAddingTag = true, editTagInput = "") + } + } + + fun onEditTagInputChanged(value: String) { + _uiState.update { + it.copy(editTagInput = value) + } + } + + fun addEditTag() { + val tagName = _uiState.value.editTagInput.trim() + if (tagName.isBlank()) { + return + } + + _uiState.update { + it.copy( + editTagInput = "", + isAddingTag = false, + editTagNames = (it.editTagNames + tagName).distinct() + ) + } + } + + fun removeEditTag(tagName: String) { + _uiState.update { + it.copy(editTagNames = it.editTagNames - tagName) + } + } + + fun saveEditedMemo() { + val currentState = _uiState.value + val memoId = currentState.editingMemoId ?: return + if (currentState.savingMemoId != null) { + return + } + + val content = currentState.editContent.trim() + val tagNames = (currentState.editTagNames + currentState.editTagInput.trim()) + .filter { it.isNotBlank() } + .distinct() + + viewModelScope.launch { + _uiState.update { + it.copy(savingMemoId = memoId, toastMessage = null) + } + + runCatching { + if (content.isBlank() && tagNames.isEmpty()) { + val response = memoRepository.deleteMemo(memoId) + if (!response.isSuccessful) { + throw HttpException(response) + } + null + } else { + val response = memoRepository.updateMemo( + memoId = memoId, + content = content, + tagNames = tagNames + ) + if (!response.isSuccessful) { + throw HttpException(response) + } + + response.body() ?: throw IllegalStateException("Memo response was empty.") + } + }.fold( + onSuccess = { memo -> + _uiState.update { + val updatedGroups = if (memo == null) { + it.groupedMemos.removeMemo(memoId) + } else { + it.groupedMemos.replaceMemo(memo.toMyMemoItem()) + } + it.copy( + savingMemoId = null, + editingMemoId = null, + editContent = "", + editTagNames = emptyList(), + isAddingTag = false, + editTagInput = "", + groupedMemos = updatedGroups, + toastMessage = if (memo == null) { + "메모가 삭제되었습니다." + } else { + "메모가 수정되었습니다." + } + ) + } + }, + onFailure = { error -> + _uiState.update { + it.copy( + savingMemoId = null, + toastMessage = mapSaveErrorMessage(error) + ) + } + } + ) + } + } + fun onToastMessageConsumed() { _uiState.update { it.copy(toastMessage = null) @@ -139,6 +263,24 @@ class MyMemosViewModel @Inject constructor( else -> error.message ?: "메모 삭제에 실패했습니다." } } + + private fun mapSaveErrorMessage(error: Throwable): String { + return when (error) { + is HttpException -> when (error.code()) { + 400 -> "메모 내용을 확인해 주세요." + 401 -> "로그인이 필요합니다." + 403 -> "메모 수정 권한이 없습니다." + 404 -> "찾을 수 없는 메모입니다." + in 500..599 -> "서버 오류가 발생했습니다. 잠시 후 다시 시도해 주세요." + else -> "메모 수정에 실패했습니다. (${error.code()})" + } + is UnknownHostException -> "인터넷 연결을 확인해 주세요." + is SocketTimeoutException -> "요청 시간이 초과되었습니다. 다시 시도해 주세요." + is IOException -> "네트워크 오류가 발생했습니다. 다시 시도해 주세요." + is IllegalStateException -> error.message ?: "메모 수정에 실패했습니다." + else -> error.message ?: "메모 수정에 실패했습니다." + } + } } private fun List.removeMemo(memoId: Long): List { @@ -152,6 +294,26 @@ private fun List.removeMemo(memoId: Long): List.replaceMemo(updatedMemo: MyMemoItem): List { + val removed = removeMemo(updatedMemo.id) + val groupIndex = removed.indexOfFirst { group -> group.dateDisplay == updatedMemo.dateDisplay } + + if (groupIndex < 0) { + return removed + MyMemoDateGroup( + dateDisplay = updatedMemo.dateDisplay, + memos = listOf(updatedMemo) + ) + } + + return removed.mapIndexed { index, group -> + if (index == groupIndex) { + group.copy(memos = group.memos + updatedMemo) + } else { + group + } + } +} + private fun List.toMyMemoDateGroups(): List { return map { memo -> memo.toMyMemoItem() } .groupBy { memo -> memo.dateDisplay } diff --git a/app/src/main/java/com/example/hangsha_android/ui/view/mypage/MyPageScreen.kt b/app/src/main/java/com/example/hangsha_android/ui/view/mypage/MyPageScreen.kt index 313e0e2..d23af5d 100644 --- a/app/src/main/java/com/example/hangsha_android/ui/view/mypage/MyPageScreen.kt +++ b/app/src/main/java/com/example/hangsha_android/ui/view/mypage/MyPageScreen.kt @@ -151,6 +151,8 @@ fun MyPageScreen( onMoreClick = onBookmarksClick ) Spacer(modifier = Modifier.height(28.dp)) + Divider() + Spacer(modifier = Modifier.height(17.dp)) MemosPreviewSection( items = uiState.memoItems, isLoading = uiState.isMemosPreviewLoading, @@ -875,62 +877,6 @@ private fun MemoPreviewTagChip(text: String) { } } -// 빈 목록 안내 영역 -@Composable -private fun EmptyShortcutSection( - title: String, - icon: @Composable () -> Unit, - emptyTitle: String, - emptyDescription: String, - onClick: (() -> Unit)? = null -) { - Column( - modifier = Modifier - .fillMaxWidth() - .then( - if (onClick != null) { - Modifier.clickable(onClick = onClick) - } else { - Modifier - } - ) - ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = title, - style = MaterialTheme.typography.bodyMedium, - color = Ink100, - fontSize = 16.sp, - fontWeight = FontWeight.Bold - ) - Spacer(modifier = Modifier.width(3.dp)) - icon() - Spacer(modifier = Modifier.weight(1f)) - Icon( - imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowRight, - contentDescription = "$title 이동", - tint = MutedIconColor, - modifier = Modifier.size(24.dp) - ) - } - Spacer(modifier = Modifier.height(20.dp)) - Text( - text = "$emptyTitle\n$emptyDescription", - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp), - style = MaterialTheme.typography.bodyMedium, - color = Ink60, - fontSize = 10.sp, - lineHeight = 13.sp, - textAlign = TextAlign.Center - ) - } -} - // 버그 신고 영역 @Composable private fun BugReportSection( From 5c6d6ec913219f499beb341abbb89096b5d809c3 Mon Sep 17 00:00:00 2001 From: dgddgd314 Date: Wed, 24 Jun 2026 23:02:36 +0900 Subject: [PATCH 4/4] feat: fix memo load logic --- .../ui/navigation/HangshaNavHost.kt | 13 ++++ .../view/eventdetail/EventDetailViewModel.kt | 38 +++++++++--- .../ui/view/mymemos/MyMemosViewModel.kt | 62 +++++++++++-------- 3 files changed, 77 insertions(+), 36 deletions(-) diff --git a/app/src/main/java/com/example/hangsha_android/ui/navigation/HangshaNavHost.kt b/app/src/main/java/com/example/hangsha_android/ui/navigation/HangshaNavHost.kt index b67ba7d..285b10e 100644 --- a/app/src/main/java/com/example/hangsha_android/ui/navigation/HangshaNavHost.kt +++ b/app/src/main/java/com/example/hangsha_android/ui/navigation/HangshaNavHost.kt @@ -451,6 +451,7 @@ fun NavGraphBuilder.mainGraph(navController: NavHostController) { val myMemosViewModel: MyMemosViewModel = hiltViewModel() val myMemosUiState by myMemosViewModel.uiState.collectAsState() val context = LocalContext.current + val myMemosLifecycleOwner = LocalLifecycleOwner.current LaunchedEffect(myMemosUiState.toastMessage) { val message = myMemosUiState.toastMessage ?: return@LaunchedEffect @@ -458,6 +459,18 @@ fun NavGraphBuilder.mainGraph(navController: NavHostController) { myMemosViewModel.onToastMessageConsumed() } + DisposableEffect(myMemosLifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + myMemosViewModel.loadMemos() + } + } + myMemosLifecycleOwner.lifecycle.addObserver(observer) + onDispose { + myMemosLifecycleOwner.lifecycle.removeObserver(observer) + } + } + MyMemosScreen( uiState = myMemosUiState, onNavigateBack = { navController.popBackStack() }, diff --git a/app/src/main/java/com/example/hangsha_android/ui/view/eventdetail/EventDetailViewModel.kt b/app/src/main/java/com/example/hangsha_android/ui/view/eventdetail/EventDetailViewModel.kt index e14894c..7731a35 100644 --- a/app/src/main/java/com/example/hangsha_android/ui/view/eventdetail/EventDetailViewModel.kt +++ b/app/src/main/java/com/example/hangsha_android/ui/view/eventdetail/EventDetailViewModel.kt @@ -166,6 +166,12 @@ class EventDetailViewModel @Inject constructor( content = content, tagNames = tagNames ).requireBody("Memo response was empty.") + } else if (content.isBlank() && tagNames.isEmpty()) { + val response = memoRepository.deleteMemo(savedMemo.id) + if (!response.isSuccessful) { + throw HttpException(response) + } + null } else { memoRepository.updateMemo( memoId = savedMemo.id, @@ -176,16 +182,28 @@ class EventDetailViewModel @Inject constructor( }.fold( onSuccess = { memo -> _uiState.update { - val savedMemo = memo.toEventDetailMemo() - it.copy( - isMemoEditorOpen = false, - memoContent = savedMemo.content, - memoTagInput = "", - memoTagNames = savedMemo.tagNames, - savedMemo = savedMemo, - isMemoSaving = false, - memoSaveMessage = "메모가 저장되었습니다." - ) + if (memo == null) { + it.copy( + isMemoEditorOpen = false, + memoContent = "", + memoTagInput = "", + memoTagNames = emptyList(), + savedMemo = null, + isMemoSaving = false, + memoSaveMessage = "메모가 삭제되었습니다." + ) + } else { + val savedMemo = memo.toEventDetailMemo() + it.copy( + isMemoEditorOpen = false, + memoContent = savedMemo.content, + memoTagInput = "", + memoTagNames = savedMemo.tagNames, + savedMemo = savedMemo, + isMemoSaving = false, + memoSaveMessage = "메모가 저장되었습니다." + ) + } } }, onFailure = { error -> diff --git a/app/src/main/java/com/example/hangsha_android/ui/view/mymemos/MyMemosViewModel.kt b/app/src/main/java/com/example/hangsha_android/ui/view/mymemos/MyMemosViewModel.kt index 1a789f8..46e15e0 100644 --- a/app/src/main/java/com/example/hangsha_android/ui/view/mymemos/MyMemosViewModel.kt +++ b/app/src/main/java/com/example/hangsha_android/ui/view/mymemos/MyMemosViewModel.kt @@ -27,43 +27,53 @@ class MyMemosViewModel @Inject constructor( ) : ViewModel() { private val _uiState = MutableStateFlow(MyMemosUiState()) val uiState: StateFlow = _uiState.asStateFlow() + private var isLoadInFlight = false init { loadMemos() } fun loadMemos() { - viewModelScope.launch { - _uiState.update { - it.copy(isLoading = true, errorMessage = null) - } + if (isLoadInFlight) { + return + } - runCatching { - val response = memoRepository.getMemos() - if (!response.isSuccessful) { - throw HttpException(response) + isLoadInFlight = true + viewModelScope.launch { + try { + _uiState.update { + it.copy(isLoading = it.groupedMemos.isEmpty(), errorMessage = null) } - response.body() ?: throw IllegalStateException("Memos response was empty.") - }.fold( - onSuccess = { body -> - _uiState.update { - it.copy( - isLoading = false, - errorMessage = null, - groupedMemos = body.items.toMyMemoDateGroups() - ) + runCatching { + val response = memoRepository.getMemos() + if (!response.isSuccessful) { + throw HttpException(response) } - }, - onFailure = { error -> - _uiState.update { - it.copy( - isLoading = false, - errorMessage = mapErrorMessage(error) - ) + + response.body() ?: throw IllegalStateException("Memos response was empty.") + }.fold( + onSuccess = { body -> + _uiState.update { + it.copy( + isLoading = false, + errorMessage = null, + groupedMemos = body.items.toMyMemoDateGroups() + ) + } + }, + onFailure = { error -> + _uiState.update { + it.copy( + isLoading = false, + errorMessage = mapErrorMessage(error) + ) + } } - } - ) + ) + } finally { + isLoadInFlight = false + } } }