diff --git a/Near/app/build.gradle.kts b/Near/app/build.gradle.kts index 7165d602..f24511cd 100644 --- a/Near/app/build.gradle.kts +++ b/Near/app/build.gradle.kts @@ -13,7 +13,7 @@ android { defaultConfig { applicationId = "com.alarmy.near" - minSdk = 24 + minSdk = 27 targetSdk = 35 versionCode = 1 versionName = "1.0" diff --git a/Near/app/src/main/java/com/alarmy/near/model/ContactFrequency.kt b/Near/app/src/main/java/com/alarmy/near/model/ContactFrequency.kt new file mode 100644 index 00000000..dfcea4a5 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/model/ContactFrequency.kt @@ -0,0 +1,7 @@ +package com.alarmy.near.model + +enum class ContactFrequency { + LOW, + MIDDLE, + HIGH, +} diff --git a/Near/app/src/main/java/com/alarmy/near/model/ContactSummary.kt b/Near/app/src/main/java/com/alarmy/near/model/ContactSummary.kt new file mode 100644 index 00000000..2a98ee89 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/model/ContactSummary.kt @@ -0,0 +1,22 @@ +package com.alarmy.near.model + +import androidx.compose.runtime.Immutable +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +@Immutable +data class ContactSummary( + val id: String, + val name: String, + val profileImageUrl: String, + val lastContactedAt: LocalDate, + val isContacted: Boolean, + val contactFrequency: ContactFrequency, +) { + val formattedDate: String + get() = lastContactedAt.format(formatter) + + companion object { + private val formatter = DateTimeFormatter.ofPattern("MMM d, yyyy") + } +} diff --git a/Near/app/src/main/java/com/alarmy/near/model/MonthlyContact.kt b/Near/app/src/main/java/com/alarmy/near/model/MonthlyContact.kt new file mode 100644 index 00000000..286a7da4 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/model/MonthlyContact.kt @@ -0,0 +1,32 @@ +package com.alarmy.near.model + +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.time.temporal.ChronoUnit + +data class MonthlyContact( + val friendId: String, + val name: String, + val type: String, + val nextContactAt: String, +) { + fun daysUntilNextContact(today: LocalDate): String { + val daysBetween = getDaysBetween(today) + return when { + daysBetween == 0L -> "D-day" + daysBetween > 0L -> "D-$daysBetween" + else -> "D+${-daysBetween}" // 과거 날짜 + } + } + + fun isNextContactDay(today: LocalDate): Boolean = getDaysBetween(today) == 0L + + private fun getDaysBetween(today: LocalDate): Long { + val targetDate = LocalDate.parse(nextContactAt, formatter) + return ChronoUnit.DAYS.between(today, targetDate) + } + + companion object { + private val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") + } +} 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 fd8eebb9..01b8a497 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 @@ -1,40 +1,342 @@ package com.alarmy.near.presentation.feature.home +import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets 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.layout.statusBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.Surface 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.draw.paint 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 +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.alarmy.near.presentation.feature.home.model.HomeUiState +import com.alarmy.near.R +import com.alarmy.near.model.ContactFrequency +import com.alarmy.near.model.ContactSummary +import com.alarmy.near.model.MonthlyContact +import com.alarmy.near.presentation.feature.home.component.MyContacts +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 java.time.LocalDate + +private const val MINIMUM_PAGE_COUNT_TO_SHOW_UI = 2 @Composable internal fun HomeRoute( viewModel: HomeViewModel = hiltViewModel(), onShowErrorSnackBar: (throwable: Throwable?) -> Unit, + onContactClick: (String) -> Unit = {}, + onAlarmClick: () -> Unit = {}, + onMyPageClick: () -> Unit = {}, ) { val uiState = viewModel.uiStateFlow.collectAsStateWithLifecycle() HomeScreen( - uiState = uiState.value, onContactClick = {}, - onRemoveContact = viewModel::removeContact, + onAlarmClick = {}, + onMyPageClick = {}, + contacts = + List(6) { + ContactSummary( + id = "2003", + name = "일이삼사오육칠팔구", + profileImageUrl = "https://search.yahoo.com/search?p=partiendo", + lastContactedAt = LocalDate.of(2025, 7, 25), + isContacted = false, + contactFrequency = ContactFrequency.LOW, + ) + }, + monthlyContacts = emptyList(), ) } +@OptIn(ExperimentalMaterial3Api::class) @Composable internal fun HomeScreen( modifier: Modifier = Modifier, - uiState: HomeUiState, - onContactClick: (Long) -> Unit = { _ -> }, - onRemoveContact: (Long) -> Unit = { _ -> }, + onContactClick: (String) -> Unit = { _ -> }, + onMyPageClick: () -> Unit = {}, + onAlarmClick: () -> Unit = {}, + contacts: List, + monthlyContacts: List, ) { - Column(modifier = Modifier.fillMaxSize().background(Color.White)) { - Text("홈 화면") + 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 + }, + ) + + Surface(modifier = modifier) { + Column( + modifier = + Modifier + .paint( + painter = + painterResource( + R.drawable.img_bg, + ), + contentScale = ContentScale.FillBounds, + ).fillMaxSize(), + ) { + Spacer(modifier = Modifier.height(statusBarHeightDp)) + 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 = "", + ) + } + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = + buildAnnotatedString { + append("주지스님,\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 (monthlyContacts.isEmpty()) { + Surface( + 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), + 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 = monthlyContacts.size, + key = { + monthlyContacts[it].friendId + }, + ) { + val monthlyContact = monthlyContacts[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), + ) { + Row( + modifier = + Modifier + .padding(start = 12.dp, end = 16.dp) + .padding(vertical = 12.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + painterResource(R.drawable.icon_visual_mail), + 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, + ) + } else { + Text( + text = monthlyContact.daysUntilNextContact(now), + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.BLACK_1A1A1A.copy(alpha = 0.5f), + ) + } + } + } + } + } + } + + Spacer(modifier = Modifier.height(24.dp)) + Box( + modifier = + Modifier + .fillMaxSize() + .background( + color = NearTheme.colors.WHITE_FFFFFF, + shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp), + ), + ) { + 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, + ) + Icon( + painterResource(R.drawable.ic_32_menu), + contentDescription = stringResource(R.string.home_my_people_setting), + ) + } + Spacer(modifier = Modifier.height(16.dp)) + MyContacts( + modifier = Modifier.align(Alignment.TopCenter), + contactsWithPage = contactsWithPage, + pagerState = pagerState, + onContactClick = onContactClick, + onAddContactClick = { + // TODO Contact 클릭 이벤트 구현 + }, + ) + + if (contactsWithPage.size >= MINIMUM_PAGE_COUNT_TO_SHOW_UI) { + Column(modifier = Modifier.align(Alignment.BottomCenter)) { + PagerIndicator(pagerState) + Spacer(modifier = Modifier.height(104.dp)) + } + } + } + } + } +} + +@Composable +private fun PagerIndicator(pagerState: PagerState) { + Row( + Modifier + .wrapContentHeight() + .fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + repeat(pagerState.pageCount) { iteration -> + val color = + if (pagerState.currentPage == iteration) { + Color(0xff737373) + } else { + Color( + 0xffe2e2e2, + ) + } + Box( + modifier = + Modifier + .padding(4.dp) + .clip(CircleShape) + .background(color) + .size(8.dp), + ) + } } } @@ -43,9 +345,27 @@ internal fun HomeScreen( internal fun HomeScreenPreview() { NearTheme { HomeScreen( - uiState = HomeUiState.Loading, onContactClick = {}, - onRemoveContact = {}, + contacts = + List(6) { + ContactSummary( + id = "2003", + name = "일이삼사오육칠팔구", + profileImageUrl = "https://search.yahoo.com/search?p=partiendo", + lastContactedAt = LocalDate.of(2025, 7, 25), + isContacted = false, + contactFrequency = ContactFrequency.HIGH, + ) + }, + monthlyContacts = + List(4) { + MonthlyContact( + friendId = "intellegat$it", + name = "Stacey Stewart", + type = "ANNIVERSARY", + nextContactAt = "2025-09-30", + ) + }, ) } } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/component/AddContactButton.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/component/AddContactButton.kt new file mode 100644 index 00000000..f3c0e3c0 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/component/AddContactButton.kt @@ -0,0 +1,77 @@ +package com.alarmy.near.presentation.feature.home.component + +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.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.stringResource +import androidx.compose.ui.text.style.TextAlign +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.onNoRippleClick +import com.alarmy.near.presentation.ui.theme.NearTheme + +private const val MAX_WIDTH_OF_NAME_TEXT = 97 + +@Composable +fun AddContactButton( + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, +) { + Column( + modifier = modifier.onNoRippleClick(onClick = onClick), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AddContactImage() + Spacer(modifier = Modifier.height(6.dp)) + Text( + modifier = Modifier.width(MAX_WIDTH_OF_NAME_TEXT.dp), + text = stringResource(R.string.home_add_people_text), + textAlign = TextAlign.Center, + style = NearTheme.typography.B2_14_MEDIUM, + color = Color(0xff222222), + ) + } +} + +@Composable +fun AddInitialContactButton( + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, +) { + Column( + modifier = + modifier + .onNoRippleClick( + onClick = onClick, + ), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AddContactImage() + Spacer(modifier = Modifier.height(12.dp)) + Text( + stringResource(R.string.home_add_contact_description), + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.BLACK_1A1A1A.copy(alpha = 0.3f), + textAlign = TextAlign.Center, + ) + } +} + +@Preview(showBackground = true) +@Composable +fun AddContactButtonPreview() { + AddContactButton() +} + +@Preview(showBackground = true) +@Composable +fun AddInitialContactButtonPreview() { + AddInitialContactButton() +} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/component/AddContactImage.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/component/AddContactImage.kt new file mode 100644 index 00000000..77b6577b --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/component/AddContactImage.kt @@ -0,0 +1,27 @@ +package com.alarmy.near.presentation.feature.home.component + +import androidx.compose.foundation.Image +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import com.alarmy.near.R + +@Composable +fun AddContactImage(modifier: Modifier = Modifier) { + Image( + modifier = modifier, + painter = painterResource(R.drawable.ic_64_adduser), + contentDescription = + stringResource( + R.string.home_add_contact, + ), + ) +} + +@Preview(widthDp = 48, heightDp = 48) +@Composable +fun AddContactImagePreview() { + AddContactImage() +} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/component/ContactItem.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/component/ContactItem.kt new file mode 100644 index 00000000..d9914ca0 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/component/ContactItem.kt @@ -0,0 +1,104 @@ +package com.alarmy.near.presentation.feature.home.component + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +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.text.style.TextAlign +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.model.ContactFrequency +import com.alarmy.near.model.ContactSummary +import com.alarmy.near.presentation.ui.extension.onNoRippleClick +import com.alarmy.near.presentation.ui.theme.NearTheme +import java.time.LocalDate + +private const val MAX_WIDTH_OF_NAME_TEXT = 97 + +@Composable +fun ContactItem( + modifier: Modifier = Modifier, + contactSummary: ContactSummary, + onClick: (contactId: String) -> Unit = {}, +) { + Column( + modifier = + modifier.onNoRippleClick { + onClick(contactSummary.id) + }, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box { + Image( + painter = painterResource(R.drawable.img_64_user1), + contentDescription = "", + ) + Image( + modifier = + Modifier + .align(Alignment.TopEnd) + .offset(x = 4.dp, y = (-4).dp), + painter = + when (contactSummary.contactFrequency) { + ContactFrequency.LOW -> painterResource(R.drawable.ic_visual_24_emoji_0) + ContactFrequency.MIDDLE -> painterResource(R.drawable.ic_visual_24_emoji_50) + ContactFrequency.HIGH -> painterResource(R.drawable.ic_visual_24_emoji_100) + }, + contentDescription = "", + ) + } + Spacer(modifier = Modifier.height(6.dp)) + Text( + modifier = Modifier.width(MAX_WIDTH_OF_NAME_TEXT.dp), + text = contactSummary.name, + style = NearTheme.typography.B2_14_BOLD, + textAlign = TextAlign.Center, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + color = NearTheme.colors.BLACK_1A1A1A, + ) + Spacer(modifier = Modifier.height(1.dp)) + Row { + Text( + contactSummary.formattedDate, + style = NearTheme.typography.FC_12_MEDIUM, + textAlign = TextAlign.Center, + color = NearTheme.colors.GRAY02_B7B7B7, + ) + Spacer(modifier = Modifier.width(2.dp)) + Image( + painter = painterResource(R.drawable.ic_12_check), + contentDescription = "", + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun ContactItemPreview_Default() { + ContactItem( + modifier = Modifier.padding(top = 10.dp), + contactSummary = + ContactSummary( + id = "123L", + name = "홍길동", + profileImageUrl = "", + lastContactedAt = LocalDate.of(2025, 5, 31), + isContacted = true, + contactFrequency = ContactFrequency.HIGH, + ), + ) +} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/component/MyContacts.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/component/MyContacts.kt new file mode 100644 index 00000000..526373b3 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/component/MyContacts.kt @@ -0,0 +1,249 @@ +package com.alarmy.near.presentation.feature.home.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.alarmy.near.model.ContactFrequency +import com.alarmy.near.model.ContactSummary +import com.alarmy.near.presentation.ui.theme.NearTheme +import java.time.LocalDate + +private const val OVERFLOW_WIDTH_OF_CONTACT_ITEM_BY_NAME_TEXT = 34 + +@Composable +fun MyContacts( + modifier: Modifier = Modifier, + contactsWithPage: List>, + pagerState: PagerState = + rememberPagerState( + initialPage = 0, + pageCount = { + contactsWithPage.count() + if (contactsWithPage.lastOrNull()?.count() == 5) 1 else 0 + }, + ), + onAddContactClick: () -> Unit = {}, + onContactClick: (contactId: String) -> Unit = {}, +) { + Column(modifier = modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { + if (contactsWithPage.isEmpty()) { // 연락처가 아무도 없는 경우 + Column { + Spacer(modifier = Modifier.height(166.dp)) + AddInitialContactButton( + onClick = onAddContactClick, + ) + } + } else { + HorizontalPager( + modifier = Modifier.height(362.dp), + state = pagerState, + ) { page -> + if (page == pagerState.pageCount - 1 && contactsWithPage + .lastOrNull() + ?.count() == 5 + ) { // 마지막 Page의 연락처가 5개인 경우 + Column { + Spacer(modifier = Modifier.height(166.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + AddContactButton( + onClick = onAddContactClick, + ) + } + } + } else { + when (contactsWithPage[page].count()) { + 1 -> { + Column { + Spacer(modifier = Modifier.height(166.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + ContactItem( + contactSummary = contactsWithPage[page][0], + onClick = onContactClick, + ) + Spacer(modifier = Modifier.width((60 - OVERFLOW_WIDTH_OF_CONTACT_ITEM_BY_NAME_TEXT).dp)) + AddContactButton( + onClick = onAddContactClick, + ) + } + } + } + + 2 -> { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.height(112.dp)) + ContactItem( + contactSummary = contactsWithPage[page][0], + onClick = onContactClick, + ) + Spacer(modifier = Modifier.height(16.dp)) + Row { + ContactItem( + contactSummary = contactsWithPage[page][1], + onClick = onContactClick, + ) + Spacer(modifier = Modifier.width((118 - OVERFLOW_WIDTH_OF_CONTACT_ITEM_BY_NAME_TEXT).dp)) + AddContactButton( + onClick = onAddContactClick, + ) + } + } + } + + 3 -> { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.height(76.dp)) + Box { + ContactItem( + modifier = Modifier.align(Alignment.TopCenter), + contactSummary = contactsWithPage[page][0], + onClick = onContactClick, + ) + Row(modifier = Modifier.padding(top = 92.dp, bottom = 78.dp)) { + ContactItem( + contactSummary = contactsWithPage[page][1], + onClick = onContactClick, + ) + Spacer(modifier = Modifier.width((141 - OVERFLOW_WIDTH_OF_CONTACT_ITEM_BY_NAME_TEXT).dp)) + ContactItem( + contactSummary = contactsWithPage[page][2], + onClick = onContactClick, + ) + } + AddContactButton( + modifier = Modifier.align(Alignment.BottomCenter), + onClick = onAddContactClick, + ) + } + } + } + + 4 -> { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.height(68.dp)) + Box { + ContactItem( + modifier = Modifier.align(Alignment.TopCenter), + contactSummary = contactsWithPage[page][0], + onClick = onContactClick, + ) + Row(modifier = Modifier.padding(top = 62.dp)) { + ContactItem( + contactSummary = contactsWithPage[page][1], + onClick = onContactClick, + ) + Spacer(modifier = Modifier.width((138 - OVERFLOW_WIDTH_OF_CONTACT_ITEM_BY_NAME_TEXT).dp)) + ContactItem( + contactSummary = contactsWithPage[page][2], + onClick = onContactClick, + ) + } + } + Spacer(modifier = Modifier.height(16.dp)) + Row { + ContactItem( + contactSummary = contactsWithPage[page][3], + onClick = onContactClick, + ) + Spacer(modifier = Modifier.width((51 - OVERFLOW_WIDTH_OF_CONTACT_ITEM_BY_NAME_TEXT).dp)) + AddContactButton( + onClick = onAddContactClick, + ) + } + } + } + + 5 -> { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.height(68.dp)) + Box { + ContactItem( + modifier = Modifier.align(Alignment.TopCenter), + contactSummary = contactsWithPage[page][0], + onClick = onContactClick, + ) + Row(modifier = Modifier.padding(top = 62.dp)) { + ContactItem( + contactSummary = contactsWithPage[page][1], + onClick = onContactClick, + ) + Spacer(modifier = Modifier.width((138 - OVERFLOW_WIDTH_OF_CONTACT_ITEM_BY_NAME_TEXT).dp)) + ContactItem( + contactSummary = contactsWithPage[page][2], + onClick = onContactClick, + ) + } + } + Spacer(modifier = Modifier.height(16.dp)) + Row { + ContactItem( + contactSummary = contactsWithPage[page][3], + onClick = onContactClick, + ) + Spacer(modifier = Modifier.width((51 - OVERFLOW_WIDTH_OF_CONTACT_ITEM_BY_NAME_TEXT).dp)) + ContactItem( + contactSummary = contactsWithPage[page][4], + onClick = onContactClick, + ) + } + } + } + } + } + } + } + } +} + +@Preview(showBackground = true, widthDp = 360, heightDp = 500) +@Composable +fun MyContactsPreview() { + NearTheme { + Box { + MyContacts( + modifier = Modifier.align(Alignment.Center), + contactsWithPage = + List(5) { + ContactSummary( + id = "2003", + name = "일이삼사오육칠팔구", + profileImageUrl = "https://search.yahoo.com/search?p=partiendo", + lastContactedAt = LocalDate.of(2025, 7, 25), + isContacted = false, + contactFrequency = ContactFrequency.LOW, + ) + }.chunked(5), + ) + } + } +} 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 748b9ab4..4569fd79 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 @@ -19,11 +19,16 @@ fun NavController.navigateToHome(navOptions: NavOptions) { fun NavGraphBuilder.homeNavGraph( onShowErrorSnackBar: (throwable: Throwable?) -> Unit, - onClickContact: (id: Long) -> Unit, + onContactClick: (String) -> Unit = {}, + onAlarmClick: () -> Unit = {}, + onMyPageClick: () -> Unit = {}, ) { composable { backStackEntry -> HomeRoute( onShowErrorSnackBar = onShowErrorSnackBar, + onContactClick = onContactClick, + onAlarmClick = onAlarmClick, + onMyPageClick = onMyPageClick, ) } } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/NearApp.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/NearApp.kt index 4730b2ac..03abcdf1 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/NearApp.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/NearApp.kt @@ -1,6 +1,8 @@ package com.alarmy.near.presentation.feature.main +import android.annotation.SuppressLint import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.exclude import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.ime @@ -19,6 +21,7 @@ import androidx.navigation.NavHostController import androidx.navigation.compose.rememberNavController import kotlinx.coroutines.launch +@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") @Composable internal fun NearApp( modifier: Modifier = Modifier, @@ -28,7 +31,8 @@ internal fun NearApp( val scope = rememberCoroutineScope() Scaffold( - modifier = Modifier.fillMaxSize(), + modifier = + Modifier.fillMaxSize(), snackbarHost = { SnackbarHost( hostState = snackBarState, @@ -42,7 +46,7 @@ internal fun NearApp( }, ) { innerPadding -> NearNavHost( - modifier = Modifier.padding(innerPadding), + modifier = Modifier.consumeWindowInsets(innerPadding), // 하위 뷰에 Padding을 소비한 것으로 알립니다. navController = navController, onShowSnackbar = { scope.launch { 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 ee75ae08..cb862357 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 @@ -21,8 +21,13 @@ internal fun NearNavHost( navController = navController, startDestination = RouteHome, ) { - homeNavGraph(onShowErrorSnackBar = onShowSnackbar, onClickContact = { - // 예시: navController.navigate(RouteContact(it)) - }) + homeNavGraph( + onShowErrorSnackBar = onShowSnackbar, + onContactClick = { contactId -> + // 예시: navController.navigate(RouteContact(it)) + }, + onMyPageClick = {}, + onAlarmClick = {}, + ) } } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/ui/extension/Modifier.kt b/Near/app/src/main/java/com/alarmy/near/presentation/ui/extension/Modifier.kt new file mode 100644 index 00000000..d4476fd4 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/ui/extension/Modifier.kt @@ -0,0 +1,73 @@ +package com.alarmy.near.presentation.ui.extension + +import android.graphics.BlurMaskFilter +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Paint +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.drawOutline +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +@Composable +fun Modifier.onNoRippleClick(onClick: () -> Unit): Modifier = + this then + Modifier.clickable( + onClick = onClick, + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) + +@Composable +fun Modifier.dropShadow( + shape: Shape, + color: Color = Color.Black.copy(0.25f), + blur: Dp = 1.dp, + offsetY: Dp = 1.dp, + offsetX: Dp = 1.dp, + spread: Dp = 1.dp, +) = composed { + val density = LocalDensity.current + + val paint = + remember(color, blur) { + Paint().apply { + this.color = color + val blurPx = with(density) { blur.toPx() } + if (blurPx > 0f) { + this.asFrameworkPaint().maskFilter = + BlurMaskFilter(blurPx, BlurMaskFilter.Blur.NORMAL) + } + } + } + + drawBehind { + val spreadPx = spread.toPx() + val offsetXPx = offsetX.toPx() + val offsetYPx = offsetY.toPx() + + val shadowWidth = size.width + spreadPx + val shadowHeight = size.height + spreadPx + + if (shadowWidth <= 0f || shadowHeight <= 0f) return@drawBehind + + val shadowSize = Size(shadowWidth, shadowHeight) + val shadowOutline = shape.createOutline(shadowSize, layoutDirection, this) + + drawIntoCanvas { canvas -> + canvas.save() + canvas.translate(offsetXPx, offsetYPx) + canvas.drawOutline(shadowOutline, paint) + canvas.restore() + } + } +} diff --git a/Near/app/src/main/res/drawable/ic_12_check.xml b/Near/app/src/main/res/drawable/ic_12_check.xml new file mode 100644 index 00000000..b20523bf --- /dev/null +++ b/Near/app/src/main/res/drawable/ic_12_check.xml @@ -0,0 +1,11 @@ + + + diff --git a/Near/app/src/main/res/drawable/ic_32_bell.xml b/Near/app/src/main/res/drawable/ic_32_bell.xml new file mode 100644 index 00000000..93197e08 --- /dev/null +++ b/Near/app/src/main/res/drawable/ic_32_bell.xml @@ -0,0 +1,12 @@ + + + + diff --git a/Near/app/src/main/res/drawable/ic_32_bell_dot.xml b/Near/app/src/main/res/drawable/ic_32_bell_dot.xml new file mode 100644 index 00000000..86d03379 --- /dev/null +++ b/Near/app/src/main/res/drawable/ic_32_bell_dot.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/Near/app/src/main/res/drawable/ic_32_menu.xml b/Near/app/src/main/res/drawable/ic_32_menu.xml new file mode 100644 index 00000000..29acf0a9 --- /dev/null +++ b/Near/app/src/main/res/drawable/ic_32_menu.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/Near/app/src/main/res/drawable/ic_64_adduser.xml b/Near/app/src/main/res/drawable/ic_64_adduser.xml new file mode 100644 index 00000000..ff818107 --- /dev/null +++ b/Near/app/src/main/res/drawable/ic_64_adduser.xml @@ -0,0 +1,13 @@ + + + + diff --git a/Near/app/src/main/res/drawable/ic_visual_24_emoji_0.xml b/Near/app/src/main/res/drawable/ic_visual_24_emoji_0.xml new file mode 100644 index 00000000..220ca0ef --- /dev/null +++ b/Near/app/src/main/res/drawable/ic_visual_24_emoji_0.xml @@ -0,0 +1,39 @@ + + + + + + + + diff --git a/Near/app/src/main/res/drawable/ic_visual_24_emoji_100.xml b/Near/app/src/main/res/drawable/ic_visual_24_emoji_100.xml new file mode 100644 index 00000000..84e4e4c2 --- /dev/null +++ b/Near/app/src/main/res/drawable/ic_visual_24_emoji_100.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/Near/app/src/main/res/drawable/ic_visual_24_emoji_50.xml b/Near/app/src/main/res/drawable/ic_visual_24_emoji_50.xml new file mode 100644 index 00000000..6c52c4db --- /dev/null +++ b/Near/app/src/main/res/drawable/ic_visual_24_emoji_50.xml @@ -0,0 +1,21 @@ + + + + + + diff --git a/Near/app/src/main/res/drawable/icon_visual_24_heart.xml b/Near/app/src/main/res/drawable/icon_visual_24_heart.xml new file mode 100644 index 00000000..9e97ba59 --- /dev/null +++ b/Near/app/src/main/res/drawable/icon_visual_24_heart.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/Near/app/src/main/res/drawable/icon_visual_cake.xml b/Near/app/src/main/res/drawable/icon_visual_cake.xml new file mode 100644 index 00000000..218bbe1c --- /dev/null +++ b/Near/app/src/main/res/drawable/icon_visual_cake.xml @@ -0,0 +1,22 @@ + + + + + + + + + diff --git a/Near/app/src/main/res/drawable/icon_visual_mail.xml b/Near/app/src/main/res/drawable/icon_visual_mail.xml new file mode 100644 index 00000000..cdacae79 --- /dev/null +++ b/Near/app/src/main/res/drawable/icon_visual_mail.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/Near/app/src/main/res/drawable/img_64_user1.xml b/Near/app/src/main/res/drawable/img_64_user1.xml new file mode 100644 index 00000000..db542c8d --- /dev/null +++ b/Near/app/src/main/res/drawable/img_64_user1.xml @@ -0,0 +1,24 @@ + + + + + + + + + diff --git a/Near/app/src/main/res/drawable/img_bg.png b/Near/app/src/main/res/drawable/img_bg.png new file mode 100644 index 00000000..ab87b697 Binary files /dev/null and b/Near/app/src/main/res/drawable/img_bg.png differ diff --git a/Near/app/src/main/res/values/strings.xml b/Near/app/src/main/res/values/strings.xml index deaa8adc..833a5f50 100644 --- a/Near/app/src/main/res/values/strings.xml +++ b/Near/app/src/main/res/values/strings.xml @@ -2,4 +2,14 @@ Near 체크 체크 해제 + + + MY + 이번달 챙길 사람 + 이번달은 챙길 사람이 없네요. + 내 사람들 + 내 사람들 설정 + 연락처 추가 + 가까워 지고 싶은 사람을\n추가해보세요. + 사람 추가