diff --git a/Near/app/src/main/java/com/alarmy/near/data/repository/DefaultFriendRepository.kt b/Near/app/src/main/java/com/alarmy/near/data/repository/DefaultFriendRepository.kt index e63d0dd0..31045cfa 100644 --- a/Near/app/src/main/java/com/alarmy/near/data/repository/DefaultFriendRepository.kt +++ b/Near/app/src/main/java/com/alarmy/near/data/repository/DefaultFriendRepository.kt @@ -39,6 +39,11 @@ class DefaultFriendRepository ) } + override fun fetchMonthlyCompleteFriends(): Flow> = + apiCallFlow { + friendService.fetchMonthlyCompleteFriends().map { it.toModel() } + } + override fun fetchFriendById(friendId: String): Flow = flow { emit(friendService.fetchFriendById(friendId).toModel()) diff --git a/Near/app/src/main/java/com/alarmy/near/data/repository/FriendRepository.kt b/Near/app/src/main/java/com/alarmy/near/data/repository/FriendRepository.kt index c437c4e4..78774f86 100644 --- a/Near/app/src/main/java/com/alarmy/near/data/repository/FriendRepository.kt +++ b/Near/app/src/main/java/com/alarmy/near/data/repository/FriendRepository.kt @@ -2,9 +2,9 @@ package com.alarmy.near.data.repository import com.alarmy.near.model.Friend import com.alarmy.near.model.FriendRecord -import com.alarmy.near.network.response.FriendInitItemEntity import com.alarmy.near.model.friendsummary.FriendSummary import com.alarmy.near.model.monthly.MonthlyFriend +import com.alarmy.near.network.response.FriendInitItemEntity import com.alarmy.near.presentation.feature.friendcontactcycle.model.FriendContactUIModel import kotlinx.coroutines.flow.Flow @@ -13,6 +13,8 @@ interface FriendRepository { fun fetchMonthlyFriends(): Flow> + fun fetchMonthlyCompleteFriends(): Flow> + fun fetchFriendById(friendId: String): Flow fun updateFriend( @@ -26,5 +28,8 @@ interface FriendRepository { fun recordContact(friendId: String): Flow - fun initFriends(contacts: List, providerType: String): Flow> + fun initFriends( + contacts: List, + providerType: String, + ): Flow> } diff --git a/Near/app/src/main/java/com/alarmy/near/model/monthly/MonthlyFriend.kt b/Near/app/src/main/java/com/alarmy/near/model/monthly/MonthlyFriend.kt index ac4fbb1b..0006ed90 100644 --- a/Near/app/src/main/java/com/alarmy/near/model/monthly/MonthlyFriend.kt +++ b/Near/app/src/main/java/com/alarmy/near/model/monthly/MonthlyFriend.kt @@ -13,7 +13,7 @@ data class MonthlyFriend( fun daysUntilNextContact(today: LocalDate): String { val daysBetween = getDaysBetween(today) return when { - daysBetween == 0L -> "D-day" + daysBetween == 0L -> "D-DAY" daysBetween > 0L -> "D-$daysBetween" else -> "D+${-daysBetween}" // 과거 날짜 } diff --git a/Near/app/src/main/java/com/alarmy/near/network/service/FriendService.kt b/Near/app/src/main/java/com/alarmy/near/network/service/FriendService.kt index c7cf3864..7fe55c5e 100644 --- a/Near/app/src/main/java/com/alarmy/near/network/service/FriendService.kt +++ b/Near/app/src/main/java/com/alarmy/near/network/service/FriendService.kt @@ -22,6 +22,9 @@ interface FriendService { @GET("/friend/monthly") suspend fun fetchMonthlyFriends(): List + @GET("/friend/monthly/complete") + suspend fun fetchMonthlyCompleteFriends(): List + @GET("/friend/{friendId}") suspend fun fetchFriendById( @Path("friendId") friendId: String, diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/HomeScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/HomeScreen.kt index 6307c964..65d1cc24 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/HomeScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/HomeScreen.kt @@ -31,9 +31,11 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.paint import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource @@ -74,6 +76,7 @@ internal fun HomeRoute( onAlarmClick: () -> Unit = {}, onMyPageClick: () -> Unit = {}, onAddContactClick: () -> Unit = {}, + onMonthlyReminderAllClick: () -> Unit = {}, ) { LaunchedEffect(Unit) { launch { @@ -90,6 +93,7 @@ internal fun HomeRoute( onAlarmClick = onAlarmClick, onMyPageClick = onMyPageClick, onAddContactClick = onAddContactClick, + onMonthlyReminderAllClick = onMonthlyReminderAllClick, contacts = friends.value, monthlyFriends = monthlyFriends.value, memberInfo = memberInfo.value, @@ -104,6 +108,7 @@ internal fun HomeScreen( onMyPageClick: () -> Unit = {}, onAlarmClick: () -> Unit = {}, onAddContactClick: () -> Unit = {}, + onMonthlyReminderAllClick: () -> Unit = {}, memberInfo: MemberInfo?, contacts: List, monthlyFriends: List, @@ -130,7 +135,8 @@ internal fun HomeScreen( R.drawable.img_bg, ), contentScale = ContentScale.FillBounds, - ).fillMaxSize(), + ) + .fillMaxSize(), ) { Spacer(modifier = Modifier.height(statusBarHeightDp)) Row( @@ -174,12 +180,23 @@ internal fun HomeScreen( color = NearTheme.colors.WHITE_FFFFFF, ) Spacer(modifier = Modifier.height(32.dp)) - Text( - text = stringResource(R.string.home_this_month_people), - modifier = Modifier.padding(horizontal = 24.dp), - style = NearTheme.typography.B1_16_BOLD, - color = NearTheme.colors.WHITE_FFFFFF, - ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResource(R.string.home_this_month_people), + modifier = Modifier.padding(horizontal = 24.dp), + style = NearTheme.typography.B1_16_BOLD, + color = NearTheme.colors.WHITE_FFFFFF, + ) + + MonthlyReminderFriendsViewAll( + modifier = Modifier.padding(horizontal = 24.dp), + onMonthlyReminderAllClick = onMonthlyReminderAllClick, + ) + } Spacer(modifier = Modifier.height(16.dp)) if (monthlyFriends.isEmpty()) { Surface( @@ -370,6 +387,36 @@ private fun PagerIndicator(pagerState: PagerState) { } } +@Composable +fun MonthlyReminderFriendsViewAll( + modifier: Modifier = Modifier, + onMonthlyReminderAllClick: () -> Unit = {}, +) { + Row( + modifier = + modifier.onNoRippleClick( + onClick = onMonthlyReminderAllClick, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.home_monthly_friends_all), + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.WHITE_FFFFFF, + modifier = Modifier.alpha(0.8f), + ) + + Spacer(modifier = Modifier.size(6.dp)) + + Image( + painter = painterResource(id = R.drawable.ic_front_8), + colorFilter = ColorFilter.tint(NearTheme.colors.WHITE_FFFFFF), + alpha = 1f, + contentDescription = null, + ) + } +} + @Preview @Composable internal fun HomeScreenPreview() { @@ -408,3 +455,11 @@ internal fun HomeScreenPreview() { ) } } + +@Preview +@Composable +fun MonthlyReminderFriendsViewAllPreview() { + NearTheme { + MonthlyReminderFriendsViewAll() + } +} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/navigation/HomeNavigation.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/navigation/HomeNavigation.kt index 382ddeb5..61d1b03e 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/navigation/HomeNavigation.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/navigation/HomeNavigation.kt @@ -5,9 +5,6 @@ import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptions import androidx.navigation.compose.composable -import com.alarmy.near.model.Friend -import com.alarmy.near.presentation.feature.friendprofile.FriendProfileViewModel -import com.alarmy.near.presentation.feature.friendprofileedittor.navigation.FRIEND_PROFILE_EDIT_COMPLETE_KEY import com.alarmy.near.presentation.feature.home.HomeRoute import com.alarmy.near.presentation.feature.home.HomeViewModel import kotlinx.serialization.Serializable @@ -30,6 +27,7 @@ fun NavGraphBuilder.homeNavGraph( onAlarmClick: () -> Unit = {}, onMyPageClick: () -> Unit = {}, onAddContactClick: () -> Unit = {}, + onMonthlyReminderAllClick: () -> Unit = {}, ) { composable { backStackEntry -> val viewModel: HomeViewModel = hiltViewModel() @@ -43,6 +41,7 @@ fun NavGraphBuilder.homeNavGraph( onAlarmClick = onAlarmClick, onMyPageClick = onMyPageClick, onAddContactClick = onAddContactClick, + onMonthlyReminderAllClick = onMonthlyReminderAllClick, ) } } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/NearNavHost.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/NearNavHost.kt index e34f35a3..c8012d1f 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/NearNavHost.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/NearNavHost.kt @@ -23,6 +23,8 @@ import com.alarmy.near.presentation.feature.home.navigation.homeNavGraph import com.alarmy.near.presentation.feature.home.navigation.navigateToHome import com.alarmy.near.presentation.feature.login.navigation.loginNavGraph import com.alarmy.near.presentation.feature.login.navigation.navigateToLogin +import com.alarmy.near.presentation.feature.mothlyreminderall.navigation.monthlyReminderAllNavGraph +import com.alarmy.near.presentation.feature.mothlyreminderall.navigation.navigateToMonthlyReminderAll import com.alarmy.near.presentation.feature.myprofile.navigation.myProfileNavGraph import com.alarmy.near.presentation.feature.myprofile.navigation.navigateToMyProfile import com.alarmy.near.presentation.feature.myprofile.navigation.navigateToWebView @@ -87,6 +89,12 @@ internal fun NearNavHost( onMyPageClick = { navController.navigateToMyProfile() }, onAlarmClick = {}, onAddContactClick = { navController.navigateToFriendContactCycle() }, + onMonthlyReminderAllClick = { navController.navigateToMonthlyReminderAll() }, + ) + + monthlyReminderAllNavGraph( + onShowErrorSnackBar = onShowSnackbar, + onNavigateBack = { navController.popBackStack() }, ) myProfileNavGraph( diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/mothlyreminderall/MonthlyReminderAllScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/mothlyreminderall/MonthlyReminderAllScreen.kt new file mode 100644 index 00000000..c5ac6fae --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/mothlyreminderall/MonthlyReminderAllScreen.kt @@ -0,0 +1,221 @@ +package com.alarmy.near.presentation.feature.mothlyreminderall + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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 androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.alarmy.near.R +import com.alarmy.near.presentation.feature.mothlyreminderall.components.MonthlyReminderComplete +import com.alarmy.near.presentation.feature.mothlyreminderall.components.MonthlyReminderEmpty +import com.alarmy.near.presentation.feature.mothlyreminderall.components.MonthlyReminderFriendCard +import com.alarmy.near.presentation.feature.mothlyreminderall.components.RecordSuccessDialog +import com.alarmy.near.presentation.feature.mothlyreminderall.model.MonthlyReminderUIModel +import com.alarmy.near.presentation.feature.mothlyreminderall.uistate.MonthlyReminderAllUIEvent +import com.alarmy.near.presentation.feature.mothlyreminderall.uistate.MonthlyReminderAllUIState +import com.alarmy.near.presentation.ui.component.NearFrame +import com.alarmy.near.presentation.ui.component.appbar.NearTopAppbar +import com.alarmy.near.presentation.ui.theme.NearTheme + +@Composable +fun MonthlyReminderAllRoute( + viewModel: MonthlyReminderAllViewModel = hiltViewModel(), + onShowErrorSnackBar: (throwable: Throwable?) -> Unit, + onNavigateBack: () -> Unit = {}, +) { + val uiState = viewModel.uiState.collectAsStateWithLifecycle() + val recordSuccessDialogState = remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + viewModel.uiEvent.collect { event -> + when (event) { + is MonthlyReminderAllUIEvent.ShowError -> { + onShowErrorSnackBar(event.throwable) + } + + is MonthlyReminderAllUIEvent.RecordFriendShipSuccess -> { + recordSuccessDialogState.value = true + } + } + } + } + + MonthlyReminderAllScreen( + uiState = uiState.value, + recordSuccessDialogState = recordSuccessDialogState.value, + onNavigateBack = onNavigateBack, + onRecordFriendShip = viewModel::onRecordFriendShip, + onDismissRecordSuccessDialog = { + recordSuccessDialogState.value = false + }, + ) +} + +@Composable +internal fun MonthlyReminderAllScreen( + modifier: Modifier = Modifier, + uiState: MonthlyReminderAllUIState, + recordSuccessDialogState: Boolean = false, + onNavigateBack: () -> Unit = {}, + onRecordFriendShip: (String) -> Unit = {}, + onDismissRecordSuccessDialog: () -> Unit = {}, +) { + if (recordSuccessDialogState) { + RecordSuccessDialog(onDismiss = onDismissRecordSuccessDialog) + } + + NearFrame( + modifier = + modifier + .background(NearTheme.colors.WHITE_FFFFFF), + ) { + NearTopAppbar( + title = stringResource(R.string.monthly_reminder_all_title), + onClickBackButton = onNavigateBack, + ) + + when (uiState) { + is MonthlyReminderAllUIState.Loading -> { + Box( + modifier = + Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(color = NearTheme.colors.BLUE01_5AA2E9) + } + } + + is MonthlyReminderAllUIState.Empty -> { + MonthlyReminderEmpty() + } + + is MonthlyReminderAllUIState.Success -> { + LazyColumn( + modifier = + Modifier + .fillMaxSize() + .padding(horizontal = 20.dp), + contentPadding = PaddingValues(bottom = 80.dp), + ) { + items( + items = uiState.monthlyReminders, + key = { "monthly_${it.friendId}" }, + ) { reminder -> + Spacer(modifier = Modifier.size(16.dp)) + + MonthlyReminderFriendCard( + reminder = reminder, + onRecordClick = onRecordFriendShip, + ) + } + + if (uiState.hasCompletedReminders) { + item { + Spacer(modifier = Modifier.size(32.dp)) + + Text( + text = stringResource(R.string.monthly_reminder_all_completed_section_title), + style = NearTheme.typography.B2_14_BOLD, + color = NearTheme.colors.BLACK_1A1A1A, + ) + Spacer(modifier = Modifier.size(16.dp)) + } + + items( + items = uiState.completedReminders, + key = { "completed_${it.friendId}" }, + ) { reminder -> + MonthlyReminderComplete(reminder = reminder) + + Spacer(modifier = Modifier.size(16.dp)) + } + } + } + } + } + } +} + +@Preview +@Composable +private fun MonthlyReminderAllScreenPreview() { + NearTheme { + MonthlyReminderAllScreen( + uiState = + MonthlyReminderAllUIState.Success( + monthlyReminders = + listOf( + MonthlyReminderUIModel( + friendId = "1", + name = "신짱구", + imageRes = R.drawable.icon_visual_cake, + descriptionRes = R.string.monthly_reminder_all_type_birthday_description, + nextContactAt = "2025-11-05", + daysUntilNextContact = "D-4", + isToday = false, + ), + MonthlyReminderUIModel( + friendId = "2", + name = "김철수", + imageRes = R.drawable.icon_visual_mail, + descriptionRes = R.string.monthly_reminder_all_type_message_description, + nextContactAt = "2025-11-01", + daysUntilNextContact = "D-day", + isToday = true, + ), + ), + completedReminders = + listOf( + MonthlyReminderUIModel( + friendId = "3", + name = "흰둥이", + imageRes = R.drawable.icon_visual_24_heart, + descriptionRes = R.string.monthly_reminder_all_type_anniversary_description, + nextContactAt = "2025-10-15", + daysUntilNextContact = "D+16", + isToday = false, + ), + ), + hasCompletedReminders = true, + ), + ) + } +} + +@Preview +@Composable +private fun MonthlyReminderAllScreenEmptyPreview() { + NearTheme { + MonthlyReminderAllScreen( + uiState = MonthlyReminderAllUIState.Empty, + ) + } +} + +@Preview +@Composable +private fun MonthlyReminderAllScreenLoadingPreview() { + NearTheme { + MonthlyReminderAllScreen( + uiState = MonthlyReminderAllUIState.Loading, + ) + } +} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/mothlyreminderall/MonthlyReminderAllViewModel.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/mothlyreminderall/MonthlyReminderAllViewModel.kt new file mode 100644 index 00000000..08aa4205 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/mothlyreminderall/MonthlyReminderAllViewModel.kt @@ -0,0 +1,133 @@ +package com.alarmy.near.presentation.feature.mothlyreminderall + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.alarmy.near.data.repository.FriendRepository +import com.alarmy.near.model.monthly.MonthlyFriend +import com.alarmy.near.presentation.feature.mothlyreminderall.model.MonthlyReminderCombinedData +import com.alarmy.near.presentation.feature.mothlyreminderall.model.MonthlyReminderTypeInfo +import com.alarmy.near.presentation.feature.mothlyreminderall.model.MonthlyReminderUIModel +import com.alarmy.near.presentation.feature.mothlyreminderall.uistate.MonthlyReminderAllUIEvent +import com.alarmy.near.presentation.feature.mothlyreminderall.uistate.MonthlyReminderAllUIState +import com.alarmy.near.utils.extensions.handleError +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import java.time.LocalDate +import javax.inject.Inject + +@HiltViewModel +class MonthlyReminderAllViewModel + @Inject + constructor( + private val friendRepository: FriendRepository, + ) : ViewModel() { + private val _uiEvent = Channel() + val uiEvent = _uiEvent.receiveAsFlow() + + private val _uiState: MutableStateFlow = + MutableStateFlow(MonthlyReminderAllUIState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + private val monthlyReminders = MutableStateFlow>(emptyList()) + private val completedReminders = MutableStateFlow>(emptyList()) + + init { + fetchMonthlyReminders() + } + + private fun fetchMonthlyReminders() { + combine( + friendRepository.fetchMonthlyFriends(), + friendRepository.fetchMonthlyCompleteFriends(), + ) { monthlyFriends, completeFriends -> + MonthlyReminderCombinedData( + monthlyFriends = monthlyFriends, + completeFriends = completeFriends, + ) + }.onEach { data -> + val monthlyUIModels = + convertToUIModels(data.monthlyFriends) + .distinctBy { it.friendId } + .sortedBy { it.nextContactAt } + monthlyReminders.value = monthlyUIModels + val completedUIModels = + convertToUIModels(data.completeFriends) + .distinctBy { it.friendId } + completedReminders.value = completedUIModels + // 두 데이터가 모두 준비된 후 UI 상태 업데이트 + combineRemindersToUIState() + }.handleError(viewModelScope, _uiEvent) { exception -> + MonthlyReminderAllUIEvent.ShowError(exception) + }.launchIn(viewModelScope) + } + + private fun combineRemindersToUIState() { + val monthlyList = monthlyReminders.value + val completedList = completedReminders.value + val completedFriendIds = completedList.map { it.friendId }.toSet() + val filteredMonthlyList = + monthlyList + .filter { it.friendId !in completedFriendIds } + .distinctBy { it.friendId } + + _uiState.update { + if (filteredMonthlyList.isEmpty() && completedList.isEmpty()) { + MonthlyReminderAllUIState.Empty + } else { + MonthlyReminderAllUIState.Success( + monthlyReminders = filteredMonthlyList, + completedReminders = completedList.distinctBy { it.friendId }, + hasCompletedReminders = completedList.isNotEmpty(), + ) + } + } + } + + fun onRecordFriendShip(friendId: String) { + friendRepository + .recordContact(friendId) + .onEach { _ -> + viewModelScope.launch { + _uiEvent.send(MonthlyReminderAllUIEvent.RecordFriendShipSuccess) + } + val recordedFriend = monthlyReminders.value.find { it.friendId == friendId } + if (recordedFriend != null) { + monthlyReminders.value = + monthlyReminders.value.filter { it.friendId != friendId } + completedReminders.value = listOf(recordedFriend) + completedReminders.value + combineRemindersToUIState() + } + }.handleError(viewModelScope, _uiEvent) { exception -> + MonthlyReminderAllUIEvent.ShowError(exception) + }.launchIn(viewModelScope) + } + + private fun convertToUIModels(monthlyFriends: List): List { + val today = LocalDate.now() + return monthlyFriends.mapNotNull { friend -> + try { + val typeInfo = MonthlyReminderTypeInfo.from(friend.type) + MonthlyReminderUIModel( + friendId = friend.friendId, + name = friend.name, + imageRes = typeInfo.imageRes, + descriptionRes = typeInfo.descriptionRes, + nextContactAt = friend.nextContactAt, + daysUntilNextContact = friend.daysUntilNextContact(today), + isToday = friend.isNextContactDay(today), + ) + } catch (e: Exception) { + null + } + } + } + } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/mothlyreminderall/components/MonthlyReminderComplete.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/mothlyreminderall/components/MonthlyReminderComplete.kt new file mode 100644 index 00000000..95a4a0d3 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/mothlyreminderall/components/MonthlyReminderComplete.kt @@ -0,0 +1,86 @@ +package com.alarmy.near.presentation.feature.mothlyreminderall.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Row +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.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.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.alarmy.near.R +import com.alarmy.near.presentation.feature.mothlyreminderall.model.MonthlyReminderUIModel +import com.alarmy.near.presentation.ui.theme.NearTheme +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +@Composable +fun MonthlyReminderComplete(reminder: MonthlyReminderUIModel) { + val formattedDate = formatDate(reminder.nextContactAt) + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(NearTheme.colors.BG02_F4F9FD) + .padding(vertical = 18.dp, horizontal = 20.dp), + ) { + Image( + painter = painterResource(reminder.imageRes), + contentDescription = null, + ) + Spacer(modifier = Modifier.size(12.dp)) + Text( + text = reminder.name, + style = NearTheme.typography.B2_14_BOLD, + color = NearTheme.colors.BLACK_1A1A1A, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Spacer(modifier = Modifier.size(10.dp)) + Text( + text = formattedDate, + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.GRAY01_888888, + ) + } +} + +private fun formatDate(dateString: String): String = + try { + val date = LocalDate.parse(dateString, DateTimeFormatter.ofPattern("yyyy-MM-dd")) + date.format(DateTimeFormatter.ofPattern("yy.MM.dd")) + } catch (e: Exception) { + dateString + } + +@Preview +@Composable +fun MonthlyReminderCompletePreview() { + NearTheme { + MonthlyReminderComplete( + reminder = + MonthlyReminderUIModel( + friendId = "1", + name = "신짱구신짱구신짱구신짱구신짱구신짱구신짱구신짱구신짱구신짱구", + imageRes = R.drawable.icon_visual_cake, + descriptionRes = R.string.monthly_reminder_all_type_birthday_description, + nextContactAt = "2025-03-20", + daysUntilNextContact = "D-9", + isToday = false, + ), + ) + } +} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/mothlyreminderall/components/MonthlyReminderEmpty.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/mothlyreminderall/components/MonthlyReminderEmpty.kt new file mode 100644 index 00000000..3e9ac6e2 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/mothlyreminderall/components/MonthlyReminderEmpty.kt @@ -0,0 +1,53 @@ +package com.alarmy.near.presentation.feature.mothlyreminderall.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +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.alarmy.near.R +import com.alarmy.near.presentation.ui.theme.NearTheme + +@Composable +fun MonthlyReminderEmpty() { + Column( + modifier = + Modifier + .fillMaxWidth() + .fillMaxHeight(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.fillMaxHeight(0.28f)) + + Image( + painter = painterResource(R.drawable.img_100_character_empty), + contentDescription = null, + ) + + Spacer(modifier = Modifier.size(16.dp)) + + Text( + text = stringResource(R.string.monthly_reminder_all_empty_text), + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.GRAY01_888888, + ) + } +} + +@Preview(showBackground = true) +@Composable +fun MonthlyReminderEmptyPreview() { + NearTheme { + MonthlyReminderEmpty() + } +} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/mothlyreminderall/components/MonthlyReminderFriendCard.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/mothlyreminderall/components/MonthlyReminderFriendCard.kt new file mode 100644 index 00000000..1d33184e --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/mothlyreminderall/components/MonthlyReminderFriendCard.kt @@ -0,0 +1,147 @@ +package com.alarmy.near.presentation.feature.mothlyreminderall.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +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.layout.wrapContentHeight +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +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.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.alarmy.near.R +import com.alarmy.near.presentation.feature.mothlyreminderall.model.MonthlyReminderUIModel +import com.alarmy.near.presentation.ui.component.button.NearBasicButton +import com.alarmy.near.presentation.ui.extension.dropShadow +import com.alarmy.near.presentation.ui.theme.NearTheme + +@Composable +fun MonthlyReminderFriendCard( + reminder: MonthlyReminderUIModel, + onRecordClick: (String) -> Unit = {}, +) { + Card( + modifier = + Modifier + .fillMaxWidth() + .wrapContentHeight() + .dropShadow( + shape = RoundedCornerShape(12.dp), + blur = 16.dp, + spread = 0.dp, + offsetX = 0.dp, + offsetY = 4.dp, + color = Color.Black.copy(0.12f), + ), + colors = + CardDefaults.cardColors( + containerColor = NearTheme.colors.WHITE_FFFFFF, + ), + ) { + Spacer(modifier = Modifier.size(20.dp)) + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + modifier = Modifier.size(32.dp), + painter = painterResource(id = reminder.imageRes), + contentDescription = null, + ) + + Spacer(modifier = Modifier.size(12.dp)) + + Column( + modifier = Modifier.weight(1f), + ) { + Text( + text = reminder.name, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + style = NearTheme.typography.B2_14_BOLD, + color = NearTheme.colors.BLACK_1A1A1A, + ) + + Spacer(modifier = Modifier.size(6.dp)) + + Text( + text = stringResource(reminder.descriptionRes), + overflow = TextOverflow.Ellipsis, + maxLines = 1, + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.GRAY01_888888, + ) + } + + Spacer(modifier = Modifier.size(10.dp)) + + val (textStyle, textColor) = + if (reminder.isToday) { + NearTheme.typography.B2_14_BOLD to NearTheme.colors.BLUE01_5AA2E9 + } else { + NearTheme.typography.B2_14_MEDIUM to NearTheme.colors.GRAY01_888888 + } + + Text( + modifier = Modifier.align(Alignment.Top), + text = reminder.daysUntilNextContact, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + style = textStyle, + color = textColor, + ) + } + + Spacer(modifier = Modifier.size(16.dp)) + + NearBasicButton( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + onClick = { onRecordClick(reminder.friendId) }, + contentPadding = PaddingValues(12.dp), + ) { + Text( + text = stringResource(R.string.monthly_reminder_all_record_button_text), + ) + } + Spacer(modifier = Modifier.size(20.dp)) + } +} + +@Preview(showBackground = true, widthDp = 360, heightDp = 700) +@Composable +fun MonthlyReminderFriendCardPreview() { + NearTheme { + MonthlyReminderFriendCard( + reminder = + MonthlyReminderUIModel( + friendId = "1", + name = "신짱구신짱구신짱구신짱구신짱구신짱구신짱구신짱구신짱구신짱구", + imageRes = R.drawable.icon_visual_cake, + descriptionRes = R.string.monthly_reminder_all_type_birthday_description, + nextContactAt = "2025-11-05", + daysUntilNextContact = "D-DAY", + isToday = true, + ), + ) + } +} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/mothlyreminderall/components/RecordSuccessDialog.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/mothlyreminderall/components/RecordSuccessDialog.kt new file mode 100644 index 00000000..c17fd226 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/mothlyreminderall/components/RecordSuccessDialog.kt @@ -0,0 +1,56 @@ +package com.alarmy.near.presentation.feature.mothlyreminderall.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.alarmy.near.R +import com.alarmy.near.presentation.ui.theme.NearTheme +import kotlinx.coroutines.delay + +@Composable +fun RecordSuccessDialog(onDismiss: () -> Unit) { + LaunchedEffect(Unit) { + delay(1500) + onDismiss() + } + + Dialog(onDismissRequest = onDismiss) { + Column( + modifier = + Modifier + .width(255.dp) + .height(186.dp) + .background( + color = NearTheme.colors.WHITE_FFFFFF, + shape = RoundedCornerShape(16.dp), + ), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + painter = painterResource(R.drawable.img_100_character_success), + contentDescription = null, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + stringResource(R.string.friend_profile_info_contact_success_text), + style = NearTheme.typography.B1_16_BOLD, + color = NearTheme.colors.BLACK_222222, + ) + } + } +} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/mothlyreminderall/model/MonthlyReminderCombinedData.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/mothlyreminderall/model/MonthlyReminderCombinedData.kt new file mode 100644 index 00000000..2314f711 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/mothlyreminderall/model/MonthlyReminderCombinedData.kt @@ -0,0 +1,8 @@ +package com.alarmy.near.presentation.feature.mothlyreminderall.model + +import com.alarmy.near.model.monthly.MonthlyFriend + +data class MonthlyReminderCombinedData( + val monthlyFriends: List, + val completeFriends: List, +) diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/mothlyreminderall/model/MonthlyReminderTypeInfo.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/mothlyreminderall/model/MonthlyReminderTypeInfo.kt new file mode 100644 index 00000000..87778504 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/mothlyreminderall/model/MonthlyReminderTypeInfo.kt @@ -0,0 +1,35 @@ +package com.alarmy.near.presentation.feature.mothlyreminderall.model + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import com.alarmy.near.R +import com.alarmy.near.model.monthly.MonthlyFriendType + +enum class MonthlyReminderTypeInfo( + @param:DrawableRes val imageRes: Int, + @param:StringRes val descriptionRes: Int, +) { + ANNIVERSARY( + imageRes = R.drawable.icon_visual_24_heart, + descriptionRes = R.string.monthly_reminder_all_type_anniversary_description, + ), + BIRTHDAY( + imageRes = R.drawable.icon_visual_cake, + descriptionRes = R.string.monthly_reminder_all_type_birthday_description, + ), + MESSAGE( + imageRes = R.drawable.icon_visual_mail, + descriptionRes = R.string.monthly_reminder_all_type_message_description, + ), + ; + + companion object { + fun from(type: MonthlyFriendType): MonthlyReminderTypeInfo = + when (type) { + MonthlyFriendType.ANNIVERSARY -> ANNIVERSARY + MonthlyFriendType.BIRTHDAY -> BIRTHDAY + MonthlyFriendType.MESSAGE -> MESSAGE + } + } +} + diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/mothlyreminderall/model/MonthlyReminderUIModel.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/mothlyreminderall/model/MonthlyReminderUIModel.kt new file mode 100644 index 00000000..74f82d6e --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/mothlyreminderall/model/MonthlyReminderUIModel.kt @@ -0,0 +1,15 @@ +package com.alarmy.near.presentation.feature.mothlyreminderall.model + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes + +data class MonthlyReminderUIModel( + val friendId: String, + val name: String, + @DrawableRes val imageRes: Int, + @StringRes val descriptionRes: Int, + val nextContactAt: String, + val daysUntilNextContact: String, + val isToday: Boolean, +) + diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/mothlyreminderall/navigation/MonthlyReminderAllNavigation.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/mothlyreminderall/navigation/MonthlyReminderAllNavigation.kt new file mode 100644 index 00000000..5c2280f7 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/mothlyreminderall/navigation/MonthlyReminderAllNavigation.kt @@ -0,0 +1,26 @@ +package com.alarmy.near.presentation.feature.mothlyreminderall.navigation + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.alarmy.near.presentation.feature.mothlyreminderall.MonthlyReminderAllRoute +import kotlinx.serialization.Serializable + +@Serializable +object RouteMonthlyReminderAll + +fun NavController.navigateToMonthlyReminderAll() { + navigate(RouteMonthlyReminderAll) +} + +fun NavGraphBuilder.monthlyReminderAllNavGraph( + onShowErrorSnackBar: (throwable: Throwable?) -> Unit, + onNavigateBack: () -> Unit, +) { + composable { backStackEntry -> + MonthlyReminderAllRoute( + onShowErrorSnackBar = onShowErrorSnackBar, + onNavigateBack = onNavigateBack, + ) + } +} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/mothlyreminderall/uistate/MonthlyReminderAllUIEvent.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/mothlyreminderall/uistate/MonthlyReminderAllUIEvent.kt new file mode 100644 index 00000000..889c234a --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/mothlyreminderall/uistate/MonthlyReminderAllUIEvent.kt @@ -0,0 +1,9 @@ +package com.alarmy.near.presentation.feature.mothlyreminderall.uistate + +sealed interface MonthlyReminderAllUIEvent { + data class ShowError( + val throwable: Throwable?, + ) : MonthlyReminderAllUIEvent + + data object RecordFriendShipSuccess : MonthlyReminderAllUIEvent +} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/mothlyreminderall/uistate/MonthlyReminderAllUIState.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/mothlyreminderall/uistate/MonthlyReminderAllUIState.kt new file mode 100644 index 00000000..e6151f63 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/mothlyreminderall/uistate/MonthlyReminderAllUIState.kt @@ -0,0 +1,15 @@ +package com.alarmy.near.presentation.feature.mothlyreminderall.uistate + +import com.alarmy.near.presentation.feature.mothlyreminderall.model.MonthlyReminderUIModel + +sealed interface MonthlyReminderAllUIState { + data object Loading : MonthlyReminderAllUIState + + data object Empty : MonthlyReminderAllUIState + + data class Success( + val monthlyReminders: List, + val completedReminders: List, + val hasCompletedReminders: Boolean, + ) : MonthlyReminderAllUIState +} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/ui/theme/Color.kt b/Near/app/src/main/java/com/alarmy/near/presentation/ui/theme/Color.kt index 45d620b4..1cc38760 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/ui/theme/Color.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/ui/theme/Color.kt @@ -6,6 +6,7 @@ import androidx.compose.ui.graphics.Color object NearColorPallete { val BLACK_1A1A1A = Color(0xFF1A1A1A) + val BLACK_222222 = Color(0xFF222222) val WHITE_FFFFFF = Color(0xFFFFFFFF) val GRAY01_888888 = Color(0xFF888888) val GRAY02_B7B7B7 = Color(0xFFB7B7B7) @@ -25,6 +26,7 @@ object NearColorPallete { @Suppress("PropertyName") data class NearColor( val BLACK_1A1A1A: Color = NearColorPallete.BLACK_1A1A1A, + val BLACK_222222: Color = NearColorPallete.BLACK_222222, val WHITE_FFFFFF: Color = NearColorPallete.WHITE_FFFFFF, val GRAY01_888888: Color = NearColorPallete.GRAY01_888888, val GRAY02_B7B7B7: Color = NearColorPallete.GRAY02_B7B7B7, diff --git a/Near/app/src/main/java/com/alarmy/near/utils/extensions/FlowExtensions.kt b/Near/app/src/main/java/com/alarmy/near/utils/extensions/FlowExtensions.kt index 3bf0e2d7..51a1260e 100644 --- a/Near/app/src/main/java/com/alarmy/near/utils/extensions/FlowExtensions.kt +++ b/Near/app/src/main/java/com/alarmy/near/utils/extensions/FlowExtensions.kt @@ -1,9 +1,24 @@ package com.alarmy.near.utils.extensions +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.launch inline fun apiCallFlow(crossinline apiCall: suspend () -> T): Flow = flow { emit(apiCall()) } + +fun Flow.handleError( + scope: CoroutineScope, + eventChannel: Channel, + createErrorEvent: (Throwable) -> E, +): Flow = + catch { exception -> + scope.launch { + eventChannel.send(createErrorEvent(exception)) + } + } diff --git a/Near/app/src/main/res/drawable/ic_front_8.xml b/Near/app/src/main/res/drawable/ic_front_8.xml new file mode 100644 index 00000000..8c0d485f --- /dev/null +++ b/Near/app/src/main/res/drawable/ic_front_8.xml @@ -0,0 +1,13 @@ + + + diff --git a/Near/app/src/main/res/values/strings.xml b/Near/app/src/main/res/values/strings.xml index b926446c..63b8b688 100644 --- a/Near/app/src/main/res/values/strings.xml +++ b/Near/app/src/main/res/values/strings.xml @@ -41,6 +41,7 @@ 가까워 지고 싶은 사람을\n추가해보세요. 사람 추가 사람 추가 + 전체 보기 전화걸기 @@ -85,6 +86,16 @@ 날짜 메모 + + 이번달 챙길 사람 + 챙김 완료 + 이번달은 챙길 사람이 없네요. + 챙김 기록하기 + 네트워크 에러가 발생했습니다. + 소중한 날 마음을 전해요 + 생일 축하 전해요 + 가볍게 안부인사 전해요 + 매일 매주 @@ -97,7 +108,7 @@ 화면을 나가면 \n수정 내용은 저장되지 않아요. 확인 취소 - + 수정을 완료하시겠습니까? 저장 @@ -133,7 +144,7 @@ 편하게 의견을 남겨주세요. 그만두기 탈퇴하기 - + 탈퇴 시 계정 및 이용 내역이\n모두 삭제되며, 복구가 불가능합니다.\n정말 탈퇴하시겠습니까? 취소 @@ -146,7 +157,7 @@ 서비스 이용약관 개인정보 수집 및 이용동의 개인정보 처리방침 - + 서비스 약관 동의 약관 전체 동의