From a89690e1d3233b28b7211f91bf4eaecc659c95f5 Mon Sep 17 00:00:00 2001 From: dgddgd314 Date: Wed, 24 Jun 2026 01:41:54 +0900 Subject: [PATCH 1/3] feat: add memo at eventdetail page --- .../data/network/api/MemoApi.kt | 14 + .../data/network/model/CreateMemoRequest.kt | 12 + .../data/network/model/MemoResponse.kt | 27 ++ .../data/repository/MemoRepository.kt | 25 ++ .../hangsha_android/di/NetworkModule.kt | 9 + .../ui/navigation/HangshaNavHost.kt | 17 ++ .../ui/view/eventdetail/EventDetailScreen.kt | 259 +++++++++++++++++- .../ui/view/eventdetail/EventDetailUiState.kt | 8 +- .../view/eventdetail/EventDetailViewModel.kt | 123 ++++++++- 9 files changed, 489 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/com/example/hangsha_android/data/network/api/MemoApi.kt create mode 100644 app/src/main/java/com/example/hangsha_android/data/network/model/CreateMemoRequest.kt create mode 100644 app/src/main/java/com/example/hangsha_android/data/network/model/MemoResponse.kt create mode 100644 app/src/main/java/com/example/hangsha_android/data/repository/MemoRepository.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 new file mode 100644 index 0000000..c8372f3 --- /dev/null +++ b/app/src/main/java/com/example/hangsha_android/data/network/api/MemoApi.kt @@ -0,0 +1,14 @@ +package com.example.hangsha_android.data.network.api + +import com.example.hangsha_android.data.network.model.CreateMemoRequest +import com.example.hangsha_android.data.network.model.MemoResponse +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.POST + +interface MemoApi { + @POST("api/v1/memos") + suspend fun createMemo( + @Body request: CreateMemoRequest + ): Response +} diff --git a/app/src/main/java/com/example/hangsha_android/data/network/model/CreateMemoRequest.kt b/app/src/main/java/com/example/hangsha_android/data/network/model/CreateMemoRequest.kt new file mode 100644 index 0000000..11ce1d1 --- /dev/null +++ b/app/src/main/java/com/example/hangsha_android/data/network/model/CreateMemoRequest.kt @@ -0,0 +1,12 @@ +package com.example.hangsha_android.data.network.model + +import com.google.gson.annotations.SerializedName + +data class CreateMemoRequest( + @SerializedName("eventId") + val eventId: Long, + @SerializedName("content") + val content: String, + @SerializedName("tagNames") + val tagNames: List +) diff --git a/app/src/main/java/com/example/hangsha_android/data/network/model/MemoResponse.kt b/app/src/main/java/com/example/hangsha_android/data/network/model/MemoResponse.kt new file mode 100644 index 0000000..b4000b2 --- /dev/null +++ b/app/src/main/java/com/example/hangsha_android/data/network/model/MemoResponse.kt @@ -0,0 +1,27 @@ +package com.example.hangsha_android.data.network.model + +import com.google.gson.annotations.SerializedName + +data class MemoResponse( + @SerializedName("id") + val id: Long, + @SerializedName("eventId") + val eventId: Long, + @SerializedName("eventTitle") + val eventTitle: String, + @SerializedName("content") + val content: String, + @SerializedName("tags") + val tags: List, + @SerializedName("createdAt") + val createdAt: String, + @SerializedName("updatedAt") + val updatedAt: String +) + +data class MemoTagResponse( + @SerializedName("id") + val id: Long, + @SerializedName("name") + val name: String +) 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 new file mode 100644 index 0000000..a6f9b74 --- /dev/null +++ b/app/src/main/java/com/example/hangsha_android/data/repository/MemoRepository.kt @@ -0,0 +1,25 @@ +package com.example.hangsha_android.data.repository + +import com.example.hangsha_android.data.network.api.MemoApi +import com.example.hangsha_android.data.network.model.CreateMemoRequest +import com.example.hangsha_android.data.network.model.MemoResponse +import javax.inject.Inject +import retrofit2.Response + +class MemoRepository @Inject constructor( + private val memoApi: MemoApi +) { + suspend fun createMemo( + eventId: Long, + content: String, + tagNames: List + ): Response { + return memoApi.createMemo( + request = CreateMemoRequest( + eventId = eventId, + content = content, + tagNames = tagNames + ) + ) + } +} diff --git a/app/src/main/java/com/example/hangsha_android/di/NetworkModule.kt b/app/src/main/java/com/example/hangsha_android/di/NetworkModule.kt index 4e6a1fa..c1af947 100644 --- a/app/src/main/java/com/example/hangsha_android/di/NetworkModule.kt +++ b/app/src/main/java/com/example/hangsha_android/di/NetworkModule.kt @@ -8,6 +8,7 @@ import com.example.hangsha_android.data.network.api.BugReportApi import com.example.hangsha_android.data.network.api.CategoryApi import com.example.hangsha_android.data.network.api.EventApi import com.example.hangsha_android.data.network.api.ExcludedKeywordsApi +import com.example.hangsha_android.data.network.api.MemoApi import com.example.hangsha_android.data.network.api.ServerHealthApi import com.example.hangsha_android.data.network.api.UserApi import dagger.Module @@ -125,6 +126,14 @@ object NetworkModule { return retrofit.create(BugReportApi::class.java) } + @Provides + @Singleton + fun provideMemoApi( + retrofit: Retrofit + ): MemoApi { + return retrofit.create(MemoApi::class.java) + } + @Provides @Singleton fun provideCategoryApi( 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 affb249..9860524 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 @@ -352,6 +352,13 @@ fun NavGraphBuilder.mainGraph(navController: NavHostController) { ) { val eventDetailViewModel: EventDetailViewModel = hiltViewModel() val eventDetailUiState by eventDetailViewModel.uiState.collectAsState() + val context = LocalContext.current + + LaunchedEffect(eventDetailUiState.memoSaveMessage) { + val message = eventDetailUiState.memoSaveMessage ?: return@LaunchedEffect + Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + eventDetailViewModel.onMemoSaveMessageConsumed() + } EventDetailScreen( uiState = eventDetailUiState, @@ -363,6 +370,16 @@ fun NavGraphBuilder.mainGraph(navController: NavHostController) { ) eventDetailViewModel.toggleBookmark() }, + onMemoClick = { eventDetailViewModel.openMemoEditor() }, + onMemoContentChanged = { value -> + eventDetailViewModel.onMemoContentChanged(value) + }, + onMemoTagInputChanged = { value -> + eventDetailViewModel.onMemoTagInputChanged(value) + }, + onAddMemoTag = { eventDetailViewModel.addMemoTag() }, + onRemoveMemoTag = { tagName -> eventDetailViewModel.removeMemoTag(tagName) }, + onSaveMemoClick = { eventDetailViewModel.saveMemo() }, onRetryClick = { eventDetailViewModel.retry() } ) } diff --git a/app/src/main/java/com/example/hangsha_android/ui/view/eventdetail/EventDetailScreen.kt b/app/src/main/java/com/example/hangsha_android/ui/view/eventdetail/EventDetailScreen.kt index 068a7d4..d2c13d1 100644 --- a/app/src/main/java/com/example/hangsha_android/ui/view/eventdetail/EventDetailScreen.kt +++ b/app/src/main/java/com/example/hangsha_android/ui/view/eventdetail/EventDetailScreen.kt @@ -6,8 +6,10 @@ import android.view.ViewGroup import android.webkit.WebResourceRequest import android.webkit.WebView import android.webkit.WebViewClient +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -19,18 +21,23 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding +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.Bookmark import androidx.compose.material.icons.rounded.BookmarkBorder +import androidx.compose.material.icons.rounded.Edit import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults 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.mutableIntStateOf @@ -61,6 +68,12 @@ fun EventDetailScreen( uiState: EventDetailUiState, onNavigateBack: () -> Unit, onBookmarkClick: () -> Unit, + onMemoClick: () -> Unit, + onMemoContentChanged: (String) -> Unit, + onMemoTagInputChanged: (String) -> Unit, + onAddMemoTag: () -> Unit, + onRemoveMemoTag: (String) -> Unit, + onSaveMemoClick: () -> Unit, onRetryClick: () -> Unit ) { Box( @@ -84,9 +97,16 @@ fun EventDetailScreen( uiState.item != null -> { // 본문 EventDetailContent( + uiState = uiState, item = uiState.item, onNavigateBack = onNavigateBack, - onBookmarkClick = onBookmarkClick + onBookmarkClick = onBookmarkClick, + onMemoClick = onMemoClick, + onMemoContentChanged = onMemoContentChanged, + onMemoTagInputChanged = onMemoTagInputChanged, + onAddMemoTag = onAddMemoTag, + onRemoveMemoTag = onRemoveMemoTag, + onSaveMemoClick = onSaveMemoClick ) } } @@ -95,9 +115,16 @@ fun EventDetailScreen( @Composable private fun EventDetailContent( + uiState: EventDetailUiState, item: EventDetailItem, onNavigateBack: () -> Unit, - onBookmarkClick: () -> Unit + onBookmarkClick: () -> Unit, + onMemoClick: () -> Unit, + onMemoContentChanged: (String) -> Unit, + onMemoTagInputChanged: (String) -> Unit, + onAddMemoTag: () -> Unit, + onRemoveMemoTag: (String) -> Unit, + onSaveMemoClick: () -> Unit ) { val uriHandler = LocalUriHandler.current val context = LocalContext.current @@ -257,11 +284,239 @@ private fun EventDetailContent( } } ) + } + + item { + EventDetailMemoSection( + isOpen = uiState.isMemoEditorOpen, + content = uiState.memoContent, + tagInput = uiState.memoTagInput, + tagNames = uiState.memoTagNames, + isSaving = uiState.isMemoSaving, + onOpen = onMemoClick, + onContentChanged = onMemoContentChanged, + onTagInputChanged = onMemoTagInputChanged, + onAddTag = onAddMemoTag, + onRemoveTag = onRemoveMemoTag, + onSaveClick = onSaveMemoClick + ) Spacer(modifier = Modifier.height(24.dp)) } } } +@Composable +private fun EventDetailMemoSection( + isOpen: Boolean, + content: String, + tagInput: String, + tagNames: List, + isSaving: Boolean, + onOpen: () -> Unit, + onContentChanged: (String) -> Unit, + onTagInputChanged: (String) -> Unit, + onAddTag: () -> Unit, + onRemoveTag: (String) -> Unit, + onSaveClick: () -> Unit +) { + if (!isOpen) { + Column( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onOpen), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Rounded.Edit, + contentDescription = null, + tint = Ink60, + modifier = Modifier.size(18.dp) + ) + Text( + text = "메모하기", + style = MaterialTheme.typography.bodyMedium, + fontSize = 14.sp, + color = Ink90 + ) + } + Surface( + shape = RoundedCornerShape(12.dp), + color = PureWhite, + border = BorderStroke(1.dp, Ink60.copy(alpha = 0.24f)) + ) { + Text( + text = "메모를 입력하세요", + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 18.dp, vertical = 15.dp), + style = MaterialTheme.typography.bodyMedium, + fontSize = 14.sp, + color = Ink60.copy(alpha = 0.55f) + ) + } + } + return + } + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + color = PureWhite, + border = BorderStroke(1.dp, Ink60.copy(alpha = 0.24f)) + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Rounded.Edit, + contentDescription = null, + tint = Ink60, + modifier = Modifier.size(18.dp) + ) + TextButton( + onClick = onSaveClick, + enabled = !isSaving + ) { + Text( + text = if (isSaving) "저장 중" else "저장하기", + color = Ink90, + fontWeight = FontWeight.SemiBold + ) + } + } + + MemoTextInput( + value = content, + onValueChange = onContentChanged, + placeholder = "메모를 입력하세요", + modifier = Modifier + .fillMaxWidth() + .height(102.dp) + ) + + if (tagNames.isNotEmpty()) { + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + tagNames.forEach { tagName -> + MemoTagChip( + text = tagName, + onClick = { onRemoveTag(tagName) } + ) + } + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + MemoTextInput( + value = tagInput, + onValueChange = onTagInputChanged, + placeholder = "태그를 입력하세요", + modifier = Modifier + .weight(1f) + .height(40.dp), + singleLine = true + ) + Button( + onClick = onAddTag, + enabled = tagInput.isNotBlank() && !isSaving, + shape = RoundedCornerShape(12.dp), + colors = ButtonDefaults.buttonColors( + containerColor = PureWhite, + contentColor = Ink90, + disabledContainerColor = PureWhite, + disabledContentColor = Ink60.copy(alpha = 0.45f) + ), + border = BorderStroke(1.dp, Ink60.copy(alpha = 0.24f)) + ) { + Text(text = "추가") + } + } + } + } +} + +@Composable +private fun MemoTextInput( + value: String, + onValueChange: (String) -> Unit, + placeholder: String, + modifier: Modifier = Modifier, + singleLine: Boolean = false +) { + Surface( + modifier = modifier, + shape = RoundedCornerShape(10.dp), + color = PureWhite, + border = BorderStroke(1.dp, Ink60.copy(alpha = 0.28f)) + ) { + BasicTextField( + value = value, + onValueChange = onValueChange, + singleLine = singleLine, + textStyle = MaterialTheme.typography.bodyMedium.copy( + fontSize = 14.sp, + lineHeight = 22.sp, + color = Ink100 + ), + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 14.dp, vertical = 10.dp), + decorationBox = { innerTextField -> + Box(modifier = Modifier.fillMaxSize()) { + if (value.isBlank()) { + Text( + text = placeholder, + style = MaterialTheme.typography.bodyMedium, + fontSize = 14.sp, + color = Ink60.copy(alpha = 0.55f) + ) + } + innerTextField() + } + } + ) + } +} + +@Composable +private fun MemoTagChip( + text: String, + onClick: () -> Unit +) { + Surface( + onClick = onClick, + shape = RoundedCornerShape(10.dp), + color = Cream10, + border = BorderStroke(1.dp, Ink60.copy(alpha = 0.18f)) + ) { + Text( + text = "#$text", + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + style = MaterialTheme.typography.bodyMedium, + fontSize = 13.sp, + color = Ink90 + ) + } +} + // 세부 설명 @Composable private fun EventDetailHtmlContent( diff --git a/app/src/main/java/com/example/hangsha_android/ui/view/eventdetail/EventDetailUiState.kt b/app/src/main/java/com/example/hangsha_android/ui/view/eventdetail/EventDetailUiState.kt index e1f86c7..9434057 100644 --- a/app/src/main/java/com/example/hangsha_android/ui/view/eventdetail/EventDetailUiState.kt +++ b/app/src/main/java/com/example/hangsha_android/ui/view/eventdetail/EventDetailUiState.kt @@ -6,7 +6,13 @@ data class EventDetailUiState( val eventId: Long = -1L, val isLoading: Boolean = true, val errorMessage: String? = null, - val item: EventDetailItem? = null + val item: EventDetailItem? = null, + val isMemoEditorOpen: Boolean = false, + val memoContent: String = "", + val memoTagInput: String = "", + val memoTagNames: List = emptyList(), + val isMemoSaving: Boolean = false, + val memoSaveMessage: String? = null ) data class EventDetailItem( 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 80aae6f..8a3aea6 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 @@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope import com.example.hangsha_android.data.network.model.EventDetailResponse import com.example.hangsha_android.data.repository.BookmarkRepository import com.example.hangsha_android.data.repository.EventRepository +import com.example.hangsha_android.data.repository.MemoRepository import com.example.hangsha_android.ui.navigation.HangshaDestinations import com.example.hangsha_android.ui.view.event.eventTypeColor import dagger.hilt.android.lifecycle.HiltViewModel @@ -31,6 +32,7 @@ import retrofit2.Response class EventDetailViewModel @Inject constructor( private val eventRepository: EventRepository, private val bookmarkRepository: BookmarkRepository, + private val memoRepository: MemoRepository, savedStateHandle: SavedStateHandle ) : ViewModel() { private val eventId = savedStateHandle.get(HangshaDestinations.EventDetail.eventIdArg) ?: -1L @@ -81,6 +83,105 @@ class EventDetailViewModel @Inject constructor( } } + fun openMemoEditor() { + _uiState.update { + it.copy(isMemoEditorOpen = true) + } + } + + fun onMemoContentChanged(value: String) { + _uiState.update { + it.copy(memoContent = value) + } + } + + fun onMemoTagInputChanged(value: String) { + _uiState.update { + it.copy(memoTagInput = value) + } + } + + fun addMemoTag() { + val tagName = _uiState.value.memoTagInput.trim() + if (tagName.isBlank()) { + return + } + + _uiState.update { + it.copy( + memoTagInput = "", + memoTagNames = (it.memoTagNames + tagName).distinct() + ) + } + } + + fun removeMemoTag(tagName: String) { + _uiState.update { + it.copy(memoTagNames = it.memoTagNames - tagName) + } + } + + fun saveMemo() { + val currentState = _uiState.value + val currentItem = currentState.item ?: return + val content = currentState.memoContent.trim() + val tagNames = (currentState.memoTagNames + currentState.memoTagInput.trim()) + .filter { it.isNotBlank() } + .distinct() + + if (content.isBlank()) { + _uiState.update { + it.copy(memoSaveMessage = "메모를 입력해주세요.") + } + return + } + + if (currentState.isMemoSaving) { + return + } + + _uiState.update { + it.copy(isMemoSaving = true, memoSaveMessage = null) + } + + viewModelScope.launch { + runCatching { + memoRepository.createMemo( + eventId = currentItem.id, + content = content, + tagNames = tagNames + ).requireBody("Memo response was empty.") + }.fold( + onSuccess = { + _uiState.update { + it.copy( + isMemoEditorOpen = false, + memoContent = "", + memoTagInput = "", + memoTagNames = emptyList(), + isMemoSaving = false, + memoSaveMessage = "메모가 저장되었습니다." + ) + } + }, + onFailure = { error -> + _uiState.update { + it.copy( + isMemoSaving = false, + memoSaveMessage = mapMemoErrorMessage(error) + ) + } + } + ) + } + } + + fun onMemoSaveMessageConsumed() { + _uiState.update { + it.copy(memoSaveMessage = null) + } + } + private fun loadEventDetail() { if (eventId <= 0L) { _uiState.update { @@ -169,6 +270,24 @@ class EventDetailViewModel @Inject constructor( } } + private fun mapMemoErrorMessage(error: Throwable): String { + return when (error) { + is UnknownHostException -> "No internet connection. Please check your network." + is SocketTimeoutException -> "The request timed out. Please try again." + is HttpException -> when (error.code()) { + 400 -> "Invalid memo request." + 401 -> "Login is required." + 403 -> "You do not have permission to create this memo." + 404 -> "Event information could not be found." + in 500..599 -> "Server error occurred. Please try again later." + else -> "Failed to save memo with code ${error.code()}." + } + is IOException -> "Network error occurred. Please try again." + is IllegalStateException -> error.message ?: "Failed to save memo." + else -> error.message ?: "Failed to save memo." + } + } + private fun onBookmarkedEventIdsChanged(eventIds: Set) { _uiState.update { currentState -> val currentItem = currentState.item ?: return@update currentState @@ -277,9 +396,9 @@ private fun formatDateTime(value: String?): String? { } } -private fun Response.requireBody( +private fun Response.requireBody( emptyMessage: String -): EventDetailResponse { +): T { if (!isSuccessful) { throw HttpException(this) } From 7eb5f76bb65962b5ab551642292fc98095bc3de1 Mon Sep 17 00:00:00 2001 From: dgddgd314 Date: Wed, 24 Jun 2026 02:37:57 +0900 Subject: [PATCH 2/3] feat: add PATCH, GET memos and impl mypage --- .../data/network/api/MemoApi.kt | 14 + .../data/network/model/MemoListResponse.kt | 8 + .../data/network/model/UpdateMemoRequest.kt | 8 + .../data/repository/MemoRepository.kt | 16 + .../ui/navigation/HangshaNavHost.kt | 7 + .../ui/view/calendar/CalendarScreen.kt | 1 - .../ui/view/eventdetail/EventDetailScreen.kt | 281 ++++++++++++++++-- .../ui/view/eventdetail/EventDetailUiState.kt | 8 + .../view/eventdetail/EventDetailViewModel.kt | 84 +++++- .../ui/view/mypage/MyPageScreen.kt | 161 +++++++++- .../ui/view/mypage/MyPageUiState.kt | 12 + .../ui/view/mypage/MyPageViewModel.kt | 84 ++++++ 12 files changed, 640 insertions(+), 44 deletions(-) create mode 100644 app/src/main/java/com/example/hangsha_android/data/network/model/MemoListResponse.kt create mode 100644 app/src/main/java/com/example/hangsha_android/data/network/model/UpdateMemoRequest.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 c8372f3..0739b7b 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 @@ -1,14 +1,28 @@ package com.example.hangsha_android.data.network.api import com.example.hangsha_android.data.network.model.CreateMemoRequest +import com.example.hangsha_android.data.network.model.MemoListResponse 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.GET +import retrofit2.http.PATCH +import retrofit2.http.Path import retrofit2.http.POST interface MemoApi { + @GET("api/v1/memos") + suspend fun getMemos(): Response + @POST("api/v1/memos") suspend fun createMemo( @Body request: CreateMemoRequest ): Response + + @PATCH("api/v1/memos/{memoId}") + suspend fun updateMemo( + @Path("memoId") memoId: Long, + @Body request: UpdateMemoRequest + ): Response } diff --git a/app/src/main/java/com/example/hangsha_android/data/network/model/MemoListResponse.kt b/app/src/main/java/com/example/hangsha_android/data/network/model/MemoListResponse.kt new file mode 100644 index 0000000..e838a1a --- /dev/null +++ b/app/src/main/java/com/example/hangsha_android/data/network/model/MemoListResponse.kt @@ -0,0 +1,8 @@ +package com.example.hangsha_android.data.network.model + +import com.google.gson.annotations.SerializedName + +data class MemoListResponse( + @SerializedName("items") + val items: List +) 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 new file mode 100644 index 0000000..84cd9ee --- /dev/null +++ b/app/src/main/java/com/example/hangsha_android/data/network/model/UpdateMemoRequest.kt @@ -0,0 +1,8 @@ +package com.example.hangsha_android.data.network.model + +import com.google.gson.annotations.SerializedName + +data class UpdateMemoRequest( + @SerializedName("content") + val content: String +) 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 a6f9b74..1e97454 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 @@ -2,13 +2,19 @@ package com.example.hangsha_android.data.repository import com.example.hangsha_android.data.network.api.MemoApi import com.example.hangsha_android.data.network.model.CreateMemoRequest +import com.example.hangsha_android.data.network.model.MemoListResponse import com.example.hangsha_android.data.network.model.MemoResponse +import com.example.hangsha_android.data.network.model.UpdateMemoRequest import javax.inject.Inject import retrofit2.Response class MemoRepository @Inject constructor( private val memoApi: MemoApi ) { + suspend fun getMemos(): Response { + return memoApi.getMemos() + } + suspend fun createMemo( eventId: Long, content: String, @@ -22,4 +28,14 @@ class MemoRepository @Inject constructor( ) ) } + + suspend fun updateMemo( + memoId: Long, + content: String + ): Response { + return memoApi.updateMemo( + memoId = memoId, + request = UpdateMemoRequest(content = content) + ) + } } 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 9860524..97a36c0 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 @@ -505,6 +505,7 @@ fun NavGraphBuilder.mainGraph(navController: NavHostController) { val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) { myPageViewModel.loadBookmarkedEventPreview() + myPageViewModel.loadMemoPreview() } } myPageLifecycleOwner.lifecycle.addObserver(observer) @@ -547,6 +548,12 @@ fun NavGraphBuilder.mainGraph(navController: NavHostController) { onBookmarkedEventClick = { eventId -> navController.navigate(HangshaDestinations.EventDetail.createRoute(eventId)) }, + onMemoListClick = { + // TODO: Navigate to the memo detail/list page when that screen is implemented. + }, + onMemoEventClick = { eventId -> + navController.navigate(HangshaDestinations.EventDetail.createRoute(eventId)) + }, onLogoutClick = { myPageViewModel.logout() }, onBugReportTitleChanged = { value -> myPageViewModel.onBugReportTitleChanged(value) diff --git a/app/src/main/java/com/example/hangsha_android/ui/view/calendar/CalendarScreen.kt b/app/src/main/java/com/example/hangsha_android/ui/view/calendar/CalendarScreen.kt index 307ba93..641bebe 100644 --- a/app/src/main/java/com/example/hangsha_android/ui/view/calendar/CalendarScreen.kt +++ b/app/src/main/java/com/example/hangsha_android/ui/view/calendar/CalendarScreen.kt @@ -307,7 +307,6 @@ private fun FilterButton( } if (hasActiveFilters) { - // TODO: 필터 적용 여부를 알려 주는 작은 배지 점 Box( modifier = Modifier .align(Alignment.TopEnd) diff --git a/app/src/main/java/com/example/hangsha_android/ui/view/eventdetail/EventDetailScreen.kt b/app/src/main/java/com/example/hangsha_android/ui/view/eventdetail/EventDetailScreen.kt index d2c13d1..dc570c2 100644 --- a/app/src/main/java/com/example/hangsha_android/ui/view/eventdetail/EventDetailScreen.kt +++ b/app/src/main/java/com/example/hangsha_android/ui/view/eventdetail/EventDetailScreen.kt @@ -37,7 +37,6 @@ 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.mutableIntStateOf @@ -287,8 +286,9 @@ private fun EventDetailContent( } item { - EventDetailMemoSection( + EventDetailMemoSectionWithSavedMemo( isOpen = uiState.isMemoEditorOpen, + savedMemo = uiState.savedMemo, content = uiState.memoContent, tagInput = uiState.memoTagInput, tagNames = uiState.memoTagNames, @@ -305,6 +305,212 @@ private fun EventDetailContent( } } +@Composable +private fun EventDetailMemoSectionWithSavedMemo( + isOpen: Boolean, + savedMemo: EventDetailMemo?, + content: String, + tagInput: String, + tagNames: List, + isSaving: Boolean, + onOpen: () -> Unit, + onContentChanged: (String) -> Unit, + onTagInputChanged: (String) -> Unit, + onAddTag: () -> Unit, + onRemoveTag: (String) -> Unit, + onSaveClick: () -> Unit +) { + if (!isOpen) { + Column( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onOpen), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + MemoSectionHeader() + if (savedMemo == null) { + MemoPlaceholder() + } else { + MemoDisplay( + content = savedMemo.content, + tagNames = savedMemo.tagNames + ) + } + } + return + } + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + color = PureWhite, + border = BorderStroke(1.dp, Ink60.copy(alpha = 0.24f)) + ) { + Column( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Rounded.Edit, + contentDescription = null, + tint = Ink60, + modifier = Modifier.size(18.dp) + ) + Text( + text = if (isSaving) "저장 중" else "저장하기", + modifier = Modifier + .clickable(enabled = !isSaving, onClick = onSaveClick) + .padding(horizontal = 4.dp, vertical = 2.dp), + color = Ink90, + fontWeight = FontWeight.SemiBold + ) + } + + MemoTextInput( + value = content, + onValueChange = onContentChanged, + placeholder = "메모를 입력하세요", + modifier = Modifier + .fillMaxWidth() + .height(102.dp) + ) + + if (tagNames.isNotEmpty()) { + MemoTagRow( + tagNames = tagNames, + onRemoveTag = onRemoveTag + ) + } + + // TODO: Connect tag creation/update API for existing memos when the backend contract is ready. + // TODO: 태그와 관련된 모든 것: 생성, 수정, 삭제 등등을 모두 하지 않음 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + MemoTextInput( + value = tagInput, + onValueChange = onTagInputChanged, + placeholder = "태그를 입력하세요", + modifier = Modifier + .weight(1f) + .height(40.dp), + singleLine = true + ) + Button( + onClick = onAddTag, + enabled = tagInput.isNotBlank() && !isSaving, + shape = RoundedCornerShape(12.dp), + colors = ButtonDefaults.buttonColors( + containerColor = PureWhite, + contentColor = Ink90, + disabledContainerColor = PureWhite, + disabledContentColor = Ink60.copy(alpha = 0.45f) + ), + border = BorderStroke(1.dp, Ink60.copy(alpha = 0.24f)) + ) { + Text(text = "추가") + } + } + } + } +} + +@Composable +private fun MemoSectionHeader() { + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Rounded.Edit, + contentDescription = null, + tint = Ink60, + modifier = Modifier.size(18.dp) + ) + Text( + text = "메모하기", + style = MaterialTheme.typography.bodyMedium, + fontSize = 14.sp, + color = Ink90 + ) + } +} + +@Composable +private fun MemoPlaceholder() { + Surface( + shape = RoundedCornerShape(12.dp), + color = PureWhite, + border = BorderStroke(1.dp, Ink60.copy(alpha = 0.24f)) + ) { + Text( + text = "메모를 입력하세요", + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 18.dp, vertical = 15.dp), + style = MaterialTheme.typography.bodyMedium, + fontSize = 14.sp, + color = Ink60.copy(alpha = 0.55f) + ) + } +} + +@Composable +private fun MemoDisplay( + content: String, + tagNames: List +) { + Column( + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Surface( + shape = RoundedCornerShape(12.dp), + color = PureWhite, + border = BorderStroke(1.dp, Ink60.copy(alpha = 0.24f)) + ) { + Text( + text = content, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 18.dp, vertical = 12.dp), + style = MaterialTheme.typography.bodyMedium, + fontSize = 14.sp, + color = Ink100 + ) + } + if (tagNames.isNotEmpty()) { + MemoTagRow(tagNames = tagNames, onRemoveTag = null) + } + } +} + +@Composable +private fun MemoTagRow( + tagNames: List, + onRemoveTag: ((String) -> Unit)? +) { + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + tagNames.forEach { tagName -> + MemoTagChip( + text = tagName, + onClick = onRemoveTag?.let { remove -> { remove(tagName) } } + ) + } + } +} + @Composable private fun EventDetailMemoSection( isOpen: Boolean, @@ -369,8 +575,8 @@ private fun EventDetailMemoSection( border = BorderStroke(1.dp, Ink60.copy(alpha = 0.24f)) ) { Column( - modifier = Modifier.padding(12.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) ) { Row( modifier = Modifier.fillMaxWidth(), @@ -383,16 +589,14 @@ private fun EventDetailMemoSection( tint = Ink60, modifier = Modifier.size(18.dp) ) - TextButton( - onClick = onSaveClick, - enabled = !isSaving - ) { - Text( - text = if (isSaving) "저장 중" else "저장하기", - color = Ink90, - fontWeight = FontWeight.SemiBold - ) - } + Text( + text = if (isSaving) "저장 중" else "저장하기", + modifier = Modifier + .clickable(enabled = !isSaving, onClick = onSaveClick) + .padding(horizontal = 4.dp, vertical = 2.dp), + color = Ink90, + fontWeight = FontWeight.SemiBold + ) } MemoTextInput( @@ -499,20 +703,45 @@ private fun MemoTextInput( @Composable private fun MemoTagChip( text: String, - onClick: () -> Unit + onClick: (() -> Unit)? = null ) { - Surface( - onClick = onClick, - shape = RoundedCornerShape(10.dp), - color = Cream10, - border = BorderStroke(1.dp, Ink60.copy(alpha = 0.18f)) - ) { - Text( - text = "#$text", + val content: @Composable () -> Unit = { + Row( modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), - style = MaterialTheme.typography.bodyMedium, - fontSize = 13.sp, - color = Ink90 + horizontalArrangement = Arrangement.spacedBy(7.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "#$text", + style = MaterialTheme.typography.bodyMedium, + fontSize = 13.sp, + color = Ink90 + ) + if (onClick != null) { + Text( + text = "x", + style = MaterialTheme.typography.bodyMedium, + fontSize = 13.sp, + color = Ink60 + ) + } + } + } + + if (onClick == null) { + Surface( + shape = RoundedCornerShape(10.dp), + color = Cream10, + border = BorderStroke(1.dp, Ink60.copy(alpha = 0.18f)), + content = content + ) + } else { + Surface( + onClick = onClick, + shape = RoundedCornerShape(10.dp), + color = Cream10, + border = BorderStroke(1.dp, Ink60.copy(alpha = 0.18f)), + content = content ) } } diff --git a/app/src/main/java/com/example/hangsha_android/ui/view/eventdetail/EventDetailUiState.kt b/app/src/main/java/com/example/hangsha_android/ui/view/eventdetail/EventDetailUiState.kt index 9434057..96f5f21 100644 --- a/app/src/main/java/com/example/hangsha_android/ui/view/eventdetail/EventDetailUiState.kt +++ b/app/src/main/java/com/example/hangsha_android/ui/view/eventdetail/EventDetailUiState.kt @@ -11,10 +11,18 @@ data class EventDetailUiState( val memoContent: String = "", val memoTagInput: String = "", val memoTagNames: List = emptyList(), + val savedMemo: EventDetailMemo? = null, val isMemoSaving: Boolean = false, val memoSaveMessage: String? = null ) +data class EventDetailMemo( + val id: Long, + val eventId: Long, + val content: String, + val tagNames: List +) + data class EventDetailItem( val id: Long, val title: String, 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 8a3aea6..b86f571 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 @@ -4,6 +4,7 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.example.hangsha_android.data.network.model.EventDetailResponse +import com.example.hangsha_android.data.network.model.MemoResponse import com.example.hangsha_android.data.repository.BookmarkRepository import com.example.hangsha_android.data.repository.EventRepository import com.example.hangsha_android.data.repository.MemoRepository @@ -41,6 +42,7 @@ class EventDetailViewModel @Inject constructor( val uiState: StateFlow = _uiState.asStateFlow() private var loadJob: Job? = null + private var memoLoadJob: Job? = null init { viewModelScope.launch { @@ -49,10 +51,12 @@ class EventDetailViewModel @Inject constructor( } } loadEventDetail() + loadMemoForEvent() } fun retry() { loadEventDetail() + loadMemoForEvent() } fun toggleBookmark() { @@ -85,7 +89,17 @@ class EventDetailViewModel @Inject constructor( fun openMemoEditor() { _uiState.update { - it.copy(isMemoEditorOpen = true) + val savedMemo = it.savedMemo + if (savedMemo == null) { + it.copy(isMemoEditorOpen = true) + } else { + it.copy( + isMemoEditorOpen = true, + memoContent = savedMemo.content, + memoTagInput = "", + memoTagNames = savedMemo.tagNames + ) + } } } @@ -125,10 +139,6 @@ class EventDetailViewModel @Inject constructor( val currentState = _uiState.value val currentItem = currentState.item ?: return val content = currentState.memoContent.trim() - val tagNames = (currentState.memoTagNames + currentState.memoTagInput.trim()) - .filter { it.isNotBlank() } - .distinct() - if (content.isBlank()) { _uiState.update { it.copy(memoSaveMessage = "메모를 입력해주세요.") @@ -146,19 +156,32 @@ class EventDetailViewModel @Inject constructor( viewModelScope.launch { runCatching { - memoRepository.createMemo( - eventId = currentItem.id, - content = content, - tagNames = tagNames - ).requireBody("Memo response was empty.") + 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, + tagNames = tagNames + ).requireBody("Memo response was empty.") + } else { + memoRepository.updateMemo( + memoId = savedMemo.id, + content = content + ).requireBody("Memo response was empty.") + } }.fold( - onSuccess = { + onSuccess = { memo -> _uiState.update { + val savedMemo = memo.toEventDetailMemo() it.copy( isMemoEditorOpen = false, - memoContent = "", + memoContent = savedMemo.content, memoTagInput = "", - memoTagNames = emptyList(), + memoTagNames = savedMemo.tagNames, + savedMemo = savedMemo, isMemoSaving = false, memoSaveMessage = "메모가 저장되었습니다." ) @@ -235,6 +258,32 @@ class EventDetailViewModel @Inject constructor( } } + private fun loadMemoForEvent() { + if (eventId <= 0L) { + return + } + + memoLoadJob?.cancel() + memoLoadJob = viewModelScope.launch { + runCatching { + memoRepository.getMemos() + .requireBody("Memo list response was empty.") + .items + .firstOrNull { it.eventId == eventId } + ?.toEventDetailMemo() + }.onSuccess { memo -> + _uiState.update { + it.copy( + savedMemo = memo, + memoContent = memo?.content.orEmpty(), + memoTagInput = "", + memoTagNames = memo?.tagNames.orEmpty() + ) + } + } + } + } + private fun mapErrorMessage(error: Throwable): String { return when (error) { is UnknownHostException -> "No internet connection. Please check your network." @@ -301,6 +350,15 @@ class EventDetailViewModel @Inject constructor( private val DetailDateTimeFormatter = DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm", Locale.KOREA) private val DetailDateFormatter = DateTimeFormatter.ofPattern("yyyy/MM/dd", Locale.KOREA) +private fun MemoResponse.toEventDetailMemo(): EventDetailMemo { + return EventDetailMemo( + id = id, + eventId = eventId, + content = content, + tagNames = tags.map { it.name } + ) +} + private fun EventDetailResponse.toEventDetailItem( bookmarkedEventIds: Set ): EventDetailItem { 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 19fe847..313e0e2 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 @@ -91,6 +91,8 @@ fun MyPageScreen( onInterestPriorityClick: () -> Unit, onBookmarksClick: () -> Unit, onBookmarkedEventClick: (Long) -> Unit, + onMemoListClick: () -> Unit, + onMemoEventClick: (Long) -> Unit, onLogoutClick: () -> Unit, onBugReportTitleChanged: (String) -> Unit, onBugReportContentChanged: (String) -> Unit, @@ -149,8 +151,10 @@ fun MyPageScreen( onMoreClick = onBookmarksClick ) Spacer(modifier = Modifier.height(28.dp)) - EmptyShortcutSection( - title = "메모 목록", + MemosPreviewSection( + items = uiState.memoItems, + isLoading = uiState.isMemosPreviewLoading, + errorMessage = uiState.memosPreviewErrorMessage, icon = { Icon( imageVector = Icons.Rounded.Edit, @@ -159,8 +163,8 @@ fun MyPageScreen( modifier = Modifier.size(16.dp) ) }, - emptyTitle = "아직 메모가 없습니다.", - emptyDescription = "관심있는 행사에 메모를 작성해보세요." + onHeaderClick = onMemoListClick, + onMemoClick = onMemoEventClick ) Spacer(modifier = Modifier.height(24.dp)) Divider() @@ -722,6 +726,155 @@ private fun MoreBookmarksPreviewCard(onClick: () -> Unit) { } } +// 메모 목록 미리보기 영역 +@Composable +private fun MemosPreviewSection( + items: List, + isLoading: Boolean, + errorMessage: String?, + icon: @Composable () -> Unit, + onHeaderClick: () -> Unit, + onMemoClick: (Long) -> Unit +) { + Column(modifier = Modifier.fillMaxWidth()) { + ShortcutSectionHeader( + title = "내 메모 목록", + icon = icon, + onClick = onHeaderClick + ) + Spacer(modifier = Modifier.height(16.dp)) + + when { + isLoading -> { + Box( + modifier = Modifier + .fillMaxWidth() + .height(126.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(modifier = Modifier.size(22.dp)) + } + } + + errorMessage != null -> { + Text( + text = errorMessage, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + style = MaterialTheme.typography.bodyMedium, + color = Ink60, + fontSize = 10.sp, + lineHeight = 13.sp, + textAlign = TextAlign.Center + ) + } + + items.isEmpty() -> { + Text( + text = "아직 메모가 없습니다.\n관심있는 행사에 메모를 작성해보세요.", + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + style = MaterialTheme.typography.bodyMedium, + color = Ink60, + fontSize = 10.sp, + lineHeight = 13.sp, + textAlign = TextAlign.Center + ) + } + + else -> { + LazyRow( + horizontalArrangement = Arrangement.spacedBy(18.dp) + ) { + items( + items = items, + key = { item -> item.id } + ) { item -> + MemoPreviewCard( + item = item, + onClick = { onMemoClick(item.eventId) } + ) + } + } + } + } + } +} + +@Composable +private fun MemoPreviewCard( + item: MyPageMemoItem, + onClick: () -> Unit +) { + Column( + modifier = Modifier + .width(215.dp) + .clickable(onClick = onClick) + ) { + Text( + text = item.content, + style = MaterialTheme.typography.bodyMedium, + color = Ink100, + fontSize = 13.sp, + lineHeight = 17.sp, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.height(74.dp)) + Text( + text = item.eventTitle, + style = MaterialTheme.typography.bodyMedium, + color = Ink60, + fontSize = 13.sp, + lineHeight = 16.sp, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.height(3.dp)) + Text( + text = item.updatedDateDisplay, + style = MaterialTheme.typography.bodyMedium, + color = Ink60, + fontSize = 12.sp + ) + if (item.tagNames.isNotEmpty()) { + Spacer(modifier = Modifier.height(10.dp)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + maxLines = 1 + ) { + item.tagNames.take(3).forEach { tagName -> + MemoPreviewTagChip(text = tagName) + } + } + } + } +} + +@Composable +private fun MemoPreviewTagChip(text: String) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background(Color(0xFFE8E8E8)) + .padding(horizontal = 9.dp, vertical = 4.dp) + ) { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = Ink60, + fontSize = 11.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + // 빈 목록 안내 영역 @Composable private fun EmptyShortcutSection( diff --git a/app/src/main/java/com/example/hangsha_android/ui/view/mypage/MyPageUiState.kt b/app/src/main/java/com/example/hangsha_android/ui/view/mypage/MyPageUiState.kt index 54497ff..ec0d5be 100644 --- a/app/src/main/java/com/example/hangsha_android/ui/view/mypage/MyPageUiState.kt +++ b/app/src/main/java/com/example/hangsha_android/ui/view/mypage/MyPageUiState.kt @@ -6,6 +6,7 @@ import com.example.hangsha_android.ui.view.bookmarks.BookmarkedEventItem data class MyPageUiState( val isLoading: Boolean = true, val isBookmarksPreviewLoading: Boolean = true, + val isMemosPreviewLoading: Boolean = true, val isSavingProfile: Boolean = false, val isDeletingAccount: Boolean = false, val username: String = "", @@ -29,5 +30,16 @@ data class MyPageUiState( val bookmarkedEvents: List = emptyList(), val hasMoreBookmarkedEvents: Boolean = false, val bookmarksPreviewErrorMessage: String? = null, + val memoItems: List = emptyList(), + val memosPreviewErrorMessage: String? = null, val errorMessage: String? = null ) + +data class MyPageMemoItem( + val id: Long, + val eventId: Long, + val eventTitle: String, + val content: String, + val tagNames: List, + val updatedDateDisplay: String +) diff --git a/app/src/main/java/com/example/hangsha_android/ui/view/mypage/MyPageViewModel.kt b/app/src/main/java/com/example/hangsha_android/ui/view/mypage/MyPageViewModel.kt index 02d186c..41b21da 100644 --- a/app/src/main/java/com/example/hangsha_android/ui/view/mypage/MyPageViewModel.kt +++ b/app/src/main/java/com/example/hangsha_android/ui/view/mypage/MyPageViewModel.kt @@ -6,8 +6,10 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.example.hangsha_android.data.local.AuthTokenStorage import com.example.hangsha_android.data.network.model.EventSummaryResponse +import com.example.hangsha_android.data.network.model.MemoResponse import com.example.hangsha_android.data.repository.BookmarkRepository import com.example.hangsha_android.data.repository.BugReportRepository +import com.example.hangsha_android.data.repository.MemoRepository import com.example.hangsha_android.data.repository.UserRepository import com.example.hangsha_android.ui.view.bookmarks.BookmarkedEventItem import dagger.hilt.android.qualifiers.ApplicationContext @@ -34,6 +36,7 @@ class MyPageViewModel @Inject constructor( private val userRepository: UserRepository, private val bookmarkRepository: BookmarkRepository, private val bugReportRepository: BugReportRepository, + private val memoRepository: MemoRepository, private val authTokenStorage: AuthTokenStorage, @param:ApplicationContext private val appContext: Context ) : ViewModel() { @@ -41,10 +44,12 @@ class MyPageViewModel @Inject constructor( private val _uiState = MutableStateFlow(MyPageUiState()) val uiState: StateFlow = _uiState.asStateFlow() private var isBookmarksPreviewInFlight = false + private var isMemosPreviewInFlight = false init { loadMyProfile() loadBookmarkedEventPreview() + loadMemoPreview() } fun loadMyProfile() { @@ -152,6 +157,53 @@ class MyPageViewModel @Inject constructor( } } + fun loadMemoPreview() { + if (isMemosPreviewInFlight) { + return + } + + isMemosPreviewInFlight = true + viewModelScope.launch { + try { + _uiState.update { + it.copy( + isMemosPreviewLoading = it.memoItems.isEmpty(), + memosPreviewErrorMessage = 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( + memoItems = body.items.map { memo -> memo.toMyPageMemoItem() }, + isMemosPreviewLoading = false, + memosPreviewErrorMessage = null + ) + } + }, + onFailure = { error -> + _uiState.update { + it.copy( + isMemosPreviewLoading = false, + memosPreviewErrorMessage = mapMemosPreviewErrorMessage(error) + ) + } + } + ) + } finally { + isMemosPreviewInFlight = false + } + } + } + fun startProfileEdit() { _uiState.update { it.copy( @@ -527,6 +579,20 @@ class MyPageViewModel @Inject constructor( } } + private fun mapMemosPreviewErrorMessage(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 mapErrorMessage(error: Throwable): String { return when (error) { is UnknownHostException -> "No internet connection. Please check your network." @@ -574,6 +640,19 @@ private fun EventSummaryResponse.toBookmarkedEventItem(): BookmarkedEventItem { ) } +private fun MemoResponse.toMyPageMemoItem(): MyPageMemoItem { + return MyPageMemoItem( + id = id, + eventId = eventId, + eventTitle = eventTitle, + content = content, + tagNames = tags.map { tag -> tag.name }, + updatedDateDisplay = formatMemoDate(updatedAt) + ?: formatMemoDate(createdAt) + ?: "-" + ) +} + private fun parseDate(value: String?): LocalDate? { if (value.isNullOrBlank()) { return null @@ -586,6 +665,11 @@ private fun parseDate(value: String?): LocalDate? { } } +private fun formatMemoDate(value: String?): String? { + val date = parseDate(value) + return date?.format(FullDateFormatter) +} + private fun formatPeriod( startValue: String?, endValue: String? From b1def48ce472da6cca6085b187355cd0a8f512e4 Mon Sep 17 00:00:00 2001 From: dgddgd314 Date: Wed, 24 Jun 2026 02:41:59 +0900 Subject: [PATCH 3/3] chore: divide memo section into diff files --- .../eventdetail/EventDetailMemoSection.kt | 331 +++++++++++++ .../ui/view/eventdetail/EventDetailScreen.kt | 450 +----------------- 2 files changed, 333 insertions(+), 448 deletions(-) create mode 100644 app/src/main/java/com/example/hangsha_android/ui/view/eventdetail/EventDetailMemoSection.kt 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 new file mode 100644 index 0000000..24e41c8 --- /dev/null +++ b/app/src/main/java/com/example/hangsha_android/ui/view/eventdetail/EventDetailMemoSection.kt @@ -0,0 +1,331 @@ +package com.example.hangsha_android.ui.view.eventdetail + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Edit +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +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.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.example.hangsha_android.ui.theme.Cream10 +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 + +@Composable +fun EventDetailMemoSection( + isOpen: Boolean, + savedMemo: EventDetailMemo?, + content: String, + tagInput: String, + tagNames: List, + isSaving: Boolean, + onOpen: () -> Unit, + onContentChanged: (String) -> Unit, + onTagInputChanged: (String) -> Unit, + onAddTag: () -> Unit, + onRemoveTag: (String) -> Unit, + onSaveClick: () -> Unit +) { + if (!isOpen) { + Column( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onOpen), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + MemoSectionHeader() + if (savedMemo == null) { + MemoPlaceholder() + } else { + MemoDisplay( + content = savedMemo.content, + tagNames = savedMemo.tagNames + ) + } + } + return + } + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + color = PureWhite, + border = BorderStroke(1.dp, Ink60.copy(alpha = 0.24f)) + ) { + Column( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Rounded.Edit, + contentDescription = null, + tint = Ink60, + modifier = Modifier.size(18.dp) + ) + Text( + text = if (isSaving) "저장 중" else "저장하기", + modifier = Modifier + .clickable(enabled = !isSaving, onClick = onSaveClick) + .padding(horizontal = 4.dp, vertical = 2.dp), + color = Ink90, + fontWeight = FontWeight.SemiBold + ) + } + + MemoTextInput( + value = content, + onValueChange = onContentChanged, + placeholder = "메모를 입력하세요", + modifier = Modifier + .fillMaxWidth() + .height(102.dp) + ) + + if (tagNames.isNotEmpty()) { + MemoTagRow( + tagNames = tagNames, + onRemoveTag = onRemoveTag + ) + } + + // TODO: Connect tag creation/update API for existing memos when the backend contract is ready. + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + MemoTextInput( + value = tagInput, + onValueChange = onTagInputChanged, + placeholder = "태그를 입력하세요", + modifier = Modifier + .weight(1f) + .height(40.dp), + singleLine = true + ) + Button( + onClick = onAddTag, + enabled = tagInput.isNotBlank() && !isSaving, + shape = RoundedCornerShape(12.dp), + colors = ButtonDefaults.buttonColors( + containerColor = PureWhite, + contentColor = Ink90, + disabledContainerColor = PureWhite, + disabledContentColor = Ink60.copy(alpha = 0.45f) + ), + border = BorderStroke(1.dp, Ink60.copy(alpha = 0.24f)) + ) { + Text(text = "추가") + } + } + } + } +} + +@Composable +private fun MemoSectionHeader() { + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Rounded.Edit, + contentDescription = null, + tint = Ink60, + modifier = Modifier.size(18.dp) + ) + Text( + text = "메모하기", + style = MaterialTheme.typography.bodyMedium, + fontSize = 14.sp, + color = Ink90 + ) + } +} + +@Composable +private fun MemoPlaceholder() { + Surface( + shape = RoundedCornerShape(12.dp), + color = PureWhite, + border = BorderStroke(1.dp, Ink60.copy(alpha = 0.24f)) + ) { + Text( + text = "메모를 입력하세요", + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 18.dp, vertical = 15.dp), + style = MaterialTheme.typography.bodyMedium, + fontSize = 14.sp, + color = Ink60.copy(alpha = 0.55f) + ) + } +} + +@Composable +private fun MemoDisplay( + content: String, + tagNames: List +) { + Column( + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Surface( + shape = RoundedCornerShape(12.dp), + color = PureWhite, + border = BorderStroke(1.dp, Ink60.copy(alpha = 0.24f)) + ) { + Text( + text = content, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 18.dp, vertical = 12.dp), + style = MaterialTheme.typography.bodyMedium, + fontSize = 14.sp, + color = Ink100 + ) + } + if (tagNames.isNotEmpty()) { + MemoTagRow(tagNames = tagNames, onRemoveTag = null) + } + } +} + +@Composable +private fun MemoTagRow( + tagNames: List, + onRemoveTag: ((String) -> Unit)? +) { + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + tagNames.forEach { tagName -> + MemoTagChip( + text = tagName, + onClick = onRemoveTag?.let { remove -> { remove(tagName) } } + ) + } + } +} + +@Composable +private fun MemoTextInput( + value: String, + onValueChange: (String) -> Unit, + placeholder: String, + modifier: Modifier = Modifier, + singleLine: Boolean = false +) { + Surface( + modifier = modifier, + shape = RoundedCornerShape(10.dp), + color = PureWhite, + border = BorderStroke(1.dp, Ink60.copy(alpha = 0.28f)) + ) { + BasicTextField( + value = value, + onValueChange = onValueChange, + singleLine = singleLine, + textStyle = MaterialTheme.typography.bodyMedium.copy( + fontSize = 14.sp, + lineHeight = 22.sp, + color = Ink100 + ), + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 14.dp, vertical = 10.dp), + decorationBox = { innerTextField -> + Box(modifier = Modifier.fillMaxSize()) { + if (value.isBlank()) { + Text( + text = placeholder, + style = MaterialTheme.typography.bodyMedium, + fontSize = 14.sp, + color = Ink60.copy(alpha = 0.55f) + ) + } + innerTextField() + } + } + ) + } +} + +@Composable +private fun MemoTagChip( + text: String, + onClick: (() -> Unit)? = null +) { + val content: @Composable () -> Unit = { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(7.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "#$text", + style = MaterialTheme.typography.bodyMedium, + fontSize = 13.sp, + color = Ink90 + ) + if (onClick != null) { + Text( + text = "x", + style = MaterialTheme.typography.bodyMedium, + fontSize = 13.sp, + color = Ink60 + ) + } + } + } + + if (onClick == null) { + Surface( + shape = RoundedCornerShape(10.dp), + color = Cream10, + border = BorderStroke(1.dp, Ink60.copy(alpha = 0.18f)), + content = content + ) + } else { + Surface( + onClick = onClick, + shape = RoundedCornerShape(10.dp), + color = Cream10, + border = BorderStroke(1.dp, Ink60.copy(alpha = 0.18f)), + content = content + ) + } +} diff --git a/app/src/main/java/com/example/hangsha_android/ui/view/eventdetail/EventDetailScreen.kt b/app/src/main/java/com/example/hangsha_android/ui/view/eventdetail/EventDetailScreen.kt index dc570c2..367149c 100644 --- a/app/src/main/java/com/example/hangsha_android/ui/view/eventdetail/EventDetailScreen.kt +++ b/app/src/main/java/com/example/hangsha_android/ui/view/eventdetail/EventDetailScreen.kt @@ -6,10 +6,8 @@ import android.view.ViewGroup import android.webkit.WebResourceRequest import android.webkit.WebView import android.webkit.WebViewClient -import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -21,17 +19,13 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding -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.Bookmark import androidx.compose.material.icons.rounded.BookmarkBorder -import androidx.compose.material.icons.rounded.Edit import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -285,8 +279,9 @@ private fun EventDetailContent( ) } + // 메모 기능 - 별도 파일로 분리 item { - EventDetailMemoSectionWithSavedMemo( + EventDetailMemoSection( isOpen = uiState.isMemoEditorOpen, savedMemo = uiState.savedMemo, content = uiState.memoContent, @@ -305,447 +300,6 @@ private fun EventDetailContent( } } -@Composable -private fun EventDetailMemoSectionWithSavedMemo( - isOpen: Boolean, - savedMemo: EventDetailMemo?, - content: String, - tagInput: String, - tagNames: List, - isSaving: Boolean, - onOpen: () -> Unit, - onContentChanged: (String) -> Unit, - onTagInputChanged: (String) -> Unit, - onAddTag: () -> Unit, - onRemoveTag: (String) -> Unit, - onSaveClick: () -> Unit -) { - if (!isOpen) { - Column( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onOpen), - verticalArrangement = Arrangement.spacedBy(10.dp) - ) { - MemoSectionHeader() - if (savedMemo == null) { - MemoPlaceholder() - } else { - MemoDisplay( - content = savedMemo.content, - tagNames = savedMemo.tagNames - ) - } - } - return - } - - Surface( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(14.dp), - color = PureWhite, - border = BorderStroke(1.dp, Ink60.copy(alpha = 0.24f)) - ) { - Column( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Rounded.Edit, - contentDescription = null, - tint = Ink60, - modifier = Modifier.size(18.dp) - ) - Text( - text = if (isSaving) "저장 중" else "저장하기", - modifier = Modifier - .clickable(enabled = !isSaving, onClick = onSaveClick) - .padding(horizontal = 4.dp, vertical = 2.dp), - color = Ink90, - fontWeight = FontWeight.SemiBold - ) - } - - MemoTextInput( - value = content, - onValueChange = onContentChanged, - placeholder = "메모를 입력하세요", - modifier = Modifier - .fillMaxWidth() - .height(102.dp) - ) - - if (tagNames.isNotEmpty()) { - MemoTagRow( - tagNames = tagNames, - onRemoveTag = onRemoveTag - ) - } - - // TODO: Connect tag creation/update API for existing memos when the backend contract is ready. - // TODO: 태그와 관련된 모든 것: 생성, 수정, 삭제 등등을 모두 하지 않음 - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - MemoTextInput( - value = tagInput, - onValueChange = onTagInputChanged, - placeholder = "태그를 입력하세요", - modifier = Modifier - .weight(1f) - .height(40.dp), - singleLine = true - ) - Button( - onClick = onAddTag, - enabled = tagInput.isNotBlank() && !isSaving, - shape = RoundedCornerShape(12.dp), - colors = ButtonDefaults.buttonColors( - containerColor = PureWhite, - contentColor = Ink90, - disabledContainerColor = PureWhite, - disabledContentColor = Ink60.copy(alpha = 0.45f) - ), - border = BorderStroke(1.dp, Ink60.copy(alpha = 0.24f)) - ) { - Text(text = "추가") - } - } - } - } -} - -@Composable -private fun MemoSectionHeader() { - Row( - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Rounded.Edit, - contentDescription = null, - tint = Ink60, - modifier = Modifier.size(18.dp) - ) - Text( - text = "메모하기", - style = MaterialTheme.typography.bodyMedium, - fontSize = 14.sp, - color = Ink90 - ) - } -} - -@Composable -private fun MemoPlaceholder() { - Surface( - shape = RoundedCornerShape(12.dp), - color = PureWhite, - border = BorderStroke(1.dp, Ink60.copy(alpha = 0.24f)) - ) { - Text( - text = "메모를 입력하세요", - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 18.dp, vertical = 15.dp), - style = MaterialTheme.typography.bodyMedium, - fontSize = 14.sp, - color = Ink60.copy(alpha = 0.55f) - ) - } -} - -@Composable -private fun MemoDisplay( - content: String, - tagNames: List -) { - Column( - verticalArrangement = Arrangement.spacedBy(10.dp) - ) { - Surface( - shape = RoundedCornerShape(12.dp), - color = PureWhite, - border = BorderStroke(1.dp, Ink60.copy(alpha = 0.24f)) - ) { - Text( - text = content, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 18.dp, vertical = 12.dp), - style = MaterialTheme.typography.bodyMedium, - fontSize = 14.sp, - color = Ink100 - ) - } - if (tagNames.isNotEmpty()) { - MemoTagRow(tagNames = tagNames, onRemoveTag = null) - } - } -} - -@Composable -private fun MemoTagRow( - tagNames: List, - onRemoveTag: ((String) -> Unit)? -) { - Row( - modifier = Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - tagNames.forEach { tagName -> - MemoTagChip( - text = tagName, - onClick = onRemoveTag?.let { remove -> { remove(tagName) } } - ) - } - } -} - -@Composable -private fun EventDetailMemoSection( - isOpen: Boolean, - content: String, - tagInput: String, - tagNames: List, - isSaving: Boolean, - onOpen: () -> Unit, - onContentChanged: (String) -> Unit, - onTagInputChanged: (String) -> Unit, - onAddTag: () -> Unit, - onRemoveTag: (String) -> Unit, - onSaveClick: () -> Unit -) { - if (!isOpen) { - Column( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onOpen), - verticalArrangement = Arrangement.spacedBy(10.dp) - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Rounded.Edit, - contentDescription = null, - tint = Ink60, - modifier = Modifier.size(18.dp) - ) - Text( - text = "메모하기", - style = MaterialTheme.typography.bodyMedium, - fontSize = 14.sp, - color = Ink90 - ) - } - Surface( - shape = RoundedCornerShape(12.dp), - color = PureWhite, - border = BorderStroke(1.dp, Ink60.copy(alpha = 0.24f)) - ) { - Text( - text = "메모를 입력하세요", - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 18.dp, vertical = 15.dp), - style = MaterialTheme.typography.bodyMedium, - fontSize = 14.sp, - color = Ink60.copy(alpha = 0.55f) - ) - } - } - return - } - - Surface( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(14.dp), - color = PureWhite, - border = BorderStroke(1.dp, Ink60.copy(alpha = 0.24f)) - ) { - Column( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Rounded.Edit, - contentDescription = null, - tint = Ink60, - modifier = Modifier.size(18.dp) - ) - Text( - text = if (isSaving) "저장 중" else "저장하기", - modifier = Modifier - .clickable(enabled = !isSaving, onClick = onSaveClick) - .padding(horizontal = 4.dp, vertical = 2.dp), - color = Ink90, - fontWeight = FontWeight.SemiBold - ) - } - - MemoTextInput( - value = content, - onValueChange = onContentChanged, - placeholder = "메모를 입력하세요", - modifier = Modifier - .fillMaxWidth() - .height(102.dp) - ) - - if (tagNames.isNotEmpty()) { - Row( - modifier = Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - tagNames.forEach { tagName -> - MemoTagChip( - text = tagName, - onClick = { onRemoveTag(tagName) } - ) - } - } - } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - MemoTextInput( - value = tagInput, - onValueChange = onTagInputChanged, - placeholder = "태그를 입력하세요", - modifier = Modifier - .weight(1f) - .height(40.dp), - singleLine = true - ) - Button( - onClick = onAddTag, - enabled = tagInput.isNotBlank() && !isSaving, - shape = RoundedCornerShape(12.dp), - colors = ButtonDefaults.buttonColors( - containerColor = PureWhite, - contentColor = Ink90, - disabledContainerColor = PureWhite, - disabledContentColor = Ink60.copy(alpha = 0.45f) - ), - border = BorderStroke(1.dp, Ink60.copy(alpha = 0.24f)) - ) { - Text(text = "추가") - } - } - } - } -} - -@Composable -private fun MemoTextInput( - value: String, - onValueChange: (String) -> Unit, - placeholder: String, - modifier: Modifier = Modifier, - singleLine: Boolean = false -) { - Surface( - modifier = modifier, - shape = RoundedCornerShape(10.dp), - color = PureWhite, - border = BorderStroke(1.dp, Ink60.copy(alpha = 0.28f)) - ) { - BasicTextField( - value = value, - onValueChange = onValueChange, - singleLine = singleLine, - textStyle = MaterialTheme.typography.bodyMedium.copy( - fontSize = 14.sp, - lineHeight = 22.sp, - color = Ink100 - ), - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 14.dp, vertical = 10.dp), - decorationBox = { innerTextField -> - Box(modifier = Modifier.fillMaxSize()) { - if (value.isBlank()) { - Text( - text = placeholder, - style = MaterialTheme.typography.bodyMedium, - fontSize = 14.sp, - color = Ink60.copy(alpha = 0.55f) - ) - } - innerTextField() - } - } - ) - } -} - -@Composable -private fun MemoTagChip( - text: String, - onClick: (() -> Unit)? = null -) { - val content: @Composable () -> Unit = { - Row( - modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), - horizontalArrangement = Arrangement.spacedBy(7.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = "#$text", - style = MaterialTheme.typography.bodyMedium, - fontSize = 13.sp, - color = Ink90 - ) - if (onClick != null) { - Text( - text = "x", - style = MaterialTheme.typography.bodyMedium, - fontSize = 13.sp, - color = Ink60 - ) - } - } - } - - if (onClick == null) { - Surface( - shape = RoundedCornerShape(10.dp), - color = Cream10, - border = BorderStroke(1.dp, Ink60.copy(alpha = 0.18f)), - content = content - ) - } else { - Surface( - onClick = onClick, - shape = RoundedCornerShape(10.dp), - color = Cream10, - border = BorderStroke(1.dp, Ink60.copy(alpha = 0.18f)), - content = content - ) - } -} - // 세부 설명 @Composable private fun EventDetailHtmlContent(