diff --git a/Near/app/build.gradle.kts b/Near/app/build.gradle.kts index 047d1968..c46d38da 100644 --- a/Near/app/build.gradle.kts +++ b/Near/app/build.gradle.kts @@ -18,7 +18,7 @@ android { applicationId = "com.alarmy.near" minSdk = 27 targetSdk = 35 - versionCode = 3 + versionCode = 4 versionName = "1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendMapper.kt b/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendMapper.kt index e797a6ae..04f91912 100644 --- a/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendMapper.kt +++ b/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendMapper.kt @@ -13,6 +13,9 @@ import com.alarmy.near.network.response.AnniversaryEntity import com.alarmy.near.network.response.ContactFrequencyEntity import com.alarmy.near.network.response.FriendEntity import com.alarmy.near.utils.logger.NearLog +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.util.Locale fun FriendEntity.toModel(): Friend = Friend( @@ -26,8 +29,25 @@ fun FriendEntity.toModel(): Friend = memo = memo, phone = phone, lastContactAt = lastContactAt, + isContactToday = lastContactAt?.isToday() ?: false, + lastContactFormat = lastContactAt?.contactFormat(), ) +fun String.contactFormat(): String { + val inputFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") + val outputFormatter = DateTimeFormatter.ofPattern("M월 d일") + + val date = LocalDate.parse(this, inputFormatter) + return date.format(outputFormatter) +} + +private fun String.isToday(): Boolean { + val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd", Locale.KOREA) + val targetDate = LocalDate.parse(this, formatter) + val today = LocalDate.now() + return targetDate == today +} + fun ContactFrequencyEntity.toModel(): ContactFrequency = ContactFrequency( reminderInterval = contactWeek.toReminderInterval(), @@ -46,7 +66,7 @@ fun Friend.toRequest(): FriendRequest = name = name, relation = relation.toString(), contactFrequency = contactFrequency.toRequest(), - birthday = birthday, + birthday = birthday?.replace(".", "-"), anniversaryList = anniversaryList.map { it.toRequest() }, memo = memo, phone = phone, 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/Friend.kt b/Near/app/src/main/java/com/alarmy/near/model/Friend.kt index b37b23a6..ba1cfcf3 100644 --- a/Near/app/src/main/java/com/alarmy/near/model/Friend.kt +++ b/Near/app/src/main/java/com/alarmy/near/model/Friend.kt @@ -22,17 +22,9 @@ data class Friend( val memo: String?, val phone: String?, val lastContactAt: String?, // "2025-07-16" -) : Parcelable { - val isContactedToday: Boolean - get() = lastContactAt?.isToday() ?: false - - private fun String.isToday(): Boolean { - val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd", Locale.KOREA) - val targetDate = LocalDate.parse(this, formatter) - val today = LocalDate.now() - return targetDate == today - } -} + val lastContactFormat: String? = null, // 친구 상세에 쓰이는 포맷 TODO 추후 UI 모델 분리 + val isContactToday: Boolean? = null, // 친구 상세에 쓰이는 포맷 TODO 추후 UI 모델 분리 +) : Parcelable @Serializable @Parcelize 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/friendcontactcycle/components/ContactCycleContent.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendcontactcycle/components/ContactCycleContent.kt index ae5c5116..0125e1fd 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendcontactcycle/components/ContactCycleContent.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendcontactcycle/components/ContactCycleContent.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.unit.dp import com.alarmy.near.R import com.alarmy.near.model.ReminderInterval import com.alarmy.near.presentation.feature.friendcontactcycle.model.FriendContactUIModel +import com.alarmy.near.presentation.ui.component.bottomsheet.CycleSettingBottomSheet import com.alarmy.near.presentation.ui.component.checkbox.NearBackgroundCheckbox import com.alarmy.near.presentation.ui.extension.onNoRippleClick import com.alarmy.near.presentation.ui.theme.NearTheme @@ -64,10 +65,10 @@ fun ColumnScope.ContactCycleContent( ) } - Spacer(modifier = Modifier.size(14.dp)) - // 한번에 설정이 활성화되었을 때만 표시 if (isBulkSettingEnabled) { + Spacer(modifier = Modifier.size(12.dp)) + Row( modifier = Modifier @@ -85,7 +86,9 @@ fun ColumnScope.ContactCycleContent( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = selectedCycle?.let { DateExtension.getCycleText(it) } ?: stringResource(R.string.friend_contact_cycle_weekly_format, DateExtension.getTodayDayOfWeekInKorean()), + text = + selectedCycle?.let { DateExtension.getCycleText(it) } + ?: stringResource(R.string.friend_contact_cycle_weekly_format, DateExtension.getTodayDayOfWeekInKorean()), style = NearTheme.typography.B2_14_MEDIUM, color = NearTheme.colors.BLACK_1A1A1A, ) @@ -101,7 +104,7 @@ fun ColumnScope.ContactCycleContent( // 리스트가 있을 때만 밑에 리스트 표시 if (contacts.isNotEmpty()) { - Spacer(modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.size(24.dp)) LazyColumn( modifier = Modifier.weight(1f), diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendcontactcycle/components/ContactLoadContent.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendcontactcycle/components/ContactLoadContent.kt index d6eed8a4..0d57214e 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendcontactcycle/components/ContactLoadContent.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendcontactcycle/components/ContactLoadContent.kt @@ -117,7 +117,7 @@ fun FriendListItem( modifier = Modifier.weight(1f), ) { Image( - painter = painterResource(R.drawable.img_100_character_default), + painter = painterResource(R.drawable.img_64_user_gray), contentDescription = null, modifier = Modifier.size(24.dp), ) diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendcontactcycle/components/CycleSettingBottomSheet.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendcontactcycle/components/CycleSettingBottomSheet.kt deleted file mode 100644 index 826334a4..00000000 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendcontactcycle/components/CycleSettingBottomSheet.kt +++ /dev/null @@ -1,214 +0,0 @@ -package com.alarmy.near.presentation.feature.friendcontactcycle.components - -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.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.BottomSheetDefaults -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Text -import androidx.compose.material3.VerticalDivider -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.withStyle -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.alarmy.near.R -import com.alarmy.near.model.ReminderInterval -import com.alarmy.near.presentation.ui.component.checkbox.NearCheckbox -import com.alarmy.near.presentation.ui.extension.onNoRippleClick -import com.alarmy.near.presentation.ui.theme.NearTheme -import com.alarmy.near.utils.extensions.DateExtension - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun CycleSettingBottomSheet( - isVisible: Boolean, - onDismiss: () -> Unit, - onComplete: (ReminderInterval) -> Unit = {}, - currentSelectedInterval: ReminderInterval? = null, - modifier: Modifier = Modifier, -) { - // 선택된 주기 상태 관리 (기존 선택값이 있으면 그것을 사용, 없으면 매주를 기본값으로) - var selectedInterval by remember(isVisible) { - mutableStateOf(currentSelectedInterval ?: ReminderInterval.EVERY_WEEK) - } - - if (isVisible) { - val bottomSheetState = - rememberModalBottomSheetState( - skipPartiallyExpanded = true, - ) - - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = bottomSheetState, - dragHandle = { - BottomSheetDefaults.DragHandle( - color = NearTheme.colors.BLACK_1A1A1A.copy(alpha = 0.1f), - width = 36.dp, - height = 5.dp, - ) - }, - modifier = modifier, - containerColor = NearTheme.colors.WHITE_FFFFFF, - ) { - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp), - ) { - Text( - text = stringResource(R.string.friend_contact_cycle_cycle_setting_text), - style = NearTheme.typography.B1_16_BOLD, - ) - - Spacer(modifier = Modifier.size(24.dp)) - - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = - modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .background(NearTheme.colors.BG02_F4F9FD) - .padding(vertical = 18.dp, horizontal = 20.dp), - ) { - Text( - text = - buildAnnotatedString { - withStyle( - style = - SpanStyle( - color = NearTheme.colors.BLACK_1A1A1A, - fontWeight = NearTheme.typography.B2_14_MEDIUM.fontWeight, - ), - ) { - append(stringResource(R.string.friend_contact_cycle_weekly_prefix)) - } - withStyle( - style = - SpanStyle( - color = NearTheme.colors.BLUE01_5AA2E9, - fontWeight = NearTheme.typography.B2_14_BOLD.fontWeight, - ), - ) { - append(DateExtension.getTodayDayOfWeekInKorean()) - } - }, - style = NearTheme.typography.B2_14_MEDIUM, - ) - - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - Spacer(modifier = Modifier.size(20.dp)) - - VerticalDivider( - modifier = - Modifier - .size(width = 1.dp, height = 28.dp), - color = - NearTheme.colors.BLACK_1A1A1A.copy( - alpha = 0.1f, - ), - ) - - Spacer(modifier = Modifier.size(20.dp)) - - Text( - text = "다음 주기 : ${ - selectedInterval?.let { DateExtension.getNextCycleDate(it) } - ?: DateExtension.getNextWeekSameDay() - }", - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.GRAY01_888888, - ) - } - } - - Spacer(modifier = Modifier.size(8.dp)) - - ReminderInterval.entries.forEach { interval -> - val isSelected = selectedInterval == interval - - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = - modifier - .fillMaxWidth() - .onNoRippleClick { - if (!isSelected) { - selectedInterval = interval - } - }.padding(vertical = 15.dp), - ) { - Text( - text = stringResource(interval.labelRes), - style = - if (isSelected) { - NearTheme.typography.B1_16_BOLD - } else { - NearTheme.typography.B2_14_MEDIUM - }, - ) - - if (isSelected) { - NearCheckbox( - checked = true, - onCheckedChange = { checked -> - // 체크박스 클릭 시 해제되지 않도록 수정 - // 체크된 상태를 유지 - }, - ) - } - } - } - Spacer(modifier = Modifier.size(24.dp)) - - ContactCycleButtons( - onLeftButtonClick = onDismiss, - onRightButtonClick = { - selectedInterval?.let { interval -> - onComplete(interval) - onDismiss() - } - }, - leftButtonText = stringResource(R.string.friend_contact_cycle_cancel_button), - rightButtonText = stringResource(R.string.friend_contact_cycle_complete_button), - ) - Spacer(modifier = Modifier.size(24.dp)) - } - } - } -} - -@Preview -@Composable -fun CycleSettingBottomSheetPreview() { - NearTheme { - CycleSettingBottomSheet( - isVisible = true, - onDismiss = {}, - ) - } -} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendcontactcycle/components/NearListModuleBackground.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendcontactcycle/components/NearListModuleBackground.kt index 151d6f8c..1e698cca 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendcontactcycle/components/NearListModuleBackground.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendcontactcycle/components/NearListModuleBackground.kt @@ -12,12 +12,13 @@ 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.shadow +import androidx.compose.ui.graphics.Color 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.extension.dropShadow import com.alarmy.near.presentation.ui.extension.onNoRippleClick import com.alarmy.near.presentation.ui.theme.NearTheme @@ -38,8 +39,12 @@ fun NearListModuleBackground( modifier = modifier .fillMaxWidth() - .shadow( - elevation = 4.dp, + .dropShadow( + blur = 16.dp, + offsetX = 0.dp, + offsetY = 4.dp, + spread = 0.dp, + color = Color.Black.copy(alpha = 0.12f), shape = RoundedCornerShape(12.dp), ).background( color = NearTheme.colors.WHITE_FFFFFF, diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt index fc6fb64c..9ca3720d 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt @@ -20,11 +20,8 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.wrapContentSize -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Surface @@ -42,6 +39,8 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.composed +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.debugInspectorInfo import androidx.compose.ui.res.painterResource @@ -75,8 +74,6 @@ import com.alarmy.near.presentation.ui.extension.onNoRippleClick import com.alarmy.near.presentation.ui.theme.NearTheme import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import java.time.LocalDate -import java.time.format.DateTimeFormatter @Composable fun FriendProfileRoute( @@ -142,54 +139,55 @@ fun FriendProfileScreen( ) { val currentTabPosition = remember { mutableIntStateOf(0) } val dropdownState = remember { mutableStateOf(false) } -// val scrollState = rememberScrollState() NearFrame(modifier = modifier) { + if (recordSuccessDialogState) { + LaunchedEffect(true) { + if (recordSuccessDialogState) { + delay(2000L) + onDismissRecordSuccessDialog() + } + } + Dialog(onDismissRequest = onDismissRecordSuccessDialog) { + 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( + painterResource(R.drawable.img_100_character_success), + contentDescription = "", + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + stringResource(R.string.friend_profile_info_contact_success_text), + style = NearTheme.typography.B1_16_BOLD, + color = Color(0xff222222), + ) + } + } + } Box { when (friendState) { is FriendState.Success -> { val friend = friendState.friend + + // 전체를 세로로, 앱바는 고정, 나머지는 weight(1f) 로 영역 제한 Column( modifier = Modifier .align(Alignment.TopStart) .fillMaxSize() -// .verticalScroll(scrollState) .background(NearTheme.colors.WHITE_FFFFFF), ) { - if (recordSuccessDialogState) { - LaunchedEffect(true) { - if (recordSuccessDialogState) { - delay(2000L) - onDismissRecordSuccessDialog() - } - } - Dialog(onDismissRequest = onDismissRecordSuccessDialog) { - 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( - painterResource(R.drawable.img_100_character_success), - contentDescription = "", - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - stringResource(R.string.friend_profile_info_contact_success_text), - style = NearTheme.typography.B1_16_BOLD, - color = Color(0xff222222), - ) - } - } - } + // (1) 상단 AppBar — 고정 NearTopAppbar( title = stringResource(R.string.friend_profile_title), onClickBackButton = onClickBackButton, @@ -197,10 +195,9 @@ fun FriendProfileScreen( Column(modifier = Modifier.padding(end = 20.dp)) { Image( modifier = - Modifier - .onNoRippleClick(onClick = { - dropdownState.value = true - }), + Modifier.onNoRippleClick(onClick = { + dropdownState.value = true + }), painter = painterResource(R.drawable.ic_32_menu), contentDescription = stringResource(R.string.common_menu_button_description), ) @@ -227,174 +224,228 @@ fun FriendProfileScreen( }, ) - Spacer(modifier = Modifier.height(18.dp)) - Row( + // (2) 나머지 영역을 제한된 높이로 만들기 -> 이 안에서 스크롤 가능 + Column( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 32.dp), - verticalAlignment = Alignment.CenterVertically, + .weight(1f), // <--- 요게 중요: 여기서 높이가 제한되어 내부 스크롤이 가능해짐 ) { - Box( - modifier = - Modifier, + // 컨텐츠는 스크롤 가능하거나 Lazy로 구성 + // 여기서는 상단의 프로필 요약(이미지/이름/버튼 등)을 스크롤 헤더로 포함시키고 + // 탭에 따라 ProfileTab(스크롤 필요 없음) 또는 RecordTab(LazyVerticalGrid)를 표시 + // 상단 요약을 포함한 전체를 LazyColumn으로 만들면 header + list 형태로 자연스럽게 동작 + + LazyColumn( + modifier = Modifier.fillMaxSize(), ) { - Image( - modifier = Modifier.align(Alignment.Center), - painter = painterResource(R.drawable.img_80_user1), - contentDescription = null, - ) - Image( - modifier = - Modifier - .align(Alignment.TopEnd) - .offset(x = 2.dp, y = (-2).dp), - painter = painterResource(R.drawable.ic_visual_24_emoji_0), - contentDescription = null, - ) - } - Spacer(modifier = Modifier.width(24.dp)) - Column { - Text( - modifier = Modifier.widthIn(max = 145.dp), - text = friend.name, - style = NearTheme.typography.B1_16_BOLD, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - if (friend.lastContactAt != null) { - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = - stringResource( - R.string.friend_profile_last_contact_date_format, - friend.lastContactAt.lastContactFormat(), + item { + Spacer(modifier = Modifier.height(18.dp)) + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box { + // 이미지 + 이모지 + Image( + modifier = Modifier.align(Alignment.Center), + painter = painterResource(R.drawable.img_80_user1), + contentDescription = null, + ) + Image( + modifier = + Modifier + .align(Alignment.TopEnd) + .offset(x = 2.dp, y = (-2).dp), + painter = painterResource(R.drawable.ic_visual_24_emoji_0), + contentDescription = null, + ) + } + Spacer(modifier = Modifier.width(24.dp)) + Column { + Text( + modifier = Modifier.widthIn(max = 145.dp), + text = friend.name, + style = NearTheme.typography.B1_16_BOLD, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + if (friend.lastContactAt != null) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = + stringResource( + R.string.friend_profile_last_contact_date_format, + friend.lastContactFormat ?: "", + ), + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.BLUE01_5AA2E9, + ) + } + } + } + Spacer(modifier = Modifier.height(24.dp)) + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + ) { + CallButton( + modifier = Modifier.weight(1f), + enabled = !friend.phone.isNullOrBlank(), + onClick = { + friend.phone?.let { + onClickCallButton(friend.phone) + } + }, + ) + Spacer(modifier = Modifier.width(7.dp)) + MessageButton( + Modifier.weight(1f), + enabled = !friend.phone.isNullOrBlank(), + onClick = { + friend.phone?.let { + onClickMessageButton(friend.phone) + } + }, + ) + } + Spacer(modifier = Modifier.height(24.dp)) + // TabRow + TabRow( + modifier = + Modifier + .padding(horizontal = 25.dp) + .width(170.dp), + containerColor = NearTheme.colors.WHITE_FFFFFF, + selectedTabIndex = currentTabPosition.intValue, + divider = {}, + indicator = { + TabRowDefaults.SecondaryIndicator( + modifier = + Modifier.customTabIndicatorOffset( + it[currentTabPosition.intValue], + 80.dp, + ), + height = 3.dp, + color = NearTheme.colors.BLUE01_5AA2E9, + ) + }, + ) { + Tab( + modifier = + Modifier + .width(85.dp) + .height(50.dp), + selectedContentColor = + NearTheme.colors.GRAY01_888888.copy( + alpha = 0.3f, + ), + selected = currentTabPosition.intValue == 0, + onClick = { currentTabPosition.intValue = 0 }, + ) { + Text( + text = stringResource(R.string.friend_profile_tab_text_profile), + style = + if (currentTabPosition.intValue == + 0 + ) { + NearTheme.typography.B2_14_BOLD + } else { + NearTheme.typography.B2_14_MEDIUM + }, + color = + if (currentTabPosition.intValue == + 0 + ) { + NearTheme.colors.BLACK_1A1A1A + } else { + NearTheme.colors.GRAY02_B7B7B7 + }, + ) + } + Tab( + modifier = + Modifier + .width(85.dp) + .height(50.dp), + selected = currentTabPosition.intValue == 1, + onClick = { currentTabPosition.intValue = 1 }, + selectedContentColor = NearTheme.colors.GRAY01_888888.copy( + alpha = 0.3f ), - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.BLUE01_5AA2E9, - ) - } - } - } - Spacer(modifier = Modifier.height(24.dp)) - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp), - ) { - CallButton( - modifier = Modifier.weight(1f), - enabled = !friend.phone.isNullOrBlank(), - onClick = { - friend.phone?.let { - onClickCallButton(friend.phone) + ) { + Text( + text = stringResource(R.string.friend_profile_tab_text_record), + style = + if (currentTabPosition.intValue == + 1 + ) { + NearTheme.typography.B2_14_BOLD + } else { + NearTheme.typography.B2_14_MEDIUM + }, + color = + if (currentTabPosition.intValue == + 1 + ) { + NearTheme.colors.BLACK_1A1A1A + } else { + NearTheme.colors.GRAY02_B7B7B7 + }, + ) + } } - }, - ) - Spacer(modifier = Modifier.width(7.dp)) - MessageButton( - Modifier.weight(1f), - enabled = !friend.phone.isNullOrBlank(), - onClick = { - friend.phone?.let { - onClickMessageButton(friend.phone) + HorizontalDivider( + thickness = 1.dp, + color = NearTheme.colors.GRAY03_EBEBEB, + ) + } // end header item + + // 탭별 본문을 lazy column의 item으로 넣음 — 이렇게 하면 중첩 스크롤 문제 회피 + item { + if (currentTabPosition.intValue == 0) { + // ProfileTab : 내부에 스크롤을 또 만들 필요 없음. LazyColumn이 전체를 스크롤함. + ProfileTab(friend = friend) + } else { + RecordTab( + friendShipRecordState = friendShipRecordState, + ) } - }, - ) - } - Spacer(modifier = Modifier.height(24.dp)) - TabRow( + } + item { Spacer(modifier = Modifier.height(80.dp)) } // 하단 여유 + } // end LazyColumn + } // end weighted Column + + // (3) 하단 고정 버튼 + Box( modifier = - Modifier - .padding(horizontal = 25.dp) - .width(170.dp), - containerColor = NearTheme.colors.WHITE_FFFFFF, - selectedTabIndex = 0, - divider = {}, - indicator = { - TabRowDefaults.SecondaryIndicator( - modifier = - Modifier - .customTabIndicatorOffset( - it[currentTabPosition.intValue], - 80.dp, - ), // 넓이, 애니메이션 지정 - // 모양 지정 - height = 3.dp, - color = NearTheme.colors.BLUE01_5AA2E9, - ) - }, + Modifier.fillMaxWidth().background( + brush = + Brush.linearGradient( + colors = listOf(Color(0x00FFFFFF), Color(0xFFFFFFFF)), // 파랑 → 밝은 하늘색 + start = Offset(0f, 0f), // 위쪽 시작 + end = Offset(0f, Float.POSITIVE_INFINITY), + ), + ), ) { - Tab( - modifier = - Modifier - .width(85.dp) - .height(50.dp), - selected = true, - onClick = { - currentTabPosition.intValue = 0 - }, - ) { - if (currentTabPosition.intValue == 0) { - Text( - text = stringResource(R.string.friend_profile_tab_text_profile), - style = NearTheme.typography.B2_14_BOLD, - color = NearTheme.colors.BLACK_1A1A1A, - ) - } else { - Text( - text = stringResource(R.string.friend_profile_tab_text_profile), - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.GRAY02_B7B7B7, - ) - } - } - Tab( + NearSolidTypeButton( modifier = Modifier - .width(85.dp) - .height(50.dp), - selected = true, - onClick = { - currentTabPosition.intValue = 1 - }, - ) { - if (currentTabPosition.intValue == 1) { - Text( - text = stringResource(R.string.friend_profile_tab_text_record), - style = NearTheme.typography.B2_14_BOLD, - color = NearTheme.colors.BLACK_1A1A1A, - ) - } else { - Text( - text = stringResource(R.string.friend_profile_tab_text_record), - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.GRAY02_B7B7B7, - ) - } - } - } - HorizontalDivider(thickness = 1.dp, color = NearTheme.colors.GRAY03_EBEBEB) - if (currentTabPosition.intValue == 0) { - ProfileTab(friend = friend) - } else { - RecordTab(friendShipRecordState = friendShipRecordState) + .fillMaxWidth() + .padding(horizontal = 20.dp) + .padding(bottom = 24.dp, top = 16.dp), + contentPadding = PaddingValues(vertical = 17.dp), + enabled = friend.isContactToday?.not() ?: false, + onClick = { onRecordFriendShip(friend.friendId) }, + text = stringResource(R.string.friend_profile_record_button_text), + ) } - Spacer(modifier = Modifier.height(60.dp)) - } - NearSolidTypeButton( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp) - .padding(bottom = 24.dp) - .align(Alignment.BottomCenter), - contentPadding = PaddingValues(vertical = 17.dp), - enabled = friend.isContactedToday.not(), - onClick = { onRecordFriendShip(friend.friendId) }, - text = stringResource(R.string.friend_profile_record_button_text), - ) + } // end parent Column } is FriendState.Loading -> { @@ -411,9 +462,9 @@ fun FriendProfileScreen( ) } } - } - } - } + } // end when + } // end Box + } // end NearFrame } @Composable @@ -442,7 +493,7 @@ private fun ProfileTab( category = stringResource(R.string.friend_profile_info_category_anniversary), content = friend.anniversaryList.joinToString(" ") { - "${it.title} (${it.date})" + "${it.title} (${it.date?.replace("-",".")})" }, ) Spacer(modifier = Modifier.height(16.dp)) @@ -458,16 +509,28 @@ private fun RecordTab( modifier: Modifier = Modifier, friendShipRecordState: FriendShipRecordState, ) { - Column(modifier = modifier.padding(horizontal = 24.dp)) { + Column( + modifier = + modifier + .fillMaxWidth() // 부모 스크롤(LazyColumn)에 의존하도록 fillMaxWidth만 사용 + .padding(horizontal = 24.dp), + ) { Spacer(modifier = Modifier.height(24.dp)) Text( stringResource(R.string.friend_profile_info_record_title_text), style = NearTheme.typography.B2_14_BOLD, color = NearTheme.colors.BLACK_1A1A1A, ) + if (friendShipRecordState.isEmpty) { - Column(modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) { - Spacer(modifier = Modifier.height(60.dp)) + // 빈 상태는 중앙 정렬이지만 높이를 무한으로 잡지 않도록 fillMaxWidth 사용 + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(top = 60.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { Image(painterResource(R.drawable.img_100_character_empty), contentDescription = null) Spacer(modifier = Modifier.height(16.dp)) Text( @@ -478,19 +541,44 @@ private fun RecordTab( } } else { Spacer(modifier = Modifier.height(13.dp)) - LazyVerticalGrid( - columns = GridCells.Fixed(3), - verticalArrangement = Arrangement.spacedBy(24.dp), - contentPadding = PaddingValues(bottom = 60.dp), - ) { - items(friendShipRecordState.records.size) { - RecordItem( - friendRecord = friendShipRecordState.records[friendShipRecordState.records.size - 1 - it], - index = - friendShipRecordState.records.size - it, - ) + + // records를 3개씩 묶어서 행(Row)으로 렌더 — 자체 스크롤 없음 + val rows = friendShipRecordState.records.chunked(3) + Column(modifier = Modifier.fillMaxWidth()) { + rows.forEach { rowItems -> + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + // 아이템 간 세로 간격 + horizontalArrangement = Arrangement.spacedBy(24.dp), + ) { + // 각 row의 아이템을 동일한 폭으로 나눔 + rowItems.forEachIndexed { index, item -> + // 인덱스나 번호 표시 로직은 기존과 동일하게 계산 + val globalIndex = + friendShipRecordState.records.size - (rows.indexOf(rowItems) * 3 + index) + Box(modifier = Modifier.weight(1f)) { + RecordItem( + modifier = Modifier.wrapContentSize(), + index = globalIndex, + friendRecord = item, + ) + } + } + + // 만약 마지막 행에 3 미만의 아이템이 있으면 빈 공간을 채워 균형 맞춤 + if (rowItems.size < 3) { + repeat(3 - rowItems.size) { + Spacer(modifier = Modifier.weight(1f)) + } + } + } } } + + Spacer(modifier = Modifier.height(24.dp)) } } } @@ -530,7 +618,8 @@ private fun RecordItem( contentDescription = null, ) Text( - stringResource(R.string.friend_profile_info_contact_record_text, index), + text = stringResource(R.string.friend_profile_info_contact_record_text, index), + textAlign = TextAlign.Center, style = NearTheme.typography.B2_14_MEDIUM, color = NearTheme.colors.BLUE01_5AA2E9, ) @@ -665,14 +754,6 @@ fun Modifier.customTabIndicatorOffset( .width(currentTabWidth) } -private fun String.lastContactFormat(): String { - val inputFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") - val outputFormatter = DateTimeFormatter.ofPattern("M월 d일") - - val date = LocalDate.parse(this, inputFormatter) - return date.format(outputFormatter) -} - @Preview(showBackground = true) @Composable fun FriendProfileScreenPreview() { diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileViewModel.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileViewModel.kt index 4a766269..90926c87 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileViewModel.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileViewModel.kt @@ -21,7 +21,6 @@ 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 java.time.format.DateTimeFormatter import java.util.Locale @@ -118,6 +117,10 @@ class FriendProfileViewModel friend = it.friend.copy( lastContactAt = getTodayDashFormat(), + lastContactFormat = it.friend.lastContactAt?.contactFormat(), + isContactToday = + it.friend.lastContactAt?.isToday() + ?: false, ), ) } @@ -146,4 +149,19 @@ class FriendProfileViewModel _friendFlow.update { (it as FriendState.Success).copy(friend = friend) } } } + + fun String.contactFormat(): String { + val inputFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") + val outputFormatter = DateTimeFormatter.ofPattern("M월 d일") + + val date = LocalDate.parse(this, inputFormatter) + return date.format(outputFormatter) + } + + private fun String.isToday(): Boolean { + val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd", Locale.KOREA) + val targetDate = LocalDate.parse(this, formatter) + val today = LocalDate.now() + return targetDate == today } +} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt index 7729f3f8..ddff2c75 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt @@ -45,7 +45,7 @@ import com.alarmy.near.model.Friend import com.alarmy.near.model.Relation import com.alarmy.near.model.ReminderInterval import com.alarmy.near.presentation.feature.friendprofileedittor.component.NearDatePicker -import com.alarmy.near.presentation.feature.friendprofileedittor.component.ReminderIntervalBottomSheet +import com.alarmy.near.presentation.ui.component.bottomsheet.CycleSettingBottomSheet import com.alarmy.near.presentation.feature.friendprofileedittor.dialog.EditorExitDialog import com.alarmy.near.presentation.feature.friendprofileedittor.uistate.FriendProfileEditorUIEvent import com.alarmy.near.presentation.feature.friendprofileedittor.uistate.FriendProfileEditorUIState @@ -140,14 +140,16 @@ fun FriendProfileEditorScreen( onCloseDialog: () -> Unit = {}, ) { val showBottomSheet = remember { mutableStateOf(false) } - if (showBottomSheet.value) { - ReminderIntervalBottomSheet(onDismissRequest = { + CycleSettingBottomSheet( + isVisible = showBottomSheet.value, + onDismiss = { showBottomSheet.value = false - }, onSelectReminderInterval = { - onReminderIntervalChanged(it) - showBottomSheet.value = false - }) - } + }, + onComplete = { selectedInterval -> + onReminderIntervalChanged(selectedInterval) + }, + currentSelectedInterval = friendProfileEditorUIState.contactFrequency.reminderInterval, + ) if (dialogState) { EditorExitDialog( onDismissRequest = { @@ -574,7 +576,7 @@ fun FriendProfileEditorScreen( style = NearTheme.typography.B2_14_MEDIUM, color = NearTheme.colors.GRAY01_888888, ) - Spacer(modifier = Modifier.width(23.dp)) + Spacer(modifier = Modifier.width(62.dp)) NearLimitedTextField( modifier = Modifier diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorViewModel.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorViewModel.kt index 8892075e..10be95b3 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorViewModel.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorViewModel.kt @@ -202,6 +202,6 @@ class FriendProfileEditorViewModel companion object { private const val MAX_NAME_LENGTH = 20 - private const val MAX_MEMO_LENGTH = 200 + private const val MAX_MEMO_LENGTH = 100 } } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/component/ReminderIntervalBottomSheet.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/component/ReminderIntervalBottomSheet.kt deleted file mode 100644 index ce7902f1..00000000 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/component/ReminderIntervalBottomSheet.kt +++ /dev/null @@ -1,205 +0,0 @@ -package com.alarmy.near.presentation.feature.friendprofileedittor.component - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -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.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.SheetState -import androidx.compose.material3.SheetValue -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.VerticalDivider -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.material3.rememberStandardBottomSheetState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.withStyle -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.alarmy.near.model.ReminderInterval -import com.alarmy.near.presentation.ui.component.button.NearLineTypeButton -import com.alarmy.near.presentation.ui.component.button.NearSolidTypeButton -import com.alarmy.near.presentation.ui.component.checkbox.NearCheckbox -import com.alarmy.near.presentation.ui.extension.onNoRippleClick -import com.alarmy.near.presentation.ui.theme.NearTheme - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ReminderIntervalBottomSheet( - modifier: Modifier = Modifier, - selectedReminderInterval: ReminderInterval = ReminderInterval.EVERY_WEEK, - onSelectReminderInterval: (ReminderInterval) -> Unit = {}, - sheetState: SheetState = - rememberModalBottomSheetState( - skipPartiallyExpanded = true, - ), - onDismissRequest: () -> Unit = {}, -) { - val initialReminderInterval = remember { selectedReminderInterval } - - val tempSelected = remember { mutableStateOf(initialReminderInterval) } - ModalBottomSheet( - modifier = modifier, - containerColor = NearTheme.colors.WHITE_FFFFFF, - sheetState = sheetState, - onDismissRequest = { - tempSelected.value = initialReminderInterval - onDismissRequest() - }, - dragHandle = { - Surface( - modifier = Modifier.padding(top = 12.dp, bottom = 24.dp), - color = Color.Black.copy(alpha = 0.1f), - ) { - Box( - modifier = - Modifier - .width(36.dp) - .height(5.dp), - ) - } - }, - ) { - Text( - modifier = Modifier.padding(start = 24.dp), - text = "주기 설정", - style = NearTheme.typography.B1_16_BOLD, - color = Color.Black, - ) - Spacer(modifier = Modifier.height(24.dp)) - Surface( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp), - color = NearTheme.colors.BG02_F4F9FD, - shape = RoundedCornerShape(12.dp), - ) { - Row( - modifier = Modifier.padding(horizontal = 20.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - buildAnnotatedString { - append("매주") - withStyle( - style = - SpanStyle( - color = NearTheme.colors.BLUE01_5AA2E9, - fontWeight = FontWeight.Bold, - ), - ) { - append(" 화요일") - } - }, - color = NearTheme.colors.BLACK_1A1A1A, - style = NearTheme.typography.B2_14_MEDIUM, - ) - Row(verticalAlignment = Alignment.CenterVertically) { - VerticalDivider( - thickness = 1.dp, - modifier = Modifier.height(28.dp), - color = NearTheme.colors.BLACK_1A1A1A.copy(0.1f), - ) - Spacer(modifier = Modifier.width(20.dp)) - Text( - text = "다음 주기 : 4/8 화", - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.GRAY01_888888, - ) - } - } - } - Spacer(modifier = Modifier.height(8.dp)) - LazyColumn( - verticalArrangement = Arrangement.spacedBy(28.dp), - contentPadding = PaddingValues(vertical = 14.dp), - ) { - items( - count = ReminderInterval.entries.size, - key = { index -> ReminderInterval.entries[index] }, - ) { item -> - val reminderInterval = ReminderInterval.entries[item] - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(start = 24.dp, end = 14.dp) - .onNoRippleClick(onClick = { - tempSelected.value = reminderInterval - }), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - stringResource(reminderInterval.labelRes), - style = - if (reminderInterval == tempSelected.value) { - NearTheme.typography.B1_16_BOLD - } else { - NearTheme.typography.B1_16_MEDIUM - }, - color = NearTheme.colors.BLACK_1A1A1A, - ) - NearCheckbox( - checked = tempSelected.value == reminderInterval, - onCheckedChange = {}, - ) - } - } - } - Spacer(modifier = Modifier.height(24.dp)) - Row(modifier = Modifier.padding(horizontal = 20.dp)) { - NearLineTypeButton( - modifier = Modifier.weight(1f), - text = "취소", - onClick = { - tempSelected.value = initialReminderInterval - onDismissRequest() - }, - contentPadding = PaddingValues(vertical = 17.dp), - enabled = true, - ) - Spacer(modifier = Modifier.width(7.dp)) - NearSolidTypeButton( - modifier = Modifier.weight(1f), - text = "확인", - onClick = { - onSelectReminderInterval(tempSelected.value) - }, - enabled = true, - contentPadding = PaddingValues(vertical = 17.dp), - ) - } - Spacer(modifier = Modifier.height(24.dp)) - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Preview(showBackground = true) -@Composable -fun ReminderIntervalBottomSheetPreview() { - NearTheme { - ReminderIntervalBottomSheet( - sheetState = rememberStandardBottomSheetState(initialValue = SheetValue.Expanded), - ) - } -} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/uistate/FriendProfileEditorUIState.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/uistate/FriendProfileEditorUIState.kt index 4e92060a..45b423ce 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/uistate/FriendProfileEditorUIState.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/uistate/FriendProfileEditorUIState.kt @@ -52,7 +52,7 @@ fun FriendProfileEditorUIState.toModel( relation = relation, contactFrequency = contactFrequency, - birthday = birthday.value?.replace(".", "-"), + birthday = birthday.value?.replace("-", "."), anniversaryList = anniversaries.map { Anniversary( 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..6a57dd60 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 @@ -5,9 +5,11 @@ 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.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -31,9 +33,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 @@ -56,12 +60,16 @@ import com.alarmy.near.model.member.MemberInfo import com.alarmy.near.model.monthly.MonthlyFriend import com.alarmy.near.model.monthly.MonthlyFriendType import com.alarmy.near.presentation.feature.home.component.MyContacts +import com.alarmy.near.presentation.feature.home.model.HomeUiState +import com.alarmy.near.presentation.feature.home.model.MonthlyFriendUIState +import com.alarmy.near.presentation.feature.home.model.MyFriendUIState import com.alarmy.near.presentation.ui.component.dropdown.NearDropdownMenu import com.alarmy.near.presentation.ui.component.dropdown.NearDropdownMenuItem +import com.alarmy.near.presentation.ui.extension.NearConditionalShimmer +import com.alarmy.near.presentation.ui.extension.ShimmerType import com.alarmy.near.presentation.ui.extension.dropShadow import com.alarmy.near.presentation.ui.extension.onNoRippleClick import com.alarmy.near.presentation.ui.theme.NearTheme -import kotlinx.coroutines.launch import java.time.LocalDate private const val MINIMUM_PAGE_COUNT_TO_SHOW_UI = 2 @@ -74,25 +82,21 @@ internal fun HomeRoute( onAlarmClick: () -> Unit = {}, onMyPageClick: () -> Unit = {}, onAddContactClick: () -> Unit = {}, + onMonthlyReminderAllClick: () -> Unit = {}, ) { LaunchedEffect(Unit) { - launch { - viewModel.errorEvent.collect { - onShowErrorSnackBar(IllegalStateException("네트워크 에러가 발생했습니다.")) - } + viewModel.errorEvent.collect { + onShowErrorSnackBar(IllegalStateException("네트워크 에러가 발생했습니다.")) } } - val memberInfo = viewModel.memberInfoFlow.collectAsStateWithLifecycle() - val friends = viewModel.friendsFlow.collectAsStateWithLifecycle() - val monthlyFriends = viewModel.monthlyFriendFlow.collectAsStateWithLifecycle() + val uiState = viewModel.uiState.collectAsStateWithLifecycle() HomeScreen( + uiState = uiState.value, onContactClick = onContactClick, onAlarmClick = onAlarmClick, onMyPageClick = onMyPageClick, onAddContactClick = onAddContactClick, - contacts = friends.value, - monthlyFriends = monthlyFriends.value, - memberInfo = memberInfo.value, + onMonthlyReminderAllClick = onMonthlyReminderAllClick, ) } @@ -104,21 +108,11 @@ internal fun HomeScreen( onMyPageClick: () -> Unit = {}, onAlarmClick: () -> Unit = {}, onAddContactClick: () -> Unit = {}, - memberInfo: MemberInfo?, - contacts: List, - monthlyFriends: List, + onMonthlyReminderAllClick: () -> Unit = {}, + uiState: HomeUiState, ) { val density = LocalDensity.current val statusBarHeightDp = with(density) { WindowInsets.statusBars.getTop(density).toDp() } - val contactsWithPage = contacts.chunked(5) - val pagerState: PagerState = - rememberPagerState( - initialPage = 0, - pageCount = { - contactsWithPage.count() + if (contactsWithPage.lastOrNull()?.count() == 5) 1 else 0 - }, - ) - val dropdownState = remember { mutableStateOf(false) } Surface(modifier = modifier) { Column( @@ -133,138 +127,161 @@ internal fun HomeScreen( ).fillMaxSize(), ) { Spacer(modifier = Modifier.height(statusBarHeightDp)) + Box(modifier = Modifier.weight(1f)) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + .padding(end = 20.dp), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.onNoRippleClick(onClick = onMyPageClick), + text = stringResource(R.string.home_my_profile_button_text), + style = NearTheme.typography.H2_18_BOLD.copy(letterSpacing = 0.sp), + color = NearTheme.colors.WHITE_FFFFFF, + ) + Spacer(modifier = Modifier.width(12.dp)) + Image( + modifier = Modifier.onNoRippleClick(onClick = onAlarmClick), + painter = painterResource(R.drawable.ic_32_bell), + contentDescription = "", + ) + } + + Box(modifier = Modifier.padding(horizontal = 24.dp).align(Alignment.CenterStart)) { + NearConditionalShimmer(enabled = uiState.memberInfo == null) { + Text( + text = + buildAnnotatedString { + append("${uiState.memberInfo?.nickname}님,\n") + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { + append("누구를 챙길지") + } + append(" 정해볼까요?") + }, + style = NearTheme.typography.H1_24_REGULAR, + color = NearTheme.colors.WHITE_FFFFFF, + ) + } + } + } Row( - modifier = - Modifier - .fillMaxWidth() - .padding(vertical = 8.dp) - .padding(end = 20.dp), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, ) { Text( - modifier = Modifier.onNoRippleClick(onClick = onMyPageClick), - text = stringResource(R.string.home_my_profile_button_text), - style = NearTheme.typography.H2_18_BOLD.copy(letterSpacing = 0.sp), + 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, ) - Spacer(modifier = Modifier.width(12.dp)) - Image( - modifier = Modifier.onNoRippleClick(onClick = onAlarmClick), - painter = painterResource(R.drawable.ic_32_bell), - contentDescription = "", + + MonthlyReminderFriendsViewAll( + modifier = Modifier.padding(horizontal = 24.dp), + onMonthlyReminderAllClick = onMonthlyReminderAllClick, ) } Spacer(modifier = Modifier.height(16.dp)) - Text( - text = - buildAnnotatedString { - append("${memberInfo?.nickname}님,\n") - withStyle( - SpanStyle( - fontWeight = FontWeight.Bold, - ), - ) { - append("누구를 챙길지") - } - append(" 정해볼까요?") - }, - modifier = Modifier.padding(horizontal = 24.dp), - style = NearTheme.typography.H1_24_REGULAR, - 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, - ) - Spacer(modifier = Modifier.height(16.dp)) - if (monthlyFriends.isEmpty()) { - Surface( + + if (uiState.monthlyFriendUIState is MonthlyFriendUIState.Loading) { + NearConditionalShimmer( + enabled = true, modifier = Modifier - .fillMaxWidth() .padding(horizontal = 20.dp) - .clip(RoundedCornerShape(12.dp)), - color = NearTheme.colors.WHITE_FFFFFF.copy(alpha = 0.2f), - ) { - Text( - text = stringResource(R.string.home_no_people_this_month), + .fillMaxWidth() + .height(48.dp), + ) {} + } else if (uiState.monthlyFriendUIState is MonthlyFriendUIState.Success) { + if (uiState.monthlyFriendUIState.monthlyFriends.isEmpty()) { + Surface( modifier = Modifier .fillMaxWidth() - .padding(vertical = 14.dp), - textAlign = TextAlign.Center, - style = - NearTheme.typography.B2_14_MEDIUM.copy( - fontWeight = FontWeight.Normal, - ), - color = NearTheme.colors.WHITE_FFFFFF, - ) - } - } else { - LazyRow( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - items( - count = monthlyFriends.size, - key = { - monthlyFriends[it].friendId - }, + .padding(horizontal = 20.dp) + .clip(RoundedCornerShape(12.dp)), + color = NearTheme.colors.WHITE_FFFFFF.copy(alpha = 0.2f), ) { - val monthlyContact = monthlyFriends[it] - val now = LocalDate.now() - Surface( - modifier.dropShadow( - shape = RoundedCornerShape(12.dp), - color = Color.Black.copy(alpha = 0.07f), - blur = 4.dp, - offsetY = 4.dp, - ), + Text( + text = stringResource(R.string.home_no_people_this_month), + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 14.dp), + textAlign = TextAlign.Center, + style = + NearTheme.typography.B2_14_MEDIUM.copy( + fontWeight = FontWeight.Normal, + ), color = NearTheme.colors.WHITE_FFFFFF, - shape = RoundedCornerShape(12.dp), + ) + } + } else { + val monthlyFriends = uiState.monthlyFriendUIState.monthlyFriends + LazyRow( + contentPadding = PaddingValues(horizontal = 20.dp), + modifier = + Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + items( + count = monthlyFriends.size, +// key = { +// "monthly_friend_${monthlyFriends[it].friendId}" +// }, ) { - Row( - modifier = - Modifier - .padding(start = 12.dp, end = 16.dp) - .padding(vertical = 12.dp), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, + val monthlyContact = monthlyFriends[it] + val now = LocalDate.now() + Surface( + modifier.dropShadow( + shape = RoundedCornerShape(12.dp), + color = Color.Black.copy(alpha = 0.07f), + blur = 4.dp, + offsetY = 4.dp, + ), + color = NearTheme.colors.WHITE_FFFFFF, + shape = RoundedCornerShape(12.dp), ) { - Image( - painterResource(monthlyContact.type.imageSrc), - contentDescription = "", - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - modifier = Modifier.widthIn(max = 97.dp), - text = monthlyContact.name, - style = NearTheme.typography.B2_14_BOLD, - textAlign = TextAlign.Center, - overflow = TextOverflow.Ellipsis, - maxLines = 1, - color = NearTheme.colors.BLACK_1A1A1A, - ) - Spacer(modifier = Modifier.width(12.dp)) - if (monthlyContact.isNextContactDay(now)) { - Text( - text = monthlyContact.daysUntilNextContact(LocalDate.now()), - style = NearTheme.typography.B2_14_BOLD, - color = NearTheme.colors.BLUE01_5AA2E9, + Row( + modifier = + Modifier + .padding(start = 12.dp, end = 16.dp) + .padding(vertical = 12.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + painterResource(monthlyContact.type.imageSrc), + contentDescription = "", ) - } else { + Spacer(modifier = Modifier.width(8.dp)) Text( - text = monthlyContact.daysUntilNextContact(now), - style = NearTheme.typography.B2_14_MEDIUM, - color = NearTheme.colors.BLACK_1A1A1A.copy(alpha = 0.5f), + modifier = Modifier.widthIn(max = 97.dp), + text = monthlyContact.name, + style = NearTheme.typography.B2_14_BOLD, + textAlign = TextAlign.Center, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + color = NearTheme.colors.BLACK_1A1A1A, ) + Spacer(modifier = Modifier.width(12.dp)) + if (monthlyContact.isNextContactDay(now)) { + Text( + text = monthlyContact.daysUntilNextContact(LocalDate.now()), + style = NearTheme.typography.B2_14_BOLD, + color = NearTheme.colors.BLUE01_5AA2E9, + ) + } else { + Text( + text = monthlyContact.daysUntilNextContact(now), + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.BLACK_1A1A1A.copy(alpha = 0.5f), + ) + } } } } @@ -274,68 +291,118 @@ internal fun HomeScreen( Spacer(modifier = Modifier.height(24.dp)) - Box( - modifier = - Modifier - .fillMaxSize() - .background( - color = NearTheme.colors.WHITE_FFFFFF, - shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp), - ), + MyFriends( + onContactClick = onContactClick, + onAddContactClick = onAddContactClick, + myFriendUIState = uiState.myFriendUIState, + ) + } + } +} + +@Composable +private fun MyFriends( + myFriendUIState: MyFriendUIState, + onContactClick: (String) -> Unit, + onAddContactClick: () -> Unit, +) { + val contactsWithPage = + if (myFriendUIState is MyFriendUIState.Success) myFriendUIState.myFriends.chunked(5) else listOf() + val pagerState: PagerState = + rememberPagerState( + initialPage = 0, + pageCount = { + contactsWithPage.count() + + if (contactsWithPage + .lastOrNull() + ?.count() == 5 + ) { + 1 + } else { + 0 + } + }, + ) + val dropdownState = remember { mutableStateOf(false) } + + Box( + modifier = + Modifier + .fillMaxWidth() + .height(490.dp) + .background( + color = NearTheme.colors.WHITE_FFFFFF, + shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp), + ), + ) { + if (myFriendUIState is MyFriendUIState.Loading) { + + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, ) { - MyContacts( - modifier = Modifier.align(Alignment.TopCenter), - contactsWithPage = contactsWithPage, - pagerState = pagerState, - onContactClick = onContactClick, - onAddContactClick = { - onAddContactClick() - }, - ) - Row( + Spacer(modifier = Modifier.height(166.dp)) + NearConditionalShimmer( + shimmerColors = ShimmerType.WHITE.colors, + modifier = Modifier.size(width = 151.dp, height = 125.dp), + enabled = true, + ) { + } + } + } else { + MyContacts( + modifier = Modifier.align(Alignment.TopCenter), + contactsWithPage = contactsWithPage, + pagerState = pagerState, + onContactClick = onContactClick, + onAddContactClick = { + onAddContactClick() + }, + ) + } + + Row( + modifier = + Modifier + .fillMaxWidth() + .align(Alignment.TopStart) + .padding(top = 20.dp, start = 24.dp, end = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + stringResource(R.string.home_my_people), + style = NearTheme.typography.H2_18_BOLD, + color = NearTheme.colors.BLACK_1A1A1A, + ) + Column { + Image( modifier = - Modifier - .fillMaxWidth() - .align(Alignment.TopStart) - .padding(top = 20.dp, start = 24.dp, end = 16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, + Modifier.onNoRippleClick(onClick = { + dropdownState.value = true + }), + painter = painterResource(R.drawable.ic_32_menu), + contentDescription = stringResource(R.string.home_my_people_setting), + ) + NearDropdownMenu( + expanded = dropdownState.value, + onDismissRequest = { dropdownState.value = false }, ) { - Text( - stringResource(R.string.home_my_people), - style = NearTheme.typography.H2_18_BOLD, - color = NearTheme.colors.BLACK_1A1A1A, + NearDropdownMenuItem( + onClick = { + onAddContactClick() + dropdownState.value = false + }, + text = stringResource(R.string.home_menu_text_add_friend), ) - Column { - Image( - modifier = - Modifier.onNoRippleClick(onClick = { - dropdownState.value = true - }), - painter = painterResource(R.drawable.ic_32_menu), - contentDescription = stringResource(R.string.home_my_people_setting), - ) - NearDropdownMenu( - expanded = dropdownState.value, - onDismissRequest = { dropdownState.value = false }, - ) { - NearDropdownMenuItem( - onClick = { - onAddContactClick() - dropdownState.value = false - }, - text = stringResource(R.string.home_menu_text_add_friend), - ) - } - } } + } + } - if (contactsWithPage.size >= MINIMUM_PAGE_COUNT_TO_SHOW_UI) { - Column(modifier = Modifier.align(Alignment.BottomCenter)) { - PagerIndicator(pagerState) - Spacer(modifier = Modifier.height(104.dp)) - } - } + if (contactsWithPage.size >= MINIMUM_PAGE_COUNT_TO_SHOW_UI) { + Column(modifier = Modifier.align(Alignment.BottomCenter)) { + PagerIndicator(pagerState) + Spacer(modifier = Modifier.height(104.dp)) } } } @@ -370,40 +437,77 @@ 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() { NearTheme { HomeScreen( onContactClick = {}, - contacts = - List(6) { - FriendSummary( - id = "2003", - name = "일이삼사오육칠팔구", - profileImageUrl = "https://search.yahoo.com/search?p=partiendo", - lastContactedAt = "2025-07-16", - isContacted = false, - contactFrequencyLevel = ContactFrequencyLevel.HIGH, - ) - }, - monthlyFriends = - List(4) { - MonthlyFriend( - friendId = "intellegat$it", - name = "Stacey Stewart", - type = MonthlyFriendType.ANNIVERSARY, - nextContactAt = "2025-09-30", - ) - }, - memberInfo = - MemberInfo( - memberId = "posidonium", - username = "주지스님", - nickname = "Audra Day", - imageUrl = "https://search.yahoo.com/search?p=class", - notificationAgreedAt = "comprehensam", - providerType = "sumo", + uiState = + HomeUiState( + myFriendUIState = + MyFriendUIState.Success( + List(7) { + FriendSummary( + id = "2003", + name = "일이삼사오육칠팔구", + profileImageUrl = "https://search.yahoo.com/search?p=partiendo", + lastContactedAt = "2025-07-16", + isContacted = false, + contactFrequencyLevel = ContactFrequencyLevel.HIGH, + ) + }, + ), + monthlyFriendUIState = + MonthlyFriendUIState.Success( + List(4) { + MonthlyFriend( + friendId = "intellegat$it", + name = "Stacey Stewart", + type = MonthlyFriendType.ANNIVERSARY, + nextContactAt = "2025-09-30", + ) + }, + ), + memberInfo = + MemberInfo( + memberId = "posidonium", + username = "주지스님", + nickname = "Audra Day", + imageUrl = "https://search.yahoo.com/search?p=class", + notificationAgreedAt = "comprehensam", + providerType = "sumo", + ), ), ) } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/HomeViewModel.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/HomeViewModel.kt index d8681d00..96b35ec4 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/HomeViewModel.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/HomeViewModel.kt @@ -1,22 +1,20 @@ package com.alarmy.near.presentation.feature.home -import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.alarmy.near.data.repository.FriendRepository import com.alarmy.near.data.repository.MemberRepository -import com.alarmy.near.model.friendsummary.FriendSummary -import com.alarmy.near.model.member.MemberInfo -import com.alarmy.near.model.monthly.MonthlyFriend +import com.alarmy.near.presentation.feature.home.model.HomeUiState +import com.alarmy.near.presentation.feature.home.model.MonthlyFriendUIState +import com.alarmy.near.presentation.feature.home.model.MyFriendUIState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject @@ -27,48 +25,66 @@ class HomeViewModel friendRepository: FriendRepository, memberRepository: MemberRepository, ) : ViewModel() { + private val _uiState = MutableStateFlow(HomeUiState()) + val uiState = _uiState.asStateFlow() + private val _errorEvent = Channel() private val deletedFriendIdsFlow = MutableStateFlow>(setOf()) val errorEvent = _errorEvent.receiveAsFlow() - val memberInfoFlow: StateFlow = - memberRepository - .getMyInfo() - .catch { - _errorEvent.send(it) - }.stateIn( - viewModelScope, - SharingStarted.WhileSubscribed(5_000), - null, - ) - - val friendsFlow: StateFlow> = - combine( - friendRepository - .fetchFriends(), - deletedFriendIdsFlow, - ) { friends, deletedIds -> - friends.filter { it.id !in deletedIds } - }.catch { - _errorEvent.send(it) - }.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000), - initialValue = emptyList(), - ) - val monthlyFriendFlow: - StateFlow> = - friendRepository - .fetchMonthlyFriends() - .catch { - _errorEvent.send(it) - }.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000), - initialValue = emptyList(), - ) + init { + viewModelScope.launch { + launch { + memberRepository + .getMyInfo() + .catch { + _errorEvent.send(it) + }.collect { + _uiState.update { state -> + state.copy( + memberInfo = it, + ) + } + } + } + launch { + combine( + friendRepository + .fetchFriends(), + deletedFriendIdsFlow, + ) { friends, deletedIds -> + friends.filter { it.id !in deletedIds } + }.catch { + _errorEvent.send(it) + }.collect { + _uiState.update { state -> + state.copy( + myFriendUIState = MyFriendUIState.Success(it), + ) + } + } + } + launch { + combine( + friendRepository + .fetchMonthlyFriends(), + deletedFriendIdsFlow, + ) { monthlyFriends, deletedIds -> + monthlyFriends.filter { it.friendId !in deletedIds } + }.catch { + _errorEvent.send(it) + }.collect { + _uiState.update { state -> + state.copy( + monthlyFriendUIState = MonthlyFriendUIState.Success(it), + ) + } + } + } + } + } fun deleteFriend(friendId: String) { viewModelScope.launch { diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/model/HomeUiState.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/model/HomeUiState.kt index c4510aef..5396dd0c 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/model/HomeUiState.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/model/HomeUiState.kt @@ -1,9 +1,27 @@ package com.alarmy.near.presentation.feature.home.model -sealed interface HomeUiState { - data object Loading : HomeUiState +import com.alarmy.near.model.friendsummary.FriendSummary +import com.alarmy.near.model.member.MemberInfo +import com.alarmy.near.model.monthly.MonthlyFriend +data class HomeUiState( + val memberInfo: MemberInfo? = null, + val monthlyFriendUIState: MonthlyFriendUIState = MonthlyFriendUIState.Loading, + val myFriendUIState: MyFriendUIState = MyFriendUIState.Loading, +) + +sealed interface MonthlyFriendUIState { + data class Success( + val monthlyFriends: List, + ) : MonthlyFriendUIState + + data object Loading : MonthlyFriendUIState +} + +sealed interface MyFriendUIState { data class Success( - val data: Any, - ) : HomeUiState + val myFriends: List, + ) : MyFriendUIState + + data object Loading : MyFriendUIState } 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/login/LoginScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginScreen.kt index cb786685..9c321d7e 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginScreen.kt @@ -21,15 +21,19 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource 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 androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.repeatOnLifecycle +import coil.size.Scale import com.alarmy.near.R import com.alarmy.near.model.ProviderType import com.alarmy.near.presentation.feature.login.auth.SocialLoginHandler @@ -164,8 +168,9 @@ private fun LoginIntroductionSection(modifier: Modifier = Modifier) { ) Image( - modifier = modifier.wrapContentSize(Alignment.Center), + modifier = modifier.height(48.dp), alignment = Alignment.Center, + contentScale = ContentScale.None, painter = painterResource(R.drawable.ic_near_logo_title_primary), contentDescription = stringResource(R.string.near_logo_title), ) @@ -173,7 +178,8 @@ private fun LoginIntroductionSection(modifier: Modifier = Modifier) { Spacer(modifier = Modifier.size(LoginScreenConstants.DESCRIPTION_SPACING.dp)) Text( - modifier = modifier.wrapContentSize(Alignment.Center), + modifier = modifier, + textAlign = TextAlign.Center, text = stringResource(R.string.login_near_description), style = NearTheme.typography.B1_16_MEDIUM, color = NearTheme.colors.GRAY01_888888, @@ -228,3 +234,12 @@ private object LoginScreenConstants { const val DESCRIPTION_SPACING = 12 const val BOTTOM_SPACING = 96 } + +@Preview +@Composable +fun LoginScreenPreview() { + LoginScreen( + onLoginClick = {}, + uiState = LoginUiState(), + ) +} 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/feature/myprofile/MyProfileScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/myprofile/MyProfileScreen.kt index 37f0092f..f62349e1 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/myprofile/MyProfileScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/myprofile/MyProfileScreen.kt @@ -9,8 +9,11 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text @@ -129,6 +132,7 @@ fun MyProfileScreen( onWithdraw: () -> Unit = {}, onTermsClick: (TermsType) -> Unit = {}, ) { + val scrollState = rememberScrollState() NearFrame { // 앱바 NearTopAppbar( @@ -137,17 +141,21 @@ fun MyProfileScreen( onClickBackButton = onNavigateBack, ) - MyProfileInfoSection(uiState) - - // 일반 정보 섹션 Column( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp), + modifier = Modifier.verticalScroll(scrollState), ) { - MyProfileGeneralSection(uiState) - MyProfileServiceInfoSection(onLogout, onWithdraw, onTermsClick) + MyProfileInfoSection(uiState) + + // 일반 정보 섹션 + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + ) { + MyProfileGeneralSection(uiState) + MyProfileServiceInfoSection(onLogout, onWithdraw, onTermsClick) + } } } } @@ -255,7 +263,7 @@ private fun ColumnScope.MyProfileServiceInfoSection( ) } - Spacer(modifier = Modifier.weight(1f)) + Spacer(modifier = Modifier.height(63.dp)) NearLogoutButton( modifier = Modifier.fillMaxWidth(), diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/onboarding/OnboardingScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/onboarding/OnboardingScreen.kt index 48928afc..e4f85707 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/onboarding/OnboardingScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/onboarding/OnboardingScreen.kt @@ -6,12 +6,14 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -21,8 +23,8 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -40,6 +42,7 @@ import com.alarmy.near.presentation.feature.onboarding.components.BackgroundArea import com.alarmy.near.presentation.feature.onboarding.components.OnboardingButton import com.alarmy.near.presentation.feature.onboarding.components.PageIndicator import com.alarmy.near.presentation.feature.onboarding.model.OnboardingPage +import com.alarmy.near.presentation.ui.component.NearFrame import com.alarmy.near.presentation.ui.theme.NearTheme import kotlinx.coroutines.launch @@ -67,30 +70,31 @@ fun OnboardingScreen( } // 온보딩 페이지 데이터 - remember로 성능 최적화 - val pages = remember { - listOf( - OnboardingPage( - titleResId = R.string.first_onboarding_title, - image = R.drawable.img_onboarding_page_first, - ), - OnboardingPage( - titleResId = R.string.second_onboarding_title, - image = R.drawable.img_onboarding_page_second, - ), - OnboardingPage( - titleResId = R.string.third_onboarding_title, - image = R.drawable.img_onboarding_page_third, - ), - OnboardingPage( - titleResId = R.string.fourth_onboarding_title, - image = R.drawable.img_onboarding_page_forth, - ), - OnboardingPage( - titleResId = R.string.fifth_onboarding_title, - image = R.drawable.img_onboarding_page_fifth, - ), - ) - } + val pages = + remember { + listOf( + OnboardingPage( + titleResId = R.string.first_onboarding_title, + image = R.drawable.img_onboarding_page_first, + ), + OnboardingPage( + titleResId = R.string.second_onboarding_title, + image = R.drawable.img_onboarding_page_second, + ), + OnboardingPage( + titleResId = R.string.third_onboarding_title, + image = R.drawable.img_onboarding_page_third, + ), + OnboardingPage( + titleResId = R.string.fourth_onboarding_title, + image = R.drawable.img_onboarding_page_forth, + ), + OnboardingPage( + titleResId = R.string.fifth_onboarding_title, + image = R.drawable.img_onboarding_page_fifth, + ), + ) + } // 상태바와 네비게이션 바 높이 계산 val density = LocalDensity.current @@ -101,53 +105,56 @@ fun OnboardingScreen( val pagerState = rememberPagerState(pageCount = { pages.size }) val scope = rememberCoroutineScope() - Box( - modifier = Modifier.fillMaxSize(), - ) { - BackgroundArea() - Column( - modifier = Modifier - .padding(top = statusBarHeightDp, bottom = navigationBarHeightDp), - horizontalAlignment = Alignment.CenterHorizontally, + NearFrame { + Box( + modifier = Modifier.fillMaxSize(), ) { - // 뷰페이저 - HorizontalPager( - state = pagerState, - modifier = Modifier.weight(1f), - ) { page -> - OnboardingPageContent( - page = pages[page], - modifier = Modifier.fillMaxSize(), - ) - } + BackgroundArea() + Column( + modifier = + Modifier + .padding(top = statusBarHeightDp, bottom = navigationBarHeightDp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + // 뷰페이저 + HorizontalPager( + state = pagerState, + ) { page -> + OnboardingPageContent( + page = pages[page], + modifier = Modifier.fillMaxWidth(), + ) + } - Spacer(modifier = Modifier.size(25.dp)) + Spacer(modifier = Modifier.size(24.dp)) - // 페이지 인디케이터 - PageIndicator( - pageCount = pages.size, - currentPage = pagerState.currentPage, - ) + // 페이지 인디케이터 + PageIndicator( + pageCount = pages.size, + currentPage = pagerState.currentPage, + ) - Spacer(modifier = Modifier.size(32.dp)) - - // 다음/완료 버튼 - OnboardingButton( - currentPage = pagerState.currentPage, - totalPages = pages.size, - isLoading = uiState.isLoading, - onNextClick = { - if (pagerState.currentPage < pages.size - 1) { - scope.launch { - pagerState.animateScrollToPage(pagerState.currentPage + 1) - } - } else { - // 온보딩 완료 시 DataStore에 저장 - viewModel.completeOnboarding() - } - }, - ) - Spacer(modifier = Modifier.size(24.dp)) + Spacer(modifier = Modifier.size(14.dp)) + } + Column(modifier = Modifier.align(Alignment.BottomCenter)) { + // 다음/완료 버튼 + OnboardingButton( + currentPage = pagerState.currentPage, + totalPages = pages.size, + isLoading = uiState.isLoading, + onNextClick = { + if (pagerState.currentPage < pages.size - 1) { + scope.launch { + pagerState.animateScrollToPage(pagerState.currentPage + 1) + } + } else { + // 온보딩 완료 시 DataStore에 저장 + viewModel.completeOnboarding() + } + }, + ) + Spacer(modifier = Modifier.size(24.dp)) + } } } } @@ -165,7 +172,7 @@ private fun OnboardingPageContent( modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally, ) { - Spacer(modifier = Modifier.size(45.dp)) + Spacer(modifier = Modifier.size(22.dp)) // 각 온보딩 페이지 타이틀 Text( @@ -178,15 +185,15 @@ private fun OnboardingPageContent( ), ) - Spacer(modifier = Modifier.size(24.dp)) + Spacer(modifier = Modifier.size(16.dp)) Image( - modifier = Modifier.weight(1f), + modifier = + Modifier + .clip(RoundedCornerShape(12.dp)), painter = painterResource(page.image), contentDescription = null, - contentScale = ContentScale.Crop, ) - } } @@ -217,7 +224,10 @@ private fun createAnnotatedText( /** * 지정된 색상으로 텍스트를 추가하는 함수 */ -private fun AnnotatedString.Builder.appendStyledText(text: String, color: Color) { +private fun AnnotatedString.Builder.appendStyledText( + text: String, + color: Color, +) { withStyle(style = SpanStyle(color = color)) { append(text) } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/ui/component/bottomsheet/CycleSettingBottomSheet.kt b/Near/app/src/main/java/com/alarmy/near/presentation/ui/component/bottomsheet/CycleSettingBottomSheet.kt new file mode 100644 index 00000000..394b7cb9 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/ui/component/bottomsheet/CycleSettingBottomSheet.kt @@ -0,0 +1,202 @@ +package com.alarmy.near.presentation.ui.component.bottomsheet + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +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.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.alarmy.near.R +import com.alarmy.near.model.ReminderInterval +import com.alarmy.near.presentation.feature.friendcontactcycle.components.ContactCycleButtons +import com.alarmy.near.presentation.ui.component.checkbox.NearCheckbox +import com.alarmy.near.presentation.ui.extension.onNoRippleClick +import com.alarmy.near.presentation.ui.theme.NearTheme +import com.alarmy.near.utils.extensions.DateExtension + +@Composable +fun CycleSettingBottomSheet( + isVisible: Boolean, + onDismiss: () -> Unit, + onComplete: (ReminderInterval) -> Unit = {}, + currentSelectedInterval: ReminderInterval? = null, + modifier: Modifier = Modifier, +) { + var selectedInterval by remember(isVisible) { + mutableStateOf(currentSelectedInterval ?: ReminderInterval.EVERY_WEEK) + } + + NearBottomSheet( + isVisible = isVisible, + onDismiss = onDismiss, + modifier = modifier, + ) { + Text( + text = stringResource(R.string.friend_contact_cycle_cycle_setting_text), + style = NearTheme.typography.B1_16_BOLD, + ) + + Spacer(modifier = Modifier.size(24.dp)) + + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(NearTheme.colors.BG02_F4F9FD) + .padding(vertical = 18.dp, horizontal = 20.dp), + ) { + Text( + text = + buildAnnotatedString { + val interval = selectedInterval ?: ReminderInterval.EVERY_WEEK + val cycleText = DateExtension.getCycleText(interval) + val firstSpaceIndex = cycleText.indexOf(' ') + + if (firstSpaceIndex != -1) { + withStyle( + style = + SpanStyle( + color = NearTheme.colors.BLACK_1A1A1A, + fontWeight = NearTheme.typography.B2_14_MEDIUM.fontWeight, + ), + ) { + append(cycleText.substring(0, firstSpaceIndex + 1)) + } + withStyle( + style = + SpanStyle( + color = NearTheme.colors.BLUE01_5AA2E9, + fontWeight = NearTheme.typography.B2_14_BOLD.fontWeight, + ), + ) { + append(cycleText.substring(firstSpaceIndex + 1)) + } + } else { + withStyle( + style = + SpanStyle( + color = NearTheme.colors.BLACK_1A1A1A, + fontWeight = NearTheme.typography.B2_14_MEDIUM.fontWeight, + ), + ) { + append(cycleText) + } + } + }, + style = NearTheme.typography.B2_14_MEDIUM, + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Spacer(modifier = Modifier.size(20.dp)) + + VerticalDivider( + modifier = + Modifier + .size(width = 1.dp, height = 28.dp), + color = + NearTheme.colors.BLACK_1A1A1A.copy( + alpha = 0.1f, + ), + ) + + Spacer(modifier = Modifier.size(20.dp)) + + Text( + text = + stringResource(R.string.friend_contact_cycle_next_cycle_prefix) + + ( + selectedInterval?.let { DateExtension.getNextCycleDate(it) } + ?: DateExtension.getNextWeekSameDay() + ), + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.GRAY01_888888, + ) + } + } + + Spacer(modifier = Modifier.size(8.dp)) + + ReminderInterval.entries.forEach { interval -> + val isSelected = selectedInterval == interval + + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .onNoRippleClick { + if (!isSelected) { + selectedInterval = interval + } + }.padding(vertical = 15.dp), + ) { + Text( + text = stringResource(interval.labelRes), + style = + if (isSelected) { + NearTheme.typography.B1_16_BOLD + } else { + NearTheme.typography.B2_14_MEDIUM + }, + ) + + if (isSelected) { + NearCheckbox( + checked = true, + onCheckedChange = {}, + ) + } + } + } + Spacer(modifier = Modifier.size(24.dp)) + + ContactCycleButtons( + onLeftButtonClick = onDismiss, + onRightButtonClick = { + selectedInterval?.let { interval -> + onComplete(interval) + onDismiss() + } + }, + leftButtonText = stringResource(R.string.friend_contact_cycle_cancel_button), + rightButtonText = stringResource(R.string.friend_contact_cycle_complete_button), + ) + Spacer(modifier = Modifier.size(24.dp)) + } +} + +@Preview +@Composable +fun CycleSettingBottomSheetPreview() { + NearTheme { + CycleSettingBottomSheet( + isVisible = true, + onDismiss = {}, + ) + } +} + diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/ui/component/bottomsheet/NearBottomSheet.kt b/Near/app/src/main/java/com/alarmy/near/presentation/ui/component/bottomsheet/NearBottomSheet.kt new file mode 100644 index 00000000..191162cf --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/ui/component/bottomsheet/NearBottomSheet.kt @@ -0,0 +1,83 @@ +package com.alarmy.near.presentation.ui.component.bottomsheet + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.rememberModalBottomSheetState +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.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.alarmy.near.presentation.ui.theme.NearTheme + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NearBottomSheet( + isVisible: Boolean, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + if (isVisible) { + val bottomSheetState = + rememberModalBottomSheetState( + skipPartiallyExpanded = true, + ) + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = bottomSheetState, + dragHandle = { + Box( + modifier = + Modifier + .fillMaxWidth() + .padding(top = 12.dp, bottom = 24.dp), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = + Modifier + .width(36.dp) + .height(5.dp) + .clip(RoundedCornerShape(2.5.dp)) + .background(NearTheme.colors.BLACK_1A1A1A.copy(alpha = 0.1f)), + ) + } + }, + modifier = modifier, + containerColor = NearTheme.colors.WHITE_FFFFFF, + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + ) { + content() + } + } + } +} + +@Preview +@Composable +fun NearBottomSheetPreview() { + NearTheme { + NearBottomSheet( + isVisible = true, + onDismiss = { }, + content = {}, + ) + } +} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/ui/component/textfield/NearOutlinedTextField.kt b/Near/app/src/main/java/com/alarmy/near/presentation/ui/component/textfield/NearOutlinedTextField.kt index 4b8e70a2..f159c151 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/ui/component/textfield/NearOutlinedTextField.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/ui/component/textfield/NearOutlinedTextField.kt @@ -151,7 +151,7 @@ fun NearOutlinedTextField( container = { OutlinedTextFieldDefaults.Container( enabled = enabled, - isError = isError, + isError = false, interactionSource = interactionSource, colors = colors, shape = shape, diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/ui/component/textfield/internal/NearSearchTextFieldDecorationBox.kt b/Near/app/src/main/java/com/alarmy/near/presentation/ui/component/textfield/internal/NearSearchTextFieldDecorationBox.kt index 04b3ac0b..c83affdd 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/ui/component/textfield/internal/NearSearchTextFieldDecorationBox.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/ui/component/textfield/internal/NearSearchTextFieldDecorationBox.kt @@ -33,39 +33,43 @@ internal fun NearSearchTextFieldDecorationBox( colors: TextFieldColors, placeHolderText: String, onSearchClick: () -> Unit, - contentPadding: PaddingValues = PaddingValues( - start = 16.dp, - top = 16.dp, - bottom = 16.dp, - end = 8.dp - ), + contentPadding: PaddingValues = + PaddingValues( + start = 16.dp, + top = 16.dp, + bottom = 16.dp, + end = 8.dp, + ), ) { OutlinedTextFieldDefaults.DecorationBox( contentPadding = contentPadding, value = value, innerTextField = { Box( - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) { Box( - modifier = Modifier - .fillMaxWidth() - .padding(end = 40.dp) + modifier = + Modifier + .fillMaxWidth() + .padding(end = 40.dp), ) { innerTextField() } IconButton( onClick = onSearchClick, - modifier = Modifier - .align(Alignment.CenterEnd) - .size(32.dp), - enabled = enabled + modifier = + Modifier + .align(Alignment.CenterEnd) + .size(32.dp), + enabled = enabled, ) { Icon( painter = painterResource(id = R.drawable.ic_24_search), contentDescription = "검색", modifier = Modifier.size(24.dp), + tint = NearTheme.colors.GRAY01_888888, ) } } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/ui/extension/NearShimmer.kt b/Near/app/src/main/java/com/alarmy/near/presentation/ui/extension/NearShimmer.kt new file mode 100644 index 00000000..0c2711fb --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/ui/extension/NearShimmer.kt @@ -0,0 +1,116 @@ +package com.alarmy.near.presentation.ui.extension + +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +internal enum class ShimmerType( + val colors: List, +) { + PRIMARY( + listOf( + Color(0xff5997E5), + Color(0xffA9D8FF), + ), + ), + WHITE( + listOf( + Color(0xffFFFFFF), + Color(0xffEBEBEB), + ), + ), +} + +@Composable +fun NearConditionalShimmer( + enabled: Boolean, + modifier: Modifier = Modifier, + shimmerColors: List = ShimmerType.PRIMARY.colors, + cornerRadius: Dp = 12.dp, + content: @Composable () -> Unit, +) { + Box(modifier = modifier) { + // 항상 content를 렌더링해서 크기 확보 (투명하게) + Box( + modifier = Modifier.alpha(if (enabled) 0f else 1f), + ) { + content() + } + + // shimmer가 enabled일 때만 효과 표시 + if (enabled) { + NearShimmerEffect( + modifier = Modifier.matchParentSize(), + shimmerColors = shimmerColors, + cornerRadius = cornerRadius, + ) + } + } +} + +@Composable +private fun NearShimmerEffect( + modifier: Modifier = Modifier, + shimmerColors: List = ShimmerType.PRIMARY.colors, + cornerRadius: Dp = 12.dp, +) { + var componentWidth by remember { mutableFloatStateOf(0f) } + + val transition = rememberInfiniteTransition(label = "shimmer") + val translateAnim = + transition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 1000, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "shimmer_translate", + ) + + val brush = + remember(translateAnim.value, componentWidth, shimmerColors) { + if (componentWidth > 0f) { + val offset = translateAnim.value * componentWidth * 2 - componentWidth + Brush.linearGradient( + colors = shimmerColors, + start = Offset(x = offset, y = 0f), + end = Offset(x = offset + componentWidth, y = 0f), + ) + } else { + Brush.linearGradient(colors = shimmerColors) + } + } + + Box( + modifier = + modifier + .onGloballyPositioned { coordinates -> + componentWidth = coordinates.size.width.toFloat() + }.clip(RoundedCornerShape(cornerRadius)) + .background(brush), + ) +} 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/drawable/img_64_user_gray.xml b/Near/app/src/main/res/drawable/img_64_user_gray.xml new file mode 100644 index 00000000..dab3a810 --- /dev/null +++ b/Near/app/src/main/res/drawable/img_64_user_gray.xml @@ -0,0 +1,24 @@ + + + + + + + + + diff --git a/Near/app/src/main/res/values/strings.xml b/Near/app/src/main/res/values/strings.xml index b926446c..145bbba6 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 @@ 서비스 이용약관 개인정보 수집 및 이용동의 개인정보 처리방침 - + 서비스 약관 동의 약관 전체 동의 @@ -179,6 +190,7 @@ 주기 설정 매주 %1$s 매주 + 다음 주기 : 취소 완료 나중에 하기