diff --git a/core/model/src/main/java/com/susu/core/model/MyStatistics.kt b/core/model/src/main/java/com/susu/core/model/MyStatistics.kt new file mode 100644 index 00000000..8badbda1 --- /dev/null +++ b/core/model/src/main/java/com/susu/core/model/MyStatistics.kt @@ -0,0 +1,15 @@ +package com.susu.core.model + +import androidx.compose.runtime.Stable + +@Stable +data class MyStatistics( + val highestAmountReceived: StatisticsElement = StatisticsElement(), + val highestAmountSent: StatisticsElement = StatisticsElement(), + val mostCategory: StatisticsElement = StatisticsElement(), + val mostRelationship: StatisticsElement = StatisticsElement(), + val mostSpentMonth: Int = 0, + val recentSpent: List = emptyList(), + val recentTotalSpent: Int = 0, + val recentMaximumSpent: Int = 0, +) diff --git a/core/model/src/main/java/com/susu/core/model/StatisticsElement.kt b/core/model/src/main/java/com/susu/core/model/StatisticsElement.kt new file mode 100644 index 00000000..363d620a --- /dev/null +++ b/core/model/src/main/java/com/susu/core/model/StatisticsElement.kt @@ -0,0 +1,9 @@ +package com.susu.core.model + +import androidx.compose.runtime.Stable + +@Stable +data class StatisticsElement( + val title: String = "", + val value: Int = 0, +) diff --git a/core/ui/src/main/res/values/strings.xml b/core/ui/src/main/res/values/strings.xml index 9ffe9638..f1402150 100644 --- a/core/ui/src/main/res/values/strings.xml +++ b/core/ui/src/main/res/values/strings.xml @@ -42,6 +42,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 20cb70fa..daf25d15 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 @@ -6,6 +6,7 @@ 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.SignUpRepositoryImpl +import com.susu.data.data.repository.StatisticsRepositoryImpl import com.susu.data.data.repository.TermRepositoryImpl import com.susu.data.data.repository.TokenRepositoryImpl import com.susu.data.data.repository.UserRepositoryImpl @@ -16,6 +17,7 @@ import com.susu.domain.repository.LedgerRecentSearchRepository import com.susu.domain.repository.LedgerRepository import com.susu.domain.repository.LoginRepository import com.susu.domain.repository.SignUpRepository +import com.susu.domain.repository.StatisticsRepository import com.susu.domain.repository.TermRepository import com.susu.domain.repository.TokenRepository import com.susu.domain.repository.UserRepository @@ -74,6 +76,11 @@ abstract class RepositoryModule { excelRepositoryImpl: ExcelRepositoryImpl, ): ExcelRepository + @Binds + abstract fun bindStatisticsRepository( + statisticsRepositoryImpl: StatisticsRepositoryImpl, + ): StatisticsRepository + @Binds abstract fun bindVoteRepository( voteRepositoryImpl: VoteRepositoryImpl, 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 new file mode 100644 index 00000000..deae1020 --- /dev/null +++ b/data/src/main/java/com/susu/data/data/repository/StatisticsRepositoryImpl.kt @@ -0,0 +1,29 @@ +package com.susu.data.data.repository + +import com.susu.core.model.MyStatistics +import com.susu.core.model.StatisticsElement +import com.susu.data.remote.api.StatisticsService +import com.susu.data.remote.model.response.toModel +import com.susu.domain.repository.StatisticsRepository +import javax.inject.Inject + +class StatisticsRepositoryImpl @Inject constructor( + private val statisticsService: StatisticsService, +) : StatisticsRepository { + override suspend fun getMyStatistics(): MyStatistics { + val originalStatistic = statisticsService.getMyStatistics().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 MyStatistics( + highestAmountReceived = originalStatistic.highestAmountReceived, + highestAmountSent = originalStatistic.highestAmountSent, + mostCategory = originalStatistic.mostCategory, + mostRelationship = originalStatistic.mostRelationship, + mostSpentMonth = originalStatistic.mostSpentMonth % 100, + recentSpent = sortedRecentSpent, + recentTotalSpent = originalStatistic.recentTotalSpent, + recentMaximumSpent = originalStatistic.recentMaximumSpent, + ) + } +} diff --git a/data/src/main/java/com/susu/data/data/repository/UserRepositoryImpl.kt b/data/src/main/java/com/susu/data/data/repository/UserRepositoryImpl.kt index b2eb8cf4..b11e4ead 100644 --- a/data/src/main/java/com/susu/data/data/repository/UserRepositoryImpl.kt +++ b/data/src/main/java/com/susu/data/data/repository/UserRepositoryImpl.kt @@ -46,7 +46,11 @@ class UserRepositoryImpl @Inject constructor( }.firstOrNull() ?: throw UserNotFoundException() val uid = json.decodeFromString(localUserInfo).id - return userService.patchUserInfo(uid = uid, UserPatchRequest(name, gender, birth)).getOrThrow().toModel() + val patchedUserInfo = userService.patchUserInfo(uid = uid, UserPatchRequest(name, gender, birth)).getOrThrow() + dataStore.edit { preferences -> + preferences[userKey] = json.encodeToString(patchedUserInfo) + } + return patchedUserInfo.toModel() } override suspend fun logout() { 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 new file mode 100644 index 00000000..9f8a0932 --- /dev/null +++ b/data/src/main/java/com/susu/data/remote/api/StatisticsService.kt @@ -0,0 +1,10 @@ +package com.susu.data.remote.api + +import com.susu.data.remote.model.response.MyStatisticsResponse +import com.susu.data.remote.retrofit.ApiResult +import retrofit2.http.GET + +interface StatisticsService { + @GET("statistics/mine") + suspend fun getMyStatistics(): 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 d0bd9268..7c6452dd 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 @@ -4,6 +4,7 @@ import com.susu.data.remote.api.AuthService import com.susu.data.remote.api.CategoryService import com.susu.data.remote.api.LedgerService import com.susu.data.remote.api.SignUpService +import com.susu.data.remote.api.StatisticsService import com.susu.data.remote.api.TermService import com.susu.data.remote.api.TokenService import com.susu.data.remote.api.UserService @@ -61,6 +62,12 @@ object ApiServiceModule { return retrofit.create(UserService::class.java) } + @Singleton + @Provides + fun providesStatisticsService(retrofit: Retrofit): StatisticsService { + return retrofit.create(StatisticsService::class.java) + } + @Singleton @Provides fun providesVoteService(retrofit: Retrofit): VoteService { 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/MyStatisticsResponse.kt new file mode 100644 index 00000000..e9be8886 --- /dev/null +++ b/data/src/main/java/com/susu/data/remote/model/response/MyStatisticsResponse.kt @@ -0,0 +1,36 @@ +package com.susu.data.remote.model.response + +import com.susu.core.model.MyStatistics +import kotlinx.serialization.Serializable + +@Serializable +data class MyStatisticsResponse( + val highestAmountReceived: StatisticsElement, + val highestAmountSent: StatisticsElement, + val mostCategory: StatisticsElement, + val mostRelationship: StatisticsElement, + val mostSpentMonth: Int, + val recentSpent: List, +) + +@Serializable +data class StatisticsElement( + val title: String, + val value: Int, +) + +fun MyStatisticsResponse.toModel() = MyStatistics( + highestAmountReceived = highestAmountReceived.toModel(), + highestAmountSent = highestAmountSent.toModel(), + mostCategory = mostCategory.toModel(), + mostRelationship = mostRelationship.toModel(), + mostSpentMonth = mostSpentMonth, + recentSpent = recentSpent.map { it.toModel() }, + recentMaximumSpent = recentSpent.maxOfOrNull { it.value } ?: 0, + recentTotalSpent = recentSpent.sumOf { it.value }, +) + +fun StatisticsElement.toModel() = com.susu.core.model.StatisticsElement( + title = title, + value = value, +) diff --git a/domain/src/main/java/com/susu/domain/repository/StatisticsRepository.kt b/domain/src/main/java/com/susu/domain/repository/StatisticsRepository.kt new file mode 100644 index 00000000..5806e125 --- /dev/null +++ b/domain/src/main/java/com/susu/domain/repository/StatisticsRepository.kt @@ -0,0 +1,7 @@ +package com.susu.domain.repository + +import com.susu.core.model.MyStatistics + +interface StatisticsRepository { + suspend fun getMyStatistics(): MyStatistics +} diff --git a/domain/src/main/java/com/susu/domain/usecase/statistics/CheckAdditionalUserInfoUseCase.kt b/domain/src/main/java/com/susu/domain/usecase/statistics/CheckAdditionalUserInfoUseCase.kt new file mode 100644 index 00000000..40aadce8 --- /dev/null +++ b/domain/src/main/java/com/susu/domain/usecase/statistics/CheckAdditionalUserInfoUseCase.kt @@ -0,0 +1,14 @@ +package com.susu.domain.usecase.statistics + +import com.susu.core.common.runCatchingIgnoreCancelled +import com.susu.domain.repository.UserRepository +import javax.inject.Inject + +class CheckAdditionalUserInfoUseCase @Inject constructor( + private val userRepository: UserRepository, +) { + suspend operator fun invoke() = runCatchingIgnoreCancelled { + val userInfo = userRepository.getUserInfo() + userInfo.birth in 1930..2030 && (userInfo.gender == "M" || userInfo.gender == "F") + } +} diff --git a/domain/src/main/java/com/susu/domain/usecase/statistics/GetMyStatisticsUseCase.kt b/domain/src/main/java/com/susu/domain/usecase/statistics/GetMyStatisticsUseCase.kt new file mode 100644 index 00000000..cee303e0 --- /dev/null +++ b/domain/src/main/java/com/susu/domain/usecase/statistics/GetMyStatisticsUseCase.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 GetMyStatisticsUseCase @Inject constructor( + private val statisticsRepository: StatisticsRepository, +) { + suspend operator fun invoke() = runCatchingIgnoreCancelled { + statisticsRepository.getMyStatistics() + } +} diff --git a/feature/mypage/src/main/java/com/susu/feature/mypage/info/MyPageInfoScreen.kt b/feature/mypage/src/main/java/com/susu/feature/mypage/info/MyPageInfoScreen.kt index 3da43baa..25d7765d 100644 --- a/feature/mypage/src/main/java/com/susu/feature/mypage/info/MyPageInfoScreen.kt +++ b/feature/mypage/src/main/java/com/susu/feature/mypage/info/MyPageInfoScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.shape.CircleShape 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.ui.Alignment import androidx.compose.ui.Modifier @@ -70,6 +71,10 @@ fun MyPageInfoRoute( val uiState by viewModel.uiState.collectAsStateWithLifecycle() + LaunchedEffect(key1 = Unit) { + viewModel.getUserInfo() + } + BackHandler { if (uiState.isEditing) { viewModel.cancelEdit() diff --git a/feature/mypage/src/main/java/com/susu/feature/mypage/info/MyPageInfoViewModel.kt b/feature/mypage/src/main/java/com/susu/feature/mypage/info/MyPageInfoViewModel.kt index bfd53849..f4001ca8 100644 --- a/feature/mypage/src/main/java/com/susu/feature/mypage/info/MyPageInfoViewModel.kt +++ b/feature/mypage/src/main/java/com/susu/feature/mypage/info/MyPageInfoViewModel.kt @@ -17,11 +17,7 @@ class MyPageInfoViewModel @Inject constructor( private val patchUserUseCase: PatchUserUseCase, ) : BaseViewModel(MyPageInfoState()) { - init { - getUserInfo() - } - - private fun getUserInfo() { + fun getUserInfo() { viewModelScope.launch { intent { copy(isLoading = true) } getUserUseCase().onSuccess { 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 eb9577a7..e2fa9921 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 @@ -122,7 +122,9 @@ internal fun MainScreen( ) statisticsNavGraph( - padding = innerPadding, + navigateToMyInfo = navigator::navigateMyPageInfo, + onShowDialog = viewModel::onShowDialog, + handleException = viewModel::handleException, ) communityNavGraph( diff --git a/feature/statistics/src/main/java/com/susu/feature/statistics/StatisticsContract.kt b/feature/statistics/src/main/java/com/susu/feature/statistics/StatisticsContract.kt new file mode 100644 index 00000000..52bfe0b9 --- /dev/null +++ b/feature/statistics/src/main/java/com/susu/feature/statistics/StatisticsContract.kt @@ -0,0 +1,21 @@ +package com.susu.feature.statistics + +import androidx.annotation.StringRes +import com.susu.core.ui.base.SideEffect +import com.susu.core.ui.base.UiState + +enum class StatisticsTab(@StringRes val stringId: Int) { + MY(R.string.statistics_tab_my), + AVERAGE(R.string.statistics_tab_average), +} + +data class StatisticsState( + val isLoading: Boolean = false, + val isBlind: Boolean = true, + val currentTab: StatisticsTab = StatisticsTab.MY, +) : UiState + +sealed interface StatisticsEffect : SideEffect { + data object ShowAdditionalInfoDialog : StatisticsEffect + data class HandleException(val throwable: Throwable, val retry: () -> Unit) : StatisticsEffect +} 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 1321af41..04e13826 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 @@ -1,28 +1,115 @@ package com.susu.feature.statistics -import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Text +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 +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.appbar.SusuDefaultAppBar +import com.susu.core.designsystem.component.appbar.icon.LogoIcon +import com.susu.core.designsystem.component.screen.LoadingScreen 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 @Composable -fun StatisticsScreen( - padding: PaddingValues, +fun StatisticsRoute( + viewModel: StatisticsViewModel = hiltViewModel(), + navigateToMyInfo: () -> Unit, + onShowDialog: (DialogToken) -> Unit, + handleException: (Throwable, () -> Unit) -> Unit, ) { - Text( - modifier = Modifier.padding(padding), - text = "통계", + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + + viewModel.sideEffect.collectWithLifecycle { sideEffect -> + when (sideEffect) { + is StatisticsEffect.HandleException -> handleException(sideEffect.throwable, sideEffect.retry) + StatisticsEffect.ShowAdditionalInfoDialog -> onShowDialog( + DialogToken( + title = context.getString(R.string.statistics_dialog_title), + text = context.getString(R.string.statistics_dialog_description), + dismissText = context.getString(com.susu.core.ui.R.string.word_close), + confirmText = context.getString(R.string.statistics_dialog_confirm), + onConfirmRequest = navigateToMyInfo, + ), + ) + } + } + + LaunchedEffect(key1 = Unit) { + viewModel.checkAdditionalInfo() + } + + StatisticsScreen( + uiState = uiState, + onTabSelected = viewModel::selectStatisticsTab, + handleException = handleException, ) } -@Preview +@Composable +fun StatisticsScreen( + uiState: StatisticsState = StatisticsState(), + onTabSelected: (StatisticsTab) -> Unit = {}, + handleException: (Throwable, () -> Unit) -> Unit = { _, _ -> }, +) { + Box( + modifier = Modifier.fillMaxSize() + .verticalScroll(rememberScrollState()), + ) { + Column( + modifier = Modifier.fillMaxSize().padding(horizontal = SusuTheme.spacing.spacing_m), + verticalArrangement = Arrangement.spacedBy(SusuTheme.spacing.spacing_xxs), + ) { + SusuDefaultAppBar( + leftIcon = { LogoIcon() }, + title = stringResource(R.string.statistics_word), + ) + StatisticsTab( + modifier = Modifier + .height(52.dp) + .padding(vertical = SusuTheme.spacing.spacing_xxs), + selectedTab = uiState.currentTab, + onTabSelect = onTabSelected, + ) + when (uiState.currentTab) { + StatisticsTab.MY -> MyStatisticsRoute( + isBlind = uiState.isBlind, + modifier = Modifier.fillMaxSize(), + handleException = handleException, + ) + + StatisticsTab.AVERAGE -> {} + } + } + + if (uiState.isLoading) { + LoadingScreen(modifier = Modifier.fillMaxSize().align(Alignment.Center)) + } + } +} + +@Preview(showBackground = true) @Composable fun SentScreenPreview() { SusuTheme { - StatisticsScreen(padding = PaddingValues(0.dp)) + StatisticsScreen() } } diff --git a/feature/statistics/src/main/java/com/susu/feature/statistics/StatisticsViewModel.kt b/feature/statistics/src/main/java/com/susu/feature/statistics/StatisticsViewModel.kt new file mode 100644 index 00000000..59539915 --- /dev/null +++ b/feature/statistics/src/main/java/com/susu/feature/statistics/StatisticsViewModel.kt @@ -0,0 +1,34 @@ +package com.susu.feature.statistics + +import androidx.lifecycle.viewModelScope +import com.susu.core.ui.base.BaseViewModel +import com.susu.domain.usecase.statistics.CheckAdditionalUserInfoUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class StatisticsViewModel @Inject constructor( + private val checkAdditionalUserInfoUseCase: CheckAdditionalUserInfoUseCase, +) : BaseViewModel(StatisticsState()) { + + fun checkAdditionalInfo() { + viewModelScope.launch { + intent { copy(isLoading = true) } + checkAdditionalUserInfoUseCase() + .onSuccess { + if (!it) { + postSideEffect(StatisticsEffect.ShowAdditionalInfoDialog) + } + intent { copy(isBlind = !it) } + }.onFailure { + postSideEffect(StatisticsEffect.HandleException(it, ::checkAdditionalInfo)) + } + intent { copy(isLoading = false) } + } + } + + fun selectStatisticsTab(tab: StatisticsTab) = intent { + copy(currentTab = tab) + } +} 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 new file mode 100644 index 00000000..fad28860 --- /dev/null +++ b/feature/statistics/src/main/java/com/susu/feature/statistics/component/RecentSpentGraph.kt @@ -0,0 +1,240 @@ +package com.susu.feature.statistics.component + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.EaseInOutCubic +import androidx.compose.animation.core.tween +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.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.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +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.theme.Blue60 +import com.susu.core.designsystem.theme.Gray10 +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.Gray60 +import com.susu.core.designsystem.theme.Gray90 +import com.susu.core.designsystem.theme.Orange30 +import com.susu.core.designsystem.theme.Orange60 +import com.susu.core.designsystem.theme.SusuTheme +import com.susu.core.model.StatisticsElement +import com.susu.feature.statistics.R +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList +import kotlin.random.Random + +@Composable +fun RecentSpentGraph( + modifier: Modifier = Modifier, + isActive: Boolean = true, + spentData: PersistentList = persistentListOf(), + totalAmount: Int = 0, + maximumAmount: Int = 0, +) { + Column( + modifier = modifier + .fillMaxWidth() + .background(color = Gray10, shape = RoundedCornerShape(4.dp)) + .padding(SusuTheme.spacing.spacing_xxs), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(SusuTheme.spacing.spacing_xxs), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResource(R.string.statistics_recent_8_total_money), + style = SusuTheme.typography.title_xs, + color = Gray100, + ) + if (isActive) { + Text( + text = stringResource(R.string.statistics_total_man_format, totalAmount.toString()), + style = SusuTheme.typography.title_xs, + color = Blue60, + ) + } else { + Text( + text = stringResource(R.string.statistics_total_man_format, stringResource(R.string.word_unknown)), + style = SusuTheme.typography.title_xs, + color = Gray40, + ) + } + } + Spacer(modifier = Modifier.height(SusuTheme.spacing.spacing_xxs)) + Row( + modifier = if (isActive) { + Modifier + .fillMaxWidth() + .padding(SusuTheme.spacing.spacing_xxs) + } else { + Modifier + .fillMaxWidth() + .blur(8.dp) + .padding(SusuTheme.spacing.spacing_xxs) + }, + 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 + } else { + Orange30 + } + } else { + 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 { + Gray40 + }, + ) + } + Spacer(modifier = Modifier.width(SusuTheme.spacing.spacing_s)) + } + } + } +} + +@Composable +fun StickGraph( + ratio: Float, + color: Color, + modifier: Modifier = Modifier, + width: Dp = 24.dp, + maximumHeight: Dp = 80.dp, + delay: Int = 0, + duration: Int = 800, +) { + var initialHeight by remember { mutableStateOf(0f) } + val fillHeight = remember { Animatable(initialHeight) } + val graphHeight = with(LocalDensity.current) { maximumHeight.toPx() * ratio } + + LaunchedEffect(key1 = ratio) { + fillHeight.animateTo( + targetValue = graphHeight, + animationSpec = tween( + delayMillis = delay, + durationMillis = duration, + easing = EaseInOutCubic, + ), + ) + initialHeight = ratio + } + + Spacer( + modifier = modifier + .size(width = width, height = maximumHeight) + .drawBehind { + drawRoundRect( + color = color, + cornerRadius = CornerRadius(4.dp.toPx()), + topLeft = Offset(0f, maximumHeight.toPx() - fillHeight.value), + size = Size(width.toPx(), fillHeight.value), + ) + }, + ) +} + +@Preview +@Composable +fun StickGraphPreview() { + SusuTheme { + var ratio by remember { mutableStateOf(1f) } + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + StickGraph(ratio, Orange30) + StickGraph(0.3f, Orange60) + Button( + onClick = { ratio = Random.nextFloat() }, + ) { + Text(text = "그래프 값 변경") + } + } + } +} + +@Preview +@Composable +fun RecentSpentGraphPreview() { + SusuTheme { + Column { + RecentSpentGraph( + modifier = Modifier.padding(16.dp), + spentData = listOf( + StatisticsElement("1월", 10000), + StatisticsElement("2월", 20000), + StatisticsElement("3월", 30000), + StatisticsElement("4월", 40000), + StatisticsElement("5월", 50000), + StatisticsElement("6월", 60000), + StatisticsElement("7월", 70000), + StatisticsElement("8월", 80000), + ).toPersistentList(), + ) + RecentSpentGraph( + modifier = Modifier.padding(16.dp), + spentData = listOf( + StatisticsElement("1월", 10000), + StatisticsElement("2월", 20000), + StatisticsElement("3월", 30000), + StatisticsElement("4월", 40000), + StatisticsElement("5월", 50000), + StatisticsElement("6월", 60000), + StatisticsElement("7월", 70000), + StatisticsElement("8월", 80000), + ).toPersistentList(), + isActive = false, + ) + } + } +} 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 new file mode 100644 index 00000000..80a4495c --- /dev/null +++ b/feature/statistics/src/main/java/com/susu/feature/statistics/component/StatisticsItem.kt @@ -0,0 +1,135 @@ +package com.susu.feature.statistics.component + +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +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.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +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.Gray50 +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 +fun StatisticsVerticalItem( + title: String, + content: String, + count: Int, + modifier: Modifier = Modifier, + isActive: Boolean = true, +) { + Column( + modifier = modifier + .background(color = Gray10, shape = RoundedCornerShape(4.dp)) + .padding(SusuTheme.spacing.spacing_m), + ) { + Text( + modifier = Modifier.align(Alignment.Start), + text = title, + style = SusuTheme.typography.title_xxs, + color = Gray100, + ) + Spacer(modifier = Modifier.height(SusuTheme.spacing.spacing_xxs)) + if (isActive) { + Text( + modifier = Modifier.align(Alignment.CenterHorizontally), + text = content, + style = SusuTheme.typography.title_l, + color = Orange60, + ) + Text( + modifier = Modifier.align(Alignment.CenterHorizontally), + text = stringResource(R.string.word_entire_count, count.toString()), + style = SusuTheme.typography.title_xxxs, + color = Gray60, + ) + } else { + Text( + modifier = Modifier.align(Alignment.CenterHorizontally), + text = stringResource(id = R.string.word_unknown), + style = SusuTheme.typography.title_l, + color = Gray40, + ) + Text( + modifier = Modifier.align(Alignment.CenterHorizontally), + text = stringResource(R.string.word_entire_count, stringResource(id = R.string.word_unknown)), + style = SusuTheme.typography.title_xxxs, + color = Gray40, + ) + } + } +} + +@Composable +fun StatisticsHorizontalItem( + title: String, + name: String, + money: Int, + modifier: Modifier = Modifier, + isActive: Boolean = true, +) { + Column( + modifier = modifier + .fillMaxWidth() + .background(color = Gray10, shape = RoundedCornerShape(4.dp)) + .padding(SusuTheme.spacing.spacing_m), + ) { + Text( + modifier = Modifier.align(Alignment.Start), + text = title, + style = SusuTheme.typography.title_xxs, + color = Gray50, + ) + Spacer(modifier = Modifier.height(SusuTheme.spacing.spacing_xxxxs)) + Row( + modifier = Modifier.fillMaxWidth(), + 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()), + style = SusuTheme.typography.title_s, + color = Gray100, + ) + } else { + Text(text = stringResource(id = R.string.word_unknown), style = SusuTheme.typography.title_s, color = Gray40) + Text( + text = stringResource(id = com.susu.core.ui.R.string.money_unit_format, stringResource(id = R.string.word_unknown)), + style = SusuTheme.typography.title_s, + color = Gray40, + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun StatisticsItemPreview() { + SusuTheme { + Column { + StatisticsVerticalItem(title = "자주 개발하는 시간", content = "밤", count = 1) + StatisticsHorizontalItem(title = "이번달에 허투루 쓴 돈", name = "배달음식", money = 60000) + StatisticsVerticalItem(title = "자주 개발하는 시간", content = "밤", count = 1, isActive = false) + StatisticsHorizontalItem(title = "이번달에 허투루 쓴 돈", name = "배달음식", money = 60000, isActive = false) + } + } +} diff --git a/feature/statistics/src/main/java/com/susu/feature/statistics/component/StatisticsTab.kt b/feature/statistics/src/main/java/com/susu/feature/statistics/component/StatisticsTab.kt new file mode 100644 index 00000000..d51aee3a --- /dev/null +++ b/feature/statistics/src/main/java/com/susu/feature/statistics/component/StatisticsTab.kt @@ -0,0 +1,80 @@ +package com.susu.feature.statistics.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +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.stringResource +import androidx.compose.ui.unit.dp +import com.susu.core.designsystem.theme.Gray10 +import com.susu.core.designsystem.theme.Gray100 +import com.susu.core.designsystem.theme.Gray20 +import com.susu.core.designsystem.theme.Gray50 +import com.susu.core.designsystem.theme.SusuTheme +import com.susu.core.ui.extension.susuClickable +import com.susu.feature.statistics.StatisticsTab + +@Composable +fun StatisticsTab( + modifier: Modifier = Modifier, + selectedTab: StatisticsTab = StatisticsTab.MY, + onTabSelect: (StatisticsTab) -> Unit = {}, +) { + Row( + modifier = modifier + .background(color = Gray10, shape = RoundedCornerShape(4.dp)) + .padding(SusuTheme.spacing.spacing_xxxxs), + ) { + StatisticsTabItem( + modifier = Modifier.weight(1f), + text = stringResource(id = StatisticsTab.MY.stringId), + isSelected = selectedTab == StatisticsTab.MY, + onClick = { onTabSelect(StatisticsTab.MY) }, + ) + Spacer(modifier = Modifier.width(SusuTheme.spacing.spacing_xxxxs)) + StatisticsTabItem( + modifier = Modifier.weight(1f), + text = stringResource(id = StatisticsTab.AVERAGE.stringId), + isSelected = selectedTab == StatisticsTab.AVERAGE, + onClick = { onTabSelect(StatisticsTab.AVERAGE) }, + ) + } +} + +@Composable +fun StatisticsTabItem( + isSelected: Boolean, + text: String, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + Box( + modifier = modifier.fillMaxSize().background( + color = if (isSelected) { + Gray20 + } else { + Gray10 + }, + shape = RoundedCornerShape(4.dp), + ).susuClickable(onClick = onClick), + ) { + Text( + modifier = Modifier.align(Alignment.Center), + text = text, + style = SusuTheme.typography.title_xxxs, + color = if (isSelected) { + Gray100 + } else { + Gray50 + }, + ) + } +} 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/MyStatisticsContent.kt new file mode 100644 index 00000000..cee4fd65 --- /dev/null +++ b/feature/statistics/src/main/java/com/susu/feature/statistics/content/MyStatisticsContent.kt @@ -0,0 +1,141 @@ +package com.susu.feature.statistics.content + +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.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +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.toPersistentList + +@Composable +fun MyStatisticsRoute( + isBlind: Boolean, + modifier: Modifier, + viewModel: MyStatisticsViewModel = hiltViewModel(), + handleException: (Throwable, () -> Unit) -> Unit, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + viewModel.sideEffect.collectWithLifecycle { sideEffect -> + when (sideEffect) { + is MyStatisticsEffect.HandleException -> handleException(sideEffect.throwable, sideEffect.retry) + } + } + + LaunchedEffect(key1 = Unit) { + viewModel.getMyStatistics() + } + + MyStatisticsContent( + uiState = uiState, + isBlind = isBlind, + modifier = modifier, + ) +} + +@Composable +fun MyStatisticsContent( + uiState: MyStatisticsState, + isBlind: Boolean, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier.fillMaxSize()) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(SusuTheme.spacing.spacing_xxs), + ) { + RecentSpentGraph( + isActive = !isBlind, + spentData = uiState.statistics.recentSpent.toPersistentList(), + maximumAmount = uiState.statistics.recentMaximumSpent, + totalAmount = uiState.statistics.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.statistics.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.statistics.mostRelationship.title, + count = uiState.statistics.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.statistics.mostCategory.title, + count = uiState.statistics.mostCategory.value, + isActive = !isBlind, + ) + } + StatisticsHorizontalItem( + title = stringResource(R.string.statistics_most_received_money), + name = uiState.statistics.highestAmountReceived.title, + money = uiState.statistics.highestAmountReceived.value, + isActive = !isBlind, + ) + StatisticsHorizontalItem( + title = stringResource(R.string.statistics_most_sent_money), + name = uiState.statistics.highestAmountSent.title, + money = uiState.statistics.highestAmountSent.value, + isActive = !isBlind, + ) + } + + if (uiState.isLoading) { + LoadingScreen( + modifier = Modifier.align(Alignment.Center), + ) + } + } +} 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/MyStatisticsContract.kt new file mode 100644 index 00000000..98384780 --- /dev/null +++ b/feature/statistics/src/main/java/com/susu/feature/statistics/content/MyStatisticsContract.kt @@ -0,0 +1,14 @@ +package com.susu.feature.statistics.content + +import com.susu.core.model.MyStatistics +import com.susu.core.ui.base.SideEffect +import com.susu.core.ui.base.UiState + +sealed interface MyStatisticsEffect : SideEffect { + data class HandleException(val throwable: Throwable, val retry: () -> Unit) : MyStatisticsEffect +} + +data class MyStatisticsState( + val isLoading: Boolean = false, + val statistics: MyStatistics = MyStatistics(), +) : UiState 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/MyStatisticsViewModel.kt new file mode 100644 index 00000000..77854360 --- /dev/null +++ b/feature/statistics/src/main/java/com/susu/feature/statistics/content/MyStatisticsViewModel.kt @@ -0,0 +1,26 @@ +package com.susu.feature.statistics.content + +import androidx.lifecycle.viewModelScope +import com.susu.core.ui.base.BaseViewModel +import com.susu.domain.usecase.statistics.GetMyStatisticsUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class MyStatisticsViewModel @Inject constructor( + private val getMyStatisticsUseCase: GetMyStatisticsUseCase, +) : BaseViewModel(MyStatisticsState()) { + fun getMyStatistics() { + viewModelScope.launch { + intent { copy(isLoading = true) } + getMyStatisticsUseCase() + .onSuccess { + intent { copy(statistics = it) } + }.onFailure { + postSideEffect(MyStatisticsEffect.HandleException(it, ::getMyStatistics)) + } + intent { copy(isLoading = 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 a357c9cd..5a4f4afe 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,21 +1,27 @@ package com.susu.feature.statistics.navigation -import androidx.compose.foundation.layout.PaddingValues import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptions import androidx.navigation.compose.composable -import com.susu.feature.statistics.StatisticsScreen +import com.susu.core.ui.DialogToken +import com.susu.feature.statistics.StatisticsRoute fun NavController.navigateStatistics(navOptions: NavOptions) { navigate(StatisticsRoute.route, navOptions) } fun NavGraphBuilder.statisticsNavGraph( - padding: PaddingValues, + navigateToMyInfo: () -> Unit, + onShowDialog: (DialogToken) -> Unit, + handleException: (Throwable, () -> Unit) -> Unit, ) { composable(route = StatisticsRoute.route) { - StatisticsScreen(padding) + StatisticsRoute( + navigateToMyInfo = navigateToMyInfo, + onShowDialog = onShowDialog, + handleException = handleException, + ) } } diff --git a/feature/statistics/src/main/res/values/strings.xml b/feature/statistics/src/main/res/values/strings.xml new file mode 100644 index 00000000..a6b24c05 --- /dev/null +++ b/feature/statistics/src/main/res/values/strings.xml @@ -0,0 +1,19 @@ + + + 최근 8개월 간 쓴 금액 + 총 %s만원 + 나의 수수 + 수수 평균 + \? + 총 %s번 + 통계 + 경조사비를 가장 많이 쓴 달 + %s월 + 최다 수수 관계 + 최다 수수 경조사 + 가장 많이 받은 금액 + 가장 많이 보낸 금액 + 통계를 위한 정보를 알려주세요 + 나의 평균 거래 상황을 분석하기 위해\n필요한 정보가 있어요 + 정보 입력하기 +