diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f9d3e89f..93a54e35 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -65,6 +65,7 @@ dependencies { implementation(projects.feature.goalEditor) implementation(projects.feature.goalManage) implementation(projects.feature.settings) + implementation(projects.feature.stats.detail) implementation(projects.core.notification) implementation(projects.core.navigationContract) diff --git a/app/src/main/java/com/yapp/twix/di/FeatureModules.kt b/app/src/main/java/com/yapp/twix/di/FeatureModules.kt index ffb55f3c..0477b09a 100644 --- a/app/src/main/java/com/yapp/twix/di/FeatureModules.kt +++ b/app/src/main/java/com/yapp/twix/di/FeatureModules.kt @@ -7,6 +7,7 @@ import com.twix.login.di.loginModule import com.twix.main.di.mainModule import com.twix.onboarding.di.onBoardingModule import com.twix.settings.di.settingsModule +import com.twix.stats.detail.di.statsDetailModule import com.twix.stats.di.statsModule import com.twix.task_certification.di.taskCertificationModule import org.koin.core.module.Module @@ -22,4 +23,5 @@ val featureModules: List = settingsModule, onBoardingModule, statsModule, + statsDetailModule, ) diff --git a/core/design-system/src/main/java/com/twix/designsystem/components/calendar/CalendarNavigator.kt b/core/design-system/src/main/java/com/twix/designsystem/components/calendar/CalendarNavigator.kt index c5544559..1f68ac50 100644 --- a/core/design-system/src/main/java/com/twix/designsystem/components/calendar/CalendarNavigator.kt +++ b/core/design-system/src/main/java/com/twix/designsystem/components/calendar/CalendarNavigator.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -26,6 +27,8 @@ fun CalendarNavigator( onNextMonth: () -> Unit, onPreviousMonth: () -> Unit, modifier: Modifier = Modifier, + hasPrevious: Boolean = true, + hasNext: Boolean = true, ) { Row( modifier = @@ -39,9 +42,10 @@ fun CalendarNavigator( contentDescription = "previous month", modifier = Modifier - .noRippleClickable(onClick = onPreviousMonth) + .noRippleClickable(enabled = hasPrevious, onClick = onPreviousMonth) .padding(6.dp) .size(24.dp), + colorFilter = ColorFilter.tint(if (hasPrevious) GrayColor.C500 else GrayColor.C200), ) AppText( @@ -56,14 +60,15 @@ fun CalendarNavigator( contentDescription = "next month", modifier = Modifier - .noRippleClickable(onClick = onNextMonth) + .noRippleClickable(enabled = hasNext, onClick = onNextMonth) .padding(6.dp) .size(24.dp), + colorFilter = ColorFilter.tint(if (hasNext) GrayColor.C500 else GrayColor.C200), ) } } -@Preview +@Preview(showBackground = true) @Composable fun CalendarNavigatorPreview() { TwixTheme { @@ -71,6 +76,8 @@ fun CalendarNavigatorPreview() { currentDate = LocalDate.now(), onNextMonth = {}, onPreviousMonth = {}, + hasPrevious = false, + hasNext = true, ) } } diff --git a/core/design-system/src/main/java/com/twix/designsystem/components/stats/PictureDayCell.kt b/core/design-system/src/main/java/com/twix/designsystem/components/stats/PictureDayCell.kt new file mode 100644 index 00000000..b24069f5 --- /dev/null +++ b/core/design-system/src/main/java/com/twix/designsystem/components/stats/PictureDayCell.kt @@ -0,0 +1,113 @@ +package com.twix.designsystem.components.stats + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import coil3.request.ImageRequest +import coil3.request.crossfade +import com.twix.designsystem.components.text.AppText +import com.twix.designsystem.theme.CommonColor +import com.twix.designsystem.theme.DimmedColor +import com.twix.designsystem.theme.GrayColor +import com.twix.domain.model.enums.AppTextStyle +import com.twix.domain.model.stats.detail.CompletedDate +import com.twix.ui.extension.noRippleClickable +import java.time.LocalDate + +@Composable +fun PictureDayCell( + date: LocalDate, + completed: CompletedDate?, + onDateSelected: (LocalDate) -> Unit, + modifier: Modifier = Modifier, +) { + val showBackgroundCard = completed?.myImageUrl != null && completed.partnerImageUrl != null + val hasImage = completed?.partnerImageUrl != null || completed?.myImageUrl != null + + val textColor = if (hasImage) CommonColor.White else GrayColor.C500 + val borderColor = if (showBackgroundCard) CommonColor.White else GrayColor.C400 + val cornerShape = RoundedCornerShape(7.dp) + val context = LocalContext.current + + Box( + modifier = + modifier + .aspectRatio(1f) + .noRippleClickable { onDateSelected(date) }, + contentAlignment = Alignment.Center, + ) { + if (showBackgroundCard) { + Box( + modifier = + Modifier + .size(36.dp) + .rotate(-16f) + .border(1.dp, GrayColor.C400, cornerShape) + .background(CommonColor.White, cornerShape), + ) + } + + Box( + modifier = + Modifier + .size(36.dp) + .clip(cornerShape) + .then( + if (showBackgroundCard) { + Modifier.border(1.dp, borderColor, cornerShape) + } else { + Modifier + }, + ), + contentAlignment = Alignment.Center, + ) { + val displayImageUrl = completed?.partnerImageUrl ?: completed?.myImageUrl + if (displayImageUrl != null) { + val imageRequest = + remember(displayImageUrl, context) { + ImageRequest + .Builder(context) + .data(displayImageUrl) + .crossfade(true) + .build() + } + + AsyncImage( + model = imageRequest, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + + Box( + modifier = + Modifier + .fillMaxSize() + .background(DimmedColor.D020), + ) + } + + AppText( + text = date.dayOfMonth.toString(), + style = AppTextStyle.B1, + color = textColor, + textAlign = TextAlign.Center, + ) + } + } +} diff --git a/core/design-system/src/main/java/com/twix/designsystem/components/stats/StatsCalendar.kt b/core/design-system/src/main/java/com/twix/designsystem/components/stats/StatsCalendar.kt new file mode 100644 index 00000000..5c463607 --- /dev/null +++ b/core/design-system/src/main/java/com/twix/designsystem/components/stats/StatsCalendar.kt @@ -0,0 +1,124 @@ +package com.twix.designsystem.components.stats + +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.twix.designsystem.R +import com.twix.designsystem.components.stats.model.StatsCalendarUiModel +import com.twix.designsystem.components.text.AppText +import com.twix.designsystem.theme.GrayColor +import com.twix.designsystem.theme.TwixTheme +import com.twix.domain.model.enums.AppTextStyle +import com.twix.domain.model.stats.detail.CompletedDate +import java.time.LocalDate + +@Composable +fun StatsCalendar( + uiModel: StatsCalendarUiModel, + onSelectedDate: (LocalDate) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier.fillMaxWidth()) { + DayOfWeekHeader() + + Spacer(modifier = Modifier.height(16.dp)) + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + uiModel.weeks.forEach { week -> + Row( + modifier = Modifier.fillMaxWidth(), + ) { + week.forEach { date -> + if (date == null) { + Box(modifier = Modifier.weight(1f)) + } else { + PictureDayCell( + date = date, + completed = uiModel.completedDateMap[date], + onDateSelected = onSelectedDate, + modifier = Modifier.weight(1f), + ) + } + } + + if (week.size < 7) { + repeat(7 - week.size) { + Box(modifier = Modifier.weight(1f)) + } + } + } + } + } + } +} + +@Composable +private fun DayOfWeekHeader() { + val days = + listOf( + stringResource(R.string.word_sunday), + stringResource(R.string.word_monday), + stringResource(R.string.word_tuesday), + stringResource(R.string.word_wednesday), + stringResource(R.string.word_thursday), + stringResource(R.string.word_friday), + stringResource(R.string.word_saturday), + ) + Row( + modifier = Modifier.fillMaxWidth(), + ) { + days.forEach { day -> + AppText( + text = day, + style = AppTextStyle.B2, + color = GrayColor.C300, + modifier = + Modifier + .weight(1f) + .height(24.dp), + textAlign = TextAlign.Center, + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun StatsCalendarPreview() { + TwixTheme { + StatsCalendar( + uiModel = + StatsCalendarUiModel.create( + currentDate = LocalDate.of(2026, 2, 1), + completedDate = + listOf( + CompletedDate( + LocalDate.of(2026, 2, 1), + "https://picsum.photos/100", + "https://picsum.photos/100", + ), + CompletedDate( + LocalDate.of(2026, 2, 3), + "https://picsum.photos/101", + null, + ), + ), + ), + onSelectedDate = { }, + ) + } +} diff --git a/core/design-system/src/main/java/com/twix/designsystem/components/stats/model/StatsCalendarUiModel.kt b/core/design-system/src/main/java/com/twix/designsystem/components/stats/model/StatsCalendarUiModel.kt new file mode 100644 index 00000000..481e05d4 --- /dev/null +++ b/core/design-system/src/main/java/com/twix/designsystem/components/stats/model/StatsCalendarUiModel.kt @@ -0,0 +1,37 @@ +package com.twix.designsystem.components.stats.model + +import androidx.compose.runtime.Immutable +import com.twix.domain.model.stats.detail.CompletedDate +import java.time.LocalDate + +@Immutable +data class StatsCalendarUiModel( + val currentDate: LocalDate = LocalDate.now(), + val completedDateMap: Map = emptyMap(), + val weeks: List> = emptyList(), +) { + companion object { + private const val WEEK_LENGTH = 7 + + fun create( + currentDate: LocalDate, + completedDate: List, + ): StatsCalendarUiModel { + val firstDayOfMonth = currentDate.withDayOfMonth(1) + val lastDay = currentDate.lengthOfMonth() + val emptyCellsBefore = firstDayOfMonth.dayOfWeek.value % WEEK_LENGTH + + val calendarItems = mutableListOf() + repeat(emptyCellsBefore) { calendarItems.add(null) } + for (i in 1..lastDay) { + calendarItems.add(currentDate.withDayOfMonth(i)) + } + + return StatsCalendarUiModel( + currentDate = currentDate, + completedDateMap = completedDate.associateBy { it.date }, + weeks = calendarItems.chunked(WEEK_LENGTH), + ) + } + } +} diff --git a/core/navigation/src/main/java/com/twix/navigation/NavRoutes.kt b/core/navigation/src/main/java/com/twix/navigation/NavRoutes.kt index 21b5bd01..1055c1f2 100644 --- a/core/navigation/src/main/java/com/twix/navigation/NavRoutes.kt +++ b/core/navigation/src/main/java/com/twix/navigation/NavRoutes.kt @@ -122,4 +122,23 @@ sealed class NavRoutes( object SettingsAccountRoute : NavRoutes("settings/account") object SettingsAboutRoute : NavRoutes("settings/about") + + /** + * StatsGraph + * */ + object StatsDetailGraph : NavRoutes("stats_detail_graph") + + object StatsDetailRoute : NavRoutes("stats_detail_graph/{goalId}?date={date}") { + const val ARG_GOAL_ID = "goalId" + const val ARG_DATE = "date" + + fun createRoute( + goalId: Long, + date: LocalDate?, + ): String { + val baseRoute = "stats_detail_graph/$goalId" + if (date != null) return "$baseRoute?date=$date" + return baseRoute + } + } } diff --git a/data/src/main/java/com/twix/data/repository/FakeStatsRepository.kt b/data/src/main/java/com/twix/data/repository/FakeStatsRepository.kt index bfb5a25a..b4cb0edc 100644 --- a/data/src/main/java/com/twix/data/repository/FakeStatsRepository.kt +++ b/data/src/main/java/com/twix/data/repository/FakeStatsRepository.kt @@ -1,11 +1,15 @@ package com.twix.data.repository import com.twix.domain.model.enums.GoalIconType +import com.twix.domain.model.enums.RepeatCycle import com.twix.domain.model.enums.StampColor import com.twix.domain.model.enums.StampType import com.twix.domain.model.stats.ParticipantStats import com.twix.domain.model.stats.Stats import com.twix.domain.model.stats.StatsGoal +import com.twix.domain.model.stats.detail.CompletedDate +import com.twix.domain.model.stats.detail.StatsDetail +import com.twix.domain.model.stats.detail.StatsSummary import com.twix.domain.repository.StatsRepository import com.twix.network.execute.safeApiCall import com.twix.result.AppResult @@ -20,6 +24,75 @@ class FakeStatsRepository : StatsRepository { initializeFakeData() } + override suspend fun fetchStatsDetail( + goalId: Long, + date: LocalDate?, + ): AppResult = + safeApiCall { + val isEnd = fakeEndGoals.any { it.goalId == goalId } + val monthStart = + when { + date != null -> date.withDayOfMonth(1) + isEnd -> LocalDate.of(2025, 9, 1) + else -> LocalDate.now().withDayOfMonth(1) + } + val today = LocalDate.now() + val lastDay = monthStart.lengthOfMonth() + + val matchedGoal = + fakeInProgressStore[monthStart] + ?.statsGoals + ?.find { it.goalId == goalId } + ?: fakeEndGoals.find { it.goalId == goalId } + + val goalName = matchedGoal?.goalName ?: "운동 인증 챌린지" + val goalIcon = matchedGoal?.goalIconType ?: GoalIconType.DEFAULT + val status = if (isEnd) "COMPLETED" else "IN_PROGRESS" + val monthlyTarget = matchedGoal?.monthlyTargetCount ?: 15 + + val maxDay = + when { + monthStart.isAfter(today.withDayOfMonth(1)) -> 0 + monthStart.year == today.year && monthStart.month == today.month -> today.dayOfMonth + else -> lastDay + } + + val completedDates = + (1..maxDay) + .filter { Random.nextBoolean() } + .take(monthlyTarget) + .map { day -> + CompletedDate( + date = monthStart.withDayOfMonth(day), + myImageUrl = if (Random.nextBoolean()) "https://picsum.photos/seed/${goalId}_my_$day/100" else null, + partnerImageUrl = if (Random.nextBoolean()) "https://picsum.photos/seed/${goalId}_partner_$day/100" else null, + ) + } + + val startDate = LocalDate.of(2025, 1, 1) + val endDate = if (isEnd) LocalDate.of(2025, 9, 30) else null + + StatsDetail( + goalId = goalId, + goalName = goalName, + goalIcon = goalIcon, + status = status, + monthDate = monthStart, + completedDate = completedDates, + statsSummary = + StatsSummary( + myNickname = "찬호", + partnerNickname = "페토", + totalCount = monthlyTarget, + myCompletedCount = completedDates.count { it.myImageUrl != null }, + partnerCompletedCount = completedDates.count { it.partnerImageUrl != null }, + repeatCycle = RepeatCycle.DAILY, + startDate = startDate, + endDate = endDate, + ), + ) + } + override suspend fun fetchInProgressStats(date: LocalDate): AppResult = safeApiCall { fakeInProgressStore[date.withDayOfMonth(1)] ?: Stats.EMPTY diff --git a/domain/src/main/java/com/twix/domain/model/stats/detail/CompletedDate.kt b/domain/src/main/java/com/twix/domain/model/stats/detail/CompletedDate.kt new file mode 100644 index 00000000..b411f898 --- /dev/null +++ b/domain/src/main/java/com/twix/domain/model/stats/detail/CompletedDate.kt @@ -0,0 +1,9 @@ +package com.twix.domain.model.stats.detail + +import java.time.LocalDate + +data class CompletedDate( + val date: LocalDate, + val myImageUrl: String?, + val partnerImageUrl: String?, +) diff --git a/domain/src/main/java/com/twix/domain/model/stats/detail/StatsDetail.kt b/domain/src/main/java/com/twix/domain/model/stats/detail/StatsDetail.kt new file mode 100644 index 00000000..5a7aa305 --- /dev/null +++ b/domain/src/main/java/com/twix/domain/model/stats/detail/StatsDetail.kt @@ -0,0 +1,27 @@ +package com.twix.domain.model.stats.detail + +import com.twix.domain.model.enums.GoalIconType +import java.time.LocalDate + +data class StatsDetail( + val goalId: Long, + val goalName: String, + val goalIcon: GoalIconType, + val status: String, + val monthDate: LocalDate, + val completedDate: List, + val statsSummary: StatsSummary, +) { + companion object { + val EMPTY = + StatsDetail( + goalId = -1, + goalName = "", + goalIcon = GoalIconType.DEFAULT, + status = "", + monthDate = LocalDate.now(), + completedDate = emptyList(), + statsSummary = StatsSummary.EMPTY, + ) + } +} diff --git a/domain/src/main/java/com/twix/domain/model/stats/detail/StatsSummary.kt b/domain/src/main/java/com/twix/domain/model/stats/detail/StatsSummary.kt new file mode 100644 index 00000000..f599c294 --- /dev/null +++ b/domain/src/main/java/com/twix/domain/model/stats/detail/StatsSummary.kt @@ -0,0 +1,29 @@ +package com.twix.domain.model.stats.detail + +import com.twix.domain.model.enums.RepeatCycle +import java.time.LocalDate + +data class StatsSummary( + val myNickname: String, + val partnerNickname: String, + val totalCount: Int, + val myCompletedCount: Int, + val partnerCompletedCount: Int, + val repeatCycle: RepeatCycle, + val startDate: LocalDate, + val endDate: LocalDate?, +) { + companion object { + val EMPTY = + StatsSummary( + myNickname = "", + partnerNickname = "", + totalCount = 0, + myCompletedCount = 0, + partnerCompletedCount = 0, + repeatCycle = RepeatCycle.DAILY, + startDate = LocalDate.now(), + endDate = null, + ) + } +} diff --git a/domain/src/main/java/com/twix/domain/repository/StatsRepository.kt b/domain/src/main/java/com/twix/domain/repository/StatsRepository.kt index a18ff8cd..a557bd70 100644 --- a/domain/src/main/java/com/twix/domain/repository/StatsRepository.kt +++ b/domain/src/main/java/com/twix/domain/repository/StatsRepository.kt @@ -2,6 +2,7 @@ package com.twix.domain.repository import com.twix.domain.model.stats.Stats import com.twix.domain.model.stats.StatsGoal +import com.twix.domain.model.stats.detail.StatsDetail import com.twix.result.AppResult import java.time.LocalDate @@ -9,4 +10,9 @@ interface StatsRepository { suspend fun fetchInProgressStats(date: LocalDate): AppResult suspend fun fetchEndStats(): AppResult> + + suspend fun fetchStatsDetail( + goalId: Long, + date: LocalDate?, + ): AppResult } diff --git a/feature/main/src/main/java/com/twix/main/MainScreen.kt b/feature/main/src/main/java/com/twix/main/MainScreen.kt index 3afc4916..6f47fc11 100644 --- a/feature/main/src/main/java/com/twix/main/MainScreen.kt +++ b/feature/main/src/main/java/com/twix/main/MainScreen.kt @@ -34,6 +34,7 @@ fun MainRoute( navigateToSettings: () -> Unit, navigateToCertification: (Long, LocalDate) -> Unit, navigateToCertificationDetail: (Long, LocalDate, BetweenUs) -> Unit, + navigateToStatsDetail: (Long, LocalDate?) -> Unit, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val homeViewModel: HomeViewModel = koinViewModel() @@ -47,6 +48,7 @@ fun MainRoute( navigateToCertificationDetail = navigateToCertificationDetail, navigateToCertification = navigateToCertification, navigateToSettings = navigateToSettings, + navigateToStatsDetail = navigateToStatsDetail, ) } @@ -60,6 +62,7 @@ private fun MainScreen( navigateToSettings: () -> Unit, navigateToCertification: (Long, LocalDate) -> Unit, navigateToCertificationDetail: (Long, LocalDate, BetweenUs) -> Unit, + navigateToStatsDetail: (Long, LocalDate?) -> Unit, ) { val calendarState by homeViewModel.calendarState.collectAsStateWithLifecycle() var showCalendarBottomSheet by remember { mutableStateOf(false) } @@ -100,7 +103,7 @@ private fun MainScreen( navigateToCertification = navigateToCertification, ) - MainTab.STATS -> StatsRoute() + MainTab.STATS -> StatsRoute(navigateToDetail = navigateToStatsDetail) MainTab.COUPLE -> Box(modifier = Modifier.fillMaxSize()) } } diff --git a/feature/main/src/main/java/com/twix/main/navigation/MainNavGraph.kt b/feature/main/src/main/java/com/twix/main/navigation/MainNavGraph.kt index 92a56514..8e284e15 100644 --- a/feature/main/src/main/java/com/twix/main/navigation/MainNavGraph.kt +++ b/feature/main/src/main/java/com/twix/main/navigation/MainNavGraph.kt @@ -61,6 +61,12 @@ object MainNavGraph : NavGraphContributor { launchSingleTop = true } }, + navigateToStatsDetail = { goalId, date -> + val destination = NavRoutes.StatsDetailRoute.createRoute(goalId, date) + navController.navigate(destination) { + launchSingleTop = true + } + }, ) } } diff --git a/feature/main/src/main/java/com/twix/stats/StatsScreen.kt b/feature/main/src/main/java/com/twix/stats/StatsScreen.kt index 02f19100..828e4520 100644 --- a/feature/main/src/main/java/com/twix/stats/StatsScreen.kt +++ b/feature/main/src/main/java/com/twix/stats/StatsScreen.kt @@ -47,9 +47,11 @@ import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel import org.koin.compose.koinInject +import java.time.LocalDate @Composable fun StatsRoute( + navigateToDetail: (Long, LocalDate?) -> Unit, toastManager: ToastManager = koinInject(), viewModel: StatsViewModel = koinViewModel(), ) { @@ -73,6 +75,15 @@ fun StatsRoute( uiState = uiState, onClickInProgressPreviousMonth = { viewModel.dispatch(StatsIntent.PreviousMonth) }, onClickInProgressNextMonth = { viewModel.dispatch(StatsIntent.NextMonth) }, + onClickStatsCard = { goalId, destination -> + val currentDate = + when (destination) { + StatsTabDestination.IN_PROGRESS -> uiState.inProgressStats.selectedDate + StatsTabDestination.END -> null + } + + navigateToDetail(goalId, currentDate) + }, ) } @@ -81,6 +92,7 @@ fun StatsScreen( uiState: StatsUiState, onClickInProgressPreviousMonth: () -> Unit, onClickInProgressNextMonth: () -> Unit, + onClickStatsCard: (Long, StatsTabDestination) -> Unit, ) { val pagerState = rememberPagerState(initialPage = StatsTabDestination.IN_PROGRESS.ordinal) { @@ -97,6 +109,7 @@ fun StatsScreen( pagerState = pagerState, onClickPreviousMonth = onClickInProgressPreviousMonth, onClickNextMonth = onClickInProgressNextMonth, + onClickStatsCard = onClickStatsCard, ) } } @@ -153,22 +166,29 @@ private fun StatsTabPager( pagerState: PagerState, onClickPreviousMonth: () -> Unit, onClickNextMonth: () -> Unit, + onClickStatsCard: (Long, StatsTabDestination) -> Unit, ) { HorizontalPager( state = pagerState, modifier = Modifier.fillMaxSize(), ) { page -> - when (StatsTabDestination.entries[page]) { + when (val tab = StatsTabDestination.entries[page]) { StatsTabDestination.IN_PROGRESS -> InProgressStatsContent( currentDate = uiState.currentDate, stats = uiState.inProgressStats, onClickPreviousMonth = onClickPreviousMonth, onClickNextMonth = onClickNextMonth, + onClickStatsCard = { + onClickStatsCard(it, tab) + }, ) StatsTabDestination.END -> - EndStatsContent(statsGoals = uiState.endStats) + EndStatsContent( + statsGoals = uiState.endStats, + onClickStatsCard = { onClickStatsCard(it, tab) }, + ) } } } @@ -193,6 +213,7 @@ fun StatsRoutePreview( uiState = uiState, onClickInProgressPreviousMonth = {}, onClickInProgressNextMonth = {}, + onClickStatsCard = { _, _ -> }, ) } } diff --git a/feature/main/src/main/java/com/twix/stats/component/EndStatsContent.kt b/feature/main/src/main/java/com/twix/stats/component/EndStatsContent.kt index f7ae9028..13d71dd9 100644 --- a/feature/main/src/main/java/com/twix/stats/component/EndStatsContent.kt +++ b/feature/main/src/main/java/com/twix/stats/component/EndStatsContent.kt @@ -15,10 +15,12 @@ import com.twix.designsystem.components.stats.EmptyStatsGuide import com.twix.designsystem.components.stats.StatsGoalCard import com.twix.designsystem.theme.GrayColor import com.twix.domain.model.stats.StatsGoal +import com.twix.ui.extension.noRippleClickable @Composable fun EndStatsContent( statsGoals: List, + onClickStatsCard: (Long) -> Unit, modifier: Modifier = Modifier, ) { LazyColumn( @@ -42,7 +44,11 @@ fun EndStatsContent( items = statsGoals, key = { it.goalId }, ) { - StatsGoalCard(it, false) + StatsGoalCard( + statsGoal = it, + showStamp = false, + modifier = Modifier.noRippleClickable(onClick = { onClickStatsCard(it.goalId) }), + ) } } } diff --git a/feature/main/src/main/java/com/twix/stats/component/InProgressStatsContent.kt b/feature/main/src/main/java/com/twix/stats/component/InProgressStatsContent.kt index b1e4c7c9..d5680aa9 100644 --- a/feature/main/src/main/java/com/twix/stats/component/InProgressStatsContent.kt +++ b/feature/main/src/main/java/com/twix/stats/component/InProgressStatsContent.kt @@ -22,6 +22,7 @@ import com.twix.designsystem.theme.TwixTheme import com.twix.domain.model.stats.Stats import com.twix.stats.contract.StatsUiState import com.twix.stats.preview.StatsUiStatePreviewProvider +import com.twix.ui.extension.noRippleClickable import java.time.LocalDate @Composable @@ -31,6 +32,7 @@ fun InProgressStatsContent( modifier: Modifier = Modifier, onClickPreviousMonth: () -> Unit, onClickNextMonth: () -> Unit, + onClickStatsCard: (Long) -> Unit, ) { LazyColumn( modifier = @@ -62,7 +64,13 @@ fun InProgressStatsContent( items = stats.statsGoals, key = { it.goalId }, ) { - StatsGoalCard(it, true) + StatsGoalCard( + statsGoal = it, + showStamp = true, + modifier = + Modifier + .noRippleClickable(onClick = { onClickStatsCard(it.goalId) }), + ) } } } @@ -80,6 +88,7 @@ fun InProgressStatsContentPreview( stats = uiState.inProgressStats, onClickPreviousMonth = {}, onClickNextMonth = {}, + onClickStatsCard = {}, ) } } diff --git a/feature/stats/detail/.gitignore b/feature/stats/detail/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/feature/stats/detail/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/stats/detail/build.gradle.kts b/feature/stats/detail/build.gradle.kts new file mode 100644 index 00000000..5776caf4 --- /dev/null +++ b/feature/stats/detail/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + alias(libs.plugins.twix.feature) +} + +android { + namespace = "com.twix.stats.detail" +} diff --git a/feature/stats/detail/consumer-rules.pro b/feature/stats/detail/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/feature/stats/detail/proguard-rules.pro b/feature/stats/detail/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/feature/stats/detail/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/feature/stats/detail/src/main/AndroidManifest.xml b/feature/stats/detail/src/main/AndroidManifest.xml new file mode 100644 index 00000000..8bdb7e14 --- /dev/null +++ b/feature/stats/detail/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/feature/stats/detail/src/main/java/com/twix/stats/detail/StatsDetailRoute.kt b/feature/stats/detail/src/main/java/com/twix/stats/detail/StatsDetailRoute.kt new file mode 100644 index 00000000..858730f8 --- /dev/null +++ b/feature/stats/detail/src/main/java/com/twix/stats/detail/StatsDetailRoute.kt @@ -0,0 +1,7 @@ +package com.twix.stats.detail + +import androidx.compose.runtime.Composable + +@Composable +fun StatsDetailRoute(onBack: () -> Unit) { +} diff --git a/feature/stats/detail/src/main/java/com/twix/stats/detail/component/StatsDetailTopbar.kt b/feature/stats/detail/src/main/java/com/twix/stats/detail/component/StatsDetailTopbar.kt new file mode 100644 index 00000000..fedd275e --- /dev/null +++ b/feature/stats/detail/src/main/java/com/twix/stats/detail/component/StatsDetailTopbar.kt @@ -0,0 +1,170 @@ +package com.twix.stats.detail.component + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +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.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import com.twix.designsystem.R +import com.twix.designsystem.components.popup.CommonPopup +import com.twix.designsystem.components.popup.CommonPopupDivider +import com.twix.designsystem.components.popup.CommonPopupItem +import com.twix.designsystem.components.text.AppText +import com.twix.designsystem.components.topbar.CommonTopBar +import com.twix.designsystem.theme.CommonColor +import com.twix.designsystem.theme.GrayColor +import com.twix.designsystem.theme.TwixTheme +import com.twix.domain.model.enums.AppTextStyle +import com.twix.ui.extension.noRippleClickable + +@Composable +internal fun StatsDetailTopbar( + goalName: String, + isInProgressStatsDetail: Boolean, + popupMenuVisibility: Boolean, + onBack: () -> Unit, + onClickAction: () -> Unit, + onClickPopupEdit: () -> Unit, + onClickPopupEnd: () -> Unit, + onClickPopupDelete: () -> Unit, + onDismiss: () -> Unit, +) { + CommonTopBar( + title = goalName, + left = { + Image( + painter = painterResource(R.drawable.ic_arrow3_left), + contentDescription = "back", + modifier = + Modifier + .padding(18.dp) + .size(24.dp) + .noRippleClickable(onClick = onBack), + ) + }, + right = { + if (isInProgressStatsDetail) { + PopupMenu( + popupMenuVisibility = popupMenuVisibility, + onClickAction = onClickAction, + onDismiss = onDismiss, + onEdit = onClickPopupEdit, + onEnd = onClickPopupEnd, + onDelete = onClickPopupDelete, + ) + } else { + DeleteButton(onClick = onClickAction) + } + }, + ) +} + +@Composable +private fun PopupMenu( + popupMenuVisibility: Boolean, + onClickAction: () -> Unit, + onDismiss: () -> Unit, + onEdit: () -> Unit, + onEnd: () -> Unit, + onDelete: () -> Unit, +) { + val density = LocalDensity.current + val popupOffset = + with(density) { + IntOffset(x = -40.dp.roundToPx(), y = 55.dp.roundToPx()) + } + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Image( + painter = painterResource(R.drawable.ic_meatball), + contentDescription = null, + modifier = + Modifier + .size(24.dp) + .rotate(90f) + .noRippleClickable(onClick = onClickAction), + ) + + CommonPopup( + visible = popupMenuVisibility, + anchorOffset = popupOffset, + onDismiss = onDismiss, + ) { + Column( + modifier = + Modifier + .width(88.dp) + .background(CommonColor.White, RoundedCornerShape(12.dp)) + .border(1.dp, GrayColor.C500, RoundedCornerShape(12.dp)), + ) { + CommonPopupItem( + text = stringResource(R.string.action_edit), + onClick = onEdit, + ) + CommonPopupDivider() + CommonPopupItem( + text = stringResource(R.string.action_finish), + onClick = onEnd, + ) + CommonPopupDivider() + CommonPopupItem( + text = stringResource(R.string.action_delete), + onClick = onDelete, + ) + } + } + } +} + +@Composable +private fun DeleteButton(onClick: () -> Unit) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(GrayColor.C100) + .noRippleClickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + AppText( + text = stringResource(R.string.word_delete), + style = AppTextStyle.T2, + color = GrayColor.C500, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun StatsDetailTopbarPreview() { + TwixTheme { + StatsDetailTopbar( + goalName = "Goal Name", + isInProgressStatsDetail = true, + popupMenuVisibility = true, + onBack = {}, + onClickAction = {}, + onDismiss = {}, + onClickPopupEdit = {}, + onClickPopupEnd = {}, + onClickPopupDelete = {}, + ) + } +} diff --git a/feature/stats/detail/src/main/java/com/twix/stats/detail/contract/StatsDetailSideEffect.kt b/feature/stats/detail/src/main/java/com/twix/stats/detail/contract/StatsDetailSideEffect.kt new file mode 100644 index 00000000..1713549a --- /dev/null +++ b/feature/stats/detail/src/main/java/com/twix/stats/detail/contract/StatsDetailSideEffect.kt @@ -0,0 +1,13 @@ +package com.twix.stats.detail.contract + +import com.twix.designsystem.components.toast.model.ToastType +import com.twix.ui.base.SideEffect + +sealed interface StatsDetailSideEffect : SideEffect { + data class ShowToast( + val message: Int, + val type: ToastType, + ) : StatsDetailSideEffect + + data object NavigateToBack : StatsDetailSideEffect +} diff --git a/feature/stats/detail/src/main/java/com/twix/stats/detail/contract/StatsDetailUiState.kt b/feature/stats/detail/src/main/java/com/twix/stats/detail/contract/StatsDetailUiState.kt new file mode 100644 index 00000000..690808b4 --- /dev/null +++ b/feature/stats/detail/src/main/java/com/twix/stats/detail/contract/StatsDetailUiState.kt @@ -0,0 +1,17 @@ +package com.twix.stats.detail.contract + +import androidx.compose.runtime.Immutable +import com.twix.designsystem.components.stats.model.StatsCalendarUiModel +import com.twix.domain.model.stats.detail.StatsDetail +import com.twix.ui.base.State +import java.time.LocalDate + +@Immutable +data class StatsDetailUiState( + val goalId: Long = -1, + val selectedDate: LocalDate? = null, + val detail: StatsDetail = StatsDetail.EMPTY, + val calendarUiModel: StatsCalendarUiModel = StatsCalendarUiModel(), +) : State { + val isInProgressStatsDetail get() = selectedDate != null +} diff --git a/feature/stats/detail/src/main/java/com/twix/stats/detail/di/StatsDetailModule.kt b/feature/stats/detail/src/main/java/com/twix/stats/detail/di/StatsDetailModule.kt new file mode 100644 index 00000000..9564caf7 --- /dev/null +++ b/feature/stats/detail/src/main/java/com/twix/stats/detail/di/StatsDetailModule.kt @@ -0,0 +1,12 @@ +package com.twix.stats.detail.di + +import com.twix.navigation.NavRoutes +import com.twix.navigation.base.NavGraphContributor +import com.twix.stats.detail.navigation.StatsDetailGraph +import org.koin.core.qualifier.named +import org.koin.dsl.module + +val statsDetailModule = + module { + single(named(NavRoutes.StatsDetailRoute.route)) { StatsDetailGraph } + } diff --git a/feature/stats/detail/src/main/java/com/twix/stats/detail/navigation/StatsDetailGraph.kt b/feature/stats/detail/src/main/java/com/twix/stats/detail/navigation/StatsDetailGraph.kt new file mode 100644 index 00000000..3af4a0fa --- /dev/null +++ b/feature/stats/detail/src/main/java/com/twix/stats/detail/navigation/StatsDetailGraph.kt @@ -0,0 +1,39 @@ +package com.twix.stats.detail.navigation + +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavHostController +import androidx.navigation.NavType +import androidx.navigation.compose.composable +import androidx.navigation.navArgument +import com.twix.navigation.NavRoutes +import com.twix.navigation.base.NavGraphContributor +import com.twix.stats.detail.StatsDetailRoute + +object StatsDetailGraph : NavGraphContributor { + override val graphRoute: NavRoutes + get() = NavRoutes.StatsDetailGraph + + override val startDestination: String + get() = NavRoutes.StatsDetailRoute.route + + override fun NavGraphBuilder.registerGraph(navController: NavHostController) { + composable( + route = NavRoutes.StatsDetailRoute.route, + arguments = + listOf( + navArgument(NavRoutes.StatsDetailRoute.ARG_GOAL_ID) { + type = NavType.LongType + }, + navArgument(NavRoutes.StatsDetailRoute.ARG_DATE) { + type = NavType.StringType + nullable = true + defaultValue = null + }, + ), + ) { + StatsDetailRoute( + onBack = navController::popBackStack, + ) + } + } +} diff --git a/feature/stats/detail/src/main/java/com/twix/stats/detail/preview/StatsDetailUiStatePreviewProvider.kt b/feature/stats/detail/src/main/java/com/twix/stats/detail/preview/StatsDetailUiStatePreviewProvider.kt new file mode 100644 index 00000000..72cbff3b --- /dev/null +++ b/feature/stats/detail/src/main/java/com/twix/stats/detail/preview/StatsDetailUiStatePreviewProvider.kt @@ -0,0 +1,75 @@ +package com.twix.stats.detail.preview + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.twix.designsystem.components.stats.model.StatsCalendarUiModel +import com.twix.domain.model.enums.GoalIconType +import com.twix.domain.model.enums.RepeatCycle +import com.twix.domain.model.stats.detail.CompletedDate +import com.twix.domain.model.stats.detail.StatsDetail +import com.twix.domain.model.stats.detail.StatsSummary +import com.twix.stats.detail.contract.StatsDetailUiState +import java.time.LocalDate + +class StatsDetailUiStatePreviewProvider : PreviewParameterProvider { + private val completedDates = + listOf( + CompletedDate( + date = LocalDate.now().minusDays(1), + myImageUrl = "https://picsum.photos/200", + partnerImageUrl = "https://picsum.photos/201", + ), + CompletedDate( + date = LocalDate.now().minusDays(3), + myImageUrl = "https://picsum.photos/202", + partnerImageUrl = null, + ), + CompletedDate( + date = LocalDate.now().minusDays(5), + myImageUrl = null, + partnerImageUrl = "https://picsum.photos/203", + ), + ) + + private val baseDetail = + StatsDetail( + goalId = 1, + goalName = "아이스크림 먹기", + goalIcon = GoalIconType.DEFAULT, + status = "진행중", + monthDate = LocalDate.now(), + completedDate = completedDates, + statsSummary = + StatsSummary( + myNickname = "나", + partnerNickname = "파트너", + totalCount = 10, + myCompletedCount = 6, + partnerCompletedCount = 4, + repeatCycle = RepeatCycle.DAILY, + startDate = LocalDate.now().minusMonths(1), + endDate = null, + ), + ) + + private val baseCalendarUiModel = + StatsCalendarUiModel.create( + currentDate = LocalDate.now(), + completedDate = completedDates, + ) + + override val values: Sequence = + sequenceOf( + StatsDetailUiState( + goalId = 1, + selectedDate = LocalDate.now(), + detail = baseDetail, + calendarUiModel = baseCalendarUiModel, + ), + StatsDetailUiState( + goalId = 1, + selectedDate = null, + detail = baseDetail.copy(status = "종료"), + calendarUiModel = baseCalendarUiModel, + ), + ) +} diff --git a/settings.gradle.kts b/settings.gradle.kts index ba6cba8e..6790f78e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -45,6 +45,7 @@ include(":feature:goal-editor") include(":feature:goal-manage") include(":feature:settings") include(":feature:onboarding") +include(":feature:stats:detail") include(":core:navigation-contract") include(":core:notification") include(":core:device-contract")