diff --git a/core/designsystem/src/main/java/com/susu/core/designsystem/component/dialog/SusuCheckedDialog.kt b/core/designsystem/src/main/java/com/susu/core/designsystem/component/dialog/SusuCheckedDialog.kt index 06f36607..c80a22af 100644 --- a/core/designsystem/src/main/java/com/susu/core/designsystem/component/dialog/SusuCheckedDialog.kt +++ b/core/designsystem/src/main/java/com/susu/core/designsystem/component/dialog/SusuCheckedDialog.kt @@ -1,11 +1,9 @@ package com.susu.core.designsystem.component.dialog import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -19,10 +17,10 @@ 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.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog import com.susu.core.designsystem.component.button.FilledButtonColor import com.susu.core.designsystem.component.button.GhostButtonColor import com.susu.core.designsystem.component.button.SmallButtonStyle @@ -34,34 +32,26 @@ import com.susu.core.designsystem.theme.Gray100 import com.susu.core.designsystem.theme.Gray40 import com.susu.core.designsystem.theme.Gray80 import com.susu.core.designsystem.theme.SusuTheme +import com.susu.core.ui.extension.susuClickable @Composable fun SusuCheckedDialog( - modifier: Modifier = Modifier, title: String? = null, text: String? = null, defaultChecked: Boolean = false, checkboxText: String = "", confirmText: String = "", dismissText: String? = null, - isDimmed: Boolean = true, textAlign: TextAlign = TextAlign.Center, onConfirmRequest: (isChecked: Boolean) -> Unit = {}, onDismissRequest: () -> Unit = {}, ) { - val rootModifier = modifier - .fillMaxSize() - .background(color = if (isDimmed) Color.Black.copy(alpha = 0.16f) else Color.Transparent) - .padding(horizontal = SusuTheme.spacing.spacing_xl) var isChecked by remember { mutableStateOf(defaultChecked) } - Box( - modifier = rootModifier, - ) { + Dialog(onDismissRequest = onDismissRequest) { Column( modifier = Modifier .fillMaxWidth() - .align(Alignment.Center) .background(color = Gray10, shape = RoundedCornerShape(8.dp)) .padding(SusuTheme.spacing.spacing_xl), horizontalAlignment = Alignment.CenterHorizontally, @@ -97,6 +87,10 @@ fun SusuCheckedDialog( text = checkboxText, style = SusuTheme.typography.title_xxs, color = if (isChecked) Gray100 else Gray40, + modifier = Modifier.susuClickable( + onClick = { isChecked = !isChecked }, + rippleEnabled = false, + ), ) } Spacer(modifier = Modifier.height(SusuTheme.spacing.spacing_xl)) diff --git a/core/designsystem/src/main/java/com/susu/core/designsystem/component/dialog/SusuDialog.kt b/core/designsystem/src/main/java/com/susu/core/designsystem/component/dialog/SusuDialog.kt index abe6cbf1..9f19b1b8 100644 --- a/core/designsystem/src/main/java/com/susu/core/designsystem/component/dialog/SusuDialog.kt +++ b/core/designsystem/src/main/java/com/susu/core/designsystem/component/dialog/SusuDialog.kt @@ -1,11 +1,9 @@ package com.susu.core.designsystem.component.dialog import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -14,10 +12,10 @@ 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.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog import com.susu.core.designsystem.component.button.FilledButtonColor import com.susu.core.designsystem.component.button.GhostButtonColor import com.susu.core.designsystem.component.button.SmallButtonStyle @@ -30,28 +28,18 @@ import com.susu.core.designsystem.theme.SusuTheme @Composable fun SusuDialog( - modifier: Modifier = Modifier, title: String? = null, text: String? = null, confirmText: String = "", dismissText: String? = null, - isDimmed: Boolean = true, textAlign: TextAlign = TextAlign.Center, onConfirmRequest: () -> Unit = {}, onDismissRequest: () -> Unit = {}, ) { - val rootModifier = modifier - .fillMaxSize() - .background(color = if (isDimmed) Color.Black.copy(alpha = 0.16f) else Color.Transparent) - .padding(horizontal = SusuTheme.spacing.spacing_xl) - - Box( - modifier = rootModifier, - ) { + Dialog(onDismissRequest = onDismissRequest) { Column( modifier = Modifier .fillMaxWidth() - .align(Alignment.Center) .background(color = Gray10, shape = RoundedCornerShape(8.dp)) .padding(SusuTheme.spacing.spacing_xl), horizontalAlignment = Alignment.CenterHorizontally, @@ -100,7 +88,7 @@ fun SusuDialog( } } -@Preview +@Preview(showBackground = true) @Composable fun SusuDialogPreview() { SusuTheme { @@ -112,7 +100,7 @@ fun SusuDialogPreview() { } } -@Preview +@Preview(showBackground = true) @Composable fun SusuDialogLongTitlePreview() { SusuTheme { diff --git a/core/designsystem/src/main/java/com/susu/core/designsystem/component/text/AnimatedCounterText.kt b/core/designsystem/src/main/java/com/susu/core/designsystem/component/text/AnimatedCounterText.kt new file mode 100644 index 00000000..fe5b4fb3 --- /dev/null +++ b/core/designsystem/src/main/java/com/susu/core/designsystem/component/text/AnimatedCounterText.kt @@ -0,0 +1,98 @@ +package com.susu.core.designsystem.component.text + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.tooling.preview.Preview +import com.susu.core.designsystem.theme.Gray100 +import com.susu.core.designsystem.theme.SusuTheme +import java.text.DecimalFormat + +private val upward = slideInVertically { it } togetherWith slideOutVertically { -it } +private val downward = slideInVertically { -it } togetherWith slideOutVertically { it } + +@Composable +fun AnimatedCounterText( + number: Int, + modifier: Modifier = Modifier, + prefix: String? = null, + postfix: String? = null, + style: TextStyle = SusuTheme.typography.title_xs, + color: Color = Gray100, +) { + val moneyFormat = remember { DecimalFormat("#,###") } + val currentString = moneyFormat.format(number) + + Row( + modifier = modifier, + horizontalArrangement = Arrangement.End, + ) { + prefix?.let { + Text( + text = it, + style = style, + color = color, + ) + } + for (i in currentString.indices) { + val char = currentString[i] + + AnimatedContent( + targetState = char, + transitionSpec = { + when { + initialState.isDigit() && targetState.isDigit() && + initialState.digitToInt() < targetState.digitToInt() -> upward + + initialState.isDigit() && targetState.isDigit() && + initialState.digitToInt() > targetState.digitToInt() -> downward + + else -> upward + } + }, + label = "animatedCounterChar$i", + ) { + Text( + text = it.toString(), + style = style, + color = color, + ) + } + } + postfix?.let { + Text( + text = it, + style = style, + color = color, + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun AnimatedCounterTextPreview() { + SusuTheme { + var number by remember { mutableStateOf(10000) } + Column { + Button(onClick = { number += 6000 }) { + Text(text = "증가") + } + AnimatedCounterText(number = number) + } + } +} diff --git a/core/model/src/main/java/com/susu/core/model/Category.kt b/core/model/src/main/java/com/susu/core/model/Category.kt index 8919864d..c45ff258 100644 --- a/core/model/src/main/java/com/susu/core/model/Category.kt +++ b/core/model/src/main/java/com/susu/core/model/Category.kt @@ -9,6 +9,7 @@ data class Category( val id: Int = 0, val seq: Int = 0, val name: String = "", + val category: String = "", val customCategory: String? = null, val style: String = "", ) diff --git a/core/model/src/main/java/com/susu/core/model/Envelope.kt b/core/model/src/main/java/com/susu/core/model/Envelope.kt index 4715c06c..501fcf10 100644 --- a/core/model/src/main/java/com/susu/core/model/Envelope.kt +++ b/core/model/src/main/java/com/susu/core/model/Envelope.kt @@ -1,8 +1,11 @@ package com.susu.core.model +import androidx.compose.runtime.Stable import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.toKotlinLocalDateTime import kotlinx.serialization.Serializable +@Stable @Serializable data class Envelope( val id: Long = 0, @@ -12,7 +15,7 @@ data class Envelope( val gift: String? = null, val memo: String? = null, val hasVisited: Boolean? = null, - val handedOverAt: LocalDateTime? = null, + val handedOverAt: LocalDateTime = java.time.LocalDateTime.now().toKotlinLocalDateTime(), val friend: Friend = Friend(), val relationship: Relationship = Relationship(), ) diff --git a/core/model/src/main/java/com/susu/core/model/EnvelopeDetail.kt b/core/model/src/main/java/com/susu/core/model/EnvelopeDetail.kt new file mode 100644 index 00000000..02860b00 --- /dev/null +++ b/core/model/src/main/java/com/susu/core/model/EnvelopeDetail.kt @@ -0,0 +1,14 @@ +package com.susu.core.model + +import androidx.compose.runtime.Stable +import kotlinx.serialization.Serializable + +@Stable +@Serializable +data class EnvelopeDetail( + val envelope: Envelope = Envelope(), + val category: Category = Category(), + val relationship: Relationship = Relationship(), + val friendRelationship: FriendRelationship = FriendRelationship(), + val friend: Friend = Friend(), +) diff --git a/core/model/src/main/java/com/susu/core/model/EnvelopeSearch.kt b/core/model/src/main/java/com/susu/core/model/EnvelopeSearch.kt new file mode 100644 index 00000000..22392430 --- /dev/null +++ b/core/model/src/main/java/com/susu/core/model/EnvelopeSearch.kt @@ -0,0 +1,8 @@ +package com.susu.core.model + +data class EnvelopeSearch( + val envelope: Envelope, + val category: Category? = null, + val friend: Friend? = null, + val relationship: Relationship? = null, +) diff --git a/core/model/src/main/java/com/susu/core/model/Friend.kt b/core/model/src/main/java/com/susu/core/model/Friend.kt index b2676e4b..909e2f03 100644 --- a/core/model/src/main/java/com/susu/core/model/Friend.kt +++ b/core/model/src/main/java/com/susu/core/model/Friend.kt @@ -1,7 +1,9 @@ package com.susu.core.model +import androidx.compose.runtime.Stable import kotlinx.serialization.Serializable +@Stable @Serializable data class Friend( val id: Long = 0, diff --git a/core/model/src/main/java/com/susu/core/model/FriendRelationship.kt b/core/model/src/main/java/com/susu/core/model/FriendRelationship.kt new file mode 100644 index 00000000..9f9e1166 --- /dev/null +++ b/core/model/src/main/java/com/susu/core/model/FriendRelationship.kt @@ -0,0 +1,12 @@ +package com.susu.core.model + +import androidx.compose.runtime.Stable +import kotlinx.serialization.Serializable + +@Stable +@Serializable +data class FriendRelationship( + val id: Long = 0, + val friendId: Long = 0, + val relationshipId: Long = 0, +) diff --git a/core/model/src/main/java/com/susu/core/model/EnvelopeStatics.kt b/core/model/src/main/java/com/susu/core/model/FriendStatistics.kt similarity index 84% rename from core/model/src/main/java/com/susu/core/model/EnvelopeStatics.kt rename to core/model/src/main/java/com/susu/core/model/FriendStatistics.kt index f123fb2e..5b189b31 100644 --- a/core/model/src/main/java/com/susu/core/model/EnvelopeStatics.kt +++ b/core/model/src/main/java/com/susu/core/model/FriendStatistics.kt @@ -1,6 +1,6 @@ package com.susu.core.model -data class EnvelopeStatics( +data class FriendStatistics( val friend: Friend = Friend(), val receivedAmounts: Int = 0, val sentAmounts: Int = 0, diff --git a/core/model/src/main/java/com/susu/core/model/Relationship.kt b/core/model/src/main/java/com/susu/core/model/Relationship.kt index 7acef7bd..4323ca9e 100644 --- a/core/model/src/main/java/com/susu/core/model/Relationship.kt +++ b/core/model/src/main/java/com/susu/core/model/Relationship.kt @@ -9,4 +9,5 @@ data class Relationship( val id: Long = -1, val relation: String = "", val customRelation: String? = null, + val description: String? = null, ) diff --git a/core/model/src/main/java/com/susu/core/model/SusuStatistics.kt b/core/model/src/main/java/com/susu/core/model/SusuStatistics.kt new file mode 100644 index 00000000..83851128 --- /dev/null +++ b/core/model/src/main/java/com/susu/core/model/SusuStatistics.kt @@ -0,0 +1,16 @@ +package com.susu.core.model + +import androidx.compose.runtime.Stable + +@Stable +data class SusuStatistics( + val averageSent: Int = 0, + val averageRelationship: StatisticsElement = StatisticsElement(), + val averageCategory: StatisticsElement = StatisticsElement(), + val recentSpent: List = emptyList(), + val recentTotalSpent: Int = 0, + val recentMaximumSpent: Int = 0, + val mostSpentMonth: Int = 0, + val mostRelationship: StatisticsElement = StatisticsElement(), + val mostCategory: StatisticsElement = StatisticsElement(), +) diff --git a/core/model/src/main/java/com/susu/core/model/Vote.kt b/core/model/src/main/java/com/susu/core/model/Vote.kt index 08b5e0a7..3a48bea8 100644 --- a/core/model/src/main/java/com/susu/core/model/Vote.kt +++ b/core/model/src/main/java/com/susu/core/model/Vote.kt @@ -11,6 +11,7 @@ data class Vote( val id: Long = 0, val uid: Long = 0, val profile: Profile = Profile(), + val boardId: Long = 0, val boardName: String = "", val content: String = "", val count: Long = 0, diff --git a/core/model/src/main/java/com/susu/core/model/exception/SusuExceptions.kt b/core/model/src/main/java/com/susu/core/model/exception/SusuExceptions.kt index dbec590e..478936b7 100644 --- a/core/model/src/main/java/com/susu/core/model/exception/SusuExceptions.kt +++ b/core/model/src/main/java/com/susu/core/model/exception/SusuExceptions.kt @@ -57,32 +57,43 @@ enum class SusuServerError(val exception: Exception) { /** Term Error Code */ NOT_FOUND_TERM_ERROR(NotFoundTermException()), - /** Vote History Error Code */ + /** Vote Error Code */ ALREADY_VOTED_POST(AlreadyVotedPostException()), + CANNOT_BLOCK_MYSELF(CannotBlockMyselfException()), + + /** Report Error Code */ + ALREADY_EXISTS_REPORT_HISTORY_ERROR(AlreadyExistsReportHistoryException()), } /** Common Exception Code */ class BadRequestException( override val message: String = "bad request", ) : RuntimeException() + class InvalidInputValueException( override val message: String = "input is invalid value", ) : RuntimeException() + class InvalidTypeValueException( override val message: String = "invalid type value", ) : RuntimeException() + class MethodNotAllowedException( override val message: String = "Method type is invalid", ) : RuntimeException() + class InvalidMediaTypeException( override val message: String = "invalid media type", ) : RuntimeException() + class QueryDslNotExistsException( override val message: String = "not found query dsl", ) : RuntimeException() + class CoroutineCancellationException( override val message: String = "coroutine cancellation Exception", ) : RuntimeException() + class NoAuthorityException( override val message: String = "수정 권한이 없습니다.", ) : RuntimeException() @@ -91,9 +102,11 @@ class NoAuthorityException( class FailToVerifyTokenException( override val message: String = "fail to verify token", ) : RuntimeException() + class NotAccessTokenException( override val message: String = "엑세스 토큰이 아닙니다.", ) : RuntimeException() + class NotRefreshTokenException( override val message: String = "리프레시 토큰이 아닙니다.", ) : RuntimeException() @@ -102,9 +115,11 @@ class NotRefreshTokenException( class UserNotFoundException( override val message: String = "유저 정보를 찾을 수 없습니다.", ) : RuntimeException() + class AlreadyRegisteredUserException( override val message: String = "이미 가입된 유저입니다.", ) : RuntimeException() + class FailToCreateUserException( override val message: String = "유저 생성을 실패했습니다.", ) : RuntimeException() @@ -113,9 +128,11 @@ class FailToCreateUserException( class LedgerInvalidDueDateException( override val message: String = "잘못된 일정 등록 요청입니다.", ) : RuntimeException() + class FailToCreateLedgerException( override val message: String = "장부 생성을 실패했습니다.", ) : RuntimeException() + class NotFoundLedgerException( override val message: String = "장부 정보가 없습니다.", ) : RuntimeException() @@ -143,6 +160,7 @@ class NotFoundFriendException( class AlreadyRegisteredFriendPhoneNumberException( override val message: String = "이미 등록된 전화번호 입니다.", ) : RuntimeException() + class FailToCreateFriendException( override val message: String = "친구 생성을 실패했습니다.", ) : RuntimeException() @@ -151,6 +169,7 @@ class FailToCreateFriendException( class FailToCreateEnvelopeException( override val message: String = "봉투 생성을 실패했습니다.", ) : RuntimeException() + class NotFoundEnvelopeException( override val message: String = "봉투 정보를 찾을 수 없습니다.", ) : RuntimeException() @@ -159,15 +178,19 @@ class NotFoundEnvelopeException( class NotFoundPostException( override val message: String = "게시글 정보를 찾을 수 없습니다.", ) : RuntimeException() + class InvalidVoteOptionSequenceException( override val message: String = "투표 옵션 순서가 잘못되었습니다.", ) : RuntimeException() + class FailToCreatePostException( override val message: String = "게시글 생성을 실패했습니다.", ) : RuntimeException() + class NotFoundVoteException( override val message: String = "투표 정보를 찾을 수 없습니다.", ) : RuntimeException() + class DuplicatedVoteException( override val message: String = "중복 투표를 할 수 없습니다.", ) : RuntimeException() @@ -182,7 +205,16 @@ class NotFoundTermException( override val message: String = "약관 정보를 찾을 수 없습니다.", ) : RuntimeException() -/** Vote History Exception Code */ +/** Vote Exception Code */ class AlreadyVotedPostException( override val message: String = "이미 진행된 투표입니다.", ) : RuntimeException() + +class CannotBlockMyselfException( + override val message: String = "본인을 차단할 수 없습니다.", +) : RuntimeException() + +/** Report Error Code */ +class AlreadyExistsReportHistoryException( + override val message: String = "이미 신고한 상태입니다.", +) : RuntimeException() diff --git a/core/ui/src/main/java/com/susu/core/ui/Consts.kt b/core/ui/src/main/java/com/susu/core/ui/Consts.kt index bdb9c1db..65ee0c3c 100644 --- a/core/ui/src/main/java/com/susu/core/ui/Consts.kt +++ b/core/ui/src/main/java/com/susu/core/ui/Consts.kt @@ -26,6 +26,9 @@ val USER_BIRTH_RANGE = 1930..2030 const val INTENT_ACTION_DOWNLOAD_COMPLETE = "android.intent.action.DOWNLOAD_COMPLETE" const val PRIVACY_POLICY_URL = "https://sites.google.com/view/team-oksusu/%ED%99%88" +const val SUSU_GOOGLE_FROM_URL = "https://forms.gle/FHky26kAQdde9RcD7" +const val SUSU_GOOGLE_PLAY_STORE_URL = "https://play.google.com/store/apps/details?id=com.oksusu.susu" + enum class SnsProviders( val path: String, @StringRes val nameId: Int, diff --git a/core/ui/src/main/java/com/susu/core/ui/DialogToken.kt b/core/ui/src/main/java/com/susu/core/ui/DialogToken.kt index 39ba8af1..e694b35b 100644 --- a/core/ui/src/main/java/com/susu/core/ui/DialogToken.kt +++ b/core/ui/src/main/java/com/susu/core/ui/DialogToken.kt @@ -14,7 +14,6 @@ data class DialogToken( val dismissText: String? = null, val checkboxText: String? = null, val defaultChecked: Boolean = false, - val isDimmed: Boolean = true, val textAlign: TextAlign = TextAlign.Center, val onConfirmRequest: () -> Unit = {}, val onCheckedAction: () -> Unit = {}, diff --git a/core/ui/src/main/java/com/susu/core/ui/extension/Long.kt b/core/ui/src/main/java/com/susu/core/ui/extension/Long.kt new file mode 100644 index 00000000..a3cb638d --- /dev/null +++ b/core/ui/src/main/java/com/susu/core/ui/extension/Long.kt @@ -0,0 +1,8 @@ +package com.susu.core.ui.extension + +import java.text.DecimalFormat + +fun Long.toMoneyFormat(): String { + // DecimalFormat은 Thread Safe하지 않으므로 지역 변수로 사용함. + return DecimalFormat("#,###").format(this) +} diff --git a/core/ui/src/main/java/com/susu/core/ui/util/Date.kt b/core/ui/src/main/java/com/susu/core/ui/util/Date.kt index d0e7fa04..024825cb 100644 --- a/core/ui/src/main/java/com/susu/core/ui/util/Date.kt +++ b/core/ui/src/main/java/com/susu/core/ui/util/Date.kt @@ -14,6 +14,15 @@ fun LocalDateTime.to_yyyy_dot_MM_dot_dd(): String { return this.format(formatter) } +/** + * 2023년 11월 25일 + */ +@Suppress("detekt:FunctionNaming") +fun LocalDateTime.to_yyyy_korYear_M_korMonth_d_korDay(): String { + val formatter = DateTimeFormatter.ofPattern("yyyy년 M월 d일") + return this.format(formatter) +} + fun getSafeLocalDateTime(year: Int, month: Int, day: Int): LocalDateTime = try { LocalDateTime.of(year, month, day, 0, 0) } catch (e: DateTimeException) { diff --git a/core/ui/src/main/res/values/strings.xml b/core/ui/src/main/res/values/strings.xml index be364da8..cac268fc 100644 --- a/core/ui/src/main/res/values/strings.xml +++ b/core/ui/src/main/res/values/strings.xml @@ -13,6 +13,7 @@ 전체 %s원 총 %d개 더보기 아이콘 + 경조사 경조사명 경조사 카테고리 경조사 기간 @@ -49,4 +50,7 @@ 전체 방문 미방문 + 나와의 관계 + 방문 여부 + 선물 diff --git a/data/src/main/java/com/susu/data/data/di/RepositoryModule.kt b/data/src/main/java/com/susu/data/data/di/RepositoryModule.kt index 203f1ebc..20b0792d 100644 --- a/data/src/main/java/com/susu/data/data/di/RepositoryModule.kt +++ b/data/src/main/java/com/susu/data/data/di/RepositoryModule.kt @@ -1,5 +1,6 @@ package com.susu.data.data.di +import com.susu.data.data.repository.BlockRepositoryImpl import com.susu.data.data.repository.CategoryConfigRepositoryImpl import com.susu.data.data.repository.EnvelopeRecentSearchRepositoryImpl import com.susu.data.data.repository.EnvelopesRepositoryImpl @@ -8,6 +9,7 @@ import com.susu.data.data.repository.FriendRepositoryImpl import com.susu.data.data.repository.LedgerRecentSearchRepositoryImpl import com.susu.data.data.repository.LedgerRepositoryImpl import com.susu.data.data.repository.LoginRepositoryImpl +import com.susu.data.data.repository.ReportRepositoryImpl import com.susu.data.data.repository.SignUpRepositoryImpl import com.susu.data.data.repository.StatisticsRepositoryImpl import com.susu.data.data.repository.TermRepositoryImpl @@ -15,6 +17,7 @@ import com.susu.data.data.repository.TokenRepositoryImpl import com.susu.data.data.repository.UserRepositoryImpl import com.susu.data.data.repository.VoteRecentSearchRepositoryImpl import com.susu.data.data.repository.VoteRepositoryImpl +import com.susu.domain.repository.BlockRepository import com.susu.domain.repository.CategoryConfigRepository import com.susu.domain.repository.EnvelopeRecentSearchRepository import com.susu.domain.repository.EnvelopesRepository @@ -23,6 +26,7 @@ import com.susu.domain.repository.FriendRepository import com.susu.domain.repository.LedgerRecentSearchRepository import com.susu.domain.repository.LedgerRepository import com.susu.domain.repository.LoginRepository +import com.susu.domain.repository.ReportRepository import com.susu.domain.repository.SignUpRepository import com.susu.domain.repository.StatisticsRepository import com.susu.domain.repository.TermRepository @@ -113,4 +117,14 @@ abstract class RepositoryModule { abstract fun bindVoteRecentSearchRepository( voteRecentSearchRepositoryImpl: VoteRecentSearchRepositoryImpl, ): VoteRecentSearchRepository + + @Binds + abstract fun bindBlockRepository( + blockRepositoryImpl: BlockRepositoryImpl, + ): BlockRepository + + @Binds + abstract fun bindReportRepository( + reportRepositoryImpl: ReportRepositoryImpl, + ): ReportRepository } diff --git a/data/src/main/java/com/susu/data/data/repository/BlockRepositoryImpl.kt b/data/src/main/java/com/susu/data/data/repository/BlockRepositoryImpl.kt new file mode 100644 index 00000000..af53834e --- /dev/null +++ b/data/src/main/java/com/susu/data/data/repository/BlockRepositoryImpl.kt @@ -0,0 +1,16 @@ +package com.susu.data.data.repository + +import com.susu.data.remote.api.BlockService +import com.susu.data.remote.model.request.BlockUserRequest +import com.susu.domain.repository.BlockRepository +import javax.inject.Inject + +class BlockRepositoryImpl @Inject constructor( + private val blockService: BlockService, +) : BlockRepository { + override suspend fun blockUser(targetId: Long) = blockService.blockUser( + BlockUserRequest( + targetId = targetId, + ), + ).getOrThrow() +} diff --git a/data/src/main/java/com/susu/data/data/repository/EnvelopesRepositoryImpl.kt b/data/src/main/java/com/susu/data/data/repository/EnvelopesRepositoryImpl.kt index 3c322612..3ba030dc 100644 --- a/data/src/main/java/com/susu/data/data/repository/EnvelopesRepositoryImpl.kt +++ b/data/src/main/java/com/susu/data/data/repository/EnvelopesRepositoryImpl.kt @@ -1,7 +1,9 @@ package com.susu.data.data.repository import com.susu.core.model.Envelope -import com.susu.core.model.EnvelopeStatics +import com.susu.core.model.EnvelopeDetail +import com.susu.core.model.EnvelopeSearch +import com.susu.core.model.FriendStatistics import com.susu.core.model.Relationship import com.susu.core.model.SearchEnvelope import com.susu.data.remote.api.EnvelopesService @@ -9,22 +11,23 @@ import com.susu.data.remote.model.request.CategoryRequest import com.susu.data.remote.model.request.EnvelopeRequest import com.susu.data.remote.model.response.toModel import com.susu.domain.repository.EnvelopesRepository +import kotlinx.datetime.LocalDateTime import javax.inject.Inject class EnvelopesRepositoryImpl @Inject constructor( private val envelopesService: EnvelopesService, ) : EnvelopesRepository { override suspend fun getEnvelopesList( - friendIds: List?, + friendIds: List?, fromTotalAmounts: Int?, toTotalAmounts: Int?, page: Int?, size: Int?, sort: String?, - ): List = envelopesService.getEnvelopesList( + ): List = envelopesService.getEnvelopesList( friendIds = friendIds, fromTotalAmounts = fromTotalAmounts, - toTotalMounts = toTotalAmounts, + toTotalAmounts = toTotalAmounts, page = page, size = size, sort = sort, @@ -87,7 +90,69 @@ class EnvelopesRepositoryImpl @Inject constructor( sort = sort, ).getOrThrow().toModel() + override suspend fun getEnvelopesHistoryList( + friendIds: List?, + ledgerId: Int?, + type: List?, + include: List?, + fromAmount: Int?, + toAmount: Int?, + page: Int?, + size: Int?, + sort: String?, + ): List = envelopesService.getEnvelopesHistoryList( + friendIds = friendIds, + ledgerId = ledgerId, + types = type, + include = include, + fromAmount = fromAmount, + toAmount = toAmount, + page = page, + size = size, + sort = sort, + ).getOrThrow().toModel() + override suspend fun getEnvelope(id: Long): Envelope = envelopesService.getEnvelope(id).getOrThrow().toModel() override suspend fun deleteEnvelope(id: Long) = envelopesService.deleteEnvelope(id).getOrThrow() + + override suspend fun editEnvelope( + id: Long, + type: String, + friendId: Long, + ledgerId: Long?, + amount: Long, + gift: String?, + memo: String?, + hasVisited: Boolean?, + handedOverAt: LocalDateTime, + categoryId: Long?, + customCategory: String?, + ): Envelope = envelopesService.editEnvelope( + id = id, + envelopeRequest = EnvelopeRequest( + type = type, + friendId = friendId, + ledgerId = ledgerId, + amount = amount, + gift = gift, + memo = memo, + hasVisited = hasVisited, + handedOverAt = handedOverAt, + category = if (categoryId != null) { + CategoryRequest( + id = categoryId, + customCategory = customCategory, + ) + } else { + null + }, + ), + ).getOrThrow().toModel() + + override suspend fun getEnvelopeDetail( + id: Long, + ): EnvelopeDetail = envelopesService.getEnvelopeDetail( + id = id, + ).getOrThrow().toModel() } diff --git a/data/src/main/java/com/susu/data/data/repository/FriendRepositoryImpl.kt b/data/src/main/java/com/susu/data/data/repository/FriendRepositoryImpl.kt index 4812ca9c..83c80ac2 100644 --- a/data/src/main/java/com/susu/data/data/repository/FriendRepositoryImpl.kt +++ b/data/src/main/java/com/susu/data/data/repository/FriendRepositoryImpl.kt @@ -27,4 +27,20 @@ class FriendRepositoryImpl @Inject constructor( override suspend fun searchFriend(name: String): List = friendService.searchFriend( name = name, ).getOrThrow().data.map { it.toModel() } + + override suspend fun editFriend( + id: Long, + name: String, + phoneNumber: String?, + relationshipId: Long, + customRelation: String?, + ) = friendService.editFriend( + id = id, + friendRequest = FriendRequest( + name = name, + phoneNumber = phoneNumber, + relationshipId = relationshipId, + customRelation = customRelation, + ), + ).getOrThrow() } diff --git a/data/src/main/java/com/susu/data/data/repository/ReportRepositoryImpl.kt b/data/src/main/java/com/susu/data/data/repository/ReportRepositoryImpl.kt new file mode 100644 index 00000000..6c90625b --- /dev/null +++ b/data/src/main/java/com/susu/data/data/repository/ReportRepositoryImpl.kt @@ -0,0 +1,16 @@ +package com.susu.data.data.repository + +import com.susu.data.remote.api.ReportService +import com.susu.data.remote.model.request.ReportVoteRequest +import com.susu.domain.repository.ReportRepository +import javax.inject.Inject + +class ReportRepositoryImpl @Inject constructor( + private val reportService: ReportService, +) : ReportRepository { + override suspend fun reportVote(targetId: Long) = reportService.reportVote( + ReportVoteRequest( + targetId = targetId, + ), + ).getOrThrow() +} diff --git a/data/src/main/java/com/susu/data/data/repository/StatisticsRepositoryImpl.kt b/data/src/main/java/com/susu/data/data/repository/StatisticsRepositoryImpl.kt index deae1020..f84b693f 100644 --- a/data/src/main/java/com/susu/data/data/repository/StatisticsRepositoryImpl.kt +++ b/data/src/main/java/com/susu/data/data/repository/StatisticsRepositoryImpl.kt @@ -2,6 +2,7 @@ package com.susu.data.data.repository import com.susu.core.model.MyStatistics import com.susu.core.model.StatisticsElement +import com.susu.core.model.SusuStatistics import com.susu.data.remote.api.StatisticsService import com.susu.data.remote.model.response.toModel import com.susu.domain.repository.StatisticsRepository @@ -26,4 +27,26 @@ class StatisticsRepositoryImpl @Inject constructor( recentMaximumSpent = originalStatistic.recentMaximumSpent, ) } + + override suspend fun getSusuStatistics(age: String, relationshipId: Int, categoryId: Int): SusuStatistics { + val originalStatistic = statisticsService.getSusuStatistics( + age = age, + relationshipId = relationshipId, + categoryId = categoryId, + ).getOrThrow().toModel() + val sortedRecentSpent = originalStatistic.recentSpent.sortedBy { it.title.toInt() } + .map { StatisticsElement(title = it.title.substring(it.title.length - 2).toInt().toString(), value = it.value) } + + return SusuStatistics( + averageSent = originalStatistic.averageSent, + averageRelationship = originalStatistic.averageRelationship, + averageCategory = originalStatistic.averageCategory, + recentSpent = sortedRecentSpent, + mostSpentMonth = originalStatistic.mostSpentMonth % 100, + mostRelationship = originalStatistic.mostRelationship, + mostCategory = originalStatistic.mostCategory, + recentTotalSpent = originalStatistic.recentTotalSpent, + recentMaximumSpent = originalStatistic.recentMaximumSpent, + ) + } } diff --git a/data/src/main/java/com/susu/data/data/repository/VoteRepositoryImpl.kt b/data/src/main/java/com/susu/data/data/repository/VoteRepositoryImpl.kt index 06976942..22ce01b0 100644 --- a/data/src/main/java/com/susu/data/data/repository/VoteRepositoryImpl.kt +++ b/data/src/main/java/com/susu/data/data/repository/VoteRepositoryImpl.kt @@ -4,6 +4,7 @@ import com.susu.core.model.Category import com.susu.core.model.Vote import com.susu.data.remote.api.VoteService import com.susu.data.remote.model.request.CreateVoteRequest +import com.susu.data.remote.model.request.EditVoteRequest import com.susu.data.remote.model.request.VoteOption import com.susu.data.remote.model.request.VoteRequest import com.susu.data.remote.model.response.toModel @@ -54,11 +55,29 @@ class VoteRepositoryImpl @Inject constructor( override suspend fun getVoteDetail(id: Long): Vote = api.getVoteDetail(id).getOrThrow().toModel() - override suspend fun vote(id: Long, isCancel: Boolean, optionId: Long) = api.vote( + override suspend fun vote( + id: Long, + isCancel: Boolean, + optionId: Long, + ) = api.vote( id = id, voteRequest = VoteRequest( isCancel = isCancel, optionId = optionId, ), ).getOrThrow() + + override suspend fun editVote( + id: Long, + boardId: Long, + content: String, + ): Vote = api.editVote( + id = id, + EditVoteRequest( + boardId = boardId, + content = content, + ), + ).getOrThrow().toModel() + + override suspend fun deleteVote(id: Long) = api.deleteVote(id).getOrThrow() } diff --git a/data/src/main/java/com/susu/data/remote/api/BlockService.kt b/data/src/main/java/com/susu/data/remote/api/BlockService.kt new file mode 100644 index 00000000..838db7da --- /dev/null +++ b/data/src/main/java/com/susu/data/remote/api/BlockService.kt @@ -0,0 +1,14 @@ +package com.susu.data.remote.api + +import com.susu.data.remote.model.request.BlockUserRequest +import com.susu.data.remote.retrofit.ApiResult +import retrofit2.http.Body +import retrofit2.http.POST + +interface BlockService { + + @POST("blocks") + suspend fun blockUser( + @Body blockUserRequest: BlockUserRequest, + ): ApiResult +} diff --git a/data/src/main/java/com/susu/data/remote/api/EnvelopesService.kt b/data/src/main/java/com/susu/data/remote/api/EnvelopesService.kt index 362a6615..26b32247 100644 --- a/data/src/main/java/com/susu/data/remote/api/EnvelopesService.kt +++ b/data/src/main/java/com/susu/data/remote/api/EnvelopesService.kt @@ -1,7 +1,9 @@ package com.susu.data.remote.api import com.susu.data.remote.model.request.EnvelopeRequest +import com.susu.data.remote.model.response.EnvelopeDetailResponse import com.susu.data.remote.model.response.EnvelopeResponse +import com.susu.data.remote.model.response.EnvelopesHistoryListResponse import com.susu.data.remote.model.response.EnvelopesListResponse import com.susu.data.remote.model.response.RelationShipListResponse import com.susu.data.remote.model.response.SearchEnvelopeResponse @@ -17,9 +19,9 @@ import retrofit2.http.Query interface EnvelopesService { @GET("envelopes/friend-statistics") suspend fun getEnvelopesList( - @Query("friendIds") friendIds: List?, + @Query("friendIds") friendIds: List?, @Query("fromTotalAmounts") fromTotalAmounts: Int?, - @Query("toTotalAmounts") toTotalMounts: Int?, + @Query("toTotalAmounts") toTotalAmounts: Int?, @Query("page") page: Int?, @Query("size") size: Int?, @Query("sort") sort: String?, @@ -33,6 +35,24 @@ interface EnvelopesService { @Body envelopeRequest: EnvelopeRequest, ): ApiResult + @GET("envelopes") + suspend fun getEnvelopesHistoryList( + @Query("friendIds") friendIds: List?, + @Query("ledgerId") ledgerId: Int?, + @Query("types") types: List?, + @Query("include") include: List?, + @Query("fromAmount") fromAmount: Int?, + @Query("toAmount") toAmount: Int?, + @Query("page") page: Int?, + @Query("size") size: Int?, + @Query("sort") sort: String?, + ): ApiResult + + @GET("envelopes/{id}") + suspend fun getEnvelopeDetail( + @Path("id") id: Long, + ): ApiResult + @PATCH("envelopes/{id}") suspend fun editEnvelope( @Path("id") id: Long, diff --git a/data/src/main/java/com/susu/data/remote/api/FriendService.kt b/data/src/main/java/com/susu/data/remote/api/FriendService.kt index 9274a7b4..b7fd40aa 100644 --- a/data/src/main/java/com/susu/data/remote/api/FriendService.kt +++ b/data/src/main/java/com/susu/data/remote/api/FriendService.kt @@ -7,6 +7,8 @@ import com.susu.data.remote.retrofit.ApiResult import retrofit2.http.Body import retrofit2.http.GET import retrofit2.http.POST +import retrofit2.http.PUT +import retrofit2.http.Path import retrofit2.http.Query interface FriendService { @@ -20,4 +22,10 @@ interface FriendService { suspend fun searchFriend( @Query("name") name: String, ): ApiResult + + @PUT("friends/{id}") + suspend fun editFriend( + @Path("id") id: Long, + @Body friendRequest: FriendRequest, + ): ApiResult } diff --git a/data/src/main/java/com/susu/data/remote/api/ReportService.kt b/data/src/main/java/com/susu/data/remote/api/ReportService.kt new file mode 100644 index 00000000..9f035626 --- /dev/null +++ b/data/src/main/java/com/susu/data/remote/api/ReportService.kt @@ -0,0 +1,14 @@ +package com.susu.data.remote.api + +import com.susu.data.remote.model.request.ReportVoteRequest +import com.susu.data.remote.retrofit.ApiResult +import retrofit2.http.Body +import retrofit2.http.POST + +interface ReportService { + + @POST("reports") + suspend fun reportVote( + @Body reportVoteRequest: ReportVoteRequest, + ): ApiResult +} diff --git a/data/src/main/java/com/susu/data/remote/api/StatisticsService.kt b/data/src/main/java/com/susu/data/remote/api/StatisticsService.kt index 9f8a0932..a3ab5df7 100644 --- a/data/src/main/java/com/susu/data/remote/api/StatisticsService.kt +++ b/data/src/main/java/com/susu/data/remote/api/StatisticsService.kt @@ -1,10 +1,19 @@ package com.susu.data.remote.api import com.susu.data.remote.model.response.MyStatisticsResponse +import com.susu.data.remote.model.response.SusuStatisticsResponse import com.susu.data.remote.retrofit.ApiResult import retrofit2.http.GET +import retrofit2.http.Query interface StatisticsService { @GET("statistics/mine") suspend fun getMyStatistics(): ApiResult + + @GET("statistics/susu") + suspend fun getSusuStatistics( + @Query("age") age: String, + @Query("relationshipId") relationshipId: Int, + @Query("categoryId") categoryId: Int, + ): ApiResult } diff --git a/data/src/main/java/com/susu/data/remote/api/VoteService.kt b/data/src/main/java/com/susu/data/remote/api/VoteService.kt index f00e43c7..f860b47f 100644 --- a/data/src/main/java/com/susu/data/remote/api/VoteService.kt +++ b/data/src/main/java/com/susu/data/remote/api/VoteService.kt @@ -1,6 +1,7 @@ package com.susu.data.remote.api import com.susu.data.remote.model.request.CreateVoteRequest +import com.susu.data.remote.model.request.EditVoteRequest import com.susu.data.remote.model.request.VoteRequest import com.susu.data.remote.model.response.PopularVoteResponse import com.susu.data.remote.model.response.PostCategoryConfig @@ -9,7 +10,9 @@ import com.susu.data.remote.model.response.VoteListResponse import com.susu.data.remote.model.response.VoteResponse import com.susu.data.remote.retrofit.ApiResult import retrofit2.http.Body +import retrofit2.http.DELETE import retrofit2.http.GET +import retrofit2.http.PATCH import retrofit2.http.POST import retrofit2.http.Path import retrofit2.http.Query @@ -48,4 +51,15 @@ interface VoteService { @Path("id") id: Long, @Body voteRequest: VoteRequest, ): ApiResult + + @PATCH("votes/{id}") + suspend fun editVote( + @Path("id") id: Long, + @Body editVoteRequest: EditVoteRequest, + ): ApiResult + + @DELETE("votes/{id}") + suspend fun deleteVote( + @Path("id") id: Long, + ): ApiResult } diff --git a/data/src/main/java/com/susu/data/remote/di/ApiServiceModule.kt b/data/src/main/java/com/susu/data/remote/di/ApiServiceModule.kt index 60ea9578..6b5041c4 100644 --- a/data/src/main/java/com/susu/data/remote/di/ApiServiceModule.kt +++ b/data/src/main/java/com/susu/data/remote/di/ApiServiceModule.kt @@ -1,10 +1,12 @@ package com.susu.data.remote.di import com.susu.data.remote.api.AuthService +import com.susu.data.remote.api.BlockService import com.susu.data.remote.api.CategoryService import com.susu.data.remote.api.EnvelopesService import com.susu.data.remote.api.FriendService import com.susu.data.remote.api.LedgerService +import com.susu.data.remote.api.ReportService import com.susu.data.remote.api.SignUpService import com.susu.data.remote.api.StatisticsService import com.susu.data.remote.api.TermService @@ -87,4 +89,16 @@ object ApiServiceModule { fun providesVoteService(retrofit: Retrofit): VoteService { return retrofit.create(VoteService::class.java) } + + @Singleton + @Provides + fun providesReportService(retrofit: Retrofit): ReportService { + return retrofit.create(ReportService::class.java) + } + + @Singleton + @Provides + fun providesBlockService(retrofit: Retrofit): BlockService { + return retrofit.create(BlockService::class.java) + } } diff --git a/data/src/main/java/com/susu/data/remote/model/request/BlockUserRequest.kt b/data/src/main/java/com/susu/data/remote/model/request/BlockUserRequest.kt new file mode 100644 index 00000000..4756bb05 --- /dev/null +++ b/data/src/main/java/com/susu/data/remote/model/request/BlockUserRequest.kt @@ -0,0 +1,9 @@ +package com.susu.data.remote.model.request + +import kotlinx.serialization.Serializable + +@Serializable +data class BlockUserRequest( + val targetId: Long, + val targetType: String = "USER", +) diff --git a/data/src/main/java/com/susu/data/remote/model/request/EditVoteRequest.kt b/data/src/main/java/com/susu/data/remote/model/request/EditVoteRequest.kt new file mode 100644 index 00000000..8f0dda21 --- /dev/null +++ b/data/src/main/java/com/susu/data/remote/model/request/EditVoteRequest.kt @@ -0,0 +1,9 @@ +package com.susu.data.remote.model.request + +import kotlinx.serialization.Serializable + +@Serializable +data class EditVoteRequest( + val boardId: Long, + val content: String, +) diff --git a/data/src/main/java/com/susu/data/remote/model/request/ReportVoteRequest.kt b/data/src/main/java/com/susu/data/remote/model/request/ReportVoteRequest.kt new file mode 100644 index 00000000..9616315f --- /dev/null +++ b/data/src/main/java/com/susu/data/remote/model/request/ReportVoteRequest.kt @@ -0,0 +1,10 @@ +package com.susu.data.remote.model.request + +import kotlinx.serialization.Serializable + +@Serializable +data class ReportVoteRequest( + val metadataId: Long = 1, + val targetId: Long, + val targetType: String = "POST", +) diff --git a/data/src/main/java/com/susu/data/remote/model/response/EnvelopeDetailResponse.kt b/data/src/main/java/com/susu/data/remote/model/response/EnvelopeDetailResponse.kt new file mode 100644 index 00000000..4fb9fd84 --- /dev/null +++ b/data/src/main/java/com/susu/data/remote/model/response/EnvelopeDetailResponse.kt @@ -0,0 +1,28 @@ +package com.susu.data.remote.model.response + +import com.susu.core.model.EnvelopeDetail +import com.susu.core.model.FriendRelationship +import kotlinx.serialization.Serializable + +@Serializable +data class EnvelopeDetailResponse( + val envelope: EnvelopeInfo, + val category: CategoryInfo, + val relationship: RelationshipInfo, + val friendRelationship: FriendRelationShipInfo, + val friend: FriendInfo, +) + +internal fun FriendRelationShipInfo.toModel() = FriendRelationship( + id = id, + friendId = friendId, + relationshipId = relationshipId, +) + +internal fun EnvelopeDetailResponse.toModel() = EnvelopeDetail( + envelope = envelope.toModel(), + category = category.toModel(), + relationship = relationship.toModel(), + friendRelationship = friendRelationship.toModel(), + friend = friend.toModel(), +) diff --git a/data/src/main/java/com/susu/data/remote/model/response/EnvelopeHistoryResponse.kt b/data/src/main/java/com/susu/data/remote/model/response/EnvelopeHistoryResponse.kt new file mode 100644 index 00000000..b160dd44 --- /dev/null +++ b/data/src/main/java/com/susu/data/remote/model/response/EnvelopeHistoryResponse.kt @@ -0,0 +1,25 @@ +package com.susu.data.remote.model.response + +import com.susu.core.model.EnvelopeSearch +import com.susu.core.model.Relationship +import kotlinx.serialization.Serializable + +@Serializable +data class EnvelopeHistoryResponse( + val envelope: EnvelopeInfo, + val category: CategoryInfo? = null, + val friend: FriendInfo? = null, + val relation: RelationshipInfo? = null, +) + +internal fun RelationshipInfo.toModel() = Relationship( + id = id, + relation = relation, +) + +internal fun EnvelopeHistoryResponse.toModel() = EnvelopeSearch( + envelope = envelope.toModel(), + category = category?.toModel(), + friend = friend?.toModel(), + relationship = relation?.toModel(), +) diff --git a/data/src/main/java/com/susu/data/remote/model/response/EnvelopeResponse.kt b/data/src/main/java/com/susu/data/remote/model/response/EnvelopeResponse.kt index bfd4c319..173f60d7 100644 --- a/data/src/main/java/com/susu/data/remote/model/response/EnvelopeResponse.kt +++ b/data/src/main/java/com/susu/data/remote/model/response/EnvelopeResponse.kt @@ -22,7 +22,13 @@ data class EnvelopeInfo( val gift: String? = null, val memo: String? = null, val hasVisited: Boolean? = null, - val handedOverAt: LocalDateTime? = null, + val handedOverAt: LocalDateTime, +) + +@Serializable +data class RelationshipInfo( + val id: Long, + val relation: String, ) @Serializable @@ -33,10 +39,15 @@ data class FriendRelationShipInfo( val customRelation: String? = null, ) -@Serializable -data class RelationshipInfo( - val id: Long, - val relation: String, +internal fun EnvelopeInfo.toModel() = Envelope( + id = id, + uid = uid, + type = type, + amount = amount, + gift = gift, + memo = memo, + hasVisited = hasVisited, + handedOverAt = handedOverAt, ) internal fun EnvelopeResponse.toModel() = Envelope( diff --git a/data/src/main/java/com/susu/data/remote/model/response/EnvelopesHistoryListResponse.kt b/data/src/main/java/com/susu/data/remote/model/response/EnvelopesHistoryListResponse.kt new file mode 100644 index 00000000..22b59969 --- /dev/null +++ b/data/src/main/java/com/susu/data/remote/model/response/EnvelopesHistoryListResponse.kt @@ -0,0 +1,19 @@ +package com.susu.data.remote.model.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class EnvelopesHistoryListResponse( + @SerialName("data") + val envelopesHistoryList: List, + val page: Int = 0, + val size: Int, + val sort: Sort, + val totalCount: Int, + val totalPage: Int, +) + +internal fun EnvelopesHistoryListResponse.toModel() = this.envelopesHistoryList.map { envelopesHistory -> + envelopesHistory.toModel() +} diff --git a/data/src/main/java/com/susu/data/remote/model/response/EnvelopesListResponse.kt b/data/src/main/java/com/susu/data/remote/model/response/EnvelopesListResponse.kt index ac76868f..d6654e12 100644 --- a/data/src/main/java/com/susu/data/remote/model/response/EnvelopesListResponse.kt +++ b/data/src/main/java/com/susu/data/remote/model/response/EnvelopesListResponse.kt @@ -6,12 +6,12 @@ import kotlinx.serialization.Serializable @Serializable data class EnvelopesListResponse( @SerialName("data") - val envelopesList: List, + val envelopesList: List, val page: Int, val size: Int, - val sort: Sort, - val totalCount: Int, val totalPage: Int, + val totalCount: Int, + val sort: Sort, ) @Serializable diff --git a/data/src/main/java/com/susu/data/remote/model/response/EnvelopesResponse.kt b/data/src/main/java/com/susu/data/remote/model/response/FriendStatisticsResponse.kt similarity index 67% rename from data/src/main/java/com/susu/data/remote/model/response/EnvelopesResponse.kt rename to data/src/main/java/com/susu/data/remote/model/response/FriendStatisticsResponse.kt index f64572c6..da61149b 100644 --- a/data/src/main/java/com/susu/data/remote/model/response/EnvelopesResponse.kt +++ b/data/src/main/java/com/susu/data/remote/model/response/FriendStatisticsResponse.kt @@ -1,11 +1,11 @@ package com.susu.data.remote.model.response -import com.susu.core.model.EnvelopeStatics import com.susu.core.model.Friend +import com.susu.core.model.FriendStatistics import kotlinx.serialization.Serializable @Serializable -data class EnvelopesResponse( +data class FriendStatisticsResponse( val friend: FriendInfo, val receivedAmounts: Int, val sentAmounts: Int, @@ -15,23 +15,17 @@ data class EnvelopesResponse( @Serializable data class FriendInfo( val id: Long, - val uid: Long = 0, - val createdAt: String = "", - val modifiedAt: String = "", val name: String, val phoneNumber: String = "", ) internal fun FriendInfo.toModel() = Friend( id = id, - uid = uid, name = name, phoneNumber = phoneNumber, - createdAt = createdAt, - modifiedAt = modifiedAt, ) -internal fun EnvelopesResponse.toModel() = EnvelopeStatics( +internal fun FriendStatisticsResponse.toModel() = FriendStatistics( friend = friend.toModel(), receivedAmounts = receivedAmounts, sentAmounts = sentAmounts, diff --git a/data/src/main/java/com/susu/data/remote/model/response/LedgerResponse.kt b/data/src/main/java/com/susu/data/remote/model/response/LedgerResponse.kt index e3a372fb..855a2dee 100644 --- a/data/src/main/java/com/susu/data/remote/model/response/LedgerResponse.kt +++ b/data/src/main/java/com/susu/data/remote/model/response/LedgerResponse.kt @@ -48,6 +48,7 @@ internal fun CategoryInfo.toModel() = Category( id = id, seq = seq, name = category, + category = category, customCategory = customCategory, style = style, ) diff --git a/data/src/main/java/com/susu/data/remote/model/response/PopularVoteResponse.kt b/data/src/main/java/com/susu/data/remote/model/response/PopularVoteResponse.kt index bb44daec..62ff276b 100644 --- a/data/src/main/java/com/susu/data/remote/model/response/PopularVoteResponse.kt +++ b/data/src/main/java/com/susu/data/remote/model/response/PopularVoteResponse.kt @@ -8,7 +8,7 @@ import java.time.LocalDateTime @Serializable data class PopularVoteResponse( val id: Long, - val boardName: String, + val board: BoardResponse, val content: String, val count: Long, val isModified: Boolean, @@ -17,7 +17,8 @@ data class PopularVoteResponse( internal fun PopularVoteResponse.toModel() = Vote( id = id, uid = 0, - boardName = boardName, + boardId = board.id, + boardName = board.name, content = content, isModified = isModified, count = count, diff --git a/data/src/main/java/com/susu/data/remote/model/response/MyStatisticsResponse.kt b/data/src/main/java/com/susu/data/remote/model/response/StatisticsResponse.kt similarity index 50% rename from data/src/main/java/com/susu/data/remote/model/response/MyStatisticsResponse.kt rename to data/src/main/java/com/susu/data/remote/model/response/StatisticsResponse.kt index e9be8886..7483ff3e 100644 --- a/data/src/main/java/com/susu/data/remote/model/response/MyStatisticsResponse.kt +++ b/data/src/main/java/com/susu/data/remote/model/response/StatisticsResponse.kt @@ -1,6 +1,7 @@ package com.susu.data.remote.model.response import com.susu.core.model.MyStatistics +import com.susu.core.model.SusuStatistics import kotlinx.serialization.Serializable @Serializable @@ -15,8 +16,19 @@ data class MyStatisticsResponse( @Serializable data class StatisticsElement( - val title: String, - val value: Int, + val title: String = "", + val value: Int = 0, +) + +@Serializable +data class SusuStatisticsResponse( + val averageSent: Int = 0, + val averageRelationship: StatisticsElement = StatisticsElement(), + val averageCategory: StatisticsElement = StatisticsElement(), + val recentSpent: List = emptyList(), + val mostSpentMonth: Int = 0, + val mostRelationship: StatisticsElement = StatisticsElement(), + val mostCategory: StatisticsElement = StatisticsElement(), ) fun MyStatisticsResponse.toModel() = MyStatistics( @@ -34,3 +46,15 @@ fun StatisticsElement.toModel() = com.susu.core.model.StatisticsElement( title = title, value = value, ) + +fun SusuStatisticsResponse.toModel() = SusuStatistics( + averageSent = averageSent, + averageRelationship = averageRelationship.toModel(), + averageCategory = averageCategory.toModel(), + recentSpent = recentSpent.map { it.toModel() }, + mostSpentMonth = mostSpentMonth, + mostRelationship = mostRelationship.toModel(), + mostCategory = mostCategory.toModel(), + recentMaximumSpent = recentSpent.maxOfOrNull { it.value } ?: 0, + recentTotalSpent = recentSpent.sumOf { it.value }, +) diff --git a/data/src/main/java/com/susu/data/remote/model/response/VoteDetailResponse.kt b/data/src/main/java/com/susu/data/remote/model/response/VoteDetailResponse.kt index 7cdf4266..5ca1b0f2 100644 --- a/data/src/main/java/com/susu/data/remote/model/response/VoteDetailResponse.kt +++ b/data/src/main/java/com/susu/data/remote/model/response/VoteDetailResponse.kt @@ -10,7 +10,7 @@ import kotlinx.serialization.Serializable data class VoteDetailResponse( val id: Long, val isMine: Boolean, - val boardName: String, + val board: BoardResponse, val content: String, val count: Long, val createdAt: LocalDateTime, @@ -29,7 +29,8 @@ internal fun VoteDetailResponse.toModel() = Vote( id = id, uid = creatorProfile.id, profile = creatorProfile.toModel(), - boardName = boardName, + boardId = board.id, + boardName = board.name, content = content, count = count, isModified = isModified, diff --git a/data/src/main/java/com/susu/data/remote/model/response/VoteResponse.kt b/data/src/main/java/com/susu/data/remote/model/response/VoteResponse.kt index e6ead965..37431197 100644 --- a/data/src/main/java/com/susu/data/remote/model/response/VoteResponse.kt +++ b/data/src/main/java/com/susu/data/remote/model/response/VoteResponse.kt @@ -11,7 +11,7 @@ import kotlinx.serialization.Serializable data class VoteResponse( val id: Long, val uid: Long = 0, - val boardName: String, + val board: BoardResponse, val content: String, val isModified: Boolean, val createdAt: LocalDateTime = java.time.LocalDateTime.now().toKotlinLocalDateTime(), @@ -20,10 +20,17 @@ data class VoteResponse( val optionList: List, ) +@Serializable +data class BoardResponse( + val id: Long, + val name: String, +) + internal fun VoteResponse.toModel() = Vote( id = id, uid = uid, - boardName = boardName, + boardId = board.id, + boardName = board.name, content = content, count = count, isModified = isModified, diff --git a/domain/src/main/java/com/susu/domain/repository/BlockRepository.kt b/domain/src/main/java/com/susu/domain/repository/BlockRepository.kt new file mode 100644 index 00000000..fb56d1de --- /dev/null +++ b/domain/src/main/java/com/susu/domain/repository/BlockRepository.kt @@ -0,0 +1,8 @@ +package com.susu.domain.repository + +interface BlockRepository { + + suspend fun blockUser( + targetId: Long, + ) +} diff --git a/domain/src/main/java/com/susu/domain/repository/EnvelopesRepository.kt b/domain/src/main/java/com/susu/domain/repository/EnvelopesRepository.kt index 33fc7104..929992c2 100644 --- a/domain/src/main/java/com/susu/domain/repository/EnvelopesRepository.kt +++ b/domain/src/main/java/com/susu/domain/repository/EnvelopesRepository.kt @@ -1,20 +1,22 @@ package com.susu.domain.repository import com.susu.core.model.Envelope -import com.susu.core.model.EnvelopeStatics +import com.susu.core.model.EnvelopeDetail +import com.susu.core.model.EnvelopeSearch +import com.susu.core.model.FriendStatistics import com.susu.core.model.Relationship import com.susu.core.model.SearchEnvelope import kotlinx.datetime.LocalDateTime interface EnvelopesRepository { suspend fun getEnvelopesList( - friendIds: List?, + friendIds: List?, fromTotalAmounts: Int?, toTotalAmounts: Int?, page: Int?, size: Int?, sort: String?, - ): List + ): List suspend fun getRelationShipConfigList(): List @@ -31,6 +33,22 @@ interface EnvelopesRepository { customCategory: String? = null, ): Envelope + suspend fun getEnvelopesHistoryList( + friendIds: List?, + ledgerId: Int?, + type: List?, + include: List?, + fromAmount: Int?, + toAmount: Int?, + page: Int?, + size: Int?, + sort: String?, + ): List + + suspend fun getEnvelopeDetail( + id: Long, + ): EnvelopeDetail + suspend fun searchEnvelope( friendIds: List?, ledgerId: Long?, @@ -49,4 +67,18 @@ interface EnvelopesRepository { suspend fun deleteEnvelope( id: Long, ) + + suspend fun editEnvelope( + id: Long, + type: String, + friendId: Long, + ledgerId: Long? = null, + amount: Long, + gift: String? = null, + memo: String? = null, + hasVisited: Boolean? = null, + handedOverAt: LocalDateTime, + categoryId: Long? = null, + customCategory: String? = null, + ): Envelope } diff --git a/domain/src/main/java/com/susu/domain/repository/FriendRepository.kt b/domain/src/main/java/com/susu/domain/repository/FriendRepository.kt index 2a1fb65a..e7c7e31a 100644 --- a/domain/src/main/java/com/susu/domain/repository/FriendRepository.kt +++ b/domain/src/main/java/com/susu/domain/repository/FriendRepository.kt @@ -13,4 +13,12 @@ interface FriendRepository { suspend fun searchFriend( name: String, ): List + + suspend fun editFriend( + id: Long, + name: String, + phoneNumber: String? = null, + relationshipId: Long, + customRelation: String? = null, + ) } diff --git a/domain/src/main/java/com/susu/domain/repository/ReportRepository.kt b/domain/src/main/java/com/susu/domain/repository/ReportRepository.kt new file mode 100644 index 00000000..b9344301 --- /dev/null +++ b/domain/src/main/java/com/susu/domain/repository/ReportRepository.kt @@ -0,0 +1,8 @@ +package com.susu.domain.repository + +interface ReportRepository { + + suspend fun reportVote( + targetId: Long, + ) +} diff --git a/domain/src/main/java/com/susu/domain/repository/StatisticsRepository.kt b/domain/src/main/java/com/susu/domain/repository/StatisticsRepository.kt index 5806e125..ec7daf76 100644 --- a/domain/src/main/java/com/susu/domain/repository/StatisticsRepository.kt +++ b/domain/src/main/java/com/susu/domain/repository/StatisticsRepository.kt @@ -1,7 +1,9 @@ package com.susu.domain.repository import com.susu.core.model.MyStatistics +import com.susu.core.model.SusuStatistics interface StatisticsRepository { suspend fun getMyStatistics(): MyStatistics + suspend fun getSusuStatistics(age: String, relationshipId: Int, categoryId: Int): SusuStatistics } diff --git a/domain/src/main/java/com/susu/domain/repository/VoteRepository.kt b/domain/src/main/java/com/susu/domain/repository/VoteRepository.kt index ad245c3a..3e8a3f40 100644 --- a/domain/src/main/java/com/susu/domain/repository/VoteRepository.kt +++ b/domain/src/main/java/com/susu/domain/repository/VoteRepository.kt @@ -33,4 +33,14 @@ interface VoteRepository { isCancel: Boolean, optionId: Long, ) + + suspend fun editVote( + id: Long, + boardId: Long, + content: String, + ): Vote + + suspend fun deleteVote( + id: Long, + ) } diff --git a/domain/src/main/java/com/susu/domain/usecase/block/BlockUserUseCase.kt b/domain/src/main/java/com/susu/domain/usecase/block/BlockUserUseCase.kt new file mode 100644 index 00000000..86058b64 --- /dev/null +++ b/domain/src/main/java/com/susu/domain/usecase/block/BlockUserUseCase.kt @@ -0,0 +1,13 @@ +package com.susu.domain.usecase.block + +import com.susu.core.common.runCatchingIgnoreCancelled +import com.susu.domain.repository.BlockRepository +import javax.inject.Inject + +class BlockUserUseCase @Inject constructor( + private val blockRepository: BlockRepository, +) { + suspend operator fun invoke(id: Long) = runCatchingIgnoreCancelled { + blockRepository.blockUser(id) + } +} diff --git a/domain/src/main/java/com/susu/domain/usecase/envelope/CreateReceivedEnvelopeUseCase.kt b/domain/src/main/java/com/susu/domain/usecase/envelope/CreateReceivedEnvelopeUseCase.kt index 4a8a3e8f..07b1ceb0 100644 --- a/domain/src/main/java/com/susu/domain/usecase/envelope/CreateReceivedEnvelopeUseCase.kt +++ b/domain/src/main/java/com/susu/domain/usecase/envelope/CreateReceivedEnvelopeUseCase.kt @@ -22,6 +22,8 @@ class CreateReceivedEnvelopeUseCase @Inject constructor( envelopesRepository.createEnvelope( type = "RECEIVED", friendId = friendId, + categoryId = categoryId, + customCategory = customCategory, ledgerId = ledgerId, amount = amount, gift = gift, @@ -35,6 +37,8 @@ class CreateReceivedEnvelopeUseCase @Inject constructor( data class Param( val friendId: Long? = null, val friendName: String? = null, + val categoryId: Long, + val customCategory: String?, val phoneNumber: String? = null, val relationshipId: Long? = null, val customRelation: String? = null, diff --git a/domain/src/main/java/com/susu/domain/usecase/envelope/EditReceivedEnvelopeUseCase.kt b/domain/src/main/java/com/susu/domain/usecase/envelope/EditReceivedEnvelopeUseCase.kt new file mode 100644 index 00000000..1de8ac32 --- /dev/null +++ b/domain/src/main/java/com/susu/domain/usecase/envelope/EditReceivedEnvelopeUseCase.kt @@ -0,0 +1,55 @@ +package com.susu.domain.usecase.envelope + +import com.susu.core.common.runCatchingIgnoreCancelled +import com.susu.domain.repository.EnvelopesRepository +import com.susu.domain.repository.FriendRepository +import kotlinx.datetime.LocalDateTime +import javax.inject.Inject + +class EditReceivedEnvelopeUseCase @Inject constructor( + private val friendRepository: FriendRepository, + private val envelopesRepository: EnvelopesRepository, +) { + suspend operator fun invoke(param: Param) = runCatchingIgnoreCancelled { + with(param) { + friendRepository.editFriend( + id = friendId, + name = friendName, + phoneNumber = phoneNumber, + relationshipId = relationshipId, + customRelation = customRelation, + ) + + envelopesRepository.editEnvelope( + id = envelopeId, + type = "RECEIVED", + friendId = friendId, + ledgerId = ledgerId, + amount = amount, + gift = gift, + memo = memo, + categoryId = categoryId, + customCategory = customCategory, + hasVisited = hasVisited, + handedOverAt = handedOverAt, + ) + } + } + + data class Param( + val envelopeId: Long, + val friendId: Long, + val friendName: String, + val categoryId: Long, + val customCategory: String?, + val phoneNumber: String? = null, + val relationshipId: Long, + val customRelation: String? = null, + val ledgerId: Long, + val amount: Long, + val gift: String? = null, + val memo: String? = null, + val handedOverAt: LocalDateTime, + val hasVisited: Boolean? = null, + ) +} diff --git a/domain/src/main/java/com/susu/domain/usecase/envelope/EditSentEnvelopeUseCase.kt b/domain/src/main/java/com/susu/domain/usecase/envelope/EditSentEnvelopeUseCase.kt new file mode 100644 index 00000000..e3e90f54 --- /dev/null +++ b/domain/src/main/java/com/susu/domain/usecase/envelope/EditSentEnvelopeUseCase.kt @@ -0,0 +1,54 @@ +package com.susu.domain.usecase.envelope + +import com.susu.core.common.runCatchingIgnoreCancelled +import com.susu.domain.repository.EnvelopesRepository +import com.susu.domain.repository.FriendRepository +import kotlinx.datetime.LocalDateTime +import javax.inject.Inject + +class EditSentEnvelopeUseCase @Inject constructor( + private val friendRepository: FriendRepository, + private val envelopesRepository: EnvelopesRepository, +) { + suspend operator fun invoke(param: Param) = runCatchingIgnoreCancelled { + with(param) { + friendRepository.editFriend( + id = friendId, + name = friendName, + phoneNumber = phoneNumber, + relationshipId = relationshipId, + customRelation = customRelation, + ) + + envelopesRepository.editEnvelope( + id = envelopeId, + type = envelopeType, + friendId = friendId, + amount = amount, + gift = gift, + memo = memo, + categoryId = categoryId.toLong(), + customCategory = customCategory, + hasVisited = hasVisited, + handedOverAt = handedOverAt, + ) + } + } + + data class Param( + val envelopeId: Long, + val envelopeType: String, + val friendId: Long, + val friendName: String, + val phoneNumber: String? = null, + val relationshipId: Long, + val customRelation: String? = null, + val categoryId: Int, + val customCategory: String? = null, + val amount: Long, + val gift: String? = null, + val memo: String? = null, + val handedOverAt: LocalDateTime, + val hasVisited: Boolean? = null, + ) +} diff --git a/domain/src/main/java/com/susu/domain/usecase/envelope/GetEnvelopeDetailUseCase.kt b/domain/src/main/java/com/susu/domain/usecase/envelope/GetEnvelopeDetailUseCase.kt new file mode 100644 index 00000000..51d807cd --- /dev/null +++ b/domain/src/main/java/com/susu/domain/usecase/envelope/GetEnvelopeDetailUseCase.kt @@ -0,0 +1,13 @@ +package com.susu.domain.usecase.envelope + +import com.susu.core.common.runCatchingIgnoreCancelled +import com.susu.domain.repository.EnvelopesRepository +import javax.inject.Inject + +class GetEnvelopeDetailUseCase @Inject constructor( + private val envelopesRepository: EnvelopesRepository, +) { + suspend operator fun invoke(id: Long) = runCatchingIgnoreCancelled { + envelopesRepository.getEnvelopeDetail(id = id) + } +} diff --git a/domain/src/main/java/com/susu/domain/usecase/envelope/GetEnvelopesHistoryListUseCase.kt b/domain/src/main/java/com/susu/domain/usecase/envelope/GetEnvelopesHistoryListUseCase.kt new file mode 100644 index 00000000..783891bf --- /dev/null +++ b/domain/src/main/java/com/susu/domain/usecase/envelope/GetEnvelopesHistoryListUseCase.kt @@ -0,0 +1,37 @@ +package com.susu.domain.usecase.envelope + +import com.susu.core.common.runCatchingIgnoreCancelled +import com.susu.domain.repository.EnvelopesRepository +import javax.inject.Inject + +class GetEnvelopesHistoryListUseCase @Inject constructor( + private val envelopesRepository: EnvelopesRepository, +) { + suspend operator fun invoke(param: Param) = runCatchingIgnoreCancelled { + with(param) { + envelopesRepository.getEnvelopesHistoryList( + friendIds = friendIds, + ledgerId = ledgerId, + type = type, + include = include, + fromAmount = fromAmount, + toAmount = toAmount, + page = page, + size = size, + sort = sort, + ) + } + } + + data class Param( + val friendIds: List? = emptyList(), + val ledgerId: Int? = null, + val type: List? = emptyList(), + val include: List? = emptyList(), + val fromAmount: Int? = null, + val toAmount: Int? = null, + val page: Int? = null, + val size: Int? = null, + val sort: String? = null, + ) +} diff --git a/domain/src/main/java/com/susu/domain/usecase/envelope/GetEnvelopesListUseCase.kt b/domain/src/main/java/com/susu/domain/usecase/envelope/GetEnvelopesListUseCase.kt index ae47170b..f9ce7911 100644 --- a/domain/src/main/java/com/susu/domain/usecase/envelope/GetEnvelopesListUseCase.kt +++ b/domain/src/main/java/com/susu/domain/usecase/envelope/GetEnvelopesListUseCase.kt @@ -21,7 +21,7 @@ class GetEnvelopesListUseCase @Inject constructor( } data class Param( - val friendIds: List? = emptyList(), + val friendIds: List? = emptyList(), val fromTotalAmounts: Int? = null, val toTotalAmounts: Int? = null, val page: Int? = null, diff --git a/domain/src/main/java/com/susu/domain/usecase/report/ReportVoteUseCase.kt b/domain/src/main/java/com/susu/domain/usecase/report/ReportVoteUseCase.kt new file mode 100644 index 00000000..94c7109c --- /dev/null +++ b/domain/src/main/java/com/susu/domain/usecase/report/ReportVoteUseCase.kt @@ -0,0 +1,13 @@ +package com.susu.domain.usecase.report + +import com.susu.core.common.runCatchingIgnoreCancelled +import com.susu.domain.repository.ReportRepository +import javax.inject.Inject + +class ReportVoteUseCase @Inject constructor( + private val reportRepository: ReportRepository, +) { + suspend operator fun invoke(id: Long) = runCatchingIgnoreCancelled { + reportRepository.reportVote(id) + } +} diff --git a/domain/src/main/java/com/susu/domain/usecase/statistics/GetSusuStatisticsUseCase.kt b/domain/src/main/java/com/susu/domain/usecase/statistics/GetSusuStatisticsUseCase.kt new file mode 100644 index 00000000..620388a6 --- /dev/null +++ b/domain/src/main/java/com/susu/domain/usecase/statistics/GetSusuStatisticsUseCase.kt @@ -0,0 +1,13 @@ +package com.susu.domain.usecase.statistics + +import com.susu.core.common.runCatchingIgnoreCancelled +import com.susu.domain.repository.StatisticsRepository +import javax.inject.Inject + +class GetSusuStatisticsUseCase @Inject constructor( + private val statisticsRepository: StatisticsRepository, +) { + suspend operator fun invoke(age: String, relationshipId: Int, categoryId: Int) = runCatchingIgnoreCancelled { + statisticsRepository.getSusuStatistics(age, relationshipId, categoryId) + } +} diff --git a/domain/src/main/java/com/susu/domain/usecase/vote/DeleteVoteUseCase.kt b/domain/src/main/java/com/susu/domain/usecase/vote/DeleteVoteUseCase.kt new file mode 100644 index 00000000..dd912b2d --- /dev/null +++ b/domain/src/main/java/com/susu/domain/usecase/vote/DeleteVoteUseCase.kt @@ -0,0 +1,15 @@ +package com.susu.domain.usecase.vote + +import com.susu.core.common.runCatchingIgnoreCancelled +import com.susu.domain.repository.VoteRepository +import javax.inject.Inject + +class DeleteVoteUseCase @Inject constructor( + private val voteRepository: VoteRepository, +) { + suspend operator fun invoke(id: Long) = runCatchingIgnoreCancelled { + voteRepository.deleteVote( + id = id, + ) + } +} diff --git a/domain/src/main/java/com/susu/domain/usecase/vote/EditVoteUseCase.kt b/domain/src/main/java/com/susu/domain/usecase/vote/EditVoteUseCase.kt new file mode 100644 index 00000000..6b8a19a8 --- /dev/null +++ b/domain/src/main/java/com/susu/domain/usecase/vote/EditVoteUseCase.kt @@ -0,0 +1,25 @@ +package com.susu.domain.usecase.vote + +import com.susu.core.common.runCatchingIgnoreCancelled +import com.susu.domain.repository.VoteRepository +import javax.inject.Inject + +class EditVoteUseCase @Inject constructor( + private val voteRepository: VoteRepository, +) { + suspend operator fun invoke(param: Param) = runCatchingIgnoreCancelled { + with(param) { + voteRepository.editVote( + id = id, + boardId = boardId, + content = content, + ) + } + } + + data class Param( + val id: Long, + val boardId: Long, + val content: String, + ) +} diff --git a/feature/community/src/main/java/com/susu/feature/community/community/CommunityContract.kt b/feature/community/src/main/java/com/susu/feature/community/community/CommunityContract.kt index 71ec5147..ae9b1846 100644 --- a/feature/community/src/main/java/com/susu/feature/community/community/CommunityContract.kt +++ b/feature/community/src/main/java/com/susu/feature/community/community/CommunityContract.kt @@ -19,7 +19,9 @@ data class CommunityState( sealed interface CommunitySideEffect : SideEffect { data class HandleException(val throwable: Throwable, val retry: () -> Unit) : CommunitySideEffect + data class ShowSnackbar(val message: String) : CommunitySideEffect data object NavigateVoteAdd : CommunitySideEffect data object NavigateVoteSearch : CommunitySideEffect data class NavigateVoteDetail(val voteId: Long) : CommunitySideEffect + data class ShowReportDialog(val onConfirmRequest: () -> Unit, val onCheckedAction: () -> Unit) : CommunitySideEffect } diff --git a/feature/community/src/main/java/com/susu/feature/community/community/CommunityScreen.kt b/feature/community/src/main/java/com/susu/feature/community/community/CommunityScreen.kt index 2d9ce65a..fba56c4f 100644 --- a/feature/community/src/main/java/com/susu/feature/community/community/CommunityScreen.kt +++ b/feature/community/src/main/java/com/susu/feature/community/community/CommunityScreen.kt @@ -30,6 +30,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview @@ -51,6 +52,9 @@ import com.susu.core.designsystem.theme.Gray50 import com.susu.core.designsystem.theme.Orange60 import com.susu.core.designsystem.theme.SusuTheme import com.susu.core.model.Category +import com.susu.core.model.Vote +import com.susu.core.ui.DialogToken +import com.susu.core.ui.SnackbarToken import com.susu.core.ui.extension.OnBottomReached import com.susu.core.ui.extension.collectWithLifecycle import com.susu.core.ui.extension.susuClickable @@ -64,20 +68,38 @@ import java.time.LocalDateTime fun CommunityRoute( padding: PaddingValues, vote: String?, + needRefresh: Boolean, + toDeleteVoteId: Long?, toUpdateVote: String?, viewModel: CommunityViewModel = hiltViewModel(), navigateVoteAdd: () -> Unit, navigateVoteSearch: () -> Unit, navigateVoteDetail: (Long) -> Unit, + onShowDialog: (DialogToken) -> Unit, handleException: (Throwable, () -> Unit) -> Unit, + onShowSnackbar: (SnackbarToken) -> Unit, ) { val uiState = viewModel.uiState.collectAsStateWithLifecycle().value + val context = LocalContext.current viewModel.sideEffect.collectWithLifecycle { sideEffect -> when (sideEffect) { is CommunitySideEffect.HandleException -> handleException(sideEffect.throwable, sideEffect.retry) CommunitySideEffect.NavigateVoteAdd -> navigateVoteAdd() is CommunitySideEffect.NavigateVoteDetail -> navigateVoteDetail(sideEffect.voteId) CommunitySideEffect.NavigateVoteSearch -> navigateVoteSearch() + is CommunitySideEffect.ShowReportDialog -> onShowDialog( + DialogToken( + title = context.getString(R.string.dialog_report_title), + text = context.getString(R.string.dialog_report_body), + confirmText = context.getString(R.string.dialog_report_confirm_text), + dismissText = context.getString(R.string.dialog_report_dismiss_text), + checkboxText = context.getString(R.string.dialog_report_checkbox_text), + onConfirmRequest = sideEffect.onConfirmRequest, + onCheckedAction = sideEffect.onCheckedAction, + ), + ) + + is CommunitySideEffect.ShowSnackbar -> onShowSnackbar(SnackbarToken(message = sideEffect.message)) } } @@ -100,6 +122,8 @@ fun CommunityRoute( viewModel.getPopularVoteList() viewModel.addVoteIfNeed(vote) viewModel.updateVoteIfNeed(toUpdateVote) + viewModel.deleteVoteIfNeed(toDeleteVoteId) + viewModel.needRefreshIfNeed(needRefresh) } voteListState.OnBottomReached(minItemsCount = 4) { @@ -117,6 +141,7 @@ fun CommunityRoute( onClickShowVotePopular = viewModel::toggleShowVotePopular, onClickVote = viewModel::navigateVoteDetail, onClickSearchIcon = viewModel::navigateVoteSearch, + onClickReport = viewModel::showReportDialog, ) } @@ -133,6 +158,7 @@ fun CommunityScreen( onClickCategory: (Category?) -> Unit = {}, onClickShowVotePopular: () -> Unit = {}, onClickShowMine: () -> Unit = {}, + onClickReport: (Vote) -> Unit = {}, ) { Box( modifier = Modifier @@ -293,13 +319,16 @@ fun CommunityScreen( vote = vote, currentTime = currentTime, onClick = { onClickVote(vote.id) }, + onClickReport = onClickReport, ) } } if (uiState.voteList.isEmpty()) { Box( - modifier = Modifier.fillMaxWidth().weight(1f), + modifier = Modifier + .fillMaxWidth() + .weight(1f), contentAlignment = Alignment.Center, ) { Text( diff --git a/feature/community/src/main/java/com/susu/feature/community/community/CommunityViewModel.kt b/feature/community/src/main/java/com/susu/feature/community/community/CommunityViewModel.kt index fc71b6eb..9f0b7e3b 100644 --- a/feature/community/src/main/java/com/susu/feature/community/community/CommunityViewModel.kt +++ b/feature/community/src/main/java/com/susu/feature/community/community/CommunityViewModel.kt @@ -3,13 +3,18 @@ package com.susu.feature.community.community import androidx.lifecycle.viewModelScope import com.susu.core.model.Category import com.susu.core.model.Vote +import com.susu.core.model.exception.AlreadyExistsReportHistoryException +import com.susu.core.model.exception.CannotBlockMyselfException import com.susu.core.ui.base.BaseViewModel import com.susu.core.ui.extension.decodeFromUri +import com.susu.domain.usecase.block.BlockUserUseCase +import com.susu.domain.usecase.report.ReportVoteUseCase import com.susu.domain.usecase.vote.GetPopularVoteListUseCase import com.susu.domain.usecase.vote.GetPostCategoryConfigUseCase import com.susu.domain.usecase.vote.GetVoteListUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -21,6 +26,8 @@ class CommunityViewModel @Inject constructor( private val getVoteListUseCase: GetVoteListUseCase, private val getPostCategoryConfigUseCase: GetPostCategoryConfigUseCase, private val getPopularVoteListUseCase: GetPopularVoteListUseCase, + private val reportVoteUseCase: ReportVoteUseCase, + private val blockUserUseCase: BlockUserUseCase, ) : BaseViewModel( CommunityState(), ) { @@ -75,6 +82,27 @@ class CommunityViewModel @Inject constructor( } } + fun deleteVoteIfNeed(toDeleteVoteId: Long?) { + if (toDeleteVoteId == null) return + + intent { + copy( + voteList = voteList + .filter { it.id != toDeleteVoteId } + .toPersistentList(), + popularVoteList = popularVoteList + .filter { it.id != toDeleteVoteId } + .toPersistentList(), + ) + } + } + + fun needRefreshIfNeed(needRefresh: Boolean) { + if (needRefresh.not()) return + + getVoteList(true) + } + fun initData() { if (isFirstVisit.not()) return getVoteList() @@ -166,4 +194,34 @@ class CommunityViewModel @Inject constructor( fun navigateVoteDetail(id: Long) = postSideEffect(CommunitySideEffect.NavigateVoteDetail(id)) fun navigateVoteSearch() = postSideEffect(CommunitySideEffect.NavigateVoteSearch) + + fun showReportDialog(vote: Vote) = postSideEffect( + CommunitySideEffect.ShowReportDialog( + onConfirmRequest = { reportVote(vote.id) }, + onCheckedAction = { blockUser(vote.uid) }, + ), + ) + + private fun reportVote(voteId: Long): Job = viewModelScope.launch { + reportVoteUseCase(voteId) + .onFailure { throwable -> + when (throwable) { + is AlreadyExistsReportHistoryException -> postSideEffect(CommunitySideEffect.ShowSnackbar(throwable.message)) + else -> postSideEffect(CommunitySideEffect.HandleException(throwable = throwable, retry = { reportVote(voteId) })) + } + } + } + + private fun blockUser(uid: Long): Job = viewModelScope.launch { + blockUserUseCase(uid) + .onSuccess { + getVoteList(true) + } + .onFailure { throwable -> + when (throwable) { + is CannotBlockMyselfException -> postSideEffect(CommunitySideEffect.ShowSnackbar(throwable.message)) + else -> postSideEffect(CommunitySideEffect.HandleException(throwable = throwable, retry = { blockUser(uid) })) + } + } + } } diff --git a/feature/community/src/main/java/com/susu/feature/community/community/component/VoteCard.kt b/feature/community/src/main/java/com/susu/feature/community/community/component/VoteCard.kt index 68639a82..4357c7b5 100644 --- a/feature/community/src/main/java/com/susu/feature/community/community/component/VoteCard.kt +++ b/feature/community/src/main/java/com/susu/feature/community/community/component/VoteCard.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.Text @@ -41,6 +42,7 @@ fun VoteCard( vote: Vote = Vote(), currentTime: LocalDateTime = LocalDateTime.now(), onClick: () -> Unit = {}, + onClickReport: (Vote) -> Unit = {}, ) { Column( modifier = Modifier @@ -126,6 +128,7 @@ fun VoteCard( ) Image( + modifier = Modifier.clip(CircleShape).susuClickable(onClick = { onClickReport(vote) }), painter = painterResource(id = R.drawable.ic_report), contentDescription = stringResource(com.susu.core.ui.R.string.content_description_report_button), ) diff --git a/feature/community/src/main/java/com/susu/feature/community/navigation/CommunityNavigation.kt b/feature/community/src/main/java/com/susu/feature/community/navigation/CommunityNavigation.kt index 69bfcba4..0abd1965 100644 --- a/feature/community/src/main/java/com/susu/feature/community/navigation/CommunityNavigation.kt +++ b/feature/community/src/main/java/com/susu/feature/community/navigation/CommunityNavigation.kt @@ -7,12 +7,16 @@ import androidx.navigation.NavOptions import androidx.navigation.NavType import androidx.navigation.compose.composable import androidx.navigation.navArgument +import com.susu.core.model.Vote import com.susu.core.ui.DialogToken import com.susu.core.ui.SnackbarToken +import com.susu.core.ui.extension.encodeToUri import com.susu.feature.community.community.CommunityRoute import com.susu.feature.community.voteadd.VoteAddRoute import com.susu.feature.community.votedetail.VoteDetailRoute +import com.susu.feature.community.voteedit.VoteEditRoute import com.susu.feature.community.votesearch.VoteSearchRoute +import kotlinx.serialization.json.Json fun NavController.navigateVoteAdd() { navigate(CommunityRoute.voteAddRoute) @@ -30,31 +34,43 @@ fun NavController.navigateVoteDetail(voteId: Long) { navigate(CommunityRoute.voteDetailRoute(voteId.toString())) } +fun NavController.navigateVoteEdit(vote: Vote) { + navigate(CommunityRoute.voteEditRoute(Json.encodeToUri(vote))) +} + fun NavGraphBuilder.communityNavGraph( padding: PaddingValues, navigateVoteAdd: () -> Unit, navigateVoteSearch: () -> Unit, navigateVoteDetail: (Long) -> Unit, + navigateVoteEdit: (Vote) -> Unit, popBackStack: () -> Unit, popBackStackWithVote: (String) -> Unit, popBackStackWithToUpdateVote: (String) -> Unit, - @Suppress("detekt:UnusedParameter") + popBackStackWithDeleteVoteId: (Long) -> Unit, + popBackStackWithNeedRefresh: (Boolean) -> Unit, onShowSnackbar: (SnackbarToken) -> Unit, - @Suppress("detekt:UnusedParameter") onShowDialog: (DialogToken) -> Unit, handleException: (Throwable, () -> Unit) -> Unit, ) { composable(route = CommunityRoute.route) { navBackStackEntry -> val vote = navBackStackEntry.savedStateHandle.get(CommunityRoute.VOTE_ARGUMENT_NAME) val toUpdateVote = navBackStackEntry.savedStateHandle.get(CommunityRoute.TO_UPDATE_VOTE_ARGUMENT_NAME) + val toDeleteVoteId = navBackStackEntry.savedStateHandle.get(CommunityRoute.VOTE_ID_ARGUMENT_NAME) + val needRefresh = navBackStackEntry.savedStateHandle.get(CommunityRoute.NEED_REFRESH_ARGUMENT_NAME) ?: false + navBackStackEntry.savedStateHandle[CommunityRoute.NEED_REFRESH_ARGUMENT_NAME] = false CommunityRoute( padding = padding, vote = vote, + needRefresh = needRefresh, + toDeleteVoteId = toDeleteVoteId, toUpdateVote = toUpdateVote, navigateVoteAdd = navigateVoteAdd, navigateVoteSearch = navigateVoteSearch, navigateVoteDetail = navigateVoteDetail, + onShowDialog = onShowDialog, + onShowSnackbar = onShowSnackbar, handleException = handleException, ) } @@ -79,6 +95,11 @@ fun NavGraphBuilder.communityNavGraph( ) { VoteDetailRoute( popBackStackWithToUpdateVote = popBackStackWithToUpdateVote, + popBackStackWithDeleteVoteId = popBackStackWithDeleteVoteId, + popBackStackWithNeedRefresh = popBackStackWithNeedRefresh, + navigateVoteEdit = navigateVoteEdit, + onShowDialog = onShowDialog, + onShowSnackbar = onShowSnackbar, handleException = handleException, ) } @@ -86,7 +107,20 @@ fun NavGraphBuilder.communityNavGraph( composable( route = CommunityRoute.voteSearchRoute, ) { - VoteSearchRoute(popBackStack = popBackStack, navigateVoteDetail = navigateVoteDetail) + VoteSearchRoute( + popBackStack = popBackStack, + navigateVoteDetail = navigateVoteDetail, + ) + } + + composable( + route = CommunityRoute.voteEditRoute("{${CommunityRoute.VOTE_ARGUMENT_NAME}}"), + ) { + VoteEditRoute( + popBackStack = popBackStack, + onShowSnackbar = onShowSnackbar, + handleException = handleException, + ) } } @@ -98,6 +132,8 @@ object CommunityRoute { const val VOTE_ARGUMENT_NAME = "vote" const val VOTE_ID_ARGUMENT_NAME = "vote-id" const val TO_UPDATE_VOTE_ARGUMENT_NAME = "to-update-vote" + const val NEED_REFRESH_ARGUMENT_NAME = "need-refresh" fun voteDetailRoute(voteId: String) = "vote-detail/$voteId" + fun voteEditRoute(vote: String) = "vote-edit/$vote" } diff --git a/feature/community/src/main/java/com/susu/feature/community/votedetail/VoteDetailContract.kt b/feature/community/src/main/java/com/susu/feature/community/votedetail/VoteDetailContract.kt index 665dd016..b3900d3e 100644 --- a/feature/community/src/main/java/com/susu/feature/community/votedetail/VoteDetailContract.kt +++ b/feature/community/src/main/java/com/susu/feature/community/votedetail/VoteDetailContract.kt @@ -10,7 +10,13 @@ data class VoteDetailState( ) : UiState sealed interface VoteDetailSideEffect : SideEffect { + data class ShowSnackbar(val message: String) : VoteDetailSideEffect + data class PopBackStackWithDeleteVoteId(val voteId: Long) : VoteDetailSideEffect + data object PopBackStackWithNeedRefresh : VoteDetailSideEffect + data class ShowDeleteDialog(val onConfirmRequest: () -> Unit) : VoteDetailSideEffect + data class ShowReportDialog(val onConfirmRequest: () -> Unit, val onCheckedAction: () -> Unit) : VoteDetailSideEffect + data object ShowDeleteSuccessSnackbar : VoteDetailSideEffect data class PopBackStackWithToUpdateVote(val vote: String) : VoteDetailSideEffect - data class PopBackStackWithVote(val vote: String) : VoteDetailSideEffect + data class NavigateVoteEdit(val vote: Vote) : VoteDetailSideEffect data class HandleException(val throwable: Throwable, val retry: () -> Unit) : VoteDetailSideEffect } diff --git a/feature/community/src/main/java/com/susu/feature/community/votedetail/VoteDetailScreen.kt b/feature/community/src/main/java/com/susu/feature/community/votedetail/VoteDetailScreen.kt index 72ba86d4..5a5b20fd 100644 --- a/feature/community/src/main/java/com/susu/feature/community/votedetail/VoteDetailScreen.kt +++ b/feature/community/src/main/java/com/susu/feature/community/votedetail/VoteDetailScreen.kt @@ -3,8 +3,8 @@ package com.susu.feature.community.votedetail import androidx.activity.compose.BackHandler import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -26,6 +26,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview @@ -44,6 +45,9 @@ import com.susu.core.designsystem.theme.Gray15 import com.susu.core.designsystem.theme.Gray50 import com.susu.core.designsystem.theme.Orange60 import com.susu.core.designsystem.theme.SusuTheme +import com.susu.core.model.Vote +import com.susu.core.ui.DialogToken +import com.susu.core.ui.SnackbarToken import com.susu.core.ui.extension.collectWithLifecycle import com.susu.core.ui.extension.susuClickable import com.susu.core.ui.util.to_yyyy_dot_MM_dot_dd @@ -57,15 +61,56 @@ import java.time.temporal.ChronoUnit @Composable fun VoteDetailRoute( viewModel: VoteDetailViewModel = hiltViewModel(), + popBackStackWithDeleteVoteId: (Long) -> Unit, popBackStackWithToUpdateVote: (String) -> Unit, + popBackStackWithNeedRefresh: (Boolean) -> Unit, + navigateVoteEdit: (Vote) -> Unit, + onShowSnackbar: (SnackbarToken) -> Unit, + onShowDialog: (DialogToken) -> Unit, handleException: (Throwable, () -> Unit) -> Unit, ) { val uiState = viewModel.uiState.collectAsStateWithLifecycle().value + val context = LocalContext.current viewModel.sideEffect.collectWithLifecycle { sideEffect -> when (sideEffect) { is VoteDetailSideEffect.HandleException -> handleException(sideEffect.throwable, sideEffect.retry) is VoteDetailSideEffect.PopBackStackWithToUpdateVote -> popBackStackWithToUpdateVote(sideEffect.vote) - is VoteDetailSideEffect.PopBackStackWithVote -> TODO() + is VoteDetailSideEffect.NavigateVoteEdit -> navigateVoteEdit(sideEffect.vote) + is VoteDetailSideEffect.PopBackStackWithDeleteVoteId -> popBackStackWithDeleteVoteId(sideEffect.voteId) + is VoteDetailSideEffect.ShowDeleteDialog -> { + onShowDialog( + DialogToken( + title = context.getString(R.string.delete_vote_dialog_title), + text = context.getString(R.string.delete_vote_dialog_body), + confirmText = context.getString(com.susu.core.ui.R.string.word_delete), + dismissText = context.getString(com.susu.core.ui.R.string.word_cancel), + onConfirmRequest = sideEffect.onConfirmRequest, + ), + ) + } + + VoteDetailSideEffect.ShowDeleteSuccessSnackbar -> { + onShowSnackbar( + SnackbarToken( + message = context.getString(R.string.delete_vote_success_toast), + ), + ) + } + + VoteDetailSideEffect.PopBackStackWithNeedRefresh -> popBackStackWithNeedRefresh(true) + is VoteDetailSideEffect.ShowReportDialog -> onShowDialog( + DialogToken( + title = context.getString(R.string.dialog_report_title), + text = context.getString(R.string.dialog_report_body), + confirmText = context.getString(R.string.dialog_report_confirm_text), + dismissText = context.getString(R.string.dialog_report_dismiss_text), + checkboxText = context.getString(R.string.dialog_report_checkbox_text), + onConfirmRequest = sideEffect.onConfirmRequest, + onCheckedAction = sideEffect.onCheckedAction, + ), + ) + + is VoteDetailSideEffect.ShowSnackbar -> onShowSnackbar(SnackbarToken(message = sideEffect.message)) } } @@ -92,7 +137,10 @@ fun VoteDetailRoute( uiState = uiState, currentTime = currentTime, onClickBack = viewModel::popBackStack, + onClickEdit = viewModel::navigateVoteEdit, + onClickDelete = viewModel::showDeleteDialog, onClickOption = viewModel::vote, + onClickReport = viewModel::showReportDialog, ) } @@ -150,14 +198,23 @@ fun VoteDetailScreen( horizontalArrangement = Arrangement.spacedBy(SusuTheme.spacing.spacing_xxs), verticalAlignment = Alignment.CenterVertically, ) { - Image( - modifier = Modifier - .clip(CircleShape) - .size(20.dp) - .border(width = 1.dp, color = Gray15, shape = CircleShape), - painter = painterResource(id = com.susu.core.ui.R.drawable.img_default_profile), - contentDescription = null, - ) + // border를 사용하지 않은 이유 see -> https://stackoverflow.com/questions/75964726/jetpack-compose-circle-shape-border-not-being-applied-as-expected + Box { + Box( + modifier = Modifier + .size(20.dp) + .clip(CircleShape) + .background(Gray15), + ) + Image( + modifier = Modifier + .size(18.dp) + .clip(CircleShape) + .align(Alignment.Center), + painter = painterResource(id = com.susu.core.ui.R.drawable.img_default_profile), + contentDescription = null, + ) + } Text(text = stringResource(R.string.word_anonymous_susu), style = SusuTheme.typography.title_xxxs) diff --git a/feature/community/src/main/java/com/susu/feature/community/votedetail/VoteDetailViewModel.kt b/feature/community/src/main/java/com/susu/feature/community/votedetail/VoteDetailViewModel.kt index fe36e27a..3a1b9cb3 100644 --- a/feature/community/src/main/java/com/susu/feature/community/votedetail/VoteDetailViewModel.kt +++ b/feature/community/src/main/java/com/susu/feature/community/votedetail/VoteDetailViewModel.kt @@ -2,25 +2,36 @@ package com.susu.feature.community.votedetail import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope +import com.susu.core.model.Vote +import com.susu.core.model.exception.AlreadyExistsReportHistoryException +import com.susu.core.model.exception.CannotBlockMyselfException import com.susu.core.ui.base.BaseViewModel import com.susu.core.ui.extension.encodeToUri +import com.susu.domain.usecase.block.BlockUserUseCase +import com.susu.domain.usecase.report.ReportVoteUseCase +import com.susu.domain.usecase.vote.DeleteVoteUseCase import com.susu.domain.usecase.vote.GetVoteDetailUseCase import com.susu.domain.usecase.vote.VoteUseCase import com.susu.feature.community.navigation.CommunityRoute import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.serialization.json.Json import javax.inject.Inject @HiltViewModel class VoteDetailViewModel @Inject constructor( + private val deleteVoteUseCase: DeleteVoteUseCase, private val getVoteDetailUseCase: GetVoteDetailUseCase, private val voteUseCase: VoteUseCase, + private val reportVoteUseCase: ReportVoteUseCase, + private val blockUserUseCase: BlockUserUseCase, savedStateHandle: SavedStateHandle, ) : BaseViewModel( VoteDetailState(), ) { private val voteId = savedStateHandle.get(CommunityRoute.VOTE_ID_ARGUMENT_NAME)!!.toLong() + private var vote: Vote = Vote() private var initCount: Long = 0 private var initIsVoted: Boolean = false @@ -31,6 +42,7 @@ class VoteDetailViewModel @Inject constructor( ).onSuccess { initCount = it.count initIsVoted = it.optionList.any { it.isVoted } + this@VoteDetailViewModel.vote = it intent { copy(vote = it) } }.onFailure { postSideEffect(VoteDetailSideEffect.HandleException(it, ::getVoteDetail)) @@ -89,5 +101,56 @@ class VoteDetailViewModel @Inject constructor( ) } + fun showDeleteDialog() = postSideEffect( + VoteDetailSideEffect.ShowDeleteDialog( + onConfirmRequest = ::deleteVote, + ), + ) + + private fun deleteVote() = viewModelScope.launch { + deleteVoteUseCase(voteId) + .onSuccess { + postSideEffect( + VoteDetailSideEffect.ShowDeleteSuccessSnackbar, + VoteDetailSideEffect.PopBackStackWithDeleteVoteId(voteId), + ) + } + .onFailure { throwable -> + postSideEffect(VoteDetailSideEffect.HandleException(throwable, ::deleteVote)) + } + } + + fun navigateVoteEdit() = postSideEffect(VoteDetailSideEffect.NavigateVoteEdit(vote)) + fun popBackStack() = postSideEffect(VoteDetailSideEffect.PopBackStackWithToUpdateVote(Json.encodeToUri(currentState.vote))) + + fun showReportDialog() = postSideEffect( + VoteDetailSideEffect.ShowReportDialog( + onConfirmRequest = { reportVote(vote.id) }, + onCheckedAction = { blockUser(vote.uid) }, + ), + ) + + private fun reportVote(voteId: Long): Job = viewModelScope.launch { + reportVoteUseCase(voteId) + .onFailure { throwable -> + when (throwable) { + is AlreadyExistsReportHistoryException -> postSideEffect(VoteDetailSideEffect.ShowSnackbar(throwable.message)) + else -> postSideEffect(VoteDetailSideEffect.HandleException(throwable = throwable, retry = { reportVote(voteId) })) + } + } + } + + private fun blockUser(uid: Long): Job = viewModelScope.launch { + blockUserUseCase(uid) + .onSuccess { + postSideEffect(VoteDetailSideEffect.PopBackStackWithNeedRefresh) + } + .onFailure { throwable -> + when (throwable) { + is CannotBlockMyselfException -> postSideEffect(VoteDetailSideEffect.ShowSnackbar(throwable.message)) + else -> postSideEffect(VoteDetailSideEffect.HandleException(throwable = throwable, retry = { blockUser(uid) })) + } + } + } } diff --git a/feature/community/src/main/java/com/susu/feature/community/votedetail/component/VoteItem.kt b/feature/community/src/main/java/com/susu/feature/community/votedetail/component/VoteItem.kt index b62bff56..535f83ab 100644 --- a/feature/community/src/main/java/com/susu/feature/community/votedetail/component/VoteItem.kt +++ b/feature/community/src/main/java/com/susu/feature/community/votedetail/component/VoteItem.kt @@ -39,8 +39,8 @@ private fun Int.safeDiv(parent: Int): Float { fun VoteItem( showResult: Boolean = true, isPick: Boolean = false, - voteCount: Long = 3, - totalVoteCount: Long = 13, + voteCount: Long = 0, + totalVoteCount: Long = 0, title: String = "", onClick: () -> Unit = {}, ) { diff --git a/feature/community/src/main/java/com/susu/feature/community/voteedit/VoteEditContract.kt b/feature/community/src/main/java/com/susu/feature/community/voteedit/VoteEditContract.kt new file mode 100644 index 00000000..c35ef10d --- /dev/null +++ b/feature/community/src/main/java/com/susu/feature/community/voteedit/VoteEditContract.kt @@ -0,0 +1,24 @@ +package com.susu.feature.community.voteedit + +import com.susu.core.model.Category +import com.susu.core.ui.base.SideEffect +import com.susu.core.ui.base.UiState +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.persistentListOf + +data class VoteEditState( + val categoryConfigList: PersistentList = persistentListOf(), + val selectedBoardId: Long = 0, + val voteOptionStateList: PersistentList = persistentListOf(), + val content: String = "", + val isLoading: Boolean = false, +) : UiState { + val buttonEnabled = content.length in 1..50 && + voteOptionStateList.all { it.length in 1..10 } +} + +sealed interface VoteEditSideEffect : SideEffect { + data object ShowCanNotChangeOptionSnackbar : VoteEditSideEffect + data object PopBackStack : VoteEditSideEffect + data class HandleException(val throwable: Throwable, val retry: () -> Unit) : VoteEditSideEffect +} diff --git a/feature/community/src/main/java/com/susu/feature/community/voteedit/VoteEditScreen.kt b/feature/community/src/main/java/com/susu/feature/community/voteedit/VoteEditScreen.kt new file mode 100644 index 00000000..8f93caeb --- /dev/null +++ b/feature/community/src/main/java/com/susu/feature/community/voteedit/VoteEditScreen.kt @@ -0,0 +1,163 @@ +package com.susu.feature.community.voteedit + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.susu.core.designsystem.component.appbar.SusuDefaultAppBar +import com.susu.core.designsystem.component.appbar.icon.BackIcon +import com.susu.core.designsystem.component.appbar.icon.RegisterText +import com.susu.core.designsystem.component.button.FilledButtonColor +import com.susu.core.designsystem.component.button.SusuFilledButton +import com.susu.core.designsystem.component.button.XSmallButtonStyle +import com.susu.core.designsystem.component.screen.LoadingScreen +import com.susu.core.designsystem.component.textfield.SusuBasicTextField +import com.susu.core.designsystem.theme.Gray100 +import com.susu.core.designsystem.theme.Gray40 +import com.susu.core.designsystem.theme.SusuTheme +import com.susu.core.model.Category +import com.susu.core.ui.SnackbarToken +import com.susu.core.ui.extension.collectWithLifecycle +import com.susu.core.ui.extension.susuClickable +import com.susu.feature.community.R +import com.susu.feature.community.votedetail.component.VoteItem + +@Composable +fun VoteEditRoute( + viewModel: VoteEditViewModel = hiltViewModel(), + popBackStack: () -> Unit, + onShowSnackbar: (SnackbarToken) -> Unit, + handleException: (Throwable, () -> Unit) -> Unit, +) { + val uiState = viewModel.uiState.collectAsStateWithLifecycle().value + val context = LocalContext.current + viewModel.sideEffect.collectWithLifecycle { sideEffect -> + when (sideEffect) { + is VoteEditSideEffect.HandleException -> handleException(sideEffect.throwable, sideEffect.retry) + VoteEditSideEffect.PopBackStack -> popBackStack() + VoteEditSideEffect.ShowCanNotChangeOptionSnackbar -> onShowSnackbar( + SnackbarToken(message = context.getString(R.string.snackbar_can_not_change_option)), + ) + } + } + + LaunchedEffect(key1 = Unit) { + viewModel.initData() + viewModel.getCategoryConfig() + } + + VoteEditScreen( + uiState = uiState, + onClickBack = viewModel::popBackStack, + onClickRegister = viewModel::editVote, + onClickCategoryButton = viewModel::selectCategory, + onTextChangeContent = viewModel::updateContent, + onClickOption = viewModel::showCannotChangeOptionSnackbar, + ) +} + +@Composable +fun VoteEditScreen( + uiState: VoteEditState = VoteEditState(), + onClickBack: () -> Unit = {}, + onClickRegister: () -> Unit = {}, + onClickCategoryButton: (Category) -> Unit = {}, + onTextChangeContent: (String) -> Unit = {}, + onClickOption: () -> Unit = {}, +) { + Column( + modifier = Modifier + .fillMaxSize() + .background(SusuTheme.colorScheme.background10), + ) { + SusuDefaultAppBar( + leftIcon = { + BackIcon( + onClick = onClickBack, + ) + }, + title = "투표 편집", + actions = { + RegisterText( + modifier = Modifier + .padding(end = SusuTheme.spacing.spacing_m) + .susuClickable( + rippleEnabled = false, + runIf = uiState.buttonEnabled, + onClick = onClickRegister, + ), + color = if (uiState.buttonEnabled) Gray100 else Gray40, + ) + }, + ) + + Column( + modifier = Modifier + .padding(SusuTheme.spacing.spacing_m) + .weight(1f, fill = false) + .verticalScroll(rememberScrollState()), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(SusuTheme.spacing.spacing_xxxxs), + ) { + uiState.categoryConfigList.forEach { + SusuFilledButton( + color = FilledButtonColor.Black, + style = XSmallButtonStyle.height28, + text = it.name, + isActive = it.id == uiState.selectedBoardId.toInt(), + onClick = { onClickCategoryButton(it) }, + ) + } + } + + Spacer(modifier = Modifier.size(SusuTheme.spacing.spacing_m)) + + SusuBasicTextField( + modifier = Modifier.fillMaxWidth(), + text = uiState.content, + onTextChange = onTextChangeContent, + textStyle = SusuTheme.typography.text_xxs, + maxLines = 10, + placeholder = stringResource(R.string.vote_add_screen_textfield_placeholder), + ) + + Spacer(modifier = Modifier.size(SusuTheme.spacing.spacing_xl)) + + Column( + verticalArrangement = Arrangement.spacedBy(SusuTheme.spacing.spacing_xxs), + ) { + uiState.voteOptionStateList.forEach { option -> + VoteItem(title = option, showResult = false, onClick = onClickOption) + } + } + } + } + + if (uiState.isLoading) { + LoadingScreen() + } +} + +@Preview +@Composable +fun VoteEditScreenPreview() { + SusuTheme { + VoteEditScreen() + } +} diff --git a/feature/community/src/main/java/com/susu/feature/community/voteedit/VoteEditViewModel.kt b/feature/community/src/main/java/com/susu/feature/community/voteedit/VoteEditViewModel.kt new file mode 100644 index 00000000..93fb5c3e --- /dev/null +++ b/feature/community/src/main/java/com/susu/feature/community/voteedit/VoteEditViewModel.kt @@ -0,0 +1,87 @@ +package com.susu.feature.community.voteedit + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import com.susu.core.model.Category +import com.susu.core.model.Vote +import com.susu.core.ui.base.BaseViewModel +import com.susu.core.ui.extension.decodeFromUri +import com.susu.domain.usecase.vote.EditVoteUseCase +import com.susu.domain.usecase.vote.GetPostCategoryConfigUseCase +import com.susu.feature.community.navigation.CommunityRoute +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import javax.inject.Inject + +@HiltViewModel +class VoteEditViewModel @Inject constructor( + private val getPostCategoryConfigUseCase: GetPostCategoryConfigUseCase, + private val editVoteUseCase: EditVoteUseCase, + savedStateHandle: SavedStateHandle, +) : BaseViewModel( + VoteEditState(), +) { + private val argument = savedStateHandle.get(CommunityRoute.VOTE_ARGUMENT_NAME)!! + private var voteId: Long = 0 + private var isFirstVisit = true + + fun initData() { + if (isFirstVisit.not()) return + + Json.decodeFromUri(argument).let { vote -> + voteId = vote.id + intent { + copy( + voteOptionStateList = vote.optionList.map { it.content }.toPersistentList(), + content = vote.content, + selectedBoardId = vote.boardId, + ) + } + } + + isFirstVisit = false + } + + fun editVote() = viewModelScope.launch { + intent { copy(isLoading = true) } + editVoteUseCase( + param = EditVoteUseCase.Param( + content = currentState.content, + boardId = currentState.selectedBoardId, + id = voteId, + ), + ).onSuccess { + postSideEffect(VoteEditSideEffect.PopBackStack) + }.onFailure { + postSideEffect(VoteEditSideEffect.HandleException(it, ::editVote)) + } + intent { copy(isLoading = false) } + } + + fun getCategoryConfig() = viewModelScope.launch { + if (currentState.categoryConfigList.isNotEmpty()) return@launch + + getPostCategoryConfigUseCase() + .onSuccess { categoryConfig -> + intent { + copy( + categoryConfigList = categoryConfig.toPersistentList(), + ) + } + } + } + + fun popBackStack() = postSideEffect(VoteEditSideEffect.PopBackStack) + + fun selectCategory(category: Category) = intent { + copy(selectedBoardId = category.id.toLong()) + } + + fun updateContent(content: String) = intent { + copy(content = content) + } + + fun showCannotChangeOptionSnackbar() = postSideEffect(VoteEditSideEffect.ShowCanNotChangeOptionSnackbar) +} diff --git a/feature/community/src/main/java/com/susu/feature/community/votesearch/VoteSearchScreen.kt b/feature/community/src/main/java/com/susu/feature/community/votesearch/VoteSearchScreen.kt index 2b392d1e..703f9499 100644 --- a/feature/community/src/main/java/com/susu/feature/community/votesearch/VoteSearchScreen.kt +++ b/feature/community/src/main/java/com/susu/feature/community/votesearch/VoteSearchScreen.kt @@ -203,27 +203,28 @@ private fun SearchResultColumn( voteList: PersistentList, onClickItem: (Vote) -> Unit, ) { - if (showSearchResultEmpty) { - ResultEmptyColumn( - title = stringResource(R.string.vote_search_screen_search_result_empty_title), + Column( + modifier = Modifier.padding(top = SusuTheme.spacing.spacing_xxl), + verticalArrangement = Arrangement.spacedBy(SusuTheme.spacing.spacing_m), + ) { + Text( + text = stringResource(com.susu.core.ui.R.string.word_search_result), + style = SusuTheme.typography.title_xxs, + color = Gray60, ) - } else { - Column( - modifier = Modifier.padding(top = SusuTheme.spacing.spacing_xxl), - verticalArrangement = Arrangement.spacedBy(SusuTheme.spacing.spacing_m), - ) { - Text( - text = stringResource(com.susu.core.ui.R.string.word_search_result), - style = SusuTheme.typography.title_xxs, - color = Gray60, + + if (showSearchResultEmpty) { + ResultEmptyColumn( + title = stringResource(R.string.vote_search_screen_search_result_empty_title), + ) + } + + voteList.forEach { vote -> + SusuRecentSearchContainer( + typeIconId = R.drawable.ic_vote, + text = vote.content, + onClick = { onClickItem(vote) }, ) - voteList.forEach { vote -> - SusuRecentSearchContainer( - typeIconId = R.drawable.ic_vote, - text = vote.content, - onClick = { onClickItem(vote) }, - ) - } } } } diff --git a/feature/community/src/main/res/values/strings.xml b/feature/community/src/main/res/values/strings.xml index d66fc827..bc1ec2d0 100644 --- a/feature/community/src/main/res/values/strings.xml +++ b/feature/community/src/main/res/values/strings.xml @@ -20,4 +20,13 @@ 궁금하신 것들의 키워드를\n검색해볼 수 있어요 어떤 투표를 찾아드릴까요? 원하는 검색 결과가 없나요? + 투표를 삭제할까요? + 삭제한 투표는 다시 복구할 수 없어요 + 투표가 삭제되었어요 + 시작된 투표는 보기를 편집할 수 없어요 + 해당 글을 신고할까요? + 신고된 글은 수수에서 확인한 후에 모두에게 제재돼요\n이 작성자의 글을 보고 싶지 않다면 작성자를 차단해주세요 + 신고하기 + 닫기 + 작성자도 바로 차단하기 diff --git a/feature/mypage/build.gradle.kts b/feature/mypage/build.gradle.kts index 6bfd827c..2b52f041 100644 --- a/feature/mypage/build.gradle.kts +++ b/feature/mypage/build.gradle.kts @@ -9,4 +9,5 @@ android { dependencies { implementation(libs.kakao.sdk.user) + implementation(libs.play.app.update) } diff --git a/feature/mypage/src/main/java/com/susu/feature/mypage/main/MyPageContract.kt b/feature/mypage/src/main/java/com/susu/feature/mypage/main/MyPageContract.kt index 23924bbb..1671f8c3 100644 --- a/feature/mypage/src/main/java/com/susu/feature/mypage/main/MyPageContract.kt +++ b/feature/mypage/src/main/java/com/susu/feature/mypage/main/MyPageContract.kt @@ -20,5 +20,5 @@ sealed interface MyPageEffect : SideEffect { data class MyPageState( val isLoading: Boolean = false, val userName: String = "", - val appVersion: String = "", + val canUpdate: Boolean = false, ) : UiState diff --git a/feature/mypage/src/main/java/com/susu/feature/mypage/main/MyPageDefaultScreen.kt b/feature/mypage/src/main/java/com/susu/feature/mypage/main/MyPageDefaultScreen.kt index 5d9a251a..b0f240d0 100644 --- a/feature/mypage/src/main/java/com/susu/feature/mypage/main/MyPageDefaultScreen.kt +++ b/feature/mypage/src/main/java/com/susu/feature/mypage/main/MyPageDefaultScreen.kt @@ -1,6 +1,8 @@ package com.susu.feature.mypage.main import android.content.Context +import android.content.Intent +import android.net.Uri import android.os.Environment import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -16,6 +18,7 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -29,6 +32,8 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.google.android.play.core.appupdate.AppUpdateManagerFactory +import com.google.android.play.core.install.model.UpdateAvailability import com.susu.core.designsystem.component.appbar.SusuDefaultAppBar import com.susu.core.designsystem.component.appbar.icon.LogoIcon import com.susu.core.designsystem.component.button.GhostButtonColor @@ -41,6 +46,8 @@ import com.susu.core.designsystem.theme.Gray60 import com.susu.core.designsystem.theme.SusuTheme import com.susu.core.ui.DialogToken import com.susu.core.ui.R +import com.susu.core.ui.SUSU_GOOGLE_FROM_URL +import com.susu.core.ui.SUSU_GOOGLE_PLAY_STORE_URL import com.susu.core.ui.SnackbarToken import com.susu.core.ui.extension.collectWithLifecycle import com.susu.core.ui.extension.susuClickable @@ -59,6 +66,14 @@ fun MyPageDefaultRoute( handleException: (Throwable, () -> Unit) -> Unit, ) { val context = LocalContext.current + val appUpdateManager = remember { AppUpdateManagerFactory.create(context) } + + LaunchedEffect(key1 = Unit) { + viewModel.getUserInfo() + appUpdateManager.appUpdateInfo.addOnSuccessListener { appUpdateInfo -> + viewModel.updateCanUpdate(appUpdateInfo.updateAvailability() == UpdateAvailability.UPDATE_AVAILABLE) + } + } viewModel.sideEffect.collectWithLifecycle { sideEffect -> when (sideEffect) { @@ -137,6 +152,9 @@ fun MyPageDefaultRoute( onLogout = viewModel::showLogoutDialog, onWithdraw = viewModel::showWithdrawDialog, onExport = viewModel::showExportDialog, + onClickFeedback = { + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(SUSU_GOOGLE_FROM_URL))) + }, navigateToInfo = navigateToInfo, navigateToSocial = navigateToSocial, navigateToPrivacyPolicy = navigateToPrivacyPolicy, @@ -150,6 +168,7 @@ fun MyPageDefaultScreen( onLogout: () -> Unit = {}, onWithdraw: () -> Unit = {}, onExport: () -> Unit = {}, + onClickFeedback: () -> Unit = {}, navigateToInfo: () -> Unit = {}, navigateToSocial: () -> Unit = {}, navigateToPrivacyPolicy: () -> Unit = {}, @@ -211,12 +230,30 @@ fun MyPageDefaultScreen( MyPageMenuItem( titleText = stringResource(com.susu.feature.mypage.R.string.mypage_app_version), + rippleEnabled = uiState.canUpdate, action = { - Text( - text = stringResource(com.susu.feature.mypage.R.string.mypage_update), - style = SusuTheme.typography.title_xxs, - color = Gray60, - ) + if (uiState.canUpdate) { + Text( + modifier = Modifier.susuClickable( + onClick = { + val intent = Intent(Intent.ACTION_VIEW).apply { + data = Uri.parse(SUSU_GOOGLE_PLAY_STORE_URL) + setPackage("com.android.vending") // Google Play 스토어 앱으로 연결되게 함. + } + context.startActivity(intent) + }, + ), + text = stringResource(com.susu.feature.mypage.R.string.mypage_update), + style = SusuTheme.typography.title_xxs, + color = Gray60, + ) + } else { + Text( + text = stringResource(com.susu.feature.mypage.R.string.mypage_default_recent_version, currentAppVersion), + style = SusuTheme.typography.title_xxs, + color = Gray60, + ) + } }, ) @@ -248,6 +285,7 @@ fun MyPageDefaultScreen( color = GhostButtonColor.Orange, style = SmallButtonStyle.height40, text = stringResource(com.susu.feature.mypage.R.string.mypage_feedback), + onClick = onClickFeedback, ) } } diff --git a/feature/mypage/src/main/java/com/susu/feature/mypage/main/MyPageViewModel.kt b/feature/mypage/src/main/java/com/susu/feature/mypage/main/MyPageViewModel.kt index 10096a3f..259d1150 100644 --- a/feature/mypage/src/main/java/com/susu/feature/mypage/main/MyPageViewModel.kt +++ b/feature/mypage/src/main/java/com/susu/feature/mypage/main/MyPageViewModel.kt @@ -21,15 +21,11 @@ class MyPageViewModel @Inject constructor( private val downloadExcelUseCase: DownloadExcelUseCase, ) : BaseViewModel(MyPageState()) { - init { - getUserInfo() - } - fun showExportDialog() = postSideEffect(MyPageEffect.ShowExportDialog) fun showLogoutDialog() = postSideEffect(MyPageEffect.ShowLogoutDialog) fun showWithdrawDialog() = postSideEffect(MyPageEffect.ShowWithdrawDialog) - private fun getUserInfo() { + fun getUserInfo() { viewModelScope.launch { intent { copy(isLoading = true) } getUserUseCase().onSuccess { @@ -44,6 +40,10 @@ class MyPageViewModel @Inject constructor( } } + fun updateCanUpdate(canUpdate: Boolean) { + intent { copy(canUpdate = canUpdate) } + } + fun logout() { UserApiClient.instance.logout { error -> if (error != null) { diff --git a/feature/mypage/src/main/java/com/susu/feature/mypage/main/component/MyPageMenuItem.kt b/feature/mypage/src/main/java/com/susu/feature/mypage/main/component/MyPageMenuItem.kt index 9520aa05..b24701a2 100644 --- a/feature/mypage/src/main/java/com/susu/feature/mypage/main/component/MyPageMenuItem.kt +++ b/feature/mypage/src/main/java/com/susu/feature/mypage/main/component/MyPageMenuItem.kt @@ -30,11 +30,13 @@ fun MyPageMenuItem( action: @Composable (() -> Unit)? = null, actionItemPadding: Dp = SusuTheme.spacing.spacing_m, onMenuClick: () -> Unit = {}, + rippleEnabled: Boolean = true, ) { Row( modifier = modifier .fillMaxWidth() .susuClickable( + rippleEnabled = rippleEnabled, onClick = onMenuClick, ) .padding(padding), diff --git a/feature/mypage/src/main/java/com/susu/feature/mypage/social/MyPageSocialScreen.kt b/feature/mypage/src/main/java/com/susu/feature/mypage/social/MyPageSocialScreen.kt index aeb64fc2..2b104e78 100644 --- a/feature/mypage/src/main/java/com/susu/feature/mypage/social/MyPageSocialScreen.kt +++ b/feature/mypage/src/main/java/com/susu/feature/mypage/social/MyPageSocialScreen.kt @@ -1,5 +1,7 @@ package com.susu.feature.mypage.social +import android.content.Intent +import android.net.Uri import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -17,6 +19,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview @@ -32,6 +35,7 @@ import com.susu.core.designsystem.theme.Gray25 import com.susu.core.designsystem.theme.Gray50 import com.susu.core.designsystem.theme.Gray70 import com.susu.core.designsystem.theme.SusuTheme +import com.susu.core.ui.SUSU_GOOGLE_FROM_URL import com.susu.core.ui.SnsProviders import com.susu.feature.mypage.R @@ -48,6 +52,8 @@ fun MyPageSocialScreen( padding: PaddingValues = PaddingValues(), popBackStack: () -> Unit = {}, ) { + val context = LocalContext.current + Column( modifier = Modifier.fillMaxSize().padding(padding), horizontalAlignment = Alignment.CenterHorizontally, @@ -83,6 +89,7 @@ fun MyPageSocialScreen( style = XSmallButtonStyle.height36, isActive = false, isClickable = true, + onClick = { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(SUSU_GOOGLE_FROM_URL))) }, ) } } diff --git a/feature/mypage/src/main/res/values/strings.xml b/feature/mypage/src/main/res/values/strings.xml index 7344015c..c3acb499 100644 --- a/feature/mypage/src/main/res/values/strings.xml +++ b/feature/mypage/src/main/res/values/strings.xml @@ -28,4 +28,5 @@ 문의 남기기 프로필 이미지 이름은 한글 또는 영문 10글자로 입력해주세요 + 최신 버전 %s diff --git a/feature/navigator/src/main/java/com/susu/feature/navigator/MainNavigator.kt b/feature/navigator/src/main/java/com/susu/feature/navigator/MainNavigator.kt index 28b334d0..e8490b33 100644 --- a/feature/navigator/src/main/java/com/susu/feature/navigator/MainNavigator.kt +++ b/feature/navigator/src/main/java/com/susu/feature/navigator/MainNavigator.kt @@ -10,11 +10,14 @@ import androidx.navigation.compose.rememberNavController import androidx.navigation.navOptions import com.susu.core.designsystem.theme.SusuTheme import com.susu.core.model.Envelope +import com.susu.core.model.EnvelopeDetail import com.susu.core.model.Ledger +import com.susu.core.model.Vote import com.susu.feature.community.navigation.CommunityRoute import com.susu.feature.community.navigation.navigateCommunity import com.susu.feature.community.navigation.navigateVoteAdd import com.susu.feature.community.navigation.navigateVoteDetail +import com.susu.feature.community.navigation.navigateVoteEdit import com.susu.feature.community.navigation.navigateVoteSearch import com.susu.feature.loginsignup.navigation.LoginSignupRoute import com.susu.feature.mypage.navigation.navigateMyPage @@ -60,16 +63,16 @@ internal class MainNavigator( in listOf( ReceivedRoute.ledgerSearchRoute, ReceivedRoute.ledgerFilterRoute("{${ReceivedRoute.FILTER_ARGUMENT_NAME}}"), - ReceivedRoute.envelopeDetailRoute("{${ReceivedRoute.ENVELOPE_ARGUMENT_NAME}}"), - ReceivedRoute.envelopeEditRoute, - SentRoute.sentEnvelopeRoute, - SentRoute.sentEnvelopeDetailRoute, - SentRoute.sentEnvelopeEditRoute, - SentRoute.sentEnvelopeSearchRoute, + ReceivedRoute.envelopeDetailRoute("{${ReceivedRoute.ENVELOPE_ARGUMENT_NAME}}", "{${ReceivedRoute.LEDGER_ID_ARGUMENT_NAME}}"), + ReceivedRoute.envelopeEditRoute("{${ReceivedRoute.ENVELOPE_ARGUMENT_NAME}}", "{${ReceivedRoute.LEDGER_ID_ARGUMENT_NAME}}"), + SentRoute.sentEnvelopeRoute("{${SentRoute.FRIEND_ID_ARGUMENT_NAME}}"), + SentRoute.sentEnvelopeDetailRoute("{${SentRoute.ENVELOPE_ID_ARGUMENT_NAME}}"), + SentRoute.sentEnvelopeEditRoute("{${SentRoute.ENVELOPE_DETAIL_ARGUMENT_NAME}}"), CommunityRoute.route, CommunityRoute.voteAddRoute, CommunityRoute.voteSearchRoute, CommunityRoute.voteDetailRoute("{${CommunityRoute.VOTE_ID_ARGUMENT_NAME}}"), + CommunityRoute.voteEditRoute("{${CommunityRoute.VOTE_ARGUMENT_NAME}}"), ), -> SusuTheme.colorScheme.background10 @@ -102,16 +105,16 @@ internal class MainNavigator( } } - fun navigateSentEnvelope() { - navController.navigateSentEnvelope() + fun navigateSentEnvelope(id: Long) { + navController.navigateSentEnvelope(id) } - fun navigateSentEnvelopeDetail() { - navController.navigateSentEnvelopeDetail() + fun navigateSentEnvelopeDetail(id: Long) { + navController.navigateSentEnvelopeDetail(id) } - fun navigateSentEnvelopeEdit() { - navController.navigateSentEnvelopeEdit() + fun navigateSentEnvelopeEdit(envelopeDetail: EnvelopeDetail) { + navController.navigateSentEnvelopeEdit(envelopeDetail) } fun navigateSentEnvelopeAdd() { @@ -166,20 +169,20 @@ internal class MainNavigator( navController.navigateMyPageSocial() } - fun navigateReceivedEnvelopeAdd(categoryName: String, ledgerId: Long) { - navController.navigateReceivedEnvelopeAdd(categoryName, ledgerId) + fun navigateReceivedEnvelopeAdd(ledger: Ledger) { + navController.navigateReceivedEnvelopeAdd(ledger) } fun navigateMyPagePrivacyPolicy() { navController.navigateMyPagePrivacyPolicy() } - fun navigateReceivedEnvelopeDetail(envelope: Envelope) { - navController.navigateReceivedEnvelopeDetail(envelope) + fun navigateReceivedEnvelopeDetail(envelope: Envelope, ledger: Ledger) { + navController.navigateReceivedEnvelopeDetail(envelope, ledger) } - fun navigateReceivedEnvelopeEdit() { - navController.navigateReceivedEnvelopeEdit() + fun navigateReceivedEnvelopeEdit(envelope: Envelope, ledger: Ledger) { + navController.navigateReceivedEnvelopeEdit(envelope, ledger) } fun navigateVoteAdd() { @@ -194,6 +197,10 @@ internal class MainNavigator( navController.navigateVoteDetail(voteId) } + fun navigateVoteEdit(vote: Vote) { + navController.navigateVoteEdit(vote) + } + fun popBackStackIfNotHome() { if (!isSameCurrentDestination(SentRoute.route)) { navController.popBackStack() diff --git a/feature/navigator/src/main/java/com/susu/feature/navigator/MainScreen.kt b/feature/navigator/src/main/java/com/susu/feature/navigator/MainScreen.kt index 1abdae26..b2e4ae34 100644 --- a/feature/navigator/src/main/java/com/susu/feature/navigator/MainScreen.kt +++ b/feature/navigator/src/main/java/com/susu/feature/navigator/MainScreen.kt @@ -86,6 +86,8 @@ internal fun MainScreen( navigateSentEnvelopeAdd = navigator::navigateSentEnvelopeAdd, navigateSentEnvelopeSearch = navigator::navigateSentEnvelopeSearch, handleException = viewModel::handleException, + onShowSnackbar = viewModel::onShowSnackbar, + onShowDialog = viewModel::onShowDialog, ) receivedNavGraph( @@ -140,6 +142,7 @@ internal fun MainScreen( ) statisticsNavGraph( + padding = innerPadding, navigateToMyInfo = navigator::navigateMyPageInfo, onShowDialog = viewModel::onShowDialog, handleException = viewModel::handleException, @@ -150,6 +153,7 @@ internal fun MainScreen( navigateVoteAdd = navigator::navigateVoteAdd, navigateVoteSearch = navigator::navigateVoteSearch, navigateVoteDetail = navigator::navigateVoteDetail, + navigateVoteEdit = navigator::navigateVoteEdit, popBackStack = navigator::popBackStackIfNotHome, popBackStackWithVote = { vote -> navigator.navController.previousBackStackEntry?.savedStateHandle?.set( @@ -165,6 +169,20 @@ internal fun MainScreen( ) navigator.popBackStackIfNotHome() }, + popBackStackWithDeleteVoteId = { voteId -> + navigator.navController.previousBackStackEntry?.savedStateHandle?.set( + CommunityRoute.VOTE_ID_ARGUMENT_NAME, + voteId, + ) + navigator.popBackStackIfNotHome() + }, + popBackStackWithNeedRefresh = { needRefresh -> + navigator.navController.previousBackStackEntry?.savedStateHandle?.set( + CommunityRoute.NEED_REFRESH_ARGUMENT_NAME, + needRefresh, + ) + navigator.popBackStackIfNotHome() + }, onShowSnackbar = viewModel::onShowSnackbar, onShowDialog = viewModel::onShowDialog, handleException = viewModel::handleException, @@ -203,7 +221,6 @@ internal fun MainScreen( text = text, confirmText = confirmText, dismissText = dismissText, - isDimmed = isDimmed, textAlign = textAlign, onConfirmRequest = { onConfirmRequest() @@ -221,7 +238,6 @@ internal fun MainScreen( confirmText = confirmText, dismissText = dismissText, checkboxText = checkboxText!!, - isDimmed = isDimmed, defaultChecked = defaultChecked, textAlign = textAlign, onConfirmRequest = { checked -> diff --git a/feature/received/src/main/java/com/susu/feature/received/envelopeadd/ReceivedEnvelopeAddViewModel.kt b/feature/received/src/main/java/com/susu/feature/received/envelopeadd/ReceivedEnvelopeAddViewModel.kt index 9b602db7..ceb9284c 100644 --- a/feature/received/src/main/java/com/susu/feature/received/envelopeadd/ReceivedEnvelopeAddViewModel.kt +++ b/feature/received/src/main/java/com/susu/feature/received/envelopeadd/ReceivedEnvelopeAddViewModel.kt @@ -2,9 +2,11 @@ package com.susu.feature.received.envelopeadd import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope +import com.susu.core.model.Ledger import com.susu.core.model.Relationship import com.susu.core.model.exception.AlreadyRegisteredFriendPhoneNumberException import com.susu.core.ui.base.BaseViewModel +import com.susu.core.ui.extension.decodeFromUri import com.susu.core.ui.extension.encodeToUri import com.susu.domain.usecase.envelope.CreateReceivedEnvelopeUseCase import com.susu.feature.received.navigation.ReceivedRoute @@ -22,8 +24,11 @@ class ReceivedEnvelopeAddViewModel @Inject constructor( ) : BaseViewModel( ReceivedEnvelopeAddState(), ) { - val categoryName = savedStateHandle.get(ReceivedRoute.CATEGORY_ARGUMENT_NAME)!! - private val ledgerId = savedStateHandle.get(ReceivedRoute.LEDGER_ID_ARGUMENT_NAME)!! + private val ledger = run { + Json.decodeFromUri(savedStateHandle.get(ReceivedRoute.LEDGER_ARGUMENT_NAME)!!) + } + val categoryName = ledger.category.customCategory ?: ledger.category.name + private val ledgerId = ledger.id private var money: Long = 0 private var name: String = "" @@ -44,10 +49,12 @@ class ReceivedEnvelopeAddViewModel @Inject constructor( param = CreateReceivedEnvelopeUseCase.Param( friendId = friendId, friendName = name, + categoryId = ledger.category.id.toLong(), + customCategory = ledger.category.customCategory, phoneNumber = phoneNumber, relationshipId = relationShip?.id, customRelation = relationShip?.customRelation, - ledgerId = ledgerId.toLong(), + ledgerId = ledgerId, amount = money, gift = present, memo = memo, diff --git a/feature/received/src/main/java/com/susu/feature/received/envelopedetail/ReceivedEnvelopeDetailContract.kt b/feature/received/src/main/java/com/susu/feature/received/envelopedetail/ReceivedEnvelopeDetailContract.kt index d8491bd6..90c96f4c 100644 --- a/feature/received/src/main/java/com/susu/feature/received/envelopedetail/ReceivedEnvelopeDetailContract.kt +++ b/feature/received/src/main/java/com/susu/feature/received/envelopedetail/ReceivedEnvelopeDetailContract.kt @@ -1,15 +1,16 @@ package com.susu.feature.received.envelopedetail import com.susu.core.model.Envelope +import com.susu.core.model.Ledger import com.susu.core.ui.base.SideEffect import com.susu.core.ui.base.UiState data class ReceivedEnvelopeDetailState( - val money: Long = 0, + val envelope: Envelope = Envelope(), ) : UiState sealed interface ReceivedEnvelopeDetailSideEffect : SideEffect { - data class NavigateReceivedEnvelopeEdit(val envelope: Envelope) : ReceivedEnvelopeDetailSideEffect + data class NavigateReceivedEnvelopeEdit(val envelope: Envelope, val ledger: Ledger) : ReceivedEnvelopeDetailSideEffect data class PopBackStackWithReceivedEnvelope(val envelope: String) : ReceivedEnvelopeDetailSideEffect data class PopBackStackWithDeleteReceivedEnvelopeId(val envelopeId: Long) : ReceivedEnvelopeDetailSideEffect data class ShowDeleteDialog(val onConfirmRequest: () -> Unit) : ReceivedEnvelopeDetailSideEffect diff --git a/feature/received/src/main/java/com/susu/feature/received/envelopedetail/ReceivedEnvelopeDetailScreen.kt b/feature/received/src/main/java/com/susu/feature/received/envelopedetail/ReceivedEnvelopeDetailScreen.kt index e68f1dd2..aacc5e5b 100644 --- a/feature/received/src/main/java/com/susu/feature/received/envelopedetail/ReceivedEnvelopeDetailScreen.kt +++ b/feature/received/src/main/java/com/susu/feature/received/envelopedetail/ReceivedEnvelopeDetailScreen.kt @@ -1,5 +1,6 @@ package com.susu.feature.received.envelopedetail +import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -14,6 +15,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -23,17 +25,23 @@ import com.susu.core.designsystem.component.appbar.icon.DeleteText import com.susu.core.designsystem.component.appbar.icon.EditText import com.susu.core.designsystem.theme.Gray100 import com.susu.core.designsystem.theme.SusuTheme +import com.susu.core.model.Envelope +import com.susu.core.model.Ledger import com.susu.core.ui.DialogToken import com.susu.core.ui.SnackbarToken import com.susu.core.ui.extension.collectWithLifecycle +import com.susu.core.ui.extension.toMoneyFormat +import com.susu.core.ui.util.to_yyyy_korYear_M_korMonth_d_korDay import com.susu.feature.received.R import com.susu.feature.received.envelopedetail.component.DetailItem +import kotlinx.datetime.toJavaLocalDateTime @Composable fun ReceivedEnvelopeDetailRoute( viewModel: ReceivedEnvelopeDetailViewModel = hiltViewModel(), popBackStackWithDeleteReceivedEnvelopeId: (Long) -> Unit, - navigateReceivedEnvelopeEdit: () -> Unit, + popBackStackWithReceivedEnvelope: (String) -> Unit, + navigateReceivedEnvelopeEdit: (Envelope, Ledger) -> Unit, handleException: (Throwable, () -> Unit) -> Unit, onShowSnackbar: (SnackbarToken) -> Unit, onShowDialog: (DialogToken) -> Unit, @@ -43,11 +51,12 @@ fun ReceivedEnvelopeDetailRoute( viewModel.sideEffect.collectWithLifecycle { sideEffect -> when (sideEffect) { is ReceivedEnvelopeDetailSideEffect.HandleException -> handleException(sideEffect.throwable, sideEffect.retry) - is ReceivedEnvelopeDetailSideEffect.NavigateReceivedEnvelopeEdit -> TODO() + is ReceivedEnvelopeDetailSideEffect.NavigateReceivedEnvelopeEdit -> navigateReceivedEnvelopeEdit(sideEffect.envelope, sideEffect.ledger) is ReceivedEnvelopeDetailSideEffect.PopBackStackWithDeleteReceivedEnvelopeId -> popBackStackWithDeleteReceivedEnvelopeId( sideEffect.envelopeId, ) - is ReceivedEnvelopeDetailSideEffect.PopBackStackWithReceivedEnvelope -> TODO() + + is ReceivedEnvelopeDetailSideEffect.PopBackStackWithReceivedEnvelope -> popBackStackWithReceivedEnvelope(sideEffect.envelope) is ReceivedEnvelopeDetailSideEffect.ShowDeleteDialog -> onShowDialog( DialogToken( title = context.getString(R.string.dialog_delete_envelope_title), @@ -57,27 +66,33 @@ fun ReceivedEnvelopeDetailRoute( onConfirmRequest = sideEffect.onConfirmRequest, ), ) + ReceivedEnvelopeDetailSideEffect.ShowDeleteSuccessSnackbar -> onShowSnackbar( SnackbarToken(message = context.getString(R.string.toast_delete_envelope_success)), ) + is ReceivedEnvelopeDetailSideEffect.ShowSnackbar -> TODO() } } + BackHandler { + viewModel.popBackStackWithEnvelope() + } + LaunchedEffect(key1 = Unit) { viewModel.getEnvelope() } ReceivedEnvelopeDetailScreen( uiState = uiState, - onClickEdit = navigateReceivedEnvelopeEdit, + onClickEdit = viewModel::navigateEnvelopeEdit, onClickDelete = viewModel::showDeleteDialog, + onClickBackIcon = viewModel::popBackStackWithEnvelope, ) } @Composable fun ReceivedEnvelopeDetailScreen( - @Suppress("detekt:UnusedParameter") uiState: ReceivedEnvelopeDetailState = ReceivedEnvelopeDetailState(), onClickBackIcon: () -> Unit = {}, onClickEdit: () -> Unit = {}, @@ -107,7 +122,7 @@ fun ReceivedEnvelopeDetailScreen( Spacer(modifier = Modifier.size(SusuTheme.spacing.spacing_m)) }, ) - // TODO: text 수정 + Column( modifier = Modifier .fillMaxSize() @@ -119,41 +134,52 @@ fun ReceivedEnvelopeDetailScreen( .verticalScroll(scrollState), ) { Text( - text = "150,000원", + text = stringResource(id = com.susu.core.ui.R.string.money_unit_format, uiState.envelope.amount.toMoneyFormat()), style = SusuTheme.typography.title_xxl, color = Gray100, ) Spacer(modifier = Modifier.size(SusuTheme.spacing.spacing_m)) Column { DetailItem( - categoryText = "이름", - contentText = "김철수", + categoryText = stringResource(id = com.susu.core.ui.R.string.word_name), + contentText = uiState.envelope.friend.name, isEmptyContent = false, ) DetailItem( - categoryText = "나와의 관계", - contentText = "친구", + categoryText = stringResource(com.susu.core.ui.R.string.word_relationship), + contentText = uiState.envelope.relationship.customRelation ?: uiState.envelope.relationship.relation, isEmptyContent = false, ) DetailItem( - categoryText = "방문 여부", - contentText = "예", + categoryText = stringResource(com.susu.core.ui.R.string.word_date), + contentText = uiState.envelope.handedOverAt.toJavaLocalDateTime().to_yyyy_korYear_M_korMonth_d_korDay(), isEmptyContent = false, ) DetailItem( - categoryText = "선물", - contentText = "한끼 식사", - isEmptyContent = false, + categoryText = stringResource(com.susu.core.ui.R.string.word_is_visited), + contentText = if (uiState.envelope.hasVisited == true) { + stringResource(id = com.susu.core.ui.R.string.word_yes) + } else { + stringResource( + id = com.susu.core.ui.R.string.word_no, + ) + }, + isEmptyContent = uiState.envelope.hasVisited == null, ) DetailItem( - categoryText = "연락처", - contentText = "01012345678", - isEmptyContent = false, + categoryText = stringResource(com.susu.core.ui.R.string.word_gift), + contentText = uiState.envelope.gift ?: "", + isEmptyContent = uiState.envelope.gift == null, ) DetailItem( - categoryText = "메모", - contentText = "가나다라마바사아자차카타파하가나다라마바사아자차카타파하가나다라마바사아자차카타파하가나다라마바사아자차카타파하가나다라마바사아자차카타파하", - isEmptyContent = false, + categoryText = stringResource(id = com.susu.core.ui.R.string.word_phone_number), + contentText = uiState.envelope.friend.phoneNumber, + isEmptyContent = uiState.envelope.friend.phoneNumber.isEmpty(), + ) + DetailItem( + categoryText = stringResource(id = com.susu.core.ui.R.string.word_memo), + contentText = uiState.envelope.memo ?: "", + isEmptyContent = uiState.envelope.memo == null, ) } } diff --git a/feature/received/src/main/java/com/susu/feature/received/envelopedetail/ReceivedEnvelopeDetailViewModel.kt b/feature/received/src/main/java/com/susu/feature/received/envelopedetail/ReceivedEnvelopeDetailViewModel.kt index 5fe92118..3de95b86 100644 --- a/feature/received/src/main/java/com/susu/feature/received/envelopedetail/ReceivedEnvelopeDetailViewModel.kt +++ b/feature/received/src/main/java/com/susu/feature/received/envelopedetail/ReceivedEnvelopeDetailViewModel.kt @@ -3,50 +3,47 @@ package com.susu.feature.received.envelopedetail import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.susu.core.model.Envelope +import com.susu.core.model.Ledger import com.susu.core.ui.base.BaseViewModel import com.susu.core.ui.extension.decodeFromUri import com.susu.core.ui.extension.encodeToUri import com.susu.domain.usecase.envelope.DeleteEnvelopeUseCase +import com.susu.domain.usecase.envelope.GetEnvelopeUseCase import com.susu.feature.received.navigation.ReceivedRoute import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch import kotlinx.serialization.json.Json -import timber.log.Timber import javax.inject.Inject @HiltViewModel class ReceivedEnvelopeDetailViewModel @Inject constructor( + private val getEnvelopeUseCase: GetEnvelopeUseCase, private val deleteEnvelopeUseCase: DeleteEnvelopeUseCase, savedStateHandle: SavedStateHandle, ) : BaseViewModel( ReceivedEnvelopeDetailState(), ) { private val argument = savedStateHandle.get(ReceivedRoute.ENVELOPE_ARGUMENT_NAME)!! + private val ledger = run { + Json.decodeFromUri(savedStateHandle.get(ReceivedRoute.LEDGER_ARGUMENT_NAME)!!) + } + private var envelope = Envelope() fun getEnvelope() = viewModelScope.launch { envelope = Json.decodeFromUri(argument) - Timber.tag("테스트").d("$envelope") -// getLedgerUseCase(id = envelope.id) -// .onSuccess { ledger -> -// this@ReceivedReceivedEnvelopeDetailViewModel.envelope = ledger -// intent { -// with(ledger) { -// val category = ledger.category -// copy( -// name = ledger.title, -// money = ledger.totalAmounts, -// count = ledger.totalCounts, -// category = if (category.customCategory.isNullOrEmpty()) category.name else category.customCategory!!, -// startDate = ledger.startAt.toJavaLocalDateTime().to_yyyy_dot_MM_dot_dd(), -// endDate = ledger.endAt.toJavaLocalDateTime().to_yyyy_dot_MM_dot_dd(), -// ) -// } -// } -// } + getEnvelopeUseCase(id = envelope.id) + .onSuccess { envelope -> + this@ReceivedEnvelopeDetailViewModel.envelope = envelope + intent { + copy( + envelope = envelope, + ) + } + } } - fun navigateEnvelopeEdit() = postSideEffect(ReceivedEnvelopeDetailSideEffect.NavigateReceivedEnvelopeEdit(envelope)) + fun navigateEnvelopeEdit() = postSideEffect(ReceivedEnvelopeDetailSideEffect.NavigateReceivedEnvelopeEdit(envelope, ledger)) fun popBackStackWithEnvelope() = postSideEffect(ReceivedEnvelopeDetailSideEffect.PopBackStackWithReceivedEnvelope(Json.encodeToUri(envelope))) fun showDeleteDialog() = postSideEffect( ReceivedEnvelopeDetailSideEffect.ShowDeleteDialog( diff --git a/feature/received/src/main/java/com/susu/feature/received/envelopeedit/ReceivedEnvelopeEditContract.kt b/feature/received/src/main/java/com/susu/feature/received/envelopeedit/ReceivedEnvelopeEditContract.kt new file mode 100644 index 00000000..a5a2ea0f --- /dev/null +++ b/feature/received/src/main/java/com/susu/feature/received/envelopeedit/ReceivedEnvelopeEditContract.kt @@ -0,0 +1,28 @@ +package com.susu.feature.received.envelopeedit + +import com.susu.core.model.Envelope +import com.susu.core.model.Relationship +import com.susu.core.ui.base.SideEffect +import com.susu.core.ui.base.UiState +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.persistentListOf + +data class ReceivedEnvelopeEditState( + val envelope: Envelope = Envelope(), + val relationshipConfig: PersistentList = persistentListOf(Relationship()), + val showCustomRelationButton: Boolean = false, + val showDateBottomSheet: Boolean = false, + val isRelationSaved: Boolean = false, +) : UiState { + val buttonEnabled = when { + envelope.friend.name.isEmpty() -> false + envelope.relationship.id == relationshipConfig.last().id && isRelationSaved.not() -> false + else -> true + } +} + +sealed interface ReceivedEnvelopeEditSideEffect : SideEffect { + data object FocusCustomRelation : ReceivedEnvelopeEditSideEffect + data object PopBackStack : ReceivedEnvelopeEditSideEffect + data class HandleException(val throwable: Throwable, val retry: () -> Unit) : ReceivedEnvelopeEditSideEffect +} diff --git a/feature/received/src/main/java/com/susu/feature/received/envelopeedit/ReceivedEnvelopeEditScreen.kt b/feature/received/src/main/java/com/susu/feature/received/envelopeedit/ReceivedEnvelopeEditScreen.kt index fef75fea..d22a0466 100644 --- a/feature/received/src/main/java/com/susu/feature/received/envelopeedit/ReceivedEnvelopeEditScreen.kt +++ b/feature/received/src/main/java/com/susu/feature/received/envelopeedit/ReceivedEnvelopeEditScreen.kt @@ -12,20 +12,25 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.susu.core.designsystem.component.appbar.SusuDefaultAppBar import com.susu.core.designsystem.component.appbar.icon.BackIcon +import com.susu.core.designsystem.component.bottomsheet.datepicker.SusuDatePickerBottomSheet import com.susu.core.designsystem.component.button.AddConditionButton import com.susu.core.designsystem.component.button.FilledButtonColor import com.susu.core.designsystem.component.button.MediumButtonStyle @@ -33,36 +38,99 @@ import com.susu.core.designsystem.component.button.SmallButtonStyle import com.susu.core.designsystem.component.button.SusuFilledButton import com.susu.core.designsystem.component.textfield.SusuBasicTextField import com.susu.core.designsystem.component.textfield.SusuPriceTextField +import com.susu.core.designsystem.component.textfieldbutton.SusuTextFieldWrapContentButton +import com.susu.core.designsystem.component.textfieldbutton.TextFieldButtonColor +import com.susu.core.designsystem.component.textfieldbutton.style.SmallTextFieldButtonStyle import com.susu.core.designsystem.theme.Gray30 import com.susu.core.designsystem.theme.Gray40 import com.susu.core.designsystem.theme.Gray70 import com.susu.core.designsystem.theme.SusuTheme +import com.susu.core.model.Relationship +import com.susu.core.ui.extension.collectWithLifecycle +import com.susu.core.ui.extension.susuClickable import com.susu.feature.received.R import com.susu.feature.received.envelopeedit.component.EditDetailItem +import kotlinx.coroutines.android.awaitFrame +import kotlinx.coroutines.launch @Composable fun ReceivedEnvelopeEditRoute( - @Suppress("detekt:UnusedParameter") + viewModel: ReceivedEnvelopeEditViewModel = hiltViewModel(), popBackStack: () -> Unit, + handleException: (Throwable, () -> Unit) -> Unit, ) { - ReceivedEnvelopeEditScreen() + val uiState = viewModel.uiState.collectAsStateWithLifecycle().value + + val focusRequester = remember { FocusRequester() } + val scope = rememberCoroutineScope() + + viewModel.sideEffect.collectWithLifecycle { sideEffect -> + when (sideEffect) { + is ReceivedEnvelopeEditSideEffect.HandleException -> handleException( + sideEffect.throwable, + sideEffect.retry, + ) + + ReceivedEnvelopeEditSideEffect.PopBackStack -> popBackStack() + ReceivedEnvelopeEditSideEffect.FocusCustomRelation -> scope.launch { + awaitFrame() + focusRequester.requestFocus() + } + } + } + + LaunchedEffect(key1 = Unit) { + viewModel.initData() + } + + ReceivedEnvelopeEditScreen( + uiState = uiState, + focusRequester = focusRequester, + onClickBackIcon = viewModel::popBackStack, + onClickSave = viewModel::editReceivedEnvelope, + onTextChangeMoney = viewModel::updateMoney, + onTextChangeName = viewModel::updateName, + onTextChangeRelation = viewModel::updateCustomRelation, + onClickRelation = viewModel::updateRelation, + onClickCustomRelationClear = { viewModel.updateCustomRelation("") }, + onClickCustomRelationClose = viewModel::closeCustomRelation, + onClickRelationInnerButton = viewModel::toggleRelationSaved, + onClickAddConditionButton = viewModel::showCustomRelation, + onClickDate = viewModel::showDateBottomSheet, + onClickHasVisited = viewModel::updateHasVisited, + onTextChangeGift = viewModel::updateGift, + onTextChangePhoneNumber = viewModel::updatePhoneNumber, + onTextChangeMemo = viewModel::updateMemo, + onDismissDateBottomSheet = viewModel::hideDateBottomSheet, + onItemSelectedDateBottomSheet = viewModel::updateDate, + ) } +@OptIn(ExperimentalMaterial3Api::class) @Composable fun ReceivedEnvelopeEditScreen( - modifier: Modifier = Modifier, + uiState: ReceivedEnvelopeEditState = ReceivedEnvelopeEditState(), + focusRequester: FocusRequester = remember { FocusRequester() }, onClickBackIcon: () -> Unit = {}, onClickSave: () -> Unit = {}, + onTextChangeMoney: (String) -> Unit = {}, + onTextChangeName: (String) -> Unit = {}, + onTextChangeRelation: (String) -> Unit = {}, + onClickRelation: (Relationship) -> Unit = {}, + onClickCustomRelationClear: () -> Unit = {}, + onClickCustomRelationClose: () -> Unit = {}, + onClickRelationInnerButton: () -> Unit = {}, + onClickAddConditionButton: () -> Unit = {}, + onClickDate: () -> Unit = {}, + onClickHasVisited: (Boolean) -> Unit = {}, + onTextChangeGift: (String) -> Unit = {}, + onTextChangePhoneNumber: (String) -> Unit = {}, + onTextChangeMemo: (String) -> Unit = {}, + onDismissDateBottomSheet: (Int, Int, Int) -> Unit = { _, _, _ -> }, + onItemSelectedDateBottomSheet: (Int, Int, Int) -> Unit = { _, _, _ -> }, ) { - // TODO: 수정 필요 - var money by remember { mutableStateOf(150000) } - var name by remember { mutableStateOf("김철수") } - var present by remember { mutableStateOf("") } - var phone by remember { mutableStateOf("") } - var memo by remember { mutableStateOf("") } - Box( - modifier = modifier + modifier = Modifier .fillMaxSize() .background(SusuTheme.colorScheme.background10), ) { @@ -75,7 +143,7 @@ fun ReceivedEnvelopeEditScreen( }, ) Column( - modifier = modifier + modifier = Modifier .verticalScroll(rememberScrollState()) .weight(1f) .padding( @@ -85,59 +153,67 @@ fun ReceivedEnvelopeEditScreen( ), ) { SusuPriceTextField( - text = money.toString(), - onTextChange = { money = it.toInt() }, + text = uiState.envelope.amount.toString(), + onTextChange = onTextChangeMoney, textStyle = SusuTheme.typography.title_xxl, - modifier = modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth(), ) - Spacer(modifier = modifier.size(SusuTheme.spacing.spacing_m)) + Spacer(modifier = Modifier.size(SusuTheme.spacing.spacing_m)) EditDetailItem( categoryText = stringResource(R.string.received_envelope_edit_screen_name), categoryTextAlign = Alignment.CenterVertically, ) { SusuBasicTextField( - text = name, - onTextChange = { name = it }, + text = uiState.envelope.friend.name, + onTextChange = onTextChangeName, placeholder = stringResource(R.string.received_envelope_edit_screen_name_placeholder), placeholderColor = Gray30, textStyle = SusuTheme.typography.title_s, - modifier = modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth(), ) } EditDetailItem( categoryText = stringResource(R.string.received_envelope_edit_screen_relationship), categoryTextAlign = Alignment.Top, ) { - SusuFilledButton( - color = FilledButtonColor.Orange, - style = SmallButtonStyle.height32, - text = "친구", - isActive = true, - onClick = {}, - ) - SusuFilledButton( - color = FilledButtonColor.Orange, - style = SmallButtonStyle.height32, - text = "가족", - isActive = false, - onClick = {}, - ) - SusuFilledButton( - color = FilledButtonColor.Orange, - style = SmallButtonStyle.height32, - text = "친척", - isActive = false, - onClick = {}, - ) - SusuFilledButton( - color = FilledButtonColor.Orange, - style = SmallButtonStyle.height32, - text = "동료", - isActive = false, - onClick = {}, - ) - AddConditionButton( - onClick = {}, + uiState.relationshipConfig.dropLast(1).forEach { + SusuFilledButton( + color = FilledButtonColor.Orange, + style = SmallButtonStyle.height32, + text = it.relation, + isActive = it == uiState.envelope.relationship, + onClick = { onClickRelation(it) }, + ) + } + + if (uiState.showCustomRelationButton) { + SusuTextFieldWrapContentButton( + focusRequester = focusRequester, + onTextChange = onTextChangeRelation, + color = TextFieldButtonColor.Orange, + style = SmallTextFieldButtonStyle.height32, + text = uiState.relationshipConfig.last().customRelation ?: "", + isFocused = uiState.relationshipConfig.last().id == uiState.envelope.relationship.id, + isSaved = uiState.isRelationSaved, + onClickClearIcon = onClickCustomRelationClear, + onClickCloseIcon = onClickCustomRelationClose, + onClickFilledButton = onClickRelationInnerButton, + onClickButton = { onClickRelation(uiState.relationshipConfig.last()) }, + ) + } else { + AddConditionButton(onClick = onClickAddConditionButton) + } + } + EditDetailItem(categoryText = stringResource(id = com.susu.core.ui.R.string.word_date), categoryTextAlign = Alignment.Top) { + Text( + modifier = Modifier.susuClickable(rippleEnabled = false, onClick = onClickDate), + text = stringResource( + R.string.ledger_add_screen_date, + uiState.envelope.handedOverAt.year, + uiState.envelope.handedOverAt.month.value, + uiState.envelope.handedOverAt.dayOfMonth, + ), + style = SusuTheme.typography.title_m, ) } EditDetailItem( @@ -148,73 +224,85 @@ fun ReceivedEnvelopeEditScreen( color = FilledButtonColor.Orange, style = SmallButtonStyle.height32, text = stringResource(com.susu.core.ui.R.string.word_yes), - isActive = true, - onClick = {}, - modifier = modifier.weight(1f), + isActive = uiState.envelope.hasVisited == true, + onClick = { onClickHasVisited(true) }, + modifier = Modifier.weight(1f), ) SusuFilledButton( color = FilledButtonColor.Orange, style = SmallButtonStyle.height32, text = stringResource(com.susu.core.ui.R.string.word_no), - isActive = false, - onClick = {}, - modifier = modifier.weight(1f), + isActive = uiState.envelope.hasVisited == false, + onClick = { onClickHasVisited(false) }, + modifier = Modifier.weight(1f), ) } EditDetailItem( categoryText = stringResource(R.string.received_envelope_edit_screen_present), - categoryTextColor = if (present.isNotEmpty()) Gray70 else Gray40, + categoryTextColor = if (!uiState.envelope.gift.isNullOrEmpty()) Gray70 else Gray40, categoryTextAlign = Alignment.CenterVertically, ) { SusuBasicTextField( - text = present, - onTextChange = { present = it }, + text = uiState.envelope.gift ?: "", + onTextChange = onTextChangeGift, placeholder = stringResource(R.string.received_envelope_edit_screen_present_placeholder), placeholderColor = Gray30, textStyle = SusuTheme.typography.title_s, - modifier = modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth(), ) } EditDetailItem( categoryText = stringResource(com.susu.core.ui.R.string.word_phone_number), - categoryTextColor = if (phone.isNotEmpty()) Gray70 else Gray40, + categoryTextColor = if (uiState.envelope.friend.phoneNumber.isNotEmpty()) Gray70 else Gray40, categoryTextAlign = Alignment.CenterVertically, ) { SusuBasicTextField( - text = phone, - onTextChange = { phone = it }, + text = uiState.envelope.friend.phoneNumber, + onTextChange = onTextChangePhoneNumber, placeholder = stringResource(R.string.received_envelope_edit_screen_phone_number_placeholder), placeholderColor = Gray30, textStyle = SusuTheme.typography.title_s, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth(), ) } EditDetailItem( categoryText = stringResource(com.susu.core.ui.R.string.word_memo), - categoryTextColor = if (memo.isNotEmpty()) Gray70 else Gray40, + categoryTextColor = if (!uiState.envelope.memo.isNullOrEmpty()) Gray70 else Gray40, categoryTextAlign = Alignment.Top, ) { SusuBasicTextField( - text = memo, - onTextChange = { memo = it }, + text = uiState.envelope.memo ?: "", + onTextChange = onTextChangeMemo, placeholder = stringResource(R.string.received_envelope_edit_screen_memo_placeholder), placeholderColor = Gray30, textStyle = SusuTheme.typography.title_s, - maxLines = 2, - modifier = modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth(), ) } - Spacer(modifier = modifier.size(240.dp)) + Spacer(modifier = Modifier.size(240.dp)) + } + + if (uiState.showDateBottomSheet) { + SusuDatePickerBottomSheet( + initialYear = uiState.envelope.handedOverAt.year, + initialMonth = uiState.envelope.handedOverAt.month.value, + initialDay = uiState.envelope.handedOverAt.dayOfMonth, + maximumContainerHeight = 346.dp, + onDismissRequest = onDismissDateBottomSheet, + onItemSelected = onItemSelectedDateBottomSheet, + ) } SusuFilledButton( - modifier = modifier + modifier = Modifier .fillMaxWidth() .imePadding(), color = FilledButtonColor.Black, style = MediumButtonStyle.height60, shape = RectangleShape, + isActive = uiState.buttonEnabled, + isClickable = uiState.buttonEnabled, text = stringResource(com.susu.core.ui.R.string.word_save), onClick = onClickSave, ) diff --git a/feature/received/src/main/java/com/susu/feature/received/envelopeedit/ReceivedEnvelopeEditViewModel.kt b/feature/received/src/main/java/com/susu/feature/received/envelopeedit/ReceivedEnvelopeEditViewModel.kt new file mode 100644 index 00000000..68198454 --- /dev/null +++ b/feature/received/src/main/java/com/susu/feature/received/envelopeedit/ReceivedEnvelopeEditViewModel.kt @@ -0,0 +1,209 @@ +package com.susu.feature.received.envelopeedit + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import com.susu.core.model.Envelope +import com.susu.core.model.Ledger +import com.susu.core.model.Relationship +import com.susu.core.ui.base.BaseViewModel +import com.susu.core.ui.extension.decodeFromUri +import com.susu.domain.usecase.envelope.EditReceivedEnvelopeUseCase +import com.susu.domain.usecase.envelope.GetRelationShipConfigListUseCase +import com.susu.feature.received.navigation.ReceivedRoute +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.launch +import kotlinx.datetime.toKotlinLocalDateTime +import kotlinx.serialization.json.Json +import java.time.LocalDateTime +import javax.inject.Inject + +@HiltViewModel +class ReceivedEnvelopeEditViewModel @Inject constructor( + private val getRelationShipConfigListUseCase: GetRelationShipConfigListUseCase, + private val editReceivedEnvelopeUseCase: EditReceivedEnvelopeUseCase, + savedStateHandle: SavedStateHandle, +) : BaseViewModel( + ReceivedEnvelopeEditState(), +) { + private val argument = savedStateHandle.get(ReceivedRoute.ENVELOPE_ARGUMENT_NAME)!! + private val ledger = run { + Json.decodeFromUri(savedStateHandle.get(ReceivedRoute.LEDGER_ARGUMENT_NAME)!!) + } + + private var isFirstVisited: Boolean = true + + fun initData() = viewModelScope.launch { + if (isFirstVisited.not()) return@launch + isFirstVisited = false + + val envelope = Json.decodeFromUri(argument) + + getRelationShipConfigListUseCase() + .onSuccess { + intent { + copy( + envelope = envelope, + relationshipConfig = it.map { + if (it.id == envelope.relationship.id) { + it.copy(customRelation = envelope.relationship.customRelation) + } else { + it + } + }.toPersistentList(), + showCustomRelationButton = it.last().id == envelope.relationship.id, + isRelationSaved = it.last().id == envelope.relationship.id, + ) + } + } + } + + fun editReceivedEnvelope() = viewModelScope.launch { + editReceivedEnvelopeUseCase( + param = with(currentState) { + EditReceivedEnvelopeUseCase.Param( + envelopeId = envelope.id, + friendId = envelope.friend.id, + friendName = envelope.friend.name, + categoryId = ledger.category.id.toLong(), + customCategory = ledger.category.customCategory, + phoneNumber = envelope.friend.phoneNumber.ifEmpty { null }, + relationshipId = envelope.relationship.id, + customRelation = envelope.relationship.customRelation, + ledgerId = ledger.id, + amount = envelope.amount, + gift = envelope.gift, + memo = envelope.memo, + handedOverAt = envelope.handedOverAt, + hasVisited = envelope.hasVisited, + ) + }, + ).onSuccess { + popBackStack() + }.onFailure { + postSideEffect(ReceivedEnvelopeEditSideEffect.HandleException(it, ::editReceivedEnvelope)) + } + } + + fun popBackStack() = postSideEffect(ReceivedEnvelopeEditSideEffect.PopBackStack) + + fun updateMoney(money: String) = intent { + copy( + envelope = envelope.copy(amount = money.toLongOrNull() ?: 0L), + ) + } + + fun updateName(name: String) = intent { + copy( + envelope = envelope.copy(friend = envelope.friend.copy(name = name)), + ) + } + + fun updateRelation(relationship: Relationship) = intent { + copy( + envelope = envelope.copy(relationship = relationship), + ) + } + + fun updateCustomRelation(customRelation: String?) = intent { + copy( + envelope = envelope.copy(relationship = envelope.relationship.copy(customRelation = customRelation)), + relationshipConfig = relationshipConfig.map { + if (it.id == envelope.relationship.id) { + it.copy(customRelation = customRelation) + } else { + it + } + }.toPersistentList(), + ) + } + + fun closeCustomRelation() = intent { + copy( + envelope = if (envelope.relationship.id == relationshipConfig.last().id) { + envelope.copy(relationship = relationshipConfig.first()) + } else { + envelope + }, + relationshipConfig = relationshipConfig.map { + it.copy(customRelation = null) + }.toPersistentList(), + isRelationSaved = false, + showCustomRelationButton = false, + ) + } + + fun toggleRelationSaved() = intent { + copy( + isRelationSaved = !isRelationSaved, + ) + } + + fun showCustomRelation() = intent { + postSideEffect(ReceivedEnvelopeEditSideEffect.FocusCustomRelation) + copy( + isRelationSaved = false, + showCustomRelationButton = true, + envelope = envelope.copy( + relationship = relationshipConfig.last(), + ), + ) + } + + fun showDateBottomSheet() = intent { + copy( + showDateBottomSheet = true, + ) + } + + fun updateHasVisited(visited: Boolean) = intent { + copy( + envelope = envelope.copy( + hasVisited = if (visited == envelope.hasVisited) null else visited, + ), + ) + } + + fun updateGift(gift: String) = intent { + copy( + envelope = envelope.copy( + gift = gift.ifEmpty { null }, + ), + ) + } + + fun updatePhoneNumber(phoneNumber: String) = intent { + copy( + envelope = envelope.copy( + friend = envelope.friend.copy( + phoneNumber = phoneNumber, + ), + ), + ) + } + + fun updateMemo(memo: String) = intent { + copy( + envelope = envelope.copy( + memo = memo.ifEmpty { null }, + ), + ) + } + + fun hideDateBottomSheet(year: Int, month: Int, day: Int) = intent { + copy( + envelope = envelope.copy( + handedOverAt = LocalDateTime.of(year, month, day, 0, 0).toKotlinLocalDateTime(), + ), + showDateBottomSheet = false, + ) + } + + fun updateDate(year: Int, month: Int, day: Int) = intent { + copy( + envelope = envelope.copy( + handedOverAt = LocalDateTime.of(year, month, day, 0, 0).toKotlinLocalDateTime(), + ), + ) + } +} diff --git a/feature/received/src/main/java/com/susu/feature/received/ledgerdetail/LedgerDetailContract.kt b/feature/received/src/main/java/com/susu/feature/received/ledgerdetail/LedgerDetailContract.kt index 3b555cbc..d750f26c 100644 --- a/feature/received/src/main/java/com/susu/feature/received/ledgerdetail/LedgerDetailContract.kt +++ b/feature/received/src/main/java/com/susu/feature/received/ledgerdetail/LedgerDetailContract.kt @@ -19,8 +19,8 @@ data class LedgerDetailState( ) : UiState sealed interface LedgerDetailSideEffect : SideEffect { - data class NavigateEnvelopeAdd(val categoryName: String, val ledgerId: Long) : LedgerDetailSideEffect - data class NavigateEnvelopeDetail(val envelope: Envelope) : LedgerDetailSideEffect + data class NavigateEnvelopeAdd(val ledger: Ledger) : LedgerDetailSideEffect + data class NavigateEnvelopeDetail(val envelope: Envelope, val ledger: Ledger) : LedgerDetailSideEffect data class NavigateLedgerEdit(val ledger: Ledger) : LedgerDetailSideEffect data class PopBackStackWithLedger(val ledger: String) : LedgerDetailSideEffect data class PopBackStackWithDeleteLedgerId(val ledgerId: Long) : LedgerDetailSideEffect diff --git a/feature/received/src/main/java/com/susu/feature/received/ledgerdetail/LedgerDetailScreen.kt b/feature/received/src/main/java/com/susu/feature/received/ledgerdetail/LedgerDetailScreen.kt index cd3a2343..7b512d6e 100644 --- a/feature/received/src/main/java/com/susu/feature/received/ledgerdetail/LedgerDetailScreen.kt +++ b/feature/received/src/main/java/com/susu/feature/received/ledgerdetail/LedgerDetailScreen.kt @@ -58,8 +58,8 @@ fun LedgerDetailRoute( envelope: String?, toDeleteEnvelopeId: Long?, navigateLedgerEdit: (Ledger) -> Unit, - navigateEnvelopAdd: (String, Long) -> Unit, - navigateEnvelopeDetail: (Envelope) -> Unit, + navigateEnvelopAdd: (Ledger) -> Unit, + navigateEnvelopeDetail: (Envelope, Ledger) -> Unit, popBackStackWithLedger: (String) -> Unit, popBackStackWithDeleteLedgerId: (Long) -> Unit, onShowSnackbar: (SnackbarToken) -> Unit, @@ -96,8 +96,8 @@ fun LedgerDetailRoute( is LedgerDetailSideEffect.PopBackStackWithDeleteLedgerId -> popBackStackWithDeleteLedgerId(sideEffect.ledgerId) is LedgerDetailSideEffect.HandleException -> handleException(sideEffect.throwable, sideEffect.retry) is LedgerDetailSideEffect.ShowSnackbar -> onShowSnackbar(SnackbarToken(message = sideEffect.msg)) - is LedgerDetailSideEffect.NavigateEnvelopeAdd -> navigateEnvelopAdd(sideEffect.categoryName, sideEffect.ledgerId) - is LedgerDetailSideEffect.NavigateEnvelopeDetail -> navigateEnvelopeDetail(sideEffect.envelope) + is LedgerDetailSideEffect.NavigateEnvelopeAdd -> navigateEnvelopAdd(sideEffect.ledger) + is LedgerDetailSideEffect.NavigateEnvelopeDetail -> navigateEnvelopeDetail(sideEffect.envelope, sideEffect.ledger) } } @@ -106,6 +106,7 @@ fun LedgerDetailRoute( viewModel.initReceivedEnvelopeList() viewModel.addEnvelopeIfNeed(envelope) viewModel.deleteEnvelopeIfNeed(toDeleteEnvelopeId) + viewModel.updateEnvelopeIfNeed(envelope) } listState.OnBottomReached(minItemsCount = 4) { diff --git a/feature/received/src/main/java/com/susu/feature/received/ledgerdetail/LedgerDetailViewModel.kt b/feature/received/src/main/java/com/susu/feature/received/ledgerdetail/LedgerDetailViewModel.kt index a28ee6d5..129f66dc 100644 --- a/feature/received/src/main/java/com/susu/feature/received/ledgerdetail/LedgerDetailViewModel.kt +++ b/feature/received/src/main/java/com/susu/feature/received/ledgerdetail/LedgerDetailViewModel.kt @@ -56,6 +56,39 @@ class LedgerDetailViewModel @Inject constructor( ) } + fun updateEnvelopeIfNeed(envelopeUri: String?) = intent { + val envelope = envelopeUri?.let { + Json.decodeFromUri(it) + } ?: return@intent this + + val searchEnvelope = SearchEnvelope( + envelope = envelope, + friend = envelope.friend, + relation = envelope.relationship, + ) + + copy( + envelopeList = envelopeList.map { + run { + if (it.envelope.id == searchEnvelope.envelope.id) { + searchEnvelope + } else { + it + } + }.run { + if (friend.id == searchEnvelope.friend.id) { + copy( + friend = searchEnvelope.friend, + relation = searchEnvelope.relation, + ) + } else { + this + } + } + }.toPersistentList(), + ) + } + fun deleteEnvelopeIfNeed(toDeleteEnvelopeId: Long?) { if (toDeleteEnvelopeId == null) return @@ -153,7 +186,8 @@ class LedgerDetailViewModel @Inject constructor( } fun navigateEnvelopeAdd() = postSideEffect( - LedgerDetailSideEffect.NavigateEnvelopeAdd(ledger.category.customCategory ?: ledger.category.name, ledger.id), + LedgerDetailSideEffect.NavigateEnvelopeAdd(ledger), ) - fun navigateEnvelopeDetail(envelope: Envelope) = postSideEffect(LedgerDetailSideEffect.NavigateEnvelopeDetail(envelope)) + + fun navigateEnvelopeDetail(envelope: Envelope) = postSideEffect(LedgerDetailSideEffect.NavigateEnvelopeDetail(envelope, ledger)) } diff --git a/feature/received/src/main/java/com/susu/feature/received/ledgeredit/LedgerEditContract.kt b/feature/received/src/main/java/com/susu/feature/received/ledgeredit/LedgerEditContract.kt index 84aa49a3..71cd52a4 100644 --- a/feature/received/src/main/java/com/susu/feature/received/ledgeredit/LedgerEditContract.kt +++ b/feature/received/src/main/java/com/susu/feature/received/ledgeredit/LedgerEditContract.kt @@ -13,18 +13,20 @@ data class LedgerEditState( val startYear: Int = 0, val startMonth: Int = 0, val startDay: Int = 0, - val endYear: Int = 0, - val endMonth: Int = 0, - val endDay: Int = 0, + val endYear: Int? = null, + val endMonth: Int? = null, + val endDay: Int? = null, val categoryConfigList: PersistentList = persistentListOf(Category()), val showCustomCategoryButton: Boolean = false, val isCustomCategoryChipSaved: Boolean = false, val showStartDateBottomSheet: Boolean = false, val showEndDateBottomSheet: Boolean = false, + val showOnlyStartDate: Boolean = false, ) : UiState { val isSelectedCustomCategory = selectedCategoryId == categoryConfigList.last().id val saveButtonEnabled = when { name.isEmpty() -> false + endYear == null -> false isSelectedCustomCategory && (customCategory.isEmpty() || isCustomCategoryChipSaved.not()) -> false else -> true } diff --git a/feature/received/src/main/java/com/susu/feature/received/ledgeredit/LedgerEditScreen.kt b/feature/received/src/main/java/com/susu/feature/received/ledgeredit/LedgerEditScreen.kt index 67b3c768..0c1dc054 100644 --- a/feature/received/src/main/java/com/susu/feature/received/ledgeredit/LedgerEditScreen.kt +++ b/feature/received/src/main/java/com/susu/feature/received/ledgeredit/LedgerEditScreen.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember @@ -21,6 +22,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -31,18 +33,25 @@ import com.susu.core.designsystem.component.appbar.icon.BackIcon import com.susu.core.designsystem.component.bottomsheet.datepicker.SusuLimitDatePickerBottomSheet import com.susu.core.designsystem.component.button.AddConditionButton import com.susu.core.designsystem.component.button.FilledButtonColor +import com.susu.core.designsystem.component.button.LinedButtonColor import com.susu.core.designsystem.component.button.MediumButtonStyle import com.susu.core.designsystem.component.button.SmallButtonStyle import com.susu.core.designsystem.component.button.SusuFilledButton +import com.susu.core.designsystem.component.button.SusuLinedButton +import com.susu.core.designsystem.component.button.XSmallButtonStyle import com.susu.core.designsystem.component.textfield.SusuBasicTextField import com.susu.core.designsystem.component.textfieldbutton.SusuTextFieldWrapContentButton import com.susu.core.designsystem.component.textfieldbutton.TextFieldButtonColor import com.susu.core.designsystem.component.textfieldbutton.style.SmallTextFieldButtonStyle +import com.susu.core.designsystem.theme.Gray100 +import com.susu.core.designsystem.theme.Gray30 import com.susu.core.designsystem.theme.Gray80 +import com.susu.core.designsystem.theme.Orange60 import com.susu.core.designsystem.theme.SusuTheme import com.susu.core.ui.extension.collectWithLifecycle import com.susu.core.ui.extension.susuClickable import com.susu.core.ui.util.AnnotatedText +import com.susu.core.ui.util.currentDate import com.susu.feature.received.R import com.susu.feature.received.ledgeredit.component.LedgerEditContainer import kotlinx.coroutines.android.awaitFrame @@ -90,6 +99,8 @@ fun LedgerEditRoute( onClickEndDateText = viewModel::showEndDateBottomSheet, onDismissEndDateBottomSheet = viewModel::hideEndDateBottomSheet, onClickSaveButton = viewModel::editLedger, + onClickAddEndDateButton = viewModel::showEndDateText, + onClickSetStartDateButton = viewModel::showOnlyStartDateText, ) } @@ -113,6 +124,8 @@ fun LedgerEditScreen( onClickEndDateText: () -> Unit = {}, onDismissEndDateBottomSheet: () -> Unit = {}, onClickSaveButton: () -> Unit = {}, + onClickAddEndDateButton: () -> Unit = {}, + onClickSetStartDateButton: () -> Unit = {}, ) { Box( modifier = Modifier @@ -195,7 +208,7 @@ fun LedgerEditScreen( AnnotatedText( modifier = Modifier.susuClickable(rippleEnabled = false, onClick = onClickStartDateText), originalText = stringResource( - R.string.ledger_edit_screen_from_date, + if (uiState.showOnlyStartDate) R.string.ledger_edit_screen_date else R.string.ledger_edit_screen_from_date, uiState.startYear, uiState.startMonth, uiState.startDay, @@ -203,27 +216,56 @@ fun LedgerEditScreen( targetTextList = listOf( stringResource(R.string.ledger_edit_screen_year), stringResource(R.string.ledger_edit_screen_month), - stringResource(R.string.ledger_edit_screen_from_day), - ), - originalTextStyle = SusuTheme.typography.title_m, - spanStyle = SusuTheme.typography.title_m.copy(Gray80).toSpanStyle(), - ) - AnnotatedText( - modifier = Modifier.susuClickable(rippleEnabled = false, onClick = onClickEndDateText), - originalText = stringResource( - R.string.ledger_edit_screen_until_date, - uiState.endYear, - uiState.endMonth, - uiState.endDay, - ), - targetTextList = listOf( - stringResource(R.string.ledger_edit_screen_year), - stringResource(R.string.ledger_edit_screen_month), - stringResource(R.string.ledger_edit_screen_until_day), + stringResource( + if (uiState.showOnlyStartDate) R.string.ledger_edit_screen_day else R.string.ledger_edit_screen_from_day, + ), ), originalTextStyle = SusuTheme.typography.title_m, spanStyle = SusuTheme.typography.title_m.copy(Gray80).toSpanStyle(), ) + + if (uiState.showOnlyStartDate.not()) { + AnnotatedText( + modifier = Modifier.susuClickable(rippleEnabled = false, onClick = onClickEndDateText), + originalText = stringResource( + R.string.ledger_edit_screen_until_date, + uiState.endYear ?: currentDate.year, + uiState.endMonth ?: currentDate.month.value, + uiState.endDay ?: currentDate.dayOfMonth, + ), + targetTextList = listOf( + stringResource(R.string.ledger_edit_screen_year), + stringResource(R.string.ledger_edit_screen_month), + stringResource(R.string.ledger_edit_screen_until_day), + ), + originalTextStyle = SusuTheme.typography.title_m.copy(if (uiState.endYear == null) Gray30 else Gray100), + spanStyle = SusuTheme.typography.title_m.copy(Gray80).toSpanStyle(), + ) + } + + Spacer(modifier = Modifier.size(SusuTheme.spacing.spacing_xxs)) + + if (uiState.showOnlyStartDate) { + SusuLinedButton( + color = LinedButtonColor.Orange, + style = XSmallButtonStyle.height28, + text = stringResource(R.string.ledger_edit_screen_add_end_date), + leftIcon = { + Icon(painter = painterResource(id = R.drawable.ic_date_change), contentDescription = null, tint = Orange60) + }, + onClick = onClickAddEndDateButton, + ) + } else { + SusuLinedButton( + color = LinedButtonColor.Orange, + style = XSmallButtonStyle.height28, + text = stringResource(R.string.ledger_edit_screen_add_start_date), + leftIcon = { + Icon(painter = painterResource(id = R.drawable.ic_date_change), contentDescription = null, tint = Orange60) + }, + onClick = onClickSetStartDateButton, + ) + } } }, ) diff --git a/feature/received/src/main/java/com/susu/feature/received/ledgeredit/LedgerEditViewModel.kt b/feature/received/src/main/java/com/susu/feature/received/ledgeredit/LedgerEditViewModel.kt index 5913be14..e7dd95ce 100644 --- a/feature/received/src/main/java/com/susu/feature/received/ledgeredit/LedgerEditViewModel.kt +++ b/feature/received/src/main/java/com/susu/feature/received/ledgeredit/LedgerEditViewModel.kt @@ -34,7 +34,7 @@ class LedgerEditViewModel @Inject constructor( id = ledgerId, title = name, startAt = LocalDateTime.of(startYear, startMonth, startDay, 0, 0).toKotlinLocalDateTime(), - endAt = LocalDateTime.of(endYear, endMonth, endDay, 0, 0).toKotlinLocalDateTime(), + endAt = LocalDateTime.of(endYear ?: startYear, endMonth ?: startMonth, endDay ?: startDay, 0, 0).toKotlinLocalDateTime(), category = Category( id = selectedCategoryId, customCategory = customCategory.ifEmpty { null }, @@ -75,6 +75,7 @@ class LedgerEditViewModel @Inject constructor( endYear = endDate.year, endMonth = endDate.monthValue, endDay = endDate.dayOfMonth, + showOnlyStartDate = startDate == endDate, customCategory = customCategory ?: "", isCustomCategoryChipSaved = customCategory.isNullOrEmpty().not(), showCustomCategoryButton = ledger.category.customCategory != null, @@ -147,4 +148,21 @@ class LedgerEditViewModel @Inject constructor( fun hideEndDateBottomSheet() = intent { copy(showEndDateBottomSheet = false) } fun popBackStack() = postSideEffect(LedgerEditSideEffect.PopBackStack) + fun showEndDateText() = intent { + copy( + endYear = null, + endMonth = null, + endDay = null, + showOnlyStartDate = false, + ) + } + + fun showOnlyStartDateText() = intent { + copy( + endYear = startYear, + endMonth = startMonth, + endDay = startDay, + showOnlyStartDate = true, + ) + } } diff --git a/feature/received/src/main/java/com/susu/feature/received/ledgerfilter/LedgerFilterScreen.kt b/feature/received/src/main/java/com/susu/feature/received/ledgerfilter/LedgerFilterScreen.kt index 3835428c..af0aa9f8 100644 --- a/feature/received/src/main/java/com/susu/feature/received/ledgerfilter/LedgerFilterScreen.kt +++ b/feature/received/src/main/java/com/susu/feature/received/ledgerfilter/LedgerFilterScreen.kt @@ -172,7 +172,7 @@ fun LedgerFilterScreen( Spacer(modifier = Modifier.weight(1f)) Column( - verticalArrangement = Arrangement.spacedBy(SusuTheme.spacing.spacing_xxs), + verticalArrangement = Arrangement.spacedBy(SusuTheme.spacing.spacing_m), ) { FlowRow( verticalArrangement = Arrangement.spacedBy(SusuTheme.spacing.spacing_xxs), diff --git a/feature/received/src/main/java/com/susu/feature/received/ledgersearch/LedgerSearchScreen.kt b/feature/received/src/main/java/com/susu/feature/received/ledgersearch/LedgerSearchScreen.kt index 31bb62ed..47579c4a 100644 --- a/feature/received/src/main/java/com/susu/feature/received/ledgersearch/LedgerSearchScreen.kt +++ b/feature/received/src/main/java/com/susu/feature/received/ledgersearch/LedgerSearchScreen.kt @@ -203,27 +203,28 @@ private fun SearchResultColumn( ledgerList: PersistentList, onClickItem: (Ledger) -> Unit, ) { - if (showSearchResultEmpty) { - ResultEmptyColumn( - title = stringResource(R.string.ledger_search_screen_empty_search_result_title), + Column( + modifier = Modifier.padding(top = SusuTheme.spacing.spacing_xxl), + verticalArrangement = Arrangement.spacedBy(SusuTheme.spacing.spacing_m), + ) { + Text( + text = stringResource(com.susu.core.ui.R.string.word_search_result), + style = SusuTheme.typography.title_xxs, + color = Gray60, ) - } else { - Column( - modifier = Modifier.padding(top = SusuTheme.spacing.spacing_xxl), - verticalArrangement = Arrangement.spacedBy(SusuTheme.spacing.spacing_m), - ) { - Text( - text = stringResource(com.susu.core.ui.R.string.word_search_result), - style = SusuTheme.typography.title_xxs, - color = Gray60, + + if (showSearchResultEmpty) { + ResultEmptyColumn( + title = stringResource(R.string.ledger_search_screen_empty_search_result_title), + ) + } + + ledgerList.forEach { ledger -> + SusuRecentSearchContainer( + typeIconId = R.drawable.ic_ledger, + text = ledger.title, + onClick = { onClickItem(ledger) }, ) - ledgerList.forEach { ledger -> - SusuRecentSearchContainer( - typeIconId = R.drawable.ic_ledger, - text = ledger.title, - onClick = { onClickItem(ledger) }, - ) - } } } } diff --git a/feature/received/src/main/java/com/susu/feature/received/navigation/ReceivedNavigation.kt b/feature/received/src/main/java/com/susu/feature/received/navigation/ReceivedNavigation.kt index 9804c948..ed209b3a 100644 --- a/feature/received/src/main/java/com/susu/feature/received/navigation/ReceivedNavigation.kt +++ b/feature/received/src/main/java/com/susu/feature/received/navigation/ReceivedNavigation.kt @@ -48,16 +48,16 @@ fun NavController.navigateLedgerAdd() { navigate(ReceivedRoute.ledgerAddRoute) } -fun NavController.navigateReceivedEnvelopeAdd(categoryName: String, ledgerId: Long) { - navigate(ReceivedRoute.envelopeAddRoute(categoryName, ledgerId.toString())) +fun NavController.navigateReceivedEnvelopeAdd(ledger: Ledger) { + navigate(ReceivedRoute.envelopeAddRoute(Json.encodeToUri(ledger))) } -fun NavController.navigateReceivedEnvelopeDetail(envelope: Envelope) { - navigate(ReceivedRoute.envelopeDetailRoute(Json.encodeToUri(envelope))) +fun NavController.navigateReceivedEnvelopeDetail(envelope: Envelope, ledger: Ledger) { + navigate(ReceivedRoute.envelopeDetailRoute(Json.encodeToUri(envelope), Json.encodeToUri(ledger))) } -fun NavController.navigateReceivedEnvelopeEdit() { - navigate(ReceivedRoute.envelopeEditRoute) +fun NavController.navigateReceivedEnvelopeEdit(envelope: Envelope, ledger: Ledger) { + navigate(ReceivedRoute.envelopeEditRoute(Json.encodeToUri(envelope), Json.encodeToUri(ledger))) } @Suppress("detekt:LongMethod") @@ -72,9 +72,9 @@ fun NavGraphBuilder.receivedNavGraph( navigateLedgerEdit: (Ledger) -> Unit, navigateLedgerFilter: (FilterArgument) -> Unit, navigateLedgerAdd: () -> Unit, - navigateEnvelopAdd: (String, Long) -> Unit, - navigateEnvelopeDetail: (Envelope) -> Unit, - navigateEnvelopeEdit: () -> Unit, + navigateEnvelopAdd: (Ledger) -> Unit, + navigateEnvelopeDetail: (Envelope, Ledger) -> Unit, + navigateEnvelopeEdit: (Envelope, Ledger) -> Unit, popBackStackWithEnvelope: (String) -> Unit, popBackStackWithDeleteReceivedEnvelopeId: (Long) -> Unit, onShowSnackbar: (SnackbarToken) -> Unit, @@ -158,13 +158,7 @@ fun NavGraphBuilder.receivedNavGraph( composable( route = ReceivedRoute.envelopeAddRoute( - categoryName = "{${ReceivedRoute.CATEGORY_ARGUMENT_NAME}}", - ledgerId = "{${ReceivedRoute.LEDGER_ID_ARGUMENT_NAME}}", - ), - arguments = listOf( - navArgument(ReceivedRoute.CATEGORY_ARGUMENT_NAME) { - type = NavType.StringType - }, + ledger = "{${ReceivedRoute.LEDGER_ARGUMENT_NAME}}", ), ) { ReceivedEnvelopeAddRoute( @@ -176,10 +170,14 @@ fun NavGraphBuilder.receivedNavGraph( } composable( - route = ReceivedRoute.envelopeDetailRoute("{${ReceivedRoute.ENVELOPE_ARGUMENT_NAME}}"), + route = ReceivedRoute.envelopeDetailRoute( + envelope = "{${ReceivedRoute.ENVELOPE_ARGUMENT_NAME}}", + ledger = "{${ReceivedRoute.LEDGER_ARGUMENT_NAME}}", + ), ) { ReceivedEnvelopeDetailRoute( popBackStackWithDeleteReceivedEnvelopeId = popBackStackWithDeleteReceivedEnvelopeId, + popBackStackWithReceivedEnvelope = popBackStackWithEnvelope, navigateReceivedEnvelopeEdit = navigateEnvelopeEdit, onShowSnackbar = onShowSnackbar, onShowDialog = onShowDialog, @@ -188,9 +186,15 @@ fun NavGraphBuilder.receivedNavGraph( } composable( - route = ReceivedRoute.envelopeEditRoute, + route = ReceivedRoute.envelopeEditRoute( + envelope = "{${ReceivedRoute.ENVELOPE_ARGUMENT_NAME}}", + ledger = "{${ReceivedRoute.LEDGER_ARGUMENT_NAME}}", + ), ) { - ReceivedEnvelopeEditRoute(popBackStack = popBackStack) + ReceivedEnvelopeEditRoute( + popBackStack = popBackStack, + handleException = handleException, + ) } } @@ -210,7 +214,7 @@ object ReceivedRoute { const val ledgerAddRoute = "ledger-add" - fun envelopeAddRoute(categoryName: String, ledgerId: String) = "envelope-add/$categoryName/$ledgerId" - fun envelopeDetailRoute(envelope: String) = "envelope-detail/$envelope" - const val envelopeEditRoute = "envelope-edit" // TODO 파라미터 넘기는 방식으로 수정해야함. + fun envelopeAddRoute(ledger: String) = "envelope-add/$ledger" + fun envelopeDetailRoute(envelope: String, ledger: String) = "envelope-detail/$envelope/$ledger" + fun envelopeEditRoute(envelope: String, ledger: String) = "envelope-edit/$envelope/$ledger" } diff --git a/feature/received/src/main/java/com/susu/feature/received/received/ReceivedScreen.kt b/feature/received/src/main/java/com/susu/feature/received/received/ReceivedScreen.kt index 1a627a29..6e4d56f7 100644 --- a/feature/received/src/main/java/com/susu/feature/received/received/ReceivedScreen.kt +++ b/feature/received/src/main/java/com/susu/feature/received/received/ReceivedScreen.kt @@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items @@ -57,7 +56,6 @@ import com.susu.feature.received.R import com.susu.feature.received.navigation.argument.FilterArgument import com.susu.feature.received.received.component.LedgerAddCard import com.susu.feature.received.received.component.LedgerCard -import com.susu.feature.received.received.component.LedgerCategoryCard import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.android.awaitFrame import kotlinx.coroutines.launch @@ -236,49 +234,24 @@ fun ReceiveScreen( verticalArrangement = Arrangement.spacedBy(SusuTheme.spacing.spacing_xxs), horizontalArrangement = Arrangement.spacedBy(SusuTheme.spacing.spacing_xxs), ) { - if (uiState.isFiltered) { - uiState.ledgerList.groupBy { it.category }.forEach { (category, ledgerList) -> - item( - span = { GridItemSpan(2) }, - contentType = "LedgerCategoryCard", - ) { - LedgerCategoryCard(name = category.name) - } - - items( - items = ledgerList, - key = { it.id }, - ) { ledger -> - LedgerCard( - ledgerType = ledger.category.name, - title = ledger.title, - money = ledger.totalAmounts, - count = ledger.totalCounts, - style = ledger.category.style, - onClick = { onClickLedgerCard(ledger) }, - ) - } - } - } else { - items( - items = uiState.ledgerList, - key = { it.id }, - ) { ledger -> - LedgerCard( - ledgerType = ledger.category.name, - title = ledger.title, - money = ledger.totalAmounts, - count = ledger.totalCounts, - style = ledger.category.style, - onClick = { onClickLedgerCard(ledger) }, - ) - } + items( + items = uiState.ledgerList, + key = { it.id }, + ) { ledger -> + LedgerCard( + ledgerType = ledger.category.name, + title = ledger.title, + money = ledger.totalAmounts, + count = ledger.totalCounts, + style = ledger.category.style, + onClick = { onClickLedgerCard(ledger) }, + ) + } - item { - LedgerAddCard( - onClick = onClickLedgerAddCard, - ) - } + item { + LedgerAddCard( + onClick = onClickLedgerAddCard, + ) } } } diff --git a/feature/received/src/main/java/com/susu/feature/received/received/component/LedgerCategoryCard.kt b/feature/received/src/main/java/com/susu/feature/received/received/component/LedgerCategoryCard.kt deleted file mode 100644 index 268248ab..00000000 --- a/feature/received/src/main/java/com/susu/feature/received/received/component/LedgerCategoryCard.kt +++ /dev/null @@ -1,69 +0,0 @@ -package com.susu.feature.received.received.component - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.susu.core.designsystem.theme.Gray40 -import com.susu.core.designsystem.theme.SusuTheme -import com.susu.feature.received.R - -@Composable -fun LedgerCategoryCard( - name: String, -) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(SusuTheme.spacing.spacing_xxs), - ) { - HorizontalDivider( - modifier = Modifier.weight(1f), - color = Gray40, - ) - - Text( - text = stringResource(R.string.ledger_category_card_event_category), - style = SusuTheme.typography.title_xxxs, - color = Gray40, - ) - - Box( - modifier = Modifier - .clip(CircleShape) - .size(4.dp) - .background(Gray40), - ) - - Text( - text = name, - style = SusuTheme.typography.title_xxxs, - color = Gray40, - ) - - HorizontalDivider( - modifier = Modifier.weight(1f), - color = Gray40, - ) - } -} - -@Preview(showBackground = true) -@Composable -fun LedgerCategoryCardPreview() { - SusuTheme { - LedgerCategoryCard( - name = "결혼식", - ) - } -} diff --git a/feature/received/src/main/res/drawable/ic_date_change.xml b/feature/received/src/main/res/drawable/ic_date_change.xml new file mode 100644 index 00000000..dfb21423 --- /dev/null +++ b/feature/received/src/main/res/drawable/ic_date_change.xml @@ -0,0 +1,10 @@ + + + diff --git a/feature/received/src/main/res/values/strings.xml b/feature/received/src/main/res/values/strings.xml index edc97036..842fda91 100644 --- a/feature/received/src/main/res/values/strings.xml +++ b/feature/received/src/main/res/values/strings.xml @@ -10,10 +10,12 @@ 아직 보낸 봉투가 없어요 받은 봉투 추가하기 %d년 %d월 %d일 부터 + %d년 %d월 %d일 %d년 %d월 %d일 까지 일 부터 + 일 까지 부터 까지 @@ -64,4 +66,6 @@ 봉투를 삭제할까요? 삭제한 봉투는 다시 복구할 수 없어요 봉투가 삭제됐어요 + 종료일 추가 + 시작일만 지정 diff --git a/feature/sent/build.gradle.kts b/feature/sent/build.gradle.kts index 32c8ef94..d9b3139b 100644 --- a/feature/sent/build.gradle.kts +++ b/feature/sent/build.gradle.kts @@ -6,3 +6,7 @@ plugins { android { namespace = "com.susu.feature.sent" } + +dependencies { + implementation(libs.kotlinx.serialization.json) +} diff --git a/feature/sent/src/main/java/com/susu/feature/envelope/SentEnvelopeContract.kt b/feature/sent/src/main/java/com/susu/feature/envelope/SentEnvelopeContract.kt index 6913cc1f..54ae4e94 100644 --- a/feature/sent/src/main/java/com/susu/feature/envelope/SentEnvelopeContract.kt +++ b/feature/sent/src/main/java/com/susu/feature/envelope/SentEnvelopeContract.kt @@ -1,12 +1,19 @@ package com.susu.feature.envelope +import com.susu.core.model.EnvelopeSearch +import com.susu.core.model.FriendStatistics import com.susu.core.ui.base.SideEffect import com.susu.core.ui.base.UiState +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.persistentListOf data class SentEnvelopeState( val isLoading: Boolean = false, + val envelopeInfo: FriendStatistics = FriendStatistics(), + val envelopeHistoryList: PersistentList = persistentListOf(), ) : UiState sealed interface SentEnvelopeSideEffect : SideEffect { + data class NavigateEnvelopeDetail(val id: Long) : SentEnvelopeSideEffect data object PopBackStack : SentEnvelopeSideEffect } diff --git a/feature/sent/src/main/java/com/susu/feature/envelope/SentEnvelopeScreen.kt b/feature/sent/src/main/java/com/susu/feature/envelope/SentEnvelopeScreen.kt index 76566496..06839aba 100644 --- a/feature/sent/src/main/java/com/susu/feature/envelope/SentEnvelopeScreen.kt +++ b/feature/sent/src/main/java/com/susu/feature/envelope/SentEnvelopeScreen.kt @@ -12,16 +12,21 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.susu.core.designsystem.component.appbar.SusuDefaultAppBar import com.susu.core.designsystem.component.appbar.icon.BackIcon import com.susu.core.designsystem.component.appbar.icon.NotificationIcon @@ -35,35 +40,53 @@ import com.susu.core.designsystem.theme.Gray60 import com.susu.core.designsystem.theme.Gray90 import com.susu.core.designsystem.theme.Orange20 import com.susu.core.designsystem.theme.SusuTheme +import com.susu.core.ui.extension.OnBottomReached import com.susu.core.ui.extension.collectWithLifecycle +import com.susu.core.ui.extension.toMoneyFormat import com.susu.feature.envelope.component.EnvelopeHistoryItem import com.susu.feature.sent.R +import kotlinx.datetime.toJavaLocalDateTime @Composable fun SentEnvelopeRoute( viewModel: SentEnvelopeViewModel = hiltViewModel(), popBackStack: () -> Unit, - navigateSentEnvelopeDetail: () -> Unit, + navigateSentEnvelopeDetail: (Long) -> Unit, ) { + val uiState = viewModel.uiState.collectAsStateWithLifecycle().value + val historyListState = rememberLazyListState() + viewModel.sideEffect.collectWithLifecycle { sideEffect -> when (sideEffect) { SentEnvelopeSideEffect.PopBackStack -> popBackStack() + is SentEnvelopeSideEffect.NavigateEnvelopeDetail -> navigateSentEnvelopeDetail(sideEffect.id) } } + LaunchedEffect(key1 = Unit) { + viewModel.initData() + } + + historyListState.OnBottomReached { + viewModel.getEnvelopeHistoryList() + } + SentEnvelopeScreen( + uiState = uiState, onClickBackIcon = viewModel::popBackStack, - onClickEnvelopeDetail = navigateSentEnvelopeDetail, + onClickEnvelopeDetail = viewModel::navigateSentEnvelopeDetail, ) } @Composable fun SentEnvelopeScreen( modifier: Modifier = Modifier, + uiState: SentEnvelopeState = SentEnvelopeState(), + historyListState: LazyListState = rememberLazyListState(), onClickBackIcon: () -> Unit = {}, onClickSearchIcon: () -> Unit = {}, onClickNotificationIcon: () -> Unit = {}, - onClickEnvelopeDetail: () -> Unit = {}, + onClickEnvelopeDetail: (Long) -> Unit = {}, ) { Box( modifier = modifier @@ -75,14 +98,13 @@ fun SentEnvelopeScreen( leftIcon = { BackIcon(onClickBackIcon) }, - title = "김철수", + title = uiState.envelopeInfo.friend.name, actions = { SearchIcon(onClickSearchIcon) NotificationIcon(onClickNotificationIcon) }, ) - // TODO: text 변경하기 Column( modifier = modifier .padding( @@ -91,14 +113,16 @@ fun SentEnvelopeScreen( ), ) { Text( - text = "전체 100,000원", + text = stringResource(R.string.sent_envelope_card_monee_total) + uiState.envelopeInfo.totalAmounts.toMoneyFormat() + + stringResource(R.string.sent_envelope_card_money_won), style = SusuTheme.typography.title_m, color = Gray100, ) Spacer(modifier = modifier.size(SusuTheme.spacing.spacing_xxs)) SusuBadge( color = BadgeColor.Gray30, - text = "-40,000원", + text = (uiState.envelopeInfo.receivedAmounts - uiState.envelopeInfo.sentAmounts).toMoneyFormat() + + stringResource(R.string.sent_envelope_card_money_won), padding = BadgeStyle.smallBadge, ) Spacer(modifier = modifier.size(SusuTheme.spacing.spacing_xl)) @@ -118,7 +142,7 @@ fun SentEnvelopeScreen( ) } LinearProgressIndicator( - progress = { 0.7f }, + progress = { uiState.envelopeInfo.sentAmounts.toFloat() / uiState.envelopeInfo.totalAmounts }, color = SusuTheme.colorScheme.primary, trackColor = Orange20, strokeCap = StrokeCap.Round, @@ -131,12 +155,12 @@ fun SentEnvelopeScreen( horizontalArrangement = Arrangement.SpaceBetween, ) { Text( - text = "70,000원", + text = uiState.envelopeInfo.sentAmounts.toMoneyFormat() + stringResource(R.string.sent_envelope_card_money_won), style = SusuTheme.typography.title_xxxxs, color = Gray90, ) Text( - text = "30,000원", + text = uiState.envelopeInfo.receivedAmounts.toMoneyFormat() + stringResource(R.string.sent_envelope_card_money_won), style = SusuTheme.typography.title_xxxxs, color = Gray60, ) @@ -148,15 +172,21 @@ fun SentEnvelopeScreen( ) LazyColumn( + state = historyListState, contentPadding = PaddingValues(vertical = SusuTheme.spacing.spacing_m), verticalArrangement = Arrangement.spacedBy(SusuTheme.spacing.spacing_m), ) { - repeat(10) { - item { - EnvelopeHistoryItem( - onClick = onClickEnvelopeDetail, - ) - } + items( + items = uiState.envelopeHistoryList, + key = { it.envelope.id }, + ) { + EnvelopeHistoryItem( + type = it.envelope.type, + event = it.category!!.category, + date = it.envelope.handedOverAt.toJavaLocalDateTime(), + money = it.envelope.amount, + onClick = { onClickEnvelopeDetail(it.envelope.id) }, + ) } } } diff --git a/feature/sent/src/main/java/com/susu/feature/envelope/SentEnvelopeViewModel.kt b/feature/sent/src/main/java/com/susu/feature/envelope/SentEnvelopeViewModel.kt index 85119a6a..50a07776 100644 --- a/feature/sent/src/main/java/com/susu/feature/envelope/SentEnvelopeViewModel.kt +++ b/feature/sent/src/main/java/com/susu/feature/envelope/SentEnvelopeViewModel.kt @@ -1,12 +1,62 @@ package com.susu.feature.envelope +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope import com.susu.core.ui.base.BaseViewModel +import com.susu.domain.usecase.envelope.GetEnvelopesHistoryListUseCase +import com.susu.domain.usecase.envelope.GetEnvelopesListUseCase +import com.susu.feature.sent.navigation.SentRoute import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel -class SentEnvelopeViewModel @Inject constructor() : BaseViewModel( +class SentEnvelopeViewModel @Inject constructor( + private val getEnvelopesListUseCase: GetEnvelopesListUseCase, + private val getEnvelopesHistoryListUseCase: GetEnvelopesHistoryListUseCase, + savedStateHandle: SavedStateHandle, +) : BaseViewModel( SentEnvelopeState(), ) { + private val friendId = savedStateHandle.get(SentRoute.FRIEND_ID_ARGUMENT_NAME)!! + + fun initData() { + getEnvelopeInfo() + getEnvelopeHistoryList() + } + + private fun getEnvelopeInfo(id: Long = friendId) = viewModelScope.launch { + val friendsList: List = listOf(id) + + getEnvelopesListUseCase( + GetEnvelopesListUseCase.Param(friendIds = friendsList), + ).onSuccess { envelope -> + val envelopeInfo = envelope.getOrNull(0) ?: return@launch + intent { + copy( + envelopeInfo = envelopeInfo, + ) + } + } + } + + fun getEnvelopeHistoryList(id: Long = friendId) = viewModelScope.launch { + val friendsList: List = listOf(id) + val includeList = listOf("CATEGORY") + + getEnvelopesHistoryListUseCase( + GetEnvelopesHistoryListUseCase.Param(friendIds = friendsList, include = includeList), + ).onSuccess { history -> + val envelopeHistoryList = history.toPersistentList() + intent { + copy( + envelopeHistoryList = envelopeHistoryList, + ) + } + } + } + + fun navigateSentEnvelopeDetail(id: Long) = postSideEffect(SentEnvelopeSideEffect.NavigateEnvelopeDetail(id = id)) fun popBackStack() = postSideEffect(SentEnvelopeSideEffect.PopBackStack) } diff --git a/feature/sent/src/main/java/com/susu/feature/envelope/component/EnvelopeHistoryItem.kt b/feature/sent/src/main/java/com/susu/feature/envelope/component/EnvelopeHistoryItem.kt index 2ea71b28..04c9b1ed 100644 --- a/feature/sent/src/main/java/com/susu/feature/envelope/component/EnvelopeHistoryItem.kt +++ b/feature/sent/src/main/java/com/susu/feature/envelope/component/EnvelopeHistoryItem.kt @@ -11,6 +11,8 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview import com.susu.core.designsystem.component.badge.BadgeColor import com.susu.core.designsystem.component.badge.BadgeStyle import com.susu.core.designsystem.component.badge.SusuBadge @@ -19,12 +21,22 @@ import com.susu.core.designsystem.theme.Gray50 import com.susu.core.designsystem.theme.Orange60 import com.susu.core.designsystem.theme.SusuTheme import com.susu.core.ui.extension.susuClickable +import com.susu.core.ui.extension.toMoneyFormat +import com.susu.core.ui.util.to_yyyy_dot_MM_dot_dd import com.susu.feature.sent.R +import java.time.LocalDateTime + +enum class EnvelopeType { + SENT, RECEIVED +} @Composable fun EnvelopeHistoryItem( modifier: Modifier = Modifier, - isSent: Boolean = true, + type: String = "", + event: String = "", + date: LocalDateTime = LocalDateTime.now(), + money: Long = 0, onClick: () -> Unit = {}, ) { Row( @@ -39,35 +51,47 @@ fun EnvelopeHistoryItem( ), verticalAlignment = Alignment.CenterVertically, ) { - // TODO: text 변경하기 Icon( painter = painterResource( - id = if (isSent) { + id = if (type == EnvelopeType.SENT.name) { R.drawable.ic_round_arrow_sent } else { R.drawable.ic_round_arrow_received }, ), contentDescription = null, - tint = if (isSent) Orange60 else Gray50, + tint = if (type == EnvelopeType.SENT.name) Orange60 else Gray50, ) Spacer(modifier = modifier.size(SusuTheme.spacing.spacing_s)) SusuBadge( - color = if (isSent) BadgeColor.Gray90 else BadgeColor.Gray40, - text = "결혼식", + color = if (type == EnvelopeType.SENT.name) BadgeColor.Gray90 else BadgeColor.Gray40, + text = event, padding = BadgeStyle.smallBadge, ) Spacer(modifier = modifier.size(SusuTheme.spacing.spacing_s)) Text( - text = "22.07.18", + text = date.to_yyyy_dot_MM_dot_dd().substring(2), style = SusuTheme.typography.title_xxs, - color = if (isSent) Gray100 else Gray50, + color = if (type == EnvelopeType.SENT.name) Gray100 else Gray50, ) Spacer(modifier = modifier.weight(1f)) Text( - text = " 100,000원", + text = money.toInt().toMoneyFormat() + stringResource(R.string.sent_envelope_card_money_won), style = SusuTheme.typography.title_xs, - color = if (isSent) Gray100 else Gray50, + color = if (type == EnvelopeType.SENT.name) Gray100 else Gray50, + ) + } +} + +@Preview(showBackground = true, backgroundColor = 0xffffff) +@Composable +fun EnvelopeHistoryItemPreView() { + SusuTheme { + EnvelopeHistoryItem( + type = "SENT", + event = "돌잔치", + date = LocalDateTime.now(), + money = 50000, ) } } diff --git a/feature/sent/src/main/java/com/susu/feature/envelopeadd/content/more/MoreContract.kt b/feature/sent/src/main/java/com/susu/feature/envelopeadd/content/more/MoreContract.kt index 17a05c02..578909da 100644 --- a/feature/sent/src/main/java/com/susu/feature/envelopeadd/content/more/MoreContract.kt +++ b/feature/sent/src/main/java/com/susu/feature/envelopeadd/content/more/MoreContract.kt @@ -1,9 +1,9 @@ package com.susu.feature.envelopeadd.content.more +import com.susu.core.ui.R import com.susu.core.ui.base.SideEffect import com.susu.core.ui.base.UiState import com.susu.feature.envelopeadd.EnvelopeAddStep -import com.susu.feature.sent.R import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentMapOf @@ -13,10 +13,10 @@ data class MoreState( ) : UiState val moreStep = persistentMapOf( - EnvelopeAddStep.VISITED to R.string.sent_envelope_edit_category_visited, - EnvelopeAddStep.PRESENT to R.string.sent_envelope_edit_category_present, - EnvelopeAddStep.MEMO to R.string.sent_envelope_edit_category_memo, - EnvelopeAddStep.PHONE to R.string.sent_envelope_edit_category_phone, + EnvelopeAddStep.VISITED to R.string.word_is_visited, + EnvelopeAddStep.PRESENT to R.string.word_gift, + EnvelopeAddStep.MEMO to R.string.word_memo, + EnvelopeAddStep.PHONE to com.susu.feature.sent.R.string.sent_add_more_step_phone, ) sealed interface MoreSideEffect : SideEffect { diff --git a/feature/sent/src/main/java/com/susu/feature/envelopeadd/content/visited/VisitedContent.kt b/feature/sent/src/main/java/com/susu/feature/envelopeadd/content/visited/VisitedContent.kt index 06693371..25bea028 100644 --- a/feature/sent/src/main/java/com/susu/feature/envelopeadd/content/visited/VisitedContent.kt +++ b/feature/sent/src/main/java/com/susu/feature/envelopeadd/content/visited/VisitedContent.kt @@ -86,7 +86,7 @@ fun VisitedContent( SusuFilledButton( color = FilledButtonColor.Orange, style = MediumButtonStyle.height60, - text = stringResource(id = R.string.sent_envelope_edit_category_visited_yes), + text = stringResource(id = com.susu.core.ui.R.string.word_yes), onClick = onClickVisitedButton, modifier = modifier.fillMaxWidth(), ) @@ -94,7 +94,7 @@ fun VisitedContent( SusuGhostButton( color = GhostButtonColor.Black, style = MediumButtonStyle.height60, - text = stringResource(id = R.string.sent_envelope_edit_category_visited_yes), + text = stringResource(id = com.susu.core.ui.R.string.word_yes), onClick = onClickVisitedButton, modifier = modifier.fillMaxWidth(), rippleEnabled = false, @@ -105,7 +105,7 @@ fun VisitedContent( SusuFilledButton( color = FilledButtonColor.Orange, style = MediumButtonStyle.height60, - text = stringResource(id = R.string.sent_envelope_edit_category_visited_no), + text = stringResource(id = com.susu.core.ui.R.string.word_no), onClick = onClickNotVisitedButton, modifier = modifier.fillMaxWidth(), ) @@ -113,7 +113,7 @@ fun VisitedContent( SusuGhostButton( color = GhostButtonColor.Black, style = MediumButtonStyle.height60, - text = stringResource(id = R.string.sent_envelope_edit_category_visited_no), + text = stringResource(id = com.susu.core.ui.R.string.word_no), onClick = onClickNotVisitedButton, modifier = modifier.fillMaxWidth(), rippleEnabled = false, diff --git a/feature/sent/src/main/java/com/susu/feature/envelopedetail/SentEnvelopeDetailContract.kt b/feature/sent/src/main/java/com/susu/feature/envelopedetail/SentEnvelopeDetailContract.kt index 3ba25034..ad8a472b 100644 --- a/feature/sent/src/main/java/com/susu/feature/envelopedetail/SentEnvelopeDetailContract.kt +++ b/feature/sent/src/main/java/com/susu/feature/envelopedetail/SentEnvelopeDetailContract.kt @@ -1,12 +1,18 @@ package com.susu.feature.envelopedetail +import com.susu.core.model.EnvelopeDetail import com.susu.core.ui.base.SideEffect import com.susu.core.ui.base.UiState data class SentEnvelopeDetailState( val isLoading: Boolean = false, + val envelopeDetail: EnvelopeDetail = EnvelopeDetail(), ) : UiState -sealed interface SentEnvelopeDetailSideEffect : SideEffect { - data object PopBackStack : SentEnvelopeDetailSideEffect +sealed interface SentEnvelopeDetailEffect : SideEffect { + data class NavigateEnvelopeEdit(val envelopeDetail: EnvelopeDetail) : SentEnvelopeDetailEffect + data object PopBackStack : SentEnvelopeDetailEffect + data object ShowDeleteSuccessSnackBar : SentEnvelopeDetailEffect + data object ShowDeleteDialog : SentEnvelopeDetailEffect + data class HandleException(val throwable: Throwable, val retry: () -> Unit) : SentEnvelopeDetailEffect } diff --git a/feature/sent/src/main/java/com/susu/feature/envelopedetail/SentEnvelopeDetailScreen.kt b/feature/sent/src/main/java/com/susu/feature/envelopedetail/SentEnvelopeDetailScreen.kt index c068cfea..ada055f6 100644 --- a/feature/sent/src/main/java/com/susu/feature/envelopedetail/SentEnvelopeDetailScreen.kt +++ b/feature/sent/src/main/java/com/susu/feature/envelopedetail/SentEnvelopeDetailScreen.kt @@ -11,39 +11,81 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.susu.core.designsystem.component.appbar.SusuDefaultAppBar import com.susu.core.designsystem.component.appbar.icon.BackIcon import com.susu.core.designsystem.component.appbar.icon.DeleteText import com.susu.core.designsystem.component.appbar.icon.EditText import com.susu.core.designsystem.theme.Gray100 import com.susu.core.designsystem.theme.SusuTheme +import com.susu.core.model.EnvelopeDetail +import com.susu.core.ui.DialogToken +import com.susu.core.ui.SnackbarToken import com.susu.core.ui.extension.collectWithLifecycle +import com.susu.core.ui.extension.toMoneyFormat +import com.susu.core.ui.util.to_yyyy_korYear_M_korMonth_d_korDay import com.susu.feature.envelopedetail.component.DetailItem +import com.susu.feature.sent.R +import kotlinx.datetime.toJavaLocalDateTime @Composable fun SentEnvelopeDetailRoute( viewModel: SentEnvelopeDetailViewModel = hiltViewModel(), popBackStack: () -> Unit, - navigateSentEnvelopeEdit: () -> Unit, + navigateSentEnvelopeEdit: (EnvelopeDetail) -> Unit, + onShowSnackbar: (SnackbarToken) -> Unit, + onShowDialog: (DialogToken) -> Unit, + handleException: (Throwable, () -> Unit) -> Unit, ) { + val context = LocalContext.current + val uiState = viewModel.uiState.collectAsStateWithLifecycle().value + viewModel.sideEffect.collectWithLifecycle { sideEffect -> when (sideEffect) { - SentEnvelopeDetailSideEffect.PopBackStack -> popBackStack() + SentEnvelopeDetailEffect.PopBackStack -> popBackStack() + is SentEnvelopeDetailEffect.NavigateEnvelopeEdit -> navigateSentEnvelopeEdit(sideEffect.envelopeDetail) + SentEnvelopeDetailEffect.ShowDeleteDialog -> onShowDialog( + DialogToken( + title = context.getString(R.string.sent_envelope_detail_delete_title), + text = context.getString(R.string.sent_envelope_detail_delete_description), + confirmText = context.getString(com.susu.core.ui.R.string.word_delete), + dismissText = context.getString(com.susu.core.ui.R.string.word_cancel), + onConfirmRequest = viewModel::deleteEnvelope, + ), + ) + + SentEnvelopeDetailEffect.ShowDeleteSuccessSnackBar -> onShowSnackbar( + SnackbarToken( + message = context.getString(R.string.sent_envelope_detail_delete_snackbar), + ), + ) + + is SentEnvelopeDetailEffect.HandleException -> handleException(sideEffect.throwable, sideEffect.retry) } } + LaunchedEffect(key1 = Unit) { + viewModel.getEnvelopeDetail() + } + SentEnvelopeDetailScreen( + uiState = uiState, onClickBackIcon = viewModel::popBackStack, - onClickEdit = navigateSentEnvelopeEdit, + onClickEdit = viewModel::navigateSentEnvelopeEdit, + onClickDelete = viewModel::showDeleteDialog, ) } @Composable fun SentEnvelopeDetailScreen( modifier: Modifier = Modifier, + uiState: SentEnvelopeDetailState = SentEnvelopeDetailState(), onClickBackIcon: () -> Unit = {}, onClickEdit: () -> Unit = {}, onClickDelete: () -> Unit = {}, @@ -72,7 +114,7 @@ fun SentEnvelopeDetailScreen( Spacer(modifier = modifier.size(SusuTheme.spacing.spacing_m)) }, ) - // TODO: text 수정 + Column( modifier = modifier .fillMaxSize() @@ -83,53 +125,58 @@ fun SentEnvelopeDetailScreen( ) .verticalScroll(scrollState), ) { - Text( - text = "150,000원", - style = SusuTheme.typography.title_xxl, - color = Gray100, - ) - Spacer(modifier = modifier.size(SusuTheme.spacing.spacing_m)) - Column { - DetailItem( - categoryText = "경조사", - contentText = "결혼식", - isEmptyContent = false, - ) - DetailItem( - categoryText = "이름", - contentText = "김철수", - isEmptyContent = false, - ) - DetailItem( - categoryText = "나와의 관계", - contentText = "친구", - isEmptyContent = false, - ) - DetailItem( - categoryText = "날짜", - contentText = "2023년 11월 25일", - isEmptyContent = false, - ) - DetailItem( - categoryText = "방문 여부", - contentText = "예", - isEmptyContent = false, - ) - DetailItem( - categoryText = "선물", - contentText = "한끼 식사", - isEmptyContent = true, - ) - DetailItem( - categoryText = "연락처", - contentText = "01012345678", - isEmptyContent = true, - ) - DetailItem( - categoryText = "메모", - contentText = "가나다라마바사아자차카타파하가나다라마바사아자차카타파하가나다라마바사아자차카타파하가나다라마바사아자차카타파하가나다라마바사아자차카타파하", - isEmptyContent = true, + with(uiState.envelopeDetail) { + Text( + text = envelope.amount.toMoneyFormat() + stringResource(R.string.sent_envelope_card_money_won), + style = SusuTheme.typography.title_xxl, + color = Gray100, ) + Spacer(modifier = modifier.size(SusuTheme.spacing.spacing_m)) + Column { + DetailItem( + categoryText = stringResource(com.susu.core.ui.R.string.word_event), + contentText = category.category, + isEmptyContent = category.category.isEmpty(), + ) + DetailItem( + categoryText = stringResource(com.susu.core.ui.R.string.word_name), + contentText = friend.name, + isEmptyContent = friend.name.isEmpty(), + ) + DetailItem( + categoryText = stringResource(com.susu.core.ui.R.string.word_relationship), + contentText = relationship.relation, + isEmptyContent = relationship.relation.isEmpty(), + ) + DetailItem( + categoryText = stringResource(com.susu.core.ui.R.string.word_date), + contentText = envelope.handedOverAt.toJavaLocalDateTime().to_yyyy_korYear_M_korMonth_d_korDay(), + ) + DetailItem( + categoryText = stringResource(com.susu.core.ui.R.string.word_is_visited), + contentText = if (envelope.hasVisited == true) { + stringResource(com.susu.core.ui.R.string.word_yes) + } else { + stringResource(com.susu.core.ui.R.string.word_no) + }, + isEmptyContent = envelope.hasVisited == null, + ) + DetailItem( + categoryText = stringResource(com.susu.core.ui.R.string.word_gift), + contentText = envelope.gift ?: "", + isEmptyContent = envelope.gift.isNullOrEmpty(), + ) + DetailItem( + categoryText = stringResource(com.susu.core.ui.R.string.word_phone_number), + contentText = friend.phoneNumber, + isEmptyContent = friend.phoneNumber.isEmpty(), + ) + DetailItem( + categoryText = stringResource(com.susu.core.ui.R.string.word_memo), + contentText = envelope.memo ?: "", + isEmptyContent = envelope.memo.isNullOrEmpty(), + ) + } } } } diff --git a/feature/sent/src/main/java/com/susu/feature/envelopedetail/SentEnvelopeDetailViewModel.kt b/feature/sent/src/main/java/com/susu/feature/envelopedetail/SentEnvelopeDetailViewModel.kt index 8636ef99..5dec3166 100644 --- a/feature/sent/src/main/java/com/susu/feature/envelopedetail/SentEnvelopeDetailViewModel.kt +++ b/feature/sent/src/main/java/com/susu/feature/envelopedetail/SentEnvelopeDetailViewModel.kt @@ -1,12 +1,53 @@ package com.susu.feature.envelopedetail +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope import com.susu.core.ui.base.BaseViewModel +import com.susu.domain.usecase.envelope.DeleteEnvelopeUseCase +import com.susu.domain.usecase.envelope.GetEnvelopeDetailUseCase +import com.susu.feature.sent.navigation.SentRoute import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel -class SentEnvelopeDetailViewModel @Inject constructor() : BaseViewModel( +class SentEnvelopeDetailViewModel @Inject constructor( + private val getEnvelopeDetailUseCase: GetEnvelopeDetailUseCase, + private val deleteEnvelopeUseCase: DeleteEnvelopeUseCase, + savedStateHandle: SavedStateHandle, +) : BaseViewModel( SentEnvelopeDetailState(), ) { - fun popBackStack() = postSideEffect(SentEnvelopeDetailSideEffect.PopBackStack) + private val envelopeId = savedStateHandle.get(SentRoute.ENVELOPE_ID_ARGUMENT_NAME)!! + fun getEnvelopeDetail(id: Long = envelopeId) = viewModelScope.launch { + getEnvelopeDetailUseCase(id).onSuccess { envelopeDetail -> + intent { + copy( + envelopeDetail = envelopeDetail, + ) + } + }.onFailure { + postSideEffect(SentEnvelopeDetailEffect.HandleException(it, ::getEnvelopeDetail)) + } + } + + fun showDeleteDialog() { + postSideEffect(SentEnvelopeDetailEffect.ShowDeleteDialog) + } + + fun deleteEnvelope() = viewModelScope.launch { + deleteEnvelopeUseCase(envelopeId).onSuccess { + postSideEffect(SentEnvelopeDetailEffect.ShowDeleteSuccessSnackBar, SentEnvelopeDetailEffect.PopBackStack) + }.onFailure { + postSideEffect(SentEnvelopeDetailEffect.HandleException(it, ::deleteEnvelope)) + } + } + + fun navigateSentEnvelopeEdit() = postSideEffect( + SentEnvelopeDetailEffect.NavigateEnvelopeEdit( + currentState.envelopeDetail, + ), + ) + + fun popBackStack() = postSideEffect(SentEnvelopeDetailEffect.PopBackStack) } diff --git a/feature/sent/src/main/java/com/susu/feature/envelopedetail/component/DetailItem.kt b/feature/sent/src/main/java/com/susu/feature/envelopedetail/component/DetailItem.kt index eee9c38a..102c1982 100644 --- a/feature/sent/src/main/java/com/susu/feature/envelopedetail/component/DetailItem.kt +++ b/feature/sent/src/main/java/com/susu/feature/envelopedetail/component/DetailItem.kt @@ -22,15 +22,15 @@ import com.susu.core.designsystem.theme.SusuTheme @Composable fun DetailItem( - modifier: Modifier = Modifier, categoryText: String, + contentText: String, + modifier: Modifier = Modifier, + isEmptyContent: Boolean = false, categoryStyle: TextStyle = SusuTheme.typography.title_xxs, categoryTextColor: Color = Gray60, categoryWidth: Dp = 72.dp, - contentText: String, contentStyle: TextStyle = SusuTheme.typography.title_s, contentColor: Color = Gray100, - isEmptyContent: Boolean, padding: PaddingValues = PaddingValues(vertical = SusuTheme.spacing.spacing_m), ) { if (!isEmptyContent) { diff --git a/feature/sent/src/main/java/com/susu/feature/envelopeedit/SentEnvelopeEditContract.kt b/feature/sent/src/main/java/com/susu/feature/envelopeedit/SentEnvelopeEditContract.kt index 966772fa..4f2b69b0 100644 --- a/feature/sent/src/main/java/com/susu/feature/envelopeedit/SentEnvelopeEditContract.kt +++ b/feature/sent/src/main/java/com/susu/feature/envelopeedit/SentEnvelopeEditContract.kt @@ -1,12 +1,39 @@ package com.susu.feature.envelopeedit +import com.susu.core.model.Category +import com.susu.core.model.Relationship import com.susu.core.ui.base.SideEffect import com.susu.core.ui.base.UiState +import com.susu.core.ui.util.currentDate +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.persistentListOf +import java.time.LocalDateTime data class SentEnvelopeEditState( val isLoading: Boolean = false, + val categoryConfig: PersistentList = persistentListOf(), + val relationshipConfig: PersistentList = persistentListOf(), + val amount: Long = 0L, + val gift: String? = null, + val memo: String? = null, + val hasVisited: Boolean? = null, + val handedOverAt: LocalDateTime = currentDate, + val friendName: String = "", + val relationshipId: Long = 0, + val customRelationship: String? = null, + val customRelationshipSaved: Boolean = false, + val phoneNumber: String? = null, + val categoryId: Int = 0, + val customCategory: String? = null, + val customCategorySaved: Boolean = false, + val showCustomCategory: Boolean = false, + val showCustomRelationship: Boolean = false, + val showDatePickerSheet: Boolean = false, ) : UiState sealed interface SentEnvelopeEditSideEffect : SideEffect { data object PopBackStack : SentEnvelopeEditSideEffect + data class HandleException(val throwable: Throwable, val retry: () -> Unit) : SentEnvelopeEditSideEffect + data object FocusCustomCategory : SentEnvelopeEditSideEffect + data object FocusCustomRelationship : SentEnvelopeEditSideEffect } diff --git a/feature/sent/src/main/java/com/susu/feature/envelopeedit/SentEnvelopeEditScreen.kt b/feature/sent/src/main/java/com/susu/feature/envelopeedit/SentEnvelopeEditScreen.kt index 7c6e24a9..6095c84a 100644 --- a/feature/sent/src/main/java/com/susu/feature/envelopeedit/SentEnvelopeEditScreen.kt +++ b/feature/sent/src/main/java/com/susu/feature/envelopeedit/SentEnvelopeEditScreen.kt @@ -1,5 +1,6 @@ package com.susu.feature.envelopeedit +import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -15,18 +16,19 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.susu.core.designsystem.component.appbar.SusuDefaultAppBar import com.susu.core.designsystem.component.appbar.icon.BackIcon import com.susu.core.designsystem.component.bottomsheet.datepicker.SusuDatePickerBottomSheet @@ -35,33 +37,94 @@ import com.susu.core.designsystem.component.button.FilledButtonColor import com.susu.core.designsystem.component.button.MediumButtonStyle import com.susu.core.designsystem.component.button.SmallButtonStyle import com.susu.core.designsystem.component.button.SusuFilledButton +import com.susu.core.designsystem.component.screen.LoadingScreen import com.susu.core.designsystem.component.textfield.SusuBasicTextField import com.susu.core.designsystem.component.textfield.SusuPriceTextField +import com.susu.core.designsystem.component.textfieldbutton.SusuTextFieldWrapContentButton +import com.susu.core.designsystem.component.textfieldbutton.TextFieldButtonColor +import com.susu.core.designsystem.component.textfieldbutton.style.SmallTextFieldButtonStyle import com.susu.core.designsystem.theme.Gray100 import com.susu.core.designsystem.theme.Gray30 import com.susu.core.designsystem.theme.Gray40 import com.susu.core.designsystem.theme.Gray70 import com.susu.core.designsystem.theme.SusuTheme +import com.susu.core.model.Category +import com.susu.core.model.Relationship import com.susu.core.ui.extension.collectWithLifecycle import com.susu.core.ui.extension.susuClickable +import com.susu.core.ui.util.to_yyyy_korYear_M_korMonth_d_korDay import com.susu.feature.envelopeedit.component.EditDetailItem import com.susu.feature.sent.R +import kotlinx.coroutines.android.awaitFrame +import kotlinx.coroutines.launch @Composable fun SentEnvelopeEditRoute( viewModel: SentEnvelopeEditViewModel = hiltViewModel(), popBackStack: () -> Unit, - navigateSentEnvelopeDetail: () -> Unit, ) { + val uiState = viewModel.uiState.collectAsStateWithLifecycle().value + val categoryFocusRequester = remember { FocusRequester() } + val relationshipFocusRequester = remember { FocusRequester() } + val scope = rememberCoroutineScope() + viewModel.sideEffect.collectWithLifecycle { sideEffect -> when (sideEffect) { SentEnvelopeEditSideEffect.PopBackStack -> popBackStack() + is SentEnvelopeEditSideEffect.HandleException -> {} + SentEnvelopeEditSideEffect.FocusCustomCategory -> { + scope.launch { + awaitFrame() + categoryFocusRequester.requestFocus() + } + } + + SentEnvelopeEditSideEffect.FocusCustomRelationship -> { + scope.launch { + awaitFrame() + relationshipFocusRequester.requestFocus() + } + } } } + LaunchedEffect(key1 = Unit) { + viewModel.run { + initData() + } + } + + BackHandler { + popBackStack() + } + SentEnvelopeEditScreen( + uiState = uiState, + categoryFocusRequester = categoryFocusRequester, + relationshipFocusRequester = relationshipFocusRequester, onClickBackIcon = viewModel::popBackStack, - onClickSave = navigateSentEnvelopeDetail, + onClickSave = viewModel::editEnvelope, + onClickCustomCategoryAdd = viewModel::showCustomCategoryInput, + onClickCustomRelationshipAdd = viewModel::showCustomRelationshipInput, + onClickDateText = viewModel::showDatePickerSheet, + onClickCustomCategoryInnerButton = viewModel::toggleCustomCategoryInputSaved, + onClickCustomRelationshipInnerButton = viewModel::toggleCustomRelationshipInputSaved, + onCloseCustomCategory = viewModel::hideCustomCategoryInput, + onCloseCustomRelationship = viewModel::hideCustomRelationshipInput, + onSelectCategory = { viewModel.updateCategoryId(it.id) }, + onSelectRelationship = { viewModel.updateRelationshipId(it.id) }, + onMoneyUpdated = viewModel::updateAmount, + onFriendNameUpdated = viewModel::updateFriendName, + onCustomCategoryUpdated = viewModel::updateCustomCategory, + onCustomCategoryCleared = { viewModel.updateCustomCategory("") }, + onCustomRelationshipUpdated = viewModel::updateCustomRelationship, + onCustomRelationshipCleared = { viewModel.updateCustomRelationship("") }, + onDatePickerSheetDismissed = viewModel::hideDatePickerSheet, + onDateUpdated = viewModel::updateHandedOverAt, + onHasVisitedUpdated = viewModel::updateHasVisited, + onPhoneNumberUpdated = viewModel::updatePhoneNumber, + onGiftUpdated = viewModel::updateGift, + onMemoUpdated = viewModel::updateMemo, ) } @@ -69,17 +132,33 @@ fun SentEnvelopeEditRoute( @Composable fun SentEnvelopeEditScreen( modifier: Modifier = Modifier, + uiState: SentEnvelopeEditState = SentEnvelopeEditState(), + categoryFocusRequester: FocusRequester = remember { FocusRequester() }, + relationshipFocusRequester: FocusRequester = remember { FocusRequester() }, onClickBackIcon: () -> Unit = {}, onClickSave: () -> Unit = {}, + onMoneyUpdated: (Long) -> Unit = {}, + onSelectCategory: (Category) -> Unit = {}, + onClickCustomCategoryAdd: () -> Unit = {}, + onCustomCategoryUpdated: (String) -> Unit = {}, + onCustomCategoryCleared: () -> Unit = {}, + onCloseCustomCategory: () -> Unit = {}, + onClickCustomCategoryInnerButton: () -> Unit = {}, + onFriendNameUpdated: (String) -> Unit = {}, + onSelectRelationship: (Relationship) -> Unit = {}, + onClickCustomRelationshipAdd: () -> Unit = {}, + onCustomRelationshipUpdated: (String) -> Unit = {}, + onCustomRelationshipCleared: () -> Unit = {}, + onCloseCustomRelationship: () -> Unit = {}, + onClickCustomRelationshipInnerButton: () -> Unit = {}, + onClickDateText: () -> Unit = {}, + onHasVisitedUpdated: (Boolean) -> Unit = {}, + onGiftUpdated: (String) -> Unit = {}, + onMemoUpdated: (String) -> Unit = {}, + onPhoneNumberUpdated: (String) -> Unit = {}, + onDateUpdated: (year: Int, month: Int, day: Int) -> Unit = { _, _, _ -> }, + onDatePickerSheetDismissed: () -> Unit = {}, ) { - // TODO: 수정 필요 - var money by remember { mutableStateOf(150000) } - var name by remember { mutableStateOf("김철수") } - var present by remember { mutableStateOf("") } - var phone by remember { mutableStateOf("") } - var memo by remember { mutableStateOf("") } - var isSheetOpen by remember { mutableStateOf(false) } - Box( modifier = modifier .fillMaxSize() @@ -94,7 +173,7 @@ fun SentEnvelopeEditScreen( }, ) Column( - modifier = modifier + modifier = Modifier .verticalScroll(rememberScrollState()) .weight(1f) .padding( @@ -104,200 +183,204 @@ fun SentEnvelopeEditScreen( ), ) { SusuPriceTextField( - text = money.toString(), - onTextChange = { money = it.toInt() }, + text = uiState.amount.toString(), + onTextChange = { onMoneyUpdated(it.toLongOrNull() ?: 0L) }, textStyle = SusuTheme.typography.title_xxl, - modifier = modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth(), ) - Spacer(modifier = modifier.size(SusuTheme.spacing.spacing_m)) + Spacer(modifier = Modifier.size(SusuTheme.spacing.spacing_m)) EditDetailItem( - categoryText = stringResource(R.string.sent_envelope_edit_category_event), + categoryText = stringResource(com.susu.core.ui.R.string.word_event), categoryTextAlign = Alignment.Top, ) { - SusuFilledButton( - color = FilledButtonColor.Orange, - style = SmallButtonStyle.height32, - text = stringResource(R.string.sent_envelope_edit_category_event_wedding), - isActive = true, - onClick = {}, - ) - SusuFilledButton( - color = FilledButtonColor.Orange, - style = SmallButtonStyle.height32, - text = stringResource(R.string.sent_envelope_edit_category_event_first_birth), - isActive = false, - onClick = {}, - ) - SusuFilledButton( - color = FilledButtonColor.Orange, - style = SmallButtonStyle.height32, - text = stringResource(R.string.sent_envelope_edit_category_edit_funeral), - isActive = false, - onClick = {}, - ) - SusuFilledButton( - color = FilledButtonColor.Orange, - style = SmallButtonStyle.height32, - text = stringResource(R.string.sent_envelope_edit_category_edit_birthday), - isActive = false, - onClick = {}, - ) - AddConditionButton( - onClick = {}, - ) + uiState.categoryConfig.dropLast(1).forEach { category -> + SusuFilledButton( + color = FilledButtonColor.Orange, + style = SmallButtonStyle.height32, + text = category.name, + isActive = category.id == uiState.categoryId, + onClick = { onSelectCategory(category) }, + ) + } + if (uiState.showCustomCategory) { + SusuTextFieldWrapContentButton( + focusRequester = categoryFocusRequester, + color = TextFieldButtonColor.Orange, + style = SmallTextFieldButtonStyle.height32, + text = uiState.customCategory ?: "", + isFocused = uiState.categoryId == uiState.categoryConfig.last().id, + isSaved = uiState.customCategorySaved, + onTextChange = onCustomCategoryUpdated, + onClickClearIcon = onCustomCategoryCleared, + onClickCloseIcon = onCloseCustomCategory, + onClickFilledButton = onClickCustomCategoryInnerButton, + onClickButton = { onSelectCategory(uiState.categoryConfig.last()) }, + ) + } else { + AddConditionButton( + onClick = onClickCustomCategoryAdd, + ) + } } EditDetailItem( - categoryText = stringResource(R.string.sent_envelope_edit_category_name), + categoryText = stringResource(com.susu.core.ui.R.string.word_name), categoryTextAlign = Alignment.CenterVertically, ) { SusuBasicTextField( - text = name, - onTextChange = { name = it }, + text = uiState.friendName, + onTextChange = onFriendNameUpdated, placeholder = stringResource(R.string.sent_envelope_edit_category_name_placeholder), placeholderColor = Gray30, textStyle = SusuTheme.typography.title_s, - modifier = modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth(), ) } EditDetailItem( - categoryText = stringResource(R.string.sent_envelope_edit_category_relationship), + categoryText = stringResource(com.susu.core.ui.R.string.word_relationship), categoryTextAlign = Alignment.Top, ) { - SusuFilledButton( - color = FilledButtonColor.Orange, - style = SmallButtonStyle.height32, - text = stringResource(R.string.sent_envelope_edit_category_relationship_friend), - isActive = true, - onClick = {}, - ) - SusuFilledButton( - color = FilledButtonColor.Orange, - style = SmallButtonStyle.height32, - text = stringResource(R.string.sent_envelope_edit_category_relationship_family), - isActive = false, - onClick = {}, - ) - SusuFilledButton( - color = FilledButtonColor.Orange, - style = SmallButtonStyle.height32, - text = stringResource(R.string.sent_envelope_edit_category_relationship_relatives), - isActive = false, - onClick = {}, - ) - SusuFilledButton( - color = FilledButtonColor.Orange, - style = SmallButtonStyle.height32, - text = stringResource(R.string.sent_envelope_edit_category_relationship_colleague), - isActive = false, - onClick = {}, - ) - AddConditionButton( - onClick = {}, - ) + uiState.relationshipConfig.dropLast(1).forEach { relationship -> + SusuFilledButton( + color = FilledButtonColor.Orange, + style = SmallButtonStyle.height32, + text = relationship.relation, + isActive = relationship.id == uiState.relationshipId, + onClick = { onSelectRelationship(relationship) }, + ) + } + if (uiState.showCustomRelationship) { + SusuTextFieldWrapContentButton( + focusRequester = relationshipFocusRequester, + style = SmallTextFieldButtonStyle.height32, + color = TextFieldButtonColor.Orange, + text = uiState.customRelationship ?: "", + isFocused = uiState.relationshipId == uiState.relationshipConfig.last().id, + isSaved = uiState.customRelationshipSaved, + onTextChange = onCustomRelationshipUpdated, + onClickClearIcon = onCustomRelationshipCleared, + onClickCloseIcon = onCloseCustomRelationship, + onClickFilledButton = onClickCustomRelationshipInnerButton, + onClickButton = { onSelectRelationship(uiState.relationshipConfig.last()) }, + ) + } else { + AddConditionButton( + onClick = onClickCustomRelationshipAdd, + ) + } } EditDetailItem( - categoryText = stringResource(R.string.sent_envelope_edit_category_date), + categoryText = stringResource(com.susu.core.ui.R.string.word_date), categoryTextAlign = Alignment.CenterVertically, ) { Text( - text = "2023년 11월 25일", + text = uiState.handedOverAt.to_yyyy_korYear_M_korMonth_d_korDay(), style = SusuTheme.typography.title_s, color = Gray100, - modifier = modifier + modifier = Modifier .fillMaxWidth() .susuClickable( rippleEnabled = false, - onClick = { isSheetOpen = true }, + onClick = onClickDateText, ), ) } EditDetailItem( - categoryText = stringResource(R.string.sent_envelope_edit_category_visited), + categoryText = stringResource(com.susu.core.ui.R.string.word_is_visited), categoryTextAlign = Alignment.Top, ) { SusuFilledButton( color = FilledButtonColor.Orange, style = SmallButtonStyle.height32, - text = stringResource(R.string.sent_envelope_edit_category_visited_yes), - isActive = true, - onClick = {}, - modifier = modifier.weight(1f), + text = stringResource(com.susu.core.ui.R.string.word_yes), + isActive = uiState.hasVisited == true, + onClick = { onHasVisitedUpdated(true) }, + modifier = Modifier.weight(1f), ) SusuFilledButton( color = FilledButtonColor.Orange, style = SmallButtonStyle.height32, - text = stringResource(R.string.sent_envelope_edit_category_visited_no), - isActive = false, - onClick = {}, - modifier = modifier.weight(1f), + text = stringResource(com.susu.core.ui.R.string.word_no), + isActive = uiState.hasVisited == false, + onClick = { onHasVisitedUpdated(false) }, + modifier = Modifier.weight(1f), ) } EditDetailItem( - categoryText = stringResource(R.string.sent_envelope_edit_category_present), - categoryTextColor = if (present.isNotEmpty()) Gray70 else Gray40, + categoryText = stringResource(com.susu.core.ui.R.string.word_gift), + categoryTextColor = if (uiState.gift != null) Gray70 else Gray40, categoryTextAlign = Alignment.CenterVertically, ) { SusuBasicTextField( - text = present, - onTextChange = { present = it }, + text = uiState.gift ?: "", + onTextChange = onGiftUpdated, placeholder = stringResource(R.string.sent_envelope_edit_category_present_placeholder), placeholderColor = Gray30, textStyle = SusuTheme.typography.title_s, - modifier = modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth(), ) } EditDetailItem( - categoryText = stringResource(R.string.sent_envelope_edit_category_phone), - categoryTextColor = if (phone.isNotEmpty()) Gray70 else Gray40, + categoryText = stringResource(com.susu.core.ui.R.string.word_phone_number), + categoryTextColor = if (uiState.phoneNumber != null) Gray70 else Gray40, categoryTextAlign = Alignment.CenterVertically, ) { SusuBasicTextField( - text = phone, - onTextChange = { phone = it }, + text = uiState.phoneNumber ?: "", + onTextChange = onPhoneNumberUpdated, placeholder = stringResource(R.string.sent_envelope_edit_category_phone_placeholder), placeholderColor = Gray30, textStyle = SusuTheme.typography.title_s, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth(), ) } EditDetailItem( - categoryText = stringResource(R.string.sent_envelope_edit_category_memo), - categoryTextColor = if (memo.isNotEmpty()) Gray70 else Gray40, + categoryText = stringResource(com.susu.core.ui.R.string.word_memo), + categoryTextColor = if (uiState.memo != null) Gray70 else Gray40, categoryTextAlign = Alignment.Top, ) { SusuBasicTextField( - text = memo, - onTextChange = { memo = it }, + text = uiState.memo ?: "", + onTextChange = onMemoUpdated, placeholder = stringResource(R.string.sent_envelope_edit_category_memo_placeholder), placeholderColor = Gray30, textStyle = SusuTheme.typography.title_s, maxLines = 2, - modifier = modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth(), ) } - Spacer(modifier = modifier.size(240.dp)) + Spacer(modifier = Modifier.size(240.dp)) } SusuFilledButton( - modifier = modifier + modifier = Modifier .fillMaxWidth() .imePadding(), color = FilledButtonColor.Black, style = MediumButtonStyle.height60, shape = RectangleShape, - text = stringResource(R.string.sent_envelope_edit_save), + text = stringResource(com.susu.core.ui.R.string.word_save), onClick = onClickSave, ) } - // DatePickerBottomSheet - if (isSheetOpen) { + if (uiState.showDatePickerSheet) { SusuDatePickerBottomSheet( maximumContainerHeight = 346.dp, - onDismissRequest = { _, _, _ -> isSheetOpen = false }, + initialYear = uiState.handedOverAt.year, + initialMonth = uiState.handedOverAt.monthValue, + initialDay = uiState.handedOverAt.dayOfMonth, + onDismissRequest = { year, month, day -> + onDateUpdated(year, month, day) + onDatePickerSheetDismissed() + }, + onItemSelected = { year, month, day -> onDateUpdated(year, month, day) }, ) } + + if (uiState.isLoading) { + LoadingScreen() + } } } diff --git a/feature/sent/src/main/java/com/susu/feature/envelopeedit/SentEnvelopeEditViewModel.kt b/feature/sent/src/main/java/com/susu/feature/envelopeedit/SentEnvelopeEditViewModel.kt index 63d1dbe8..2e273fee 100644 --- a/feature/sent/src/main/java/com/susu/feature/envelopeedit/SentEnvelopeEditViewModel.kt +++ b/feature/sent/src/main/java/com/susu/feature/envelopeedit/SentEnvelopeEditViewModel.kt @@ -1,12 +1,201 @@ package com.susu.feature.envelopeedit +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import com.susu.core.model.EnvelopeDetail import com.susu.core.ui.base.BaseViewModel +import com.susu.core.ui.extension.decodeFromUri +import com.susu.core.ui.util.getSafeLocalDateTime +import com.susu.domain.usecase.categoryconfig.GetCategoryConfigUseCase +import com.susu.domain.usecase.envelope.EditSentEnvelopeUseCase +import com.susu.domain.usecase.envelope.GetRelationShipConfigListUseCase +import com.susu.feature.sent.navigation.SentRoute import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.launch +import kotlinx.datetime.toJavaLocalDateTime +import kotlinx.datetime.toKotlinLocalDateTime +import kotlinx.serialization.json.Json import javax.inject.Inject @HiltViewModel -class SentEnvelopeEditViewModel @Inject constructor() : BaseViewModel( +class SentEnvelopeEditViewModel @Inject constructor( + private val getCategoryConfigUseCase: GetCategoryConfigUseCase, + private val getRelationShipConfigListUseCase: GetRelationShipConfigListUseCase, + private val editSentEnvelopeUseCase: EditSentEnvelopeUseCase, + savedStateHandle: SavedStateHandle, +) : BaseViewModel( SentEnvelopeEditState(), ) { + private val envelopeDetail = Json.decodeFromUri( + savedStateHandle.get(SentRoute.ENVELOPE_DETAIL_ARGUMENT_NAME)!!, + ) + + fun initData() { + viewModelScope.launch { + val relationshipConfigResult = getRelationShipConfigListUseCase() + val categoryConfigResult = getCategoryConfigUseCase() + + if (relationshipConfigResult.isSuccess && categoryConfigResult.isSuccess) { + val relationshipConfig = relationshipConfigResult.getOrDefault(emptyList()).toPersistentList() + val categoryConfig = categoryConfigResult.getOrDefault(emptyList()).toPersistentList() + with(envelopeDetail) { + intent { + copy( + categoryConfig = categoryConfig, + relationshipConfig = relationshipConfig, + amount = envelope.amount, + gift = envelope.gift, + memo = envelope.memo, + hasVisited = envelope.hasVisited, + handedOverAt = envelope.handedOverAt.toJavaLocalDateTime(), + friendName = friend.name, + relationshipId = relationship.id, + customRelationship = relationship.customRelation, + phoneNumber = friend.phoneNumber.ifEmpty { null }, + categoryId = category.id, + customCategory = category.customCategory, + showCustomCategory = category.id == categoryConfig.last().id, + customCategorySaved = category.id == categoryConfig.last().id, + showCustomRelationship = relationship.id == relationshipConfig.last().id, + customRelationshipSaved = relationship.id == relationshipConfig.last().id, + ) + } + } + } + } + } + + fun editEnvelope() { + viewModelScope.launch { + intent { copy(isLoading = true) } + editSentEnvelopeUseCase( + param = with(currentState) { + EditSentEnvelopeUseCase.Param( + envelopeId = envelopeDetail.envelope.id, + envelopeType = envelopeDetail.envelope.type, + friendId = envelopeDetail.friend.id, + friendName = friendName, + phoneNumber = phoneNumber, + relationshipId = relationshipId, + customRelation = customRelationship, + categoryId = categoryId, + customCategory = customCategory, + amount = amount, + gift = gift, + memo = memo, + handedOverAt = handedOverAt.toKotlinLocalDateTime(), + hasVisited = hasVisited, + ) + }, + ).onSuccess { + popBackStack() + }.onFailure { + postSideEffect(SentEnvelopeEditSideEffect.HandleException(it, ::editEnvelope)) + } + intent { copy(isLoading = false) } + } + } + fun popBackStack() = postSideEffect(SentEnvelopeEditSideEffect.PopBackStack) + + fun updateAmount(amount: Long) { + intent { copy(amount = amount) } + } + + fun updateGift(gift: String?) { + intent { copy(gift = gift?.ifEmpty { null }) } + } + + fun updateMemo(memo: String?) { + intent { copy(memo = memo?.ifEmpty { null }) } + } + + fun updateHasVisited(hasVisited: Boolean?) { + intent { + if (hasVisited == currentState.hasVisited) { + copy(hasVisited = null) + } else { + copy(hasVisited = hasVisited) + } + } + } + + fun updateHandedOverAt(year: Int, month: Int, day: Int) { + intent { copy(handedOverAt = getSafeLocalDateTime(year = year, month = month, day = day)) } + } + + fun updateFriendName(name: String) { + intent { copy(friendName = name) } + } + + fun updateRelationshipId(relationshipId: Long) { + intent { copy(relationshipId = relationshipId) } + } + + fun updateCustomRelationship(customRelationship: String) { + intent { copy(customRelationship = customRelationship) } + } + + fun updatePhoneNumber(phoneNumber: String?) { + intent { copy(phoneNumber = phoneNumber?.ifEmpty { null }) } + } + + fun updateCategoryId(categoryId: Int) { + intent { copy(categoryId = categoryId) } + } + + fun updateCustomCategory(customCategory: String?) { + intent { copy(customCategory = customCategory) } + } + + fun showCustomCategoryInput() { + intent { + copy( + showCustomCategory = true, + categoryId = categoryConfig.last().id, + customCategorySaved = false, + ) + } + postSideEffect(SentEnvelopeEditSideEffect.FocusCustomCategory) + } + + fun toggleCustomCategoryInputSaved() = intent { + copy( + customCategorySaved = !customCategorySaved, + ) + } + + fun hideCustomCategoryInput() { + intent { copy(showCustomCategory = false) } + } + + fun showCustomRelationshipInput() { + intent { + copy( + showCustomRelationship = true, + relationshipId = relationshipConfig.last().id, + customRelationshipSaved = false, + ) + } + postSideEffect(SentEnvelopeEditSideEffect.FocusCustomRelationship) + } + + fun toggleCustomRelationshipInputSaved() = intent { + copy( + customRelationshipSaved = !customRelationshipSaved, + ) + } + + fun hideCustomRelationshipInput() { + intent { copy(showCustomRelationship = false) } + } + + fun showDatePickerSheet() { + intent { copy(showDatePickerSheet = true) } + } + + fun hideDatePickerSheet() { + intent { copy(showDatePickerSheet = false) } + } } diff --git a/feature/sent/src/main/java/com/susu/feature/envelopeedit/component/EditDetailItem.kt b/feature/sent/src/main/java/com/susu/feature/envelopeedit/component/EditDetailItem.kt index f321a21c..9f7a73dd 100644 --- a/feature/sent/src/main/java/com/susu/feature/envelopeedit/component/EditDetailItem.kt +++ b/feature/sent/src/main/java/com/susu/feature/envelopeedit/component/EditDetailItem.kt @@ -1,7 +1,6 @@ package com.susu.feature.envelopeedit.component import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row @@ -13,26 +12,15 @@ import androidx.compose.foundation.layout.width import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import com.susu.core.designsystem.component.button.AddConditionButton -import com.susu.core.designsystem.component.button.FilledButtonColor -import com.susu.core.designsystem.component.button.SmallButtonStyle -import com.susu.core.designsystem.component.button.SusuFilledButton -import com.susu.core.designsystem.component.textfield.SusuBasicTextField -import com.susu.core.designsystem.theme.Gray30 import com.susu.core.designsystem.theme.Gray70 import com.susu.core.designsystem.theme.SusuTheme -import com.susu.feature.sent.R @OptIn(ExperimentalLayoutApi::class) @Composable @@ -69,67 +57,3 @@ fun EditDetailItem( } } } - -@Preview(showBackground = true, backgroundColor = 0xffffff) -@Composable -fun DetailItem() { - var name by remember { mutableStateOf("김철수") } - - SusuTheme { - Column( - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - // TextField 버전 - EditDetailItem( - categoryText = "이름", - categoryTextAlign = Alignment.CenterVertically, - ) { - SusuBasicTextField( - text = name, - onTextChange = { name = it }, - placeholder = stringResource(R.string.sent_envelope_edit_category_name_placeholder), - placeholderColor = Gray30, - textStyle = SusuTheme.typography.title_s, - modifier = Modifier.fillMaxWidth(), - ) - } - // Button 버전 - EditDetailItem( - categoryText = stringResource(R.string.sent_envelope_edit_category_event), - categoryTextAlign = Alignment.Top, - ) { - SusuFilledButton( - color = FilledButtonColor.Orange, - style = SmallButtonStyle.height32, - text = stringResource(R.string.sent_envelope_edit_category_event_wedding), - isActive = true, - onClick = {}, - ) - SusuFilledButton( - color = FilledButtonColor.Orange, - style = SmallButtonStyle.height32, - text = stringResource(R.string.sent_envelope_edit_category_event_first_birth), - isActive = false, - onClick = {}, - ) - SusuFilledButton( - color = FilledButtonColor.Orange, - style = SmallButtonStyle.height32, - text = stringResource(R.string.sent_envelope_edit_category_edit_funeral), - isActive = false, - onClick = {}, - ) - SusuFilledButton( - color = FilledButtonColor.Orange, - style = SmallButtonStyle.height32, - text = stringResource(R.string.sent_envelope_edit_category_edit_birthday), - isActive = false, - onClick = {}, - ) - AddConditionButton( - onClick = {}, - ) - } - } - } -} diff --git a/feature/sent/src/main/java/com/susu/feature/envelopefilter/EnvelopeFilterContract.kt b/feature/sent/src/main/java/com/susu/feature/envelopefilter/EnvelopeFilterContract.kt new file mode 100644 index 00000000..2cba5ee8 --- /dev/null +++ b/feature/sent/src/main/java/com/susu/feature/envelopefilter/EnvelopeFilterContract.kt @@ -0,0 +1,14 @@ +package com.susu.feature.envelopefilter + +import com.susu.core.ui.base.SideEffect +import com.susu.core.ui.base.UiState + +data class EnvelopeFilterState( + val temp: String = "", +) : UiState + +sealed interface EnvelopeFilterSideEffect : SideEffect { + data object PopBackStack : EnvelopeFilterSideEffect + data class PopBackStackWithFilter(val filter: String) : EnvelopeFilterSideEffect + data class HandleException(val throwable: Throwable, val retry: () -> Unit) : EnvelopeFilterSideEffect +} diff --git a/feature/sent/src/main/java/com/susu/feature/envelopefilter/EnvelopeFilterScreen.kt b/feature/sent/src/main/java/com/susu/feature/envelopefilter/EnvelopeFilterScreen.kt new file mode 100644 index 00000000..134a6d07 --- /dev/null +++ b/feature/sent/src/main/java/com/susu/feature/envelopefilter/EnvelopeFilterScreen.kt @@ -0,0 +1,167 @@ +package com.susu.feature.envelopefilter + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.susu.core.designsystem.component.appbar.SusuDefaultAppBar +import com.susu.core.designsystem.component.appbar.icon.BackIcon +import com.susu.core.designsystem.component.button.FilledButtonColor +import com.susu.core.designsystem.component.button.LinedButtonColor +import com.susu.core.designsystem.component.button.RefreshButton +import com.susu.core.designsystem.component.button.SelectedFilterButton +import com.susu.core.designsystem.component.button.SmallButtonStyle +import com.susu.core.designsystem.component.button.SusuFilledButton +import com.susu.core.designsystem.component.button.SusuLinedButton +import com.susu.core.designsystem.component.button.XSmallButtonStyle +import com.susu.core.designsystem.theme.SusuTheme +import com.susu.core.ui.extension.collectWithLifecycle +import com.susu.feature.envelopefilter.component.MoneySlider +import com.susu.feature.sent.R + +@Composable +fun EnvelopeFilterRoute( + viewModel: EnvelopeFilterViewModel = hiltViewModel(), + popBackStack: () -> Unit, + popBackStackWithFilter: (String) -> Unit, + handleException: (Throwable, () -> Unit) -> Unit, +) { + val uiState = viewModel.uiState.collectAsStateWithLifecycle().value + viewModel.sideEffect.collectWithLifecycle { sideEffect -> + when (sideEffect) { + is EnvelopeFilterSideEffect.HandleException -> handleException(sideEffect.throwable, sideEffect.retry) + EnvelopeFilterSideEffect.PopBackStack -> popBackStack() + is EnvelopeFilterSideEffect.PopBackStackWithFilter -> popBackStackWithFilter(sideEffect.filter) + } + } + + LaunchedEffect(key1 = Unit) { + viewModel.initData() + } + + EnvelopeFilterScreen( + uiState = uiState, + onClickBackIcon = viewModel::popBackStack, + onClickApplyFilterButton = viewModel::popBackStackWithFilter, + ) +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun EnvelopeFilterScreen( + @Suppress("detekt:UnusedParameter") + uiState: EnvelopeFilterState = EnvelopeFilterState(), + onClickBackIcon: () -> Unit = {}, + onClickApplyFilterButton: () -> Unit = {}, + onClickRefreshButton: () -> Unit = {}, +) { + Column( + modifier = Modifier + .background(SusuTheme.colorScheme.background10) + .fillMaxSize(), + ) { + SusuDefaultAppBar( + leftIcon = { + BackIcon(onClickBackIcon) + }, + title = stringResource(id = com.susu.core.ui.R.string.word_filter), + ) + + Column( + modifier = Modifier.padding( + top = SusuTheme.spacing.spacing_xl, + start = SusuTheme.spacing.spacing_m, + end = SusuTheme.spacing.spacing_m, + bottom = SusuTheme.spacing.spacing_xxs, + ), + ) { + Text(text = stringResource(R.string.envelope_filter_screen_friend), style = SusuTheme.typography.title_xs) + Spacer(modifier = Modifier.size(SusuTheme.spacing.spacing_m)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(SusuTheme.spacing.spacing_xxs), + verticalArrangement = Arrangement.spacedBy(SusuTheme.spacing.spacing_xxs), + ) { + listOf("이진욱", "김철수", "홍길동", "박예은", "박미영", "서한누리", "서한누리").forEach { category -> + SusuLinedButton( + color = LinedButtonColor.Black, + style = XSmallButtonStyle.height28, + isActive = true, + text = category, + onClick = { }, + ) + } + } + + Spacer(modifier = Modifier.size(SusuTheme.spacing.spacing_xxxxxxl)) + Text( + text = stringResource(R.string.envelope_filter_screen_money), + style = SusuTheme.typography.title_xs, + ) + Spacer(modifier = Modifier.size(SusuTheme.spacing.spacing_m)) + + Text(text = "20,000원~100,000원", style = SusuTheme.typography.title_m) + + Spacer(modifier = Modifier.size(SusuTheme.spacing.spacing_xxs)) + + MoneySlider(value = 20_000f..100_000f, onValueChange = {}, valueRange = 0f..100_000f) + + Spacer(modifier = Modifier.weight(1f)) + + Column( + verticalArrangement = Arrangement.spacedBy(SusuTheme.spacing.spacing_m), + ) { + FlowRow( + verticalArrangement = Arrangement.spacedBy(SusuTheme.spacing.spacing_xxs), + horizontalArrangement = Arrangement.spacedBy(SusuTheme.spacing.spacing_xxs), + ) { + SelectedFilterButton( + name = "이진욱", + ) + + SelectedFilterButton( + name = "20,000~10,000", + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(SusuTheme.spacing.spacing_m), + ) { + RefreshButton(onClick = onClickRefreshButton) + + SusuFilledButton( + modifier = Modifier.fillMaxWidth(), + color = FilledButtonColor.Black, + style = SmallButtonStyle.height48, + isActive = true, + text = stringResource(com.susu.core.ui.R.string.word_apply_filter), + onClick = onClickApplyFilterButton, + ) + } + } + } + } +} + +@Preview +@Composable +fun EnvelopeFilterScreenPreview() { + SusuTheme { + EnvelopeFilterScreen() + } +} diff --git a/feature/sent/src/main/java/com/susu/feature/envelopefilter/EnvelopeFilterViewModel.kt b/feature/sent/src/main/java/com/susu/feature/envelopefilter/EnvelopeFilterViewModel.kt new file mode 100644 index 00000000..b16f4b49 --- /dev/null +++ b/feature/sent/src/main/java/com/susu/feature/envelopefilter/EnvelopeFilterViewModel.kt @@ -0,0 +1,43 @@ +package com.susu.feature.envelopefilter + +import androidx.lifecycle.SavedStateHandle +import com.susu.core.ui.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class EnvelopeFilterViewModel @Inject constructor( + @Suppress("detekt:UnusedPrivateProperty") + savedStateHandle: SavedStateHandle, +) : BaseViewModel( + EnvelopeFilterState(), +) { +// private val argument = savedStateHandle.get(ReceivedRoute.FILTER_ARGUMENT_NAME)!! +// private var filter = FilterArgument() + + fun initData() { + initFilter() + } + + private fun initFilter() { +// filter = Json.decodeFromUri(argument) +// intent { +// copy( +// selectedCategoryList = filter.selectedCategoryList.toPersistentList(), +// startAt = filter.startAt?.toJavaLocalDateTime(), +// endAt = filter.endAt?.toJavaLocalDateTime(), +// ) +// } + } + + fun popBackStack() = postSideEffect(EnvelopeFilterSideEffect.PopBackStack) + fun popBackStackWithFilter() { +// val filter = FilterArgument( +// selectedCategoryList = currentState.selectedCategoryList, +// startAt = currentState.startAt?.toKotlinLocalDateTime(), +// endAt = currentState.endAt?.toKotlinLocalDateTime(), +// ) +// +// postSideEffect(EnvelopeFilterSideEffect.PopBackStackWithFilter(Json.encodeToUri(filter))) + } +} diff --git a/feature/sent/src/main/java/com/susu/feature/envelopefilter/component/MoneySlider.kt b/feature/sent/src/main/java/com/susu/feature/envelopefilter/component/MoneySlider.kt new file mode 100644 index 00000000..ea36a7e0 --- /dev/null +++ b/feature/sent/src/main/java/com/susu/feature/envelopefilter/component/MoneySlider.kt @@ -0,0 +1,221 @@ +package com.susu.feature.envelopefilter.component + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.RangeSlider +import androidx.compose.material3.RangeSliderState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import com.susu.core.designsystem.theme.Gray10 +import com.susu.core.designsystem.theme.Orange20 +import com.susu.core.designsystem.theme.Orange60 +import com.susu.core.designsystem.theme.SusuTheme +import kotlin.math.roundToInt + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MoneySlider( + modifier: Modifier = Modifier, + height: Dp = 8.dp, + value: ClosedFloatingPointRange, + valueRange: ClosedFloatingPointRange = 0f..1f, + onValueChange: (ClosedFloatingPointRange) -> Unit, +) { + Box( + modifier = Modifier.height(24.dp), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .fillMaxWidth() + .height(height) + .background(Orange20), + ) + RangeSlider( + modifier = modifier, + valueRange = valueRange, + value = value, + onValueChange = onValueChange, + steps = 0, + startThumb = { + MoneySliderThumb() + }, + endThumb = { + MoneySliderThumb() + }, + track = { + MoneySliderTrack( + rangeSliderState = it, + height = height, + ) + }, + ) + } +} + +@Composable +private fun MoneySliderThumb() { + Box( + modifier = Modifier + .shadow(elevation = 8.dp, spotColor = Color(0x14000000), ambientColor = Color(0x14000000), clip = true, shape = CircleShape) + .size(24.dp) + .background(color = Gray10, shape = CircleShape) + .padding(SusuTheme.spacing.spacing_xxxs), + ) { + Box( + modifier = Modifier + .size(12.dp) + .background(color = Orange60, shape = CircleShape), + ) + } +} + +@Composable +@ExperimentalMaterial3Api +fun MoneySliderTrack( + modifier: Modifier = Modifier, + rangeSliderState: RangeSliderState, + height: Dp = 8.dp, +) { + Canvas( + modifier + .fillMaxWidth() + .height(height), + ) { + drawTrack( + activeRangeStart = rangeSliderState.coercedActiveRangeStartAsFraction, + activeRangeEnd = rangeSliderState.coercedActiveRangeEndAsFraction, + inactiveTrackColor = Orange20, + activeTrackColor = Orange60, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +internal val RangeSliderState.coercedActiveRangeStartAsFraction + get() = calcFraction( + valueRange.start, + valueRange.endInclusive, + activeRangeStart, + ) + +@OptIn(ExperimentalMaterial3Api::class) +internal val RangeSliderState.coercedActiveRangeEndAsFraction + get() = calcFraction( + valueRange.start, + valueRange.endInclusive, + activeRangeEnd, + ) + +// Calculate the 0..1 fraction that `pos` value represents between `a` and `b` +private fun calcFraction(a: Float, b: Float, pos: Float) = + (if (b - a == 0f) 0f else (pos - a) / (b - a)).coerceIn(0f, 1f) + +private fun DrawScope.drawTrack( + activeRangeStart: Float, + activeRangeEnd: Float, + inactiveTrackColor: Color, + activeTrackColor: Color, +) { + val isRtl = layoutDirection == LayoutDirection.Rtl + val sliderLeft = Offset(0f, center.y) + val sliderRight = Offset(size.width, center.y) + val sliderStart = if (isRtl) sliderRight else sliderLeft + val sliderEnd = if (isRtl) sliderLeft else sliderRight + val trackStrokeWidth = 8.dp.toPx() + drawLine( + inactiveTrackColor, + sliderStart, + sliderEnd, + trackStrokeWidth, + StrokeCap.Round, + ) + val sliderValueEnd = Offset( + sliderStart.x + + (sliderEnd.x - sliderStart.x) * activeRangeEnd, + center.y, + ) + + val sliderValueStart = Offset( + sliderStart.x + + (sliderEnd.x - sliderStart.x) * activeRangeStart, + center.y, + ) + + drawLine( + activeTrackColor, + sliderValueStart, + sliderValueEnd, + trackStrokeWidth, + StrokeCap.Round, + ) +} + +@Preview(showBackground = true) +@Composable +fun MoneySliderPreview() { + SusuTheme { + var value by remember { + mutableStateOf(0f..3_222f) + } + + val step = when { + value.endInclusive <= 10_000 -> 1f + value.endInclusive <= 10_000 -> 1000f + value.endInclusive <= 1_000_000 -> 10_000f // 0원 ~ 100만원 범위, 1만원 간격 + value.endInclusive <= 5_000_000 -> 50_000f // 101만원 ~ 500만원 범위, 5만원 간격 + else -> 10_0000f // 500만원 이상, 10만원 간격 + } + + Column( + modifier = Modifier.padding(10.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(text = "${value.start}") + + Text(text = "${value.endInclusive}") + } + MoneySlider( + value = value, + valueRange = 0f..3_222f.roundToStep(step) + step, + onValueChange = { value = it.start.roundToStep(step)..it.endInclusive.roundToStep(step) }, + ) + } + } +} + +fun Float.roundToStep(step: Float): Float { + return (this / step).roundToInt() * step +} diff --git a/feature/sent/src/main/java/com/susu/feature/sent/SentContract.kt b/feature/sent/src/main/java/com/susu/feature/sent/SentContract.kt index c93f4654..072b4608 100644 --- a/feature/sent/src/main/java/com/susu/feature/sent/SentContract.kt +++ b/feature/sent/src/main/java/com/susu/feature/sent/SentContract.kt @@ -1,6 +1,8 @@ package com.susu.feature.sent -import com.susu.core.model.EnvelopeStatics +import com.susu.core.model.EnvelopeSearch +import com.susu.core.model.Friend +import com.susu.core.model.FriendStatistics import com.susu.core.ui.base.SideEffect import com.susu.core.ui.base.UiState import kotlinx.collections.immutable.PersistentList @@ -8,12 +10,27 @@ import kotlinx.collections.immutable.persistentListOf data class SentState( val isLoading: Boolean = false, - val envelopesList: PersistentList = persistentListOf(), + val envelopesList: PersistentList = persistentListOf(), val showEmptyEnvelopes: Boolean = false, ) : UiState -sealed interface SentSideEffect : SideEffect { - data object NavigateEnvelopeDetail : SentSideEffect - data object NavigateEnvelopeAdd : SentSideEffect - data object NavigateEnvelopeSearch : SentSideEffect +data class FriendStatisticsState( + val friend: Friend = Friend(), + val receivedAmounts: Int = 0, + val sentAmounts: Int = 0, + val totalAmounts: Int = 0, + val envelopesHistoryList: PersistentList = persistentListOf(), + val expand: Boolean = false, +) + +internal fun FriendStatistics.toState() = FriendStatisticsState( + friend = friend, + receivedAmounts = receivedAmounts, + sentAmounts = sentAmounts, + totalAmounts = totalAmounts, +) + +sealed interface SentEffect : SideEffect { + data class NavigateEnvelope(val id: Long) : SentEffect + data object NavigateEnvelopeAdd : SentEffect } diff --git a/feature/sent/src/main/java/com/susu/feature/sent/SentScreen.kt b/feature/sent/src/main/java/com/susu/feature/sent/SentScreen.kt index 7066d057..9b1f7eb0 100644 --- a/feature/sent/src/main/java/com/susu/feature/sent/SentScreen.kt +++ b/feature/sent/src/main/java/com/susu/feature/sent/SentScreen.kt @@ -12,19 +12,20 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.susu.core.designsystem.component.appbar.SusuDefaultAppBar import com.susu.core.designsystem.component.appbar.icon.LogoIcon import com.susu.core.designsystem.component.appbar.icon.NotificationIcon @@ -36,35 +37,59 @@ import com.susu.core.designsystem.component.button.SusuGhostButton import com.susu.core.designsystem.theme.Gray100 import com.susu.core.designsystem.theme.Gray50 import com.susu.core.designsystem.theme.SusuTheme +import com.susu.core.ui.extension.OnBottomReached +import com.susu.core.ui.extension.collectWithLifecycle import com.susu.feature.sent.component.SentCard @Composable fun SentRoute( + viewModel: SentViewModel = hiltViewModel(), padding: PaddingValues, - navigateSentEnvelope: () -> Unit, + navigateSentEnvelope: (Long) -> Unit, navigateSentEnvelopeAdd: () -> Unit, navigateSentEnvelopeSearch: () -> Unit, ) { + val uiState = viewModel.uiState.collectAsStateWithLifecycle().value + val envelopesListState = rememberLazyListState() + + viewModel.sideEffect.collectWithLifecycle { sideEffect -> + when (sideEffect) { + SentEffect.NavigateEnvelopeAdd -> navigateSentEnvelopeAdd() + is SentEffect.NavigateEnvelope -> navigateSentEnvelope(sideEffect.id) + } + } + + envelopesListState.OnBottomReached { + viewModel.getEnvelopesList() + } + SentScreen( + uiState = uiState, + envelopesListState = envelopesListState, padding = padding, onClickHistoryShowAll = navigateSentEnvelope, onClickAddEnvelope = navigateSentEnvelopeAdd, onClickSearchIcon = navigateSentEnvelopeSearch, + onClickHistory = { friendId -> + viewModel.getEnvelopesHistoryList(friendId) + }, + onClickHistoryShowAll = viewModel::navigateSentEnvelope, + onClickAddEnvelope = viewModel::navigateSentAdd, ) } @Composable fun SentScreen( - padding: PaddingValues, modifier: Modifier = Modifier, + uiState: SentState = SentState(), + envelopesListState: LazyListState = rememberLazyListState(), + padding: PaddingValues, onClickSearchIcon: () -> Unit = {}, onClickNotificationIcon: () -> Unit = {}, - onClickHistoryShowAll: () -> Unit = {}, + onClickHistory: (Long) -> Unit = {}, + onClickHistoryShowAll: (Long) -> Unit = {}, onClickAddEnvelope: () -> Unit = {}, ) { - // TODO: 수정 필요 (확인을 위해 false로 설정) - var isEmpty by remember { mutableStateOf(false) } - Box( modifier = Modifier .background(SusuTheme.colorScheme.background15) @@ -84,28 +109,43 @@ fun SentScreen( }, ) - if (!isEmpty) { - LazyColumn( - verticalArrangement = Arrangement.spacedBy(SusuTheme.spacing.spacing_xxs), - contentPadding = PaddingValues(SusuTheme.spacing.spacing_m), + LazyColumn( + modifier = modifier.fillMaxSize(), + state = envelopesListState, + verticalArrangement = Arrangement.spacedBy(SusuTheme.spacing.spacing_xxs), + contentPadding = PaddingValues(SusuTheme.spacing.spacing_m), + ) { + item { + FilterSection( + padding = PaddingValues( + bottom = SusuTheme.spacing.spacing_xxs, + ), + ) + } + + items( + items = uiState.envelopesList, + key = { it.friend.id }, ) { - // TODO: 수정 필요 - item { - FilterSection( - padding = PaddingValues( - bottom = SusuTheme.spacing.spacing_xxs, - ), - ) - } - items(8) { - SentCard(onClick = onClickHistoryShowAll) - } + SentCard( + uiState = it, + friend = it.friend, + totalAmounts = it.totalAmounts, + sentAmounts = it.sentAmounts, + receivedAmounts = it.receivedAmounts, + onClickHistory = onClickHistory, + onClickHistoryShowAll = onClickHistoryShowAll, + ) } - } else { + } + + if (uiState.showEmptyEnvelopes) { FilterSection( padding = PaddingValues(SusuTheme.spacing.spacing_m), ) - EmptyView() + EmptyView( + onClickAddEnvelope = onClickAddEnvelope, + ) } } @@ -161,6 +201,7 @@ fun FilterSection( @Composable fun EmptyView( modifier: Modifier = Modifier, + onClickAddEnvelope: () -> Unit = {}, ) { Column( modifier = modifier.fillMaxSize(), @@ -177,6 +218,7 @@ fun EmptyView( color = GhostButtonColor.Black, style = SmallButtonStyle.height40, text = stringResource(R.string.sent_screen_empty_view_add_button), + onClick = onClickAddEnvelope, ) } } diff --git a/feature/sent/src/main/java/com/susu/feature/sent/SentViewModel.kt b/feature/sent/src/main/java/com/susu/feature/sent/SentViewModel.kt index 965509ce..f138d45e 100644 --- a/feature/sent/src/main/java/com/susu/feature/sent/SentViewModel.kt +++ b/feature/sent/src/main/java/com/susu/feature/sent/SentViewModel.kt @@ -2,22 +2,63 @@ package com.susu.feature.sent import androidx.lifecycle.viewModelScope import com.susu.core.ui.base.BaseViewModel +import com.susu.domain.usecase.envelope.GetEnvelopesHistoryListUseCase import com.susu.domain.usecase.envelope.GetEnvelopesListUseCase import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class SentViewModel @Inject constructor( - @Suppress("detekt:UnusedPrivateProperty") private val getEnvelopesListUseCase: GetEnvelopesListUseCase, -) : BaseViewModel( + private val getEnvelopesHistoryListUseCase: GetEnvelopesHistoryListUseCase, +) : BaseViewModel( SentState(), ) { - @Suppress("detekt:UnusedPrivateProperty") private var page = 0 fun getEnvelopesList() = viewModelScope.launch { - // TODO: 리스트 불러오기 + getEnvelopesListUseCase( + GetEnvelopesListUseCase.Param(page = page), + ).onSuccess { envelopesList -> + page++ + val newEnvelopesList = currentState.envelopesList.plus(envelopesList.map { it.toState() }).toPersistentList() + intent { + copy( + envelopesList = newEnvelopesList, + showEmptyEnvelopes = newEnvelopesList.isEmpty(), + ) + } + } } + + fun getEnvelopesHistoryList(id: Long) = viewModelScope.launch { + val friendsList: List = listOf(id) + val includeList = listOf("CATEGORY", "FRIEND") + + getEnvelopesHistoryListUseCase( + GetEnvelopesHistoryListUseCase.Param(friendIds = friendsList, include = includeList), + ).onSuccess { history -> + val envelopesHistorySubList = if (history.size < 3) history else history.take(3) + val newEnvelopesHistoryList = envelopesHistorySubList.toPersistentList() + intent { + copy( + envelopesList = envelopesList.map { + if (it.friend.id == id) { + it.copy( + envelopesHistoryList = newEnvelopesHistoryList, + expand = !it.expand, + ) + } else { + it + } + }.toPersistentList(), + ) + } + } + } + + fun navigateSentEnvelope(id: Long) = postSideEffect(SentEffect.NavigateEnvelope(id = id)) + fun navigateSentAdd() = postSideEffect(SentEffect.NavigateEnvelopeAdd) } diff --git a/feature/sent/src/main/java/com/susu/feature/sent/component/SentCard.kt b/feature/sent/src/main/java/com/susu/feature/sent/component/SentCard.kt index 43e30906..3d0ca963 100644 --- a/feature/sent/src/main/java/com/susu/feature/sent/component/SentCard.kt +++ b/feature/sent/src/main/java/com/susu/feature/sent/component/SentCard.kt @@ -23,9 +23,6 @@ import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -43,18 +40,24 @@ import com.susu.core.designsystem.theme.Gray60 import com.susu.core.designsystem.theme.Gray90 import com.susu.core.designsystem.theme.Orange20 import com.susu.core.designsystem.theme.SusuTheme +import com.susu.core.model.Friend import com.susu.core.ui.extension.susuClickable +import com.susu.core.ui.extension.toMoneyFormat +import com.susu.feature.sent.FriendStatisticsState import com.susu.feature.sent.R @Composable fun SentCard( modifier: Modifier = Modifier, - onClick: () -> Unit = {}, + uiState: FriendStatisticsState = FriendStatisticsState(), + friend: Friend, + totalAmounts: Int = 0, + sentAmounts: Int = 0, + receivedAmounts: Int = 0, + onClickHistory: (Long) -> Unit = {}, + onClickHistoryShowAll: (Long) -> Unit = {}, ) { - // TODO: 수정 필요 - var expanded by remember { mutableStateOf(false) } - val degrees by animateFloatAsState(if (expanded) 180f else 0f, label = "") - val historyCount = 3 + val degrees by animateFloatAsState(if (uiState.expand) 180f else 0f, label = "") Box( modifier = modifier @@ -76,14 +79,15 @@ fun SentCard( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = "김철수", + text = friend.name, style = SusuTheme.typography.title_xs, color = Gray100, ) Spacer(modifier = modifier.size(SusuTheme.spacing.spacing_s)) SusuBadge( color = BadgeColor.Gray20, - text = "전체 100,000원", + text = stringResource(R.string.sent_envelope_card_monee_total) + totalAmounts.toMoneyFormat() + + stringResource(R.string.sent_envelope_card_money_won), padding = BadgeStyle.smallBadge, ) Spacer(modifier = modifier.weight(1f)) @@ -93,9 +97,11 @@ fun SentCard( tint = Gray100, modifier = modifier .clip(CircleShape) - .susuClickable { - expanded = !expanded - } + .susuClickable( + onClick = { + onClickHistory(friend.id) + }, + ) .rotate(degrees = degrees), ) } @@ -118,13 +124,13 @@ fun SentCard( ) } LinearProgressIndicator( - progress = { 0.7f }, - color = SusuTheme.colorScheme.primary, - trackColor = Orange20, - strokeCap = StrokeCap.Round, + progress = { sentAmounts.toFloat() / totalAmounts }, modifier = modifier .fillMaxWidth() .padding(vertical = SusuTheme.spacing.spacing_xxxxs), + color = SusuTheme.colorScheme.primary, + trackColor = Orange20, + strokeCap = StrokeCap.Round, ) Row( modifier = modifier @@ -133,12 +139,12 @@ fun SentCard( horizontalArrangement = Arrangement.SpaceBetween, ) { Text( - text = "70,000원", + text = sentAmounts.toMoneyFormat() + stringResource(R.string.sent_envelope_card_money_won), style = SusuTheme.typography.title_xxxs, color = Gray90, ) Text( - text = "30,000원", + text = receivedAmounts.toMoneyFormat() + stringResource(R.string.sent_envelope_card_money_won), style = SusuTheme.typography.title_xxxs, color = Gray60, ) @@ -146,13 +152,14 @@ fun SentCard( } } AnimatedVisibility( - visible = expanded, + visible = uiState.expand, enter = fadeIn() + expandVertically(), exit = fadeOut() + shrinkVertically(), ) { SentHistoryCard( - historyCount = historyCount, - onClick = onClick, + envelopeHistoryList = uiState.envelopesHistoryList, + friendId = friend.id, + onClickHistoryShowAll = onClickHistoryShowAll, ) } } diff --git a/feature/sent/src/main/java/com/susu/feature/sent/component/SentHistoryCard.kt b/feature/sent/src/main/java/com/susu/feature/sent/component/SentHistoryCard.kt index 5c950b3a..5c049174 100644 --- a/feature/sent/src/main/java/com/susu/feature/sent/component/SentHistoryCard.kt +++ b/feature/sent/src/main/java/com/susu/feature/sent/component/SentHistoryCard.kt @@ -19,13 +19,17 @@ import com.susu.core.designsystem.component.button.XSmallButtonStyle import com.susu.core.designsystem.theme.Gray10 import com.susu.core.designsystem.theme.Gray20 import com.susu.core.designsystem.theme.SusuTheme +import com.susu.core.model.EnvelopeSearch import com.susu.feature.sent.R +import kotlinx.collections.immutable.PersistentList +import kotlinx.datetime.toJavaLocalDateTime @Composable fun SentHistoryCard( modifier: Modifier = Modifier, - historyCount: Int, - onClick: () -> Unit = {}, + envelopeHistoryList: PersistentList, + friendId: Long, + onClickHistoryShowAll: (Long) -> Unit = {}, ) { Card( modifier = modifier @@ -49,10 +53,16 @@ fun SentHistoryCard( end = SusuTheme.spacing.spacing_m, ), ) { - repeat(historyCount) { - SentHistoryItem(isSent = true) + envelopeHistoryList.forEach { + SentHistoryItem( + type = it.envelope.type, + event = it.category!!.category, + date = it.envelope.handedOverAt.toJavaLocalDateTime(), + money = it.envelope.amount, + ) Spacer(modifier = modifier.size(SusuTheme.spacing.spacing_xxs)) } + Spacer(modifier = modifier.size(SusuTheme.spacing.spacing_xxs)) SusuFilledButton( color = FilledButtonColor.Black, @@ -60,7 +70,7 @@ fun SentHistoryCard( text = stringResource(R.string.sent_screen_envelope_history_show_all), modifier = modifier .fillMaxWidth(), - onClick = onClick, + onClick = { onClickHistoryShowAll(friendId) }, ) } } diff --git a/feature/sent/src/main/java/com/susu/feature/sent/component/SentHistoryItem.kt b/feature/sent/src/main/java/com/susu/feature/sent/component/SentHistoryItem.kt index e37dea12..938e05e6 100644 --- a/feature/sent/src/main/java/com/susu/feature/sent/component/SentHistoryItem.kt +++ b/feature/sent/src/main/java/com/susu/feature/sent/component/SentHistoryItem.kt @@ -10,7 +10,8 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource -import androidx.compose.ui.unit.dp +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview import com.susu.core.designsystem.component.badge.BadgeColor import com.susu.core.designsystem.component.badge.BadgeStyle import com.susu.core.designsystem.component.badge.SusuBadge @@ -19,12 +20,22 @@ import com.susu.core.designsystem.theme.Gray40 import com.susu.core.designsystem.theme.Gray50 import com.susu.core.designsystem.theme.Orange60 import com.susu.core.designsystem.theme.SusuTheme +import com.susu.core.ui.extension.toMoneyFormat +import com.susu.core.ui.util.to_yyyy_dot_MM_dot_dd import com.susu.feature.sent.R +import java.time.LocalDateTime + +enum class EnvelopeType { + SENT, RECEIVED +} @Composable fun SentHistoryItem( modifier: Modifier = Modifier, - isSent: Boolean = true, + type: String = "", + event: String = "", + date: LocalDateTime = LocalDateTime.now(), + money: Long = 0, ) { Row( modifier = modifier.fillMaxWidth(), @@ -32,35 +43,47 @@ fun SentHistoryItem( ) { Icon( painter = painterResource( - id = if (isSent) { + id = if (type == EnvelopeType.SENT.name) { R.drawable.ic_round_arrow_sent } else { R.drawable.ic_round_arrow_received }, ), contentDescription = null, - tint = if (isSent) Orange60 else Gray40, - modifier = modifier.size(20.dp), + tint = if (type == EnvelopeType.SENT.name) Orange60 else Gray40, + modifier = modifier.size(SusuTheme.spacing.spacing_l), ) Spacer(modifier = modifier.size(SusuTheme.spacing.spacing_s)) - // TODO: text 수정 필요 SusuBadge( - color = if (isSent) BadgeColor.Gray90 else BadgeColor.Gray40, - text = "돌잔치", + color = if (type == EnvelopeType.SENT.name) BadgeColor.Gray90 else BadgeColor.Gray40, + text = event, padding = BadgeStyle.extraSmallBadge, ) Spacer(modifier = modifier.size(SusuTheme.spacing.spacing_s)) Text( - text = "23.07.18", + text = date.to_yyyy_dot_MM_dot_dd().substring(2), style = SusuTheme.typography.title_xxxs, - color = if (isSent) Gray100 else Gray50, + color = if (type == EnvelopeType.SENT.name) Gray100 else Gray50, ) Spacer(modifier = modifier.weight(1f)) Text( - text = "50,000원", + text = money.toInt().toMoneyFormat() + stringResource(R.string.sent_envelope_card_money_won), style = SusuTheme.typography.title_xxs, - color = if (isSent) Gray100 else Gray50, + color = if (type == EnvelopeType.SENT.name) Gray100 else Gray50, + ) + } +} + +@Preview(showBackground = true, backgroundColor = 0xFFFFFF) +@Composable +fun SentHistoryItemPreview() { + SusuTheme { + SentHistoryItem( + type = "SENT", + event = "돌잔치", + date = LocalDateTime.now(), + money = 50000, ) } } diff --git a/feature/sent/src/main/java/com/susu/feature/sent/navigation/SentNavigation.kt b/feature/sent/src/main/java/com/susu/feature/sent/navigation/SentNavigation.kt index aa63e741..0ad9d833 100644 --- a/feature/sent/src/main/java/com/susu/feature/sent/navigation/SentNavigation.kt +++ b/feature/sent/src/main/java/com/susu/feature/sent/navigation/SentNavigation.kt @@ -4,28 +4,35 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptions +import androidx.navigation.NavType import androidx.navigation.compose.composable +import androidx.navigation.navArgument +import com.susu.core.model.EnvelopeDetail +import com.susu.core.ui.DialogToken +import com.susu.core.ui.SnackbarToken +import com.susu.core.ui.extension.encodeToUri import com.susu.feature.envelope.SentEnvelopeRoute import com.susu.feature.envelopeadd.SentEnvelopeAddRoute import com.susu.feature.envelopedetail.SentEnvelopeDetailRoute import com.susu.feature.envelopeedit.SentEnvelopeEditRoute import com.susu.feature.envelopesearch.SentEnvelopeSearchRoute import com.susu.feature.sent.SentRoute +import kotlinx.serialization.json.Json fun NavController.navigateSent(navOptions: NavOptions) { navigate(SentRoute.route, navOptions) } -fun NavController.navigateSentEnvelope() { - navigate(SentRoute.sentEnvelopeRoute) +fun NavController.navigateSentEnvelope(id: Long) { + navigate(SentRoute.sentEnvelopeRoute(id = id.toString())) } -fun NavController.navigateSentEnvelopeDetail() { - navigate(SentRoute.sentEnvelopeDetailRoute) +fun NavController.navigateSentEnvelopeDetail(id: Long) { + navigate(SentRoute.sentEnvelopeDetailRoute(id = id.toString())) } -fun NavController.navigateSentEnvelopeEdit() { - navigate(SentRoute.sentEnvelopeEditRoute) +fun NavController.navigateSentEnvelopeEdit(envelopeDetail: EnvelopeDetail) { + navigate(SentRoute.sentEnvelopeEditRoute(Json.encodeToUri(envelopeDetail))) } fun NavController.navigateSentEnvelopeAdd() { @@ -39,11 +46,13 @@ fun NavController.navigateSentEnvelopeSearch() { fun NavGraphBuilder.sentNavGraph( padding: PaddingValues, popBackStack: () -> Unit, - navigateSentEnvelope: () -> Unit, - navigateSentEnvelopeDetail: () -> Unit, - navigateSentEnvelopeEdit: () -> Unit, + navigateSentEnvelope: (Long) -> Unit, + navigateSentEnvelopeDetail: (Long) -> Unit, + navigateSentEnvelopeEdit: (EnvelopeDetail) -> Unit, navigateSentEnvelopeAdd: () -> Unit, navigateSentEnvelopeSearch: () -> Unit, + onShowSnackbar: (SnackbarToken) -> Unit, + onShowDialog: (DialogToken) -> Unit, handleException: (Throwable, () -> Unit) -> Unit, ) { composable(route = SentRoute.route) { @@ -55,24 +64,40 @@ fun NavGraphBuilder.sentNavGraph( ) } - composable(route = SentRoute.sentEnvelopeRoute) { + composable( + route = SentRoute.sentEnvelopeRoute("{${SentRoute.FRIEND_ID_ARGUMENT_NAME}}"), + arguments = listOf( + navArgument(SentRoute.FRIEND_ID_ARGUMENT_NAME) { + type = NavType.LongType + }, + ), + ) { SentEnvelopeRoute( popBackStack = popBackStack, navigateSentEnvelopeDetail = navigateSentEnvelopeDetail, ) } - composable(route = SentRoute.sentEnvelopeDetailRoute) { + composable( + route = SentRoute.sentEnvelopeDetailRoute("{${SentRoute.ENVELOPE_ID_ARGUMENT_NAME}}"), + arguments = listOf( + navArgument(SentRoute.ENVELOPE_ID_ARGUMENT_NAME) { + type = NavType.LongType + }, + ), + ) { SentEnvelopeDetailRoute( popBackStack = popBackStack, navigateSentEnvelopeEdit = navigateSentEnvelopeEdit, + onShowSnackbar = onShowSnackbar, + onShowDialog = onShowDialog, + handleException = handleException, ) } - composable(route = SentRoute.sentEnvelopeEditRoute) { + composable(route = SentRoute.sentEnvelopeEditRoute("{${SentRoute.ENVELOPE_DETAIL_ARGUMENT_NAME}}")) { SentEnvelopeEditRoute( popBackStack = popBackStack, - navigateSentEnvelopeDetail = navigateSentEnvelopeDetail, ) } @@ -92,9 +117,15 @@ fun NavGraphBuilder.sentNavGraph( object SentRoute { const val route = "sent" - const val sentEnvelopeRoute = "sent-envelope" - const val sentEnvelopeDetailRoute = "sent-envelope-detail" - const val sentEnvelopeEditRoute = "sent-envelope-edit" const val sentEnvelopeAddRoute = "sent-envelope-add" + + fun sentEnvelopeRoute(id: String) = "sent-envelope/$id" + const val FRIEND_ID_ARGUMENT_NAME = "sent-envelope-id" + + fun sentEnvelopeDetailRoute(id: String) = "sent-envelope-detail/$id" + const val ENVELOPE_ID_ARGUMENT_NAME = "sent-envelope-detail-id" + + fun sentEnvelopeEditRoute(envelopeDetail: String) = "sent-envelope-edit/$envelopeDetail" + const val ENVELOPE_DETAIL_ARGUMENT_NAME = "envelope-detail" const val sentEnvelopeSearchRoute = "sent-envelope-search" } diff --git a/feature/sent/src/main/res/values/strings.xml b/feature/sent/src/main/res/values/strings.xml index 508a1f9d..9208b6cc 100644 --- a/feature/sent/src/main/res/values/strings.xml +++ b/feature/sent/src/main/res/values/strings.xml @@ -6,31 +6,13 @@ 보냈어요 받았어요 전체 보기 - 내역 보기 - 경조사 - 이름 김철수 - 나와의 관계 - 날짜 - 방문 여부 - 선물 - 연락처 - 메모 - 결혼식 - 돌잔치 - 장례식 - 생일 기념일 - 친구 - 가족 - 친척 - 동료 - - 아니요 한끼 식사 01012345678 입력해주세요 저장 + %d년 %d월 %d일 누구에게 보냈나요 이름을 입력해주세요 @@ -60,4 +42,16 @@ 어떤 봉투를 찾아드릴까요? 사람 이름, 보낸 금액, 경조사 명 등을\n검색해볼 수 있어요 원하는 검색 결과가 없나요? + 보낸 사람 + 받은 봉투 금액 + 님에게 + "님의 " + "을 " + "전체 " + + + 봉투를 삭제할까요? + 삭제한 봉투는 다시 복구할 수 없어요 + 봉투가 삭제됐어요 + 받은 이의 연락처 diff --git a/feature/statistics/src/main/java/com/susu/feature/statistics/StatisticsScreen.kt b/feature/statistics/src/main/java/com/susu/feature/statistics/StatisticsScreen.kt index 04e13826..918da16f 100644 --- a/feature/statistics/src/main/java/com/susu/feature/statistics/StatisticsScreen.kt +++ b/feature/statistics/src/main/java/com/susu/feature/statistics/StatisticsScreen.kt @@ -3,11 +3,10 @@ package com.susu.feature.statistics import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -26,10 +25,12 @@ import com.susu.core.designsystem.theme.SusuTheme import com.susu.core.ui.DialogToken import com.susu.core.ui.extension.collectWithLifecycle import com.susu.feature.statistics.component.StatisticsTab -import com.susu.feature.statistics.content.MyStatisticsRoute +import com.susu.feature.statistics.content.my.MyStatisticsRoute +import com.susu.feature.statistics.content.susu.SusuStatisticsRoute @Composable fun StatisticsRoute( + padding: PaddingValues, viewModel: StatisticsViewModel = hiltViewModel(), navigateToMyInfo: () -> Unit, onShowDialog: (DialogToken) -> Unit, @@ -58,6 +59,7 @@ fun StatisticsRoute( } StatisticsScreen( + padding = padding, uiState = uiState, onTabSelected = viewModel::selectStatisticsTab, handleException = handleException, @@ -66,13 +68,13 @@ fun StatisticsRoute( @Composable fun StatisticsScreen( + padding: PaddingValues = PaddingValues(), uiState: StatisticsState = StatisticsState(), onTabSelected: (StatisticsTab) -> Unit = {}, handleException: (Throwable, () -> Unit) -> Unit = { _, _ -> }, ) { Box( - modifier = Modifier.fillMaxSize() - .verticalScroll(rememberScrollState()), + modifier = Modifier.fillMaxSize().padding(padding), ) { Column( modifier = Modifier.fillMaxSize().padding(horizontal = SusuTheme.spacing.spacing_m), @@ -96,7 +98,11 @@ fun StatisticsScreen( handleException = handleException, ) - StatisticsTab.AVERAGE -> {} + StatisticsTab.AVERAGE -> SusuStatisticsRoute( + isBlind = uiState.isBlind, + modifier = Modifier.fillMaxSize(), + handleException = handleException, + ) } } diff --git a/feature/statistics/src/main/java/com/susu/feature/statistics/component/RecentSpentGraph.kt b/feature/statistics/src/main/java/com/susu/feature/statistics/component/RecentSpentGraph.kt index fad28860..06f0d64a 100644 --- a/feature/statistics/src/main/java/com/susu/feature/statistics/component/RecentSpentGraph.kt +++ b/feature/statistics/src/main/java/com/susu/feature/statistics/component/RecentSpentGraph.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -35,6 +36,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import com.susu.core.designsystem.component.text.AnimatedCounterText import com.susu.core.designsystem.theme.Blue60 import com.susu.core.designsystem.theme.Gray10 import com.susu.core.designsystem.theme.Gray100 @@ -59,6 +61,7 @@ fun RecentSpentGraph( spentData: PersistentList = persistentListOf(), totalAmount: Int = 0, maximumAmount: Int = 0, + graphTitle: String = "", ) { Column( modifier = modifier @@ -74,15 +77,17 @@ fun RecentSpentGraph( horizontalArrangement = Arrangement.SpaceBetween, ) { Text( - text = stringResource(R.string.statistics_recent_8_total_money), + text = graphTitle, style = SusuTheme.typography.title_xs, color = Gray100, ) if (isActive) { - Text( - text = stringResource(R.string.statistics_total_man_format, totalAmount.toString()), + AnimatedCounterText( + number = totalAmount, style = SusuTheme.typography.title_xs, color = Blue60, + prefix = stringResource(id = R.string.statistics_total_man_prefix), + postfix = stringResource(id = R.string.statistics_total_man_postfix), ) } else { Text( @@ -107,38 +112,43 @@ fun RecentSpentGraph( horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, ) { - spentData.forEachIndexed { i, data -> - Column( - horizontalAlignment = Alignment.CenterHorizontally, - ) { - StickGraph( - ratio = data.value.toFloat() / maximumAmount, - color = if (isActive) { - if (i == spentData.lastIndex) { - Orange60 + if (maximumAmount > 0) { + spentData.forEachIndexed { i, data -> + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + StickGraph( + ratio = data.value.toFloat() / maximumAmount, + color = if (isActive) { + if (i == spentData.lastIndex) { + Orange60 + } else { + Orange30 + } } else { - Orange30 - } - } else { - if (i == spentData.lastIndex) { - Gray60 + if (i == spentData.lastIndex) { + Gray60 + } else { + Gray30 + } + }, + ) + Spacer(modifier = Modifier.height(SusuTheme.spacing.spacing_xxxxs)) + Text( + text = stringResource(id = R.string.word_month_format, data.title), + style = SusuTheme.typography.title_xxxs, + color = if (i == spentData.lastIndex) { + Gray90 } else { - Gray30 - } - }, - ) - Spacer(modifier = Modifier.height(SusuTheme.spacing.spacing_xxxxs)) - Text( - text = stringResource(id = R.string.word_month_format, data.title), - style = SusuTheme.typography.title_xxxs, - color = if (i == spentData.lastIndex) { - Gray90 - } else { - Gray40 - }, - ) + Gray40 + }, + ) + } + Spacer(modifier = Modifier.width(SusuTheme.spacing.spacing_s)) } - Spacer(modifier = Modifier.width(SusuTheme.spacing.spacing_s)) + } else { + // TODO: 데이터가 없을 땐? + Spacer(modifier = Modifier.fillMaxSize()) } } } diff --git a/feature/statistics/src/main/java/com/susu/feature/statistics/component/StatisticsItem.kt b/feature/statistics/src/main/java/com/susu/feature/statistics/component/StatisticsItem.kt index 80a4495c..ba18fd77 100644 --- a/feature/statistics/src/main/java/com/susu/feature/statistics/component/StatisticsItem.kt +++ b/feature/statistics/src/main/java/com/susu/feature/statistics/component/StatisticsItem.kt @@ -1,5 +1,9 @@ package com.susu.feature.statistics.component +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -16,6 +20,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import com.susu.core.designsystem.component.text.AnimatedCounterText import com.susu.core.designsystem.theme.Gray10 import com.susu.core.designsystem.theme.Gray100 import com.susu.core.designsystem.theme.Gray40 @@ -24,7 +29,6 @@ import com.susu.core.designsystem.theme.Gray60 import com.susu.core.designsystem.theme.Gray80 import com.susu.core.designsystem.theme.Orange60 import com.susu.core.designsystem.theme.SusuTheme -import com.susu.core.ui.extension.toMoneyFormat import com.susu.feature.statistics.R @Composable @@ -103,11 +107,20 @@ fun StatisticsHorizontalItem( horizontalArrangement = Arrangement.SpaceBetween, ) { if (isActive) { - Text(text = name, style = SusuTheme.typography.title_s, color = Gray80) - Text( - text = stringResource(id = com.susu.core.ui.R.string.money_unit_format, money.toMoneyFormat()), + AnimatedContent( + targetState = name, + transitionSpec = { + slideInVertically { -it } togetherWith slideOutVertically { it } + }, + label = "StatisticsHorizontalItemName", + ) { + Text(text = it, style = SusuTheme.typography.title_s, color = Gray80) + } + AnimatedCounterText( + number = money, style = SusuTheme.typography.title_s, color = Gray100, + postfix = stringResource(id = com.susu.core.designsystem.R.string.money_unit), ) } else { Text(text = stringResource(id = R.string.word_unknown), style = SusuTheme.typography.title_s, color = Gray40) diff --git a/feature/statistics/src/main/java/com/susu/feature/statistics/content/MyStatisticsContent.kt b/feature/statistics/src/main/java/com/susu/feature/statistics/content/my/MyStatisticsContent.kt similarity index 94% rename from feature/statistics/src/main/java/com/susu/feature/statistics/content/MyStatisticsContent.kt rename to feature/statistics/src/main/java/com/susu/feature/statistics/content/my/MyStatisticsContent.kt index cee4fd65..5ce22d53 100644 --- a/feature/statistics/src/main/java/com/susu/feature/statistics/content/MyStatisticsContent.kt +++ b/feature/statistics/src/main/java/com/susu/feature/statistics/content/my/MyStatisticsContent.kt @@ -1,4 +1,4 @@ -package com.susu.feature.statistics.content +package com.susu.feature.statistics.content.my import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -10,7 +10,9 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -68,7 +70,7 @@ fun MyStatisticsContent( ) { Box(modifier = modifier.fillMaxSize()) { Column( - modifier = Modifier.fillMaxSize(), + modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(SusuTheme.spacing.spacing_xxs), ) { RecentSpentGraph( @@ -76,6 +78,7 @@ fun MyStatisticsContent( spentData = uiState.statistics.recentSpent.toPersistentList(), maximumAmount = uiState.statistics.recentMaximumSpent, totalAmount = uiState.statistics.recentTotalSpent, + graphTitle = stringResource(R.string.statistics_recent_8_total_money), ) Row( modifier = Modifier diff --git a/feature/statistics/src/main/java/com/susu/feature/statistics/content/MyStatisticsContract.kt b/feature/statistics/src/main/java/com/susu/feature/statistics/content/my/MyStatisticsContract.kt similarity index 89% rename from feature/statistics/src/main/java/com/susu/feature/statistics/content/MyStatisticsContract.kt rename to feature/statistics/src/main/java/com/susu/feature/statistics/content/my/MyStatisticsContract.kt index 98384780..14b418e6 100644 --- a/feature/statistics/src/main/java/com/susu/feature/statistics/content/MyStatisticsContract.kt +++ b/feature/statistics/src/main/java/com/susu/feature/statistics/content/my/MyStatisticsContract.kt @@ -1,4 +1,4 @@ -package com.susu.feature.statistics.content +package com.susu.feature.statistics.content.my import com.susu.core.model.MyStatistics import com.susu.core.ui.base.SideEffect diff --git a/feature/statistics/src/main/java/com/susu/feature/statistics/content/MyStatisticsViewModel.kt b/feature/statistics/src/main/java/com/susu/feature/statistics/content/my/MyStatisticsViewModel.kt similarity index 95% rename from feature/statistics/src/main/java/com/susu/feature/statistics/content/MyStatisticsViewModel.kt rename to feature/statistics/src/main/java/com/susu/feature/statistics/content/my/MyStatisticsViewModel.kt index 77854360..949a85c6 100644 --- a/feature/statistics/src/main/java/com/susu/feature/statistics/content/MyStatisticsViewModel.kt +++ b/feature/statistics/src/main/java/com/susu/feature/statistics/content/my/MyStatisticsViewModel.kt @@ -1,4 +1,4 @@ -package com.susu.feature.statistics.content +package com.susu.feature.statistics.content.my import androidx.lifecycle.viewModelScope import com.susu.core.ui.base.BaseViewModel diff --git a/feature/statistics/src/main/java/com/susu/feature/statistics/content/susu/SusuStatisticsContent.kt b/feature/statistics/src/main/java/com/susu/feature/statistics/content/susu/SusuStatisticsContent.kt new file mode 100644 index 00000000..dea25448 --- /dev/null +++ b/feature/statistics/src/main/java/com/susu/feature/statistics/content/susu/SusuStatisticsContent.kt @@ -0,0 +1,239 @@ +package com.susu.feature.statistics.content.susu + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.susu.core.designsystem.component.bottomsheet.SusuSelectionBottomSheet +import com.susu.core.designsystem.component.screen.LoadingScreen +import com.susu.core.designsystem.theme.Blue60 +import com.susu.core.designsystem.theme.Gray10 +import com.susu.core.designsystem.theme.Gray100 +import com.susu.core.designsystem.theme.Gray40 +import com.susu.core.designsystem.theme.SusuTheme +import com.susu.core.ui.extension.collectWithLifecycle +import com.susu.feature.statistics.R +import com.susu.feature.statistics.component.RecentSpentGraph +import com.susu.feature.statistics.component.StatisticsHorizontalItem +import com.susu.feature.statistics.component.StatisticsVerticalItem +import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toPersistentList + +@Composable +fun SusuStatisticsRoute( + isBlind: Boolean, + modifier: Modifier = Modifier, + viewModel: SusuStatisticsViewModel = hiltViewModel(), + handleException: (Throwable, () -> Unit) -> Unit, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + viewModel.sideEffect.collectWithLifecycle { sideEffect -> + when (sideEffect) { + is SusuStatisticsEffect.HandleException -> handleException(sideEffect.throwable, sideEffect.retry) + } + } + + LaunchedEffect(key1 = Unit) { + viewModel.getStatisticsOption() + } + + LaunchedEffect(key1 = uiState.age, key2 = uiState.category, key3 = uiState.relationship) { + viewModel.getSusuStatistics() + } + + SusuStatisticsScreen( + uiState = uiState, + isBlind = isBlind, + modifier = modifier, + onClickAge = viewModel::showAgeSheet, + onClickRelationship = viewModel::showRelationshipSheet, + onClickCategory = viewModel::showCategorySheet, + onSelectAge = viewModel::selectAge, + onSelectCategory = viewModel::selectCategory, + onSelectRelationship = viewModel::selectRelationship, + onDismissAge = viewModel::hideAgeSheet, + onDismissCategory = viewModel::hideCategorySheet, + onDismissRelationship = viewModel::hideRelationshipSheet, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SusuStatisticsScreen( + modifier: Modifier = Modifier, + uiState: SusuStatisticsState = SusuStatisticsState(), + isBlind: Boolean = true, + onClickAge: () -> Unit = {}, + onClickRelationship: () -> Unit = {}, + onClickCategory: () -> Unit = {}, + onDismissAge: () -> Unit = {}, + onDismissRelationship: () -> Unit = {}, + onDismissCategory: () -> Unit = {}, + onSelectAge: (StatisticsAge) -> Unit = {}, + onSelectRelationship: (Int) -> Unit = {}, + onSelectCategory: (Int) -> Unit = {}, +) { + val context = LocalContext.current + val ageItems = remember { + StatisticsAge.entries.map { context.getString(R.string.word_age_unit, it.num) }.toImmutableList() + } + + Box(modifier = modifier.fillMaxSize()) { + Column( + modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(SusuTheme.spacing.spacing_xxs), + ) { + SusuStatisticsOptionSlot( + title = stringResource(R.string.statistics_susu_average_title), + age = stringResource(id = R.string.word_age_unit, uiState.age.num), + money = uiState.susuStatistics.averageSent, + relationship = uiState.relationship.relation, + category = uiState.category.name, + onAgeClick = onClickAge, + onCategoryClick = onClickCategory, + onRelationshipClick = onClickRelationship, + ) + StatisticsHorizontalItem( + title = stringResource(R.string.statistics_susu_relationship_average), + name = uiState.susuStatistics.averageRelationship.title, + money = uiState.susuStatistics.averageRelationship.value, + isActive = !isBlind, + ) + StatisticsHorizontalItem( + title = stringResource(R.string.statistics_susu_category_average), + name = uiState.susuStatistics.averageCategory.title, + money = uiState.susuStatistics.averageCategory.value, + isActive = !isBlind, + ) + + RecentSpentGraph( + isActive = !isBlind, + graphTitle = stringResource(R.string.statistics_susu_this_year_spent), + spentData = uiState.susuStatistics.recentSpent.toPersistentList(), + maximumAmount = uiState.susuStatistics.recentMaximumSpent, + totalAmount = uiState.susuStatistics.recentTotalSpent, + ) + Row( + modifier = Modifier + .fillMaxWidth() + .background(color = Gray10, shape = RoundedCornerShape(4.dp)) + .padding(SusuTheme.spacing.spacing_m), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(text = stringResource(R.string.statistics_most_spent_month), style = SusuTheme.typography.title_xs, color = Gray100) + if (isBlind) { + Text( + text = stringResource(R.string.word_month_format, stringResource(id = R.string.word_unknown)), + style = SusuTheme.typography.title_xs, + color = Gray40, + ) + } else { + Text( + text = stringResource(R.string.word_month_format, uiState.susuStatistics.mostSpentMonth.toString()), + style = SusuTheme.typography.title_xs, + color = Blue60, + ) + } + } + Row( + modifier = Modifier.fillMaxWidth(), + ) { + StatisticsVerticalItem( + modifier = Modifier.weight(1f), + title = stringResource(R.string.statistics_most_susu_relationship), + content = uiState.susuStatistics.mostRelationship.title, + count = uiState.susuStatistics.mostRelationship.value, + isActive = !isBlind, + ) + Spacer(modifier = Modifier.width(SusuTheme.spacing.spacing_xxs)) + StatisticsVerticalItem( + modifier = Modifier.weight(1f), + title = stringResource(R.string.statistics_most_susu_event), + content = uiState.susuStatistics.mostCategory.title, + count = uiState.susuStatistics.mostCategory.value, + isActive = !isBlind, + ) + } + } + + if (uiState.isAgeSheetOpen) { + SusuSelectionBottomSheet( + containerHeight = 322.dp, + items = ageItems, + selectedItemPosition = uiState.age.ordinal, + onClickItem = { onSelectAge(StatisticsAge.entries[it]) }, + onDismissRequest = onDismissAge, + ) + } + + if (uiState.isRelationshipSheetOpen) { + SusuSelectionBottomSheet( + containerHeight = 322.dp, + items = uiState.relationshipConfig.map { it.relation }.toImmutableList(), + selectedItemPosition = uiState.relationshipConfig.indexOf(uiState.relationship), + onClickItem = onSelectRelationship, + onDismissRequest = onDismissRelationship, + ) + } + + if (uiState.isCategorySheetOpen) { + SusuSelectionBottomSheet( + containerHeight = 322.dp, + items = uiState.categoryConfig.map { it.name }.toImmutableList(), + selectedItemPosition = uiState.categoryConfig.indexOf(uiState.category), + onClickItem = onSelectCategory, + onDismissRequest = onDismissCategory, + ) + } + + if (uiState.isLoading) { + LoadingScreen( + modifier = Modifier.align(Alignment.Center), + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun SusuStatisticsScreenPreview() { + SusuTheme { + SusuStatisticsScreen( + isBlind = false, + ) + } +} + +@Preview(showBackground = true) +@Composable +fun SusuStatisticsScreenBlindPreview() { + SusuTheme { + SusuStatisticsScreen( + isBlind = true, + ) + } +} diff --git a/feature/statistics/src/main/java/com/susu/feature/statistics/content/susu/SusuStatisticsContract.kt b/feature/statistics/src/main/java/com/susu/feature/statistics/content/susu/SusuStatisticsContract.kt new file mode 100644 index 00000000..b1c78e20 --- /dev/null +++ b/feature/statistics/src/main/java/com/susu/feature/statistics/content/susu/SusuStatisticsContract.kt @@ -0,0 +1,30 @@ +package com.susu.feature.statistics.content.susu + +import com.susu.core.model.Category +import com.susu.core.model.Relationship +import com.susu.core.model.SusuStatistics +import com.susu.core.ui.base.SideEffect +import com.susu.core.ui.base.UiState +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.persistentListOf + +sealed interface SusuStatisticsEffect : SideEffect { + data class HandleException(val throwable: Throwable, val retry: () -> Unit) : SusuStatisticsEffect +} + +data class SusuStatisticsState( + val isLoading: Boolean = false, + val age: StatisticsAge = StatisticsAge.TWENTY, + val relationship: Relationship = Relationship(), + val category: Category = Category(), + val categoryConfig: PersistentList = persistentListOf(), + val relationshipConfig: PersistentList = persistentListOf(), + val isAgeSheetOpen: Boolean = false, + val isRelationshipSheetOpen: Boolean = false, + val isCategorySheetOpen: Boolean = false, + val susuStatistics: SusuStatistics = SusuStatistics(), +) : UiState + +enum class StatisticsAge(val num: Int) { + TEN(10), TWENTY(20), THIRTY(30), FOURTY(40), FIFTY(50), SIXTY(60), SEVENTY(70), EIGHTY(80), NINETY(90) +} diff --git a/feature/statistics/src/main/java/com/susu/feature/statistics/content/susu/SusuStatisticsOptionSlot.kt b/feature/statistics/src/main/java/com/susu/feature/statistics/content/susu/SusuStatisticsOptionSlot.kt new file mode 100644 index 00000000..05d9e12f --- /dev/null +++ b/feature/statistics/src/main/java/com/susu/feature/statistics/content/susu/SusuStatisticsOptionSlot.kt @@ -0,0 +1,137 @@ +package com.susu.feature.statistics.content.susu + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.susu.core.designsystem.component.text.AnimatedCounterText +import com.susu.core.designsystem.theme.Gray10 +import com.susu.core.designsystem.theme.Gray50 +import com.susu.core.designsystem.theme.Gray80 +import com.susu.core.designsystem.theme.Orange10 +import com.susu.core.designsystem.theme.Orange60 +import com.susu.core.designsystem.theme.SusuTheme +import com.susu.core.ui.extension.susuClickable +import com.susu.feature.statistics.R + +@Composable +fun SusuStatisticsOptionSlot( + modifier: Modifier = Modifier, + age: String = "", + relationship: String = "", + category: String = "", + money: Int = 0, + title: String = "", + onAgeClick: () -> Unit = {}, + onRelationshipClick: () -> Unit = {}, + onCategoryClick: () -> Unit = {}, +) { + Column( + modifier = modifier.fillMaxWidth() + .background(color = Gray10, shape = RoundedCornerShape(4.dp)) + .padding(SusuTheme.spacing.spacing_m), + ) { + Text( + text = title, + style = SusuTheme.typography.title_xxs, + color = Gray50, + ) + Spacer(modifier = Modifier.height(SusuTheme.spacing.spacing_xxs)) + Column( + modifier = Modifier.fillMaxWidth().background(color = Orange10, shape = RoundedCornerShape(4.dp)) + .padding(horizontal = SusuTheme.spacing.spacing_s, vertical = SusuTheme.spacing.spacing_xxs), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + OptionSlot(text = age, onClick = onAgeClick) + Spacer(modifier = Modifier.width(SusuTheme.spacing.spacing_xxxxs)) + Text( + text = stringResource(R.string.word_statistics_is), + style = SusuTheme.typography.title_xxs, + color = Gray80, + ) + Spacer(modifier = Modifier.width(SusuTheme.spacing.spacing_m)) + OptionSlot(text = relationship, onClick = onRelationshipClick) + Spacer(modifier = Modifier.width(SusuTheme.spacing.spacing_xxxxs)) + OptionSlot(text = category, onClick = onCategoryClick) + Spacer(modifier = Modifier.width(SusuTheme.spacing.spacing_xxxxs)) + Text( + text = stringResource(R.string.word_statistics_to), + style = SusuTheme.typography.title_xxs, + color = Gray80, + ) + } + Spacer(modifier = Modifier.height(SusuTheme.spacing.spacing_xxs)) + Row( + verticalAlignment = Alignment.Bottom, + ) { + AnimatedCounterText( + number = money, + style = SusuTheme.typography.title_s, + color = Orange60, + postfix = stringResource(id = com.susu.core.designsystem.R.string.money_unit), + ) + Spacer(modifier = Modifier.width(SusuTheme.spacing.spacing_xxs)) + Text( + text = stringResource(R.string.word_statistics_send), + style = SusuTheme.typography.title_xxs, + color = Gray80, + ) + } + } + } +} + +@Composable +fun OptionSlot( + modifier: Modifier = Modifier, + text: String = "", + onClick: () -> Unit = {}, +) { + Row( + modifier = modifier.background(color = Gray10, shape = RoundedCornerShape(4.dp)) + .susuClickable(onClick = onClick) + .padding( + horizontal = SusuTheme.spacing.spacing_xxs, + vertical = SusuTheme.spacing.spacing_xxxxs, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(text = text, color = Orange60, style = SusuTheme.typography.title_xxxs) + Spacer(modifier = Modifier.width(SusuTheme.spacing.spacing_xxxxs)) + Icon( + painter = painterResource(id = R.drawable.ic_statistics_arrow_down), + tint = Orange60, + contentDescription = stringResource(R.string.word_select_option), + ) + } +} + +@Preview +@Composable +fun SusuStatisticsOptionSlotPreview() { + SusuTheme { + SusuStatisticsOptionSlot( + title = "제목", + age = "20대", + relationship = "친구", + category = "결혼식", + money = 50000, + ) + } +} diff --git a/feature/statistics/src/main/java/com/susu/feature/statistics/content/susu/SusuStatisticsViewModel.kt b/feature/statistics/src/main/java/com/susu/feature/statistics/content/susu/SusuStatisticsViewModel.kt new file mode 100644 index 00000000..d72549d7 --- /dev/null +++ b/feature/statistics/src/main/java/com/susu/feature/statistics/content/susu/SusuStatisticsViewModel.kt @@ -0,0 +1,79 @@ +package com.susu.feature.statistics.content.susu + +import androidx.lifecycle.viewModelScope +import com.susu.core.model.Category +import com.susu.core.model.Relationship +import com.susu.core.model.exception.UnknownException +import com.susu.core.ui.base.BaseViewModel +import com.susu.domain.usecase.categoryconfig.GetCategoryConfigUseCase +import com.susu.domain.usecase.envelope.GetRelationShipConfigListUseCase +import com.susu.domain.usecase.statistics.GetSusuStatisticsUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class SusuStatisticsViewModel @Inject constructor( + private val getCategoryConfigUseCase: GetCategoryConfigUseCase, + private val getRelationShipConfigListUseCase: GetRelationShipConfigListUseCase, + private val getSusuStatisticsUseCase: GetSusuStatisticsUseCase, +) : BaseViewModel(SusuStatisticsState()) { + + fun getStatisticsOption() { + viewModelScope.launch { + intent { copy(isLoading = true) } + val categoryConfigResult = getCategoryConfigUseCase() + val relationshipConfigResult = getRelationShipConfigListUseCase() + + if (categoryConfigResult.isSuccess && relationshipConfigResult.isSuccess) { + val categoryConfig = categoryConfigResult.getOrDefault(emptyList()).toPersistentList() + val relationshipConfig = relationshipConfigResult.getOrDefault(emptyList()).toPersistentList() + intent { + copy( + categoryConfig = categoryConfig, + relationshipConfig = relationshipConfig, + category = categoryConfig.firstOrNull() ?: Category(), + relationship = relationshipConfig.firstOrNull() ?: Relationship(), + ) + } + } else { + val exception = categoryConfigResult.exceptionOrNull() + ?: relationshipConfigResult.exceptionOrNull() + ?: UnknownException() + postSideEffect(SusuStatisticsEffect.HandleException(exception, ::getStatisticsOption)) + } + intent { copy(isLoading = false) } + } + } + + fun getSusuStatistics() { + if (currentState.relationship !in currentState.relationshipConfig && + currentState.category !in currentState.categoryConfig + ) { + return + } + + viewModelScope.launch { + getSusuStatisticsUseCase( + age = currentState.age.name, + relationshipId = currentState.relationship.id.toInt(), + categoryId = currentState.category.id, + ).onSuccess { + intent { copy(susuStatistics = it) } + }.onFailure { + postSideEffect(SusuStatisticsEffect.HandleException(it, ::getSusuStatistics)) + } + } + } + + fun selectAge(age: StatisticsAge) = intent { copy(age = age, isAgeSheetOpen = false) } + fun selectRelationship(index: Int) = intent { copy(relationship = relationshipConfig[index], isRelationshipSheetOpen = false) } + fun selectCategory(index: Int) = intent { copy(category = categoryConfig[index], isCategorySheetOpen = false) } + fun showAgeSheet() = intent { copy(isAgeSheetOpen = true) } + fun showRelationshipSheet() = intent { copy(isRelationshipSheetOpen = true) } + fun showCategorySheet() = intent { copy(isCategorySheetOpen = true) } + fun hideAgeSheet() = intent { copy(isAgeSheetOpen = false) } + fun hideRelationshipSheet() = intent { copy(isRelationshipSheetOpen = false) } + fun hideCategorySheet() = intent { copy(isCategorySheetOpen = false) } +} diff --git a/feature/statistics/src/main/java/com/susu/feature/statistics/navigation/StatisticsNavigation.kt b/feature/statistics/src/main/java/com/susu/feature/statistics/navigation/StatisticsNavigation.kt index 5a4f4afe..8548173d 100644 --- a/feature/statistics/src/main/java/com/susu/feature/statistics/navigation/StatisticsNavigation.kt +++ b/feature/statistics/src/main/java/com/susu/feature/statistics/navigation/StatisticsNavigation.kt @@ -1,5 +1,6 @@ package com.susu.feature.statistics.navigation +import androidx.compose.foundation.layout.PaddingValues import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptions @@ -12,12 +13,14 @@ fun NavController.navigateStatistics(navOptions: NavOptions) { } fun NavGraphBuilder.statisticsNavGraph( + padding: PaddingValues, navigateToMyInfo: () -> Unit, onShowDialog: (DialogToken) -> Unit, handleException: (Throwable, () -> Unit) -> Unit, ) { composable(route = StatisticsRoute.route) { StatisticsRoute( + padding = padding, navigateToMyInfo = navigateToMyInfo, onShowDialog = onShowDialog, handleException = handleException, diff --git a/feature/statistics/src/main/res/drawable/ic_statistics_arrow_down.xml b/feature/statistics/src/main/res/drawable/ic_statistics_arrow_down.xml new file mode 100644 index 00000000..412cfffb --- /dev/null +++ b/feature/statistics/src/main/res/drawable/ic_statistics_arrow_down.xml @@ -0,0 +1,9 @@ + + + diff --git a/feature/statistics/src/main/res/values/strings.xml b/feature/statistics/src/main/res/values/strings.xml index a6b24c05..11e6c45d 100644 --- a/feature/statistics/src/main/res/values/strings.xml +++ b/feature/statistics/src/main/res/values/strings.xml @@ -2,6 +2,8 @@ 최근 8개월 간 쓴 금액 총 %s만원 + + 만원 나의 수수 수수 평균 \? @@ -16,4 +18,13 @@ 통계를 위한 정보를 알려주세요 나의 평균 거래 상황을 분석하기 위해\n필요한 정보가 있어요 정보 입력하기 + + + 을 보내고 있어요 + %d대 + 옵션 선택 + 지금 평균 수수 보기 + 관계 별 평균 수수 + 경조사 카테고리 별 평균 수수 + 올해 쓴 금액 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bdf20bf1..cdf7900d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -65,6 +65,7 @@ protobuf = "3.24.4" junit-junit = "4.13.2" kakao-user = "2.18.0" +play-app-update-ktx = "2.1.0" [plugins] ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } @@ -185,6 +186,7 @@ protobuf-protoc = { group = "com.google.protobuf", name = "protoc", version.ref junit-junit = { group = "junit", name = "junit", version.ref = "junit-junit" } kakao-sdk-user = { group = "com.kakao.sdk", name = "v2-user", version.ref = "kakao-user"} +play-app-update = { group = "com.google.android.play", name = "app-update-ktx", version.ref = "play-app-update-ktx"} [bundles] firebase = ["firebase-analytics"]