diff --git a/app/src/main/java/com/doyoonkim/knutice/MainActivity.kt b/app/src/main/java/com/doyoonkim/knutice/MainActivity.kt index a09cf3c5..d9e20ff3 100644 --- a/app/src/main/java/com/doyoonkim/knutice/MainActivity.kt +++ b/app/src/main/java/com/doyoonkim/knutice/MainActivity.kt @@ -50,6 +50,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog import androidx.core.view.WindowCompat import androidx.lifecycle.ViewModelProvider import androidx.navigation.NavHostController @@ -271,12 +272,11 @@ class MainActivity : ComponentActivity() { } if (showPermissionRationale) { - Box( - modifier = Modifier.fillMaxSize() - .clickable { /* CLICK TO DISMISS NOT ALLOWED */ } + Dialog( + onDismissRequest = { /* DO NOTHING. PERMISSION IS MANDATORY */ } ) { PermissionRationaleComposable( - modifier = Modifier.align(Alignment.Center).padding(start = 20.dp, end = 20.dp), + modifier = Modifier, permissionName = stringResource(R.string.title_alarm_and_reminder), rationaleTitle = stringResource(R.string.text_rationale_title), description = stringResource(R.string.text_rationale_description) diff --git a/common/build.gradle.kts b/common/build.gradle.kts index 12cf86d8..1f9d93db 100644 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -49,7 +49,7 @@ dependencies { implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.material3) implementation(platform(libs.androidx.compose.bom)) - + debugImplementation(libs.ui.tooling) testImplementation(libs.junit) @@ -62,6 +62,7 @@ dependencies { implementation(libs.dagger) implementation(libs.dagger.android) implementation(libs.dagger.android.support) + debugImplementation(libs.ui.tooling) kapt(libs.dagger.compiler) kapt(libs.dagger.android.processor) diff --git a/common/src/main/java/com/doyoonkim/common/theme/Theme.kt b/common/src/main/java/com/doyoonkim/common/theme/Theme.kt index a3a58736..b4af8e49 100644 --- a/common/src/main/java/com/doyoonkim/common/theme/Theme.kt +++ b/common/src/main/java/com/doyoonkim/common/theme/Theme.kt @@ -63,6 +63,10 @@ val ColorScheme.containerBackgroundSolid: Color @Composable get() = if(isSystemInDarkTheme()) ContainerBlack else ContainerLight +val ColorScheme.containerGray: Color + @Composable + get() = if(isSystemInDarkTheme()) Color.Gray else Color.LightGray + val ColorScheme.bottomNavContainer: Color @Composable get() = if(isSystemInDarkTheme()) bottomNavBarBlack else bottomNavBarWhite diff --git a/common/src/main/java/com/doyoonkim/common/ui/DateTimePicker.kt b/common/src/main/java/com/doyoonkim/common/ui/DateTimePicker.kt index 5c698cd7..f5c8e633 100644 --- a/common/src/main/java/com/doyoonkim/common/ui/DateTimePicker.kt +++ b/common/src/main/java/com/doyoonkim/common/ui/DateTimePicker.kt @@ -1,136 +1,109 @@ package com.doyoonkim.common.ui -import android.util.Log import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog import androidx.compose.material3.DisplayMode import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TimeInput import androidx.compose.material3.rememberDatePickerState -import androidx.compose.material3.rememberTimePickerState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.doyoonkim.common.R +import androidx.compose.ui.unit.sp +import com.doyoonkim.common.theme.containerGray +import com.doyoonkim.common.theme.title import java.text.SimpleDateFormat -import java.time.LocalDate -import java.time.LocalDateTime -import java.time.LocalTime -import java.time.ZoneId import java.util.Calendar -import java.util.Date import java.util.Locale import java.util.TimeZone @OptIn(ExperimentalMaterial3Api::class) @Composable -fun DateTimePicker( +fun DatePickerDialog( modifier: Modifier = Modifier, - initialTime: Long = System.currentTimeMillis(), - onDateTimeConfirmed: (Long?) -> Unit + initialTime: Long? = null, + onDismissed: (Int, Int, Int) -> Unit ) { - val calendar = Calendar.getInstance().also { - it.timeInMillis = initialTime - } + val calendar = Calendar.getInstance(TimeZone.getTimeZone("UTC")) + .apply { + set(Calendar.HOUR_OF_DAY, 0) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + } + val localOffset = TimeZone.getDefault().getOffset(System.currentTimeMillis()) + val datePickerState = rememberDatePickerState( - initialDisplayMode = DisplayMode.Input, - initialSelectedDateMillis = initialTime - ) - val timePickerState = rememberTimePickerState( - initialHour = calendar.get(Calendar.HOUR_OF_DAY), - initialMinute = calendar.get(Calendar.MINUTE), - is24Hour = false + initialDisplayMode = DisplayMode.Picker, + initialSelectedDateMillis = initialTime ?: (calendar.timeInMillis + localOffset) ) - var selectedDateTime by remember { mutableStateOf(null) } - var confirmEnabled by remember { mutableStateOf(false) } - LaunchedEffect( - datePickerState.selectedDateMillis, timePickerState.hour, timePickerState.minute - ) { - if (datePickerState.selectedDateMillis == null) { - confirmEnabled = false - } else { - val target = combineDateTime( - datePickerState.selectedDateMillis!!, - Pair(timePickerState.hour, timePickerState.minute) - ) - confirmEnabled = target!! > (calendar.timeInMillis ?: (target + 1)) - } - } + var pickerVisible by remember { mutableStateOf(false) } - Surface( - modifier = modifier.wrapContentSize().background(Color.Transparent), - color = MaterialTheme.colorScheme.surfaceContainer, - shape = RoundedCornerShape(15.dp) + Box( + modifier = modifier.wrapContentWidth() ) { - Column( - modifier = Modifier.wrapContentSize(), - verticalArrangement = Arrangement.spacedBy(3.dp), - horizontalAlignment = Alignment.CenterHorizontally + Surface( + modifier = Modifier.wrapContentSize() + .background(Color.Transparent) + .clip(RoundedCornerShape(10.dp)) + .clickable { pickerVisible = !pickerVisible }, + color = MaterialTheme.colorScheme.containerGray ) { - DatePicker( - state = datePickerState, - showModeToggle = false + Text( + text = datePickerState.selectedDateMillis!!.toFormattedString(), + fontSize = 15.sp, + textAlign = TextAlign.Center, + fontWeight = FontWeight.Normal, + color = MaterialTheme.colorScheme.title, + modifier = Modifier.padding(10.dp) ) - TimeInput( - state = timePickerState - ) - TextButton( - onClick = { - selectedDateTime = combineDateTime( - datePickerState.selectedDateMillis!!, - Pair(timePickerState.hour, timePickerState.minute) - ) - Log.d("DateTimePicker", "$selectedDateTime") - onDateTimeConfirmed(selectedDateTime) - }, - enabled = confirmEnabled, - colors = ButtonDefaults.textButtonColors() - ) { Text(stringResource(R.string.btn_confirm)) } + } + if (pickerVisible) { + DatePickerDialog( + onDismissRequest = { + datePickerState.selectedDateMillis?.let { + with(getInfo(it.toFormattedString())) { + onDismissed(this[0], this[1], this[2]) + } + }.also { pickerVisible = !pickerVisible } + }, + confirmButton = { } + ) { + DatePicker( + state = datePickerState, + showModeToggle = false + ) + } } } } -private fun Long.toFormattedDate(f: SimpleDateFormat): String { - return f.format(Date(this).time) -} - -private fun String.toMillis(f: SimpleDateFormat): Long? { - return f.parse(this)?.time -} +private fun Long.toFormattedString() = + SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(this) -private fun combineDateTime(d: Long, t: Pair): Long? { - val date = LocalDate.parse(d.toFormattedDate(SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()))) - val time = LocalTime.of(t.first, t.second) - return LocalDateTime.of(date, time).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli() -} - -private fun Long.dropSeconds(): Long? { - val dateFormat = SimpleDateFormat("yyyy/MM/dd a Hm", Locale.getDefault()).apply { - timeZone = TimeZone.getTimeZone(ZoneId.systemDefault()) - } - return this.toFormattedDate(dateFormat).toMillis(dateFormat) -} +private fun getInfo(formattedDate: String) = + formattedDate.split("-").map { it.toInt() } @Preview(showBackground = true) @Composable diff --git a/common/src/main/java/com/doyoonkim/common/ui/TimePickerWheel.kt b/common/src/main/java/com/doyoonkim/common/ui/TimePickerWheel.kt new file mode 100644 index 00000000..8835fa66 --- /dev/null +++ b/common/src/main/java/com/doyoonkim/common/ui/TimePickerWheel.kt @@ -0,0 +1,268 @@ +package com.doyoonkim.common.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +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.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import com.doyoonkim.common.R +import com.doyoonkim.common.theme.containerBackground +import com.doyoonkim.common.theme.containerGray +import com.doyoonkim.common.theme.subTitle +import com.doyoonkim.common.theme.textPurple +import com.doyoonkim.common.theme.title +import kotlinx.coroutines.flow.filter +import java.util.Calendar + +/** + * @author kimdoyoon + * Created 6/20/25 at 9:54 PM + */ + +@Composable +fun TimePickerDialog( + modifier: Modifier = Modifier, + initialTime: Long? = null, + onDismissed: (Int, Int) -> Unit +) { + val calendar = Calendar.getInstance() + .apply { initialTime?.let { timeInMillis = it } } + + val hours = (0..23).map { if (it < 10) "0$it" else it.toString() }.toList() + val minutes = (0..59).map { if (it< 10) "0$it" else it.toString() }.toList() + var hourSelected by remember { + mutableIntStateOf(calendar[Calendar.HOUR_OF_DAY]) + } + var minuteSelected by remember { + mutableIntStateOf(calendar[Calendar.MINUTE]) + } + + var pickerVisible by remember { mutableStateOf(false) } + + Box( + modifier = modifier.wrapContentSize() + ) { + Surface( + modifier = Modifier.wrapContentSize() + .background(Color.Transparent) + .clip(RoundedCornerShape(10.dp)) + .clickable { pickerVisible = !pickerVisible }, + color = MaterialTheme.colorScheme.containerGray + ) { + Text( + text = "${hours[hourSelected]}:${minutes[minuteSelected]}", + fontSize = 15.sp, + textAlign = TextAlign.Center, + fontWeight = FontWeight.Normal, + color = MaterialTheme.colorScheme.title, + modifier = Modifier.padding(PaddingValues( + horizontal = 20.dp, + vertical = 10.dp + )) + ) + } + + if (pickerVisible) { + Dialog( + onDismissRequest = { + onDismissed(hourSelected, minuteSelected) + pickerVisible = !pickerVisible + } + ) { + Surface( + modifier = Modifier + .clip(RoundedCornerShape(10.dp)) + .background(Color.Transparent), + color = MaterialTheme.colorScheme.containerBackground + ) { + Column( + modifier = Modifier.wrapContentSize() + .padding(10.dp) + ) { + Text( + text = stringResource(R.string.text_select_time), + fontSize = 15.sp, + fontWeight = FontWeight.Normal, + textAlign = TextAlign.Start, + color = MaterialTheme.colorScheme.title, + modifier = Modifier.padding(15.dp) + ) + Row( + modifier = Modifier.wrapContentWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + WheelPicker( + modifier = Modifier.background(Color.Transparent) + .padding(start = 35.dp, end = 35.dp), + elements = hours, + initialItemIndex = hourSelected, + ) { + hourSelected = it + } + + Text( + text = ":", + textAlign = TextAlign.Center, + fontWeight = FontWeight.SemiBold, + fontSize = 23.sp, + color = MaterialTheme.colorScheme.title, + modifier = Modifier.wrapContentHeight(Alignment.CenterVertically) + ) + + WheelPicker( + modifier = Modifier.background(Color.Transparent) + .padding(start = 35.dp, end = 35.dp), + elements = minutes, + initialItemIndex = minuteSelected + ) { + minuteSelected = it + } + } + Spacer(Modifier.height(30.dp)) + } + } + } + + } + } +} + +@Composable +internal fun WheelPicker( + modifier: Modifier = Modifier, + elements: List, + initialItemIndex: Int = 0, + numberOfVisibleItem: Int = 3, + onSelected: (Int) -> Unit +) { + // List State + val listState = rememberLazyListState( + initialFirstVisibleItemIndex = initialItemIndex + ) + // Selection State + var selectedItem by remember { mutableIntStateOf(initialItemIndex) } + + val columnHeight = numberOfVisibleItem * 40 + val spacerCount = numberOfVisibleItem / 2 + val itemHeightPx = with(LocalDensity.current) { 40.dp.toPx() } + + LaunchedEffect(listState) { + snapshotFlow { listState.isScrollInProgress } + .filter { isScrolling -> + // Filter status in order to emit value when isScrollInProgress finishes scrolling. + !isScrolling + }.collect { + val firstVisibleItem = listState.firstVisibleItemIndex + val offset = listState.firstVisibleItemScrollOffset + + // Determine items to be selected, when scroll stops, and two elements takes the center area. + val offsetFraction = offset / itemHeightPx + // When two elements takes the center area, and element on bottom takes more than half of the center area, take bottom element as selected. + val adjusted = if (offsetFraction > 0.5) 1 else 0 + val indexInList = firstVisibleItem + adjusted - spacerCount + // Prevent Unexpected IndexOutOfBoundsException + val clampedIndex = (indexInList + 1).coerceIn(0, elements.lastIndex) + + selectedItem = clampedIndex + } + } + + LaunchedEffect(selectedItem) { + listState.animateScrollToItem(selectedItem) + onSelected(selectedItem) + } + + LaunchedEffect(initialItemIndex) { + listState.animateScrollToItem(initialItemIndex) + } + + LazyColumn( + modifier = modifier + .height(columnHeight.dp), + state = listState, + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Spacers to be inserted to top and bottom, to make very first and very last element + // possible to be shown on center. + items(spacerCount) { Spacer(Modifier.height(40.dp)) } + + itemsIndexed(elements) { index, item -> + WheelPickerItem( + content = item, + isHighlighted = index == selectedItem + ) + } + + items(spacerCount) { Spacer(Modifier.height(40.dp)) } + } +} + +@Composable +internal fun WheelPickerItem( + content: String, + isHighlighted: Boolean = false +) { + Text( + text = content, + fontStyle = FontStyle.Normal, + fontWeight = FontWeight.SemiBold, + fontSize = 25.sp, + textAlign = TextAlign.Center, + color = if (isHighlighted) { + MaterialTheme.colorScheme.textPurple + } else { + MaterialTheme.colorScheme.subTitle + }, + modifier = Modifier.height(40.dp) + .wrapContentHeight(Alignment.CenterVertically) + .background(Color.Transparent) + ) +} + + +@Preview(showBackground = true, + uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL +) +@Composable +fun TimePickerWheel_Preview() { + +} diff --git a/common/src/main/res/values-ja/strings.xml b/common/src/main/res/values-ja/strings.xml index f3dc24c7..3dc69e5b 100644 --- a/common/src/main/res/values-ja/strings.xml +++ b/common/src/main/res/values-ja/strings.xml @@ -65,4 +65,9 @@ 確認不可能 リマインダーを設定することは出来ません スマホ設定の「アラームとリマインダー」を確認してください + 新しい + お知らせがあります + 就職 + 時間設定 + 時間設定 \ No newline at end of file diff --git a/common/src/main/res/values-ko-rKR/strings.xml b/common/src/main/res/values-ko-rKR/strings.xml index 60c6ba1e..17329c24 100644 --- a/common/src/main/res/values-ko-rKR/strings.xml +++ b/common/src/main/res/values-ko-rKR/strings.xml @@ -65,4 +65,9 @@ 저장하는 중 문제가 생겼어요. 현재 리마인더를 설정할 수 없어요. 설정의 알람 및 리마인더 메뉴에서 권한을 확인 해 주세요. + 새로운 + 알림이 도착했어요 + 취업 + 시간 선택 + 알림 시간 \ No newline at end of file diff --git a/common/src/main/res/values/strings.xml b/common/src/main/res/values/strings.xml index db0f4777..74859db4 100644 --- a/common/src/main/res/values/strings.xml +++ b/common/src/main/res/values/strings.xml @@ -75,4 +75,9 @@ Unable to save. Reminder is not available at this time. Please check permission status under Alarm and Reminder at Settings + New + notice has been delivered + Career + Select Time + Select time for reminder \ No newline at end of file diff --git a/core/notification/src/main/java/com/doyoonkim/notification/fcm/PushNotificationHandler.kt b/core/notification/src/main/java/com/doyoonkim/notification/fcm/PushNotificationHandler.kt index f1770ecc..076b584a 100644 --- a/core/notification/src/main/java/com/doyoonkim/notification/fcm/PushNotificationHandler.kt +++ b/core/notification/src/main/java/com/doyoonkim/notification/fcm/PushNotificationHandler.kt @@ -16,6 +16,7 @@ import androidx.core.net.toUri import com.doyoonkim.common.BitmapHandler import com.doyoonkim.common.R import com.doyoonkim.domain.ImageRepository +import com.doyoonkim.model.NoticeCategory import com.google.firebase.messaging.RemoteMessage import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -55,12 +56,14 @@ class PushNotificationHandler @Inject constructor( private fun RemoteMessage.toPushNotification() { // Create Pending Intent (For access push notification while the app is in foreground) - // TODO: Migrate to Deep Link. val nttId = this@toPushNotification.data["nttId"] val url = this@toPushNotification.data["contentUrl"] val imageUrl = this@toPushNotification.data["contentImage"] val fabVisible = true + val noticeCategory = this@toPushNotification.data["noticeName"] ?: "" + val contentText = this@toPushNotification.data["contentTitle"] ?: "No title found." + // Deeplink featured by Jetpack Navigation won't work because notification payload consumes custom-defined deeplink intent using ACTION_VIEW val deeplinkIntent = Intent( Intent.ACTION_VIEW, @@ -82,9 +85,8 @@ class PushNotificationHandler @Inject constructor( context, context.getString(R.string.inapp_notification_channel_id) ).apply { setSmallIcon(R.mipmap.ic_launcher) - setLargeIcon(Icon.createWithResource(context, R.mipmap.ic_launcher)) - setContentTitle(context.getString(R.string.new_notice)) - setContentText(this@toPushNotification.data["contentTitle"] ?: "No message body.") + setContentTitle(localizedTitle(noticeCategory)) + setContentText(contentText) setContentIntent(pendingIntent) setPriority(NotificationCompat.PRIORITY_DEFAULT) setAutoCancel(true) @@ -146,4 +148,20 @@ class PushNotificationHandler @Inject constructor( } } } + + private fun localizedTitle(noticeCategory: String): String { + return with(context) { + when (noticeCategory) { + "일반소식" -> getString(R.string.general_news) + "학사공지사항" -> getString(R.string.academic_news) + "장학안내" -> getString(R.string.scholarship_news) + "행사안내" -> getString(R.string.event_news) + NoticeCategory.JOB_NEWS.name -> getString(R.string.job_news) + else -> null + }?.let { + "${getString(R.string.push_title_new)} " + + "$it ${getString(R.string.push_title_arrived)}" + } ?: "${getString(R.string.push_title_new)} ${getString(R.string.push_title_arrived)}" + } + } } \ No newline at end of file diff --git a/feature/bookmark/src/main/java/com/doyoonkim/bookmark/edit/EditBookmarkScreen.kt b/feature/bookmark/src/main/java/com/doyoonkim/bookmark/edit/EditBookmarkScreen.kt index 19c92a27..cca25633 100644 --- a/feature/bookmark/src/main/java/com/doyoonkim/bookmark/edit/EditBookmarkScreen.kt +++ b/feature/bookmark/src/main/java/com/doyoonkim/bookmark/edit/EditBookmarkScreen.kt @@ -1,16 +1,15 @@ package com.doyoonkim.bookmark.edit -import android.util.Log import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.clickable 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 @@ -35,10 +34,11 @@ import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Surface import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TextField @@ -50,7 +50,6 @@ 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.draw.shadow import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -61,20 +60,18 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.doyoonkim.bookmark.viewmodel.EditBookmarkViewModel import com.doyoonkim.common.navigation.BookmarkInfo import com.doyoonkim.common.navigation.NoticeDetail -import com.doyoonkim.common.ui.DateTimePicker import com.doyoonkim.common.R import com.doyoonkim.common.theme.buttonPurple import com.doyoonkim.common.theme.containerBackground +import com.doyoonkim.common.theme.containerGray import com.doyoonkim.common.theme.subTitle +import com.doyoonkim.common.theme.textPurple import com.doyoonkim.common.theme.title +import com.doyoonkim.common.ui.DatePickerDialog import com.doyoonkim.common.ui.NotificationPreviewCard +import com.doyoonkim.common.ui.TimePickerDialog import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import java.text.SimpleDateFormat -import java.time.ZoneId -import java.util.Date -import java.util.Locale -import java.util.TimeZone @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -101,11 +98,6 @@ fun EditBookmarkScreen( } } - LaunchedEffect(uiState.isReminderRequested) { - if (uiState.timeForRemind == 0L) - viewModel.updateReminderOptions(timeForRemind = System.currentTimeMillis()) - } - Column( modifier = modifier.fillMaxSize() .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Bottom)) @@ -131,17 +123,15 @@ fun EditBookmarkScreen( Column( modifier = Modifier.fillMaxWidth().wrapContentHeight() - .background(Color.Transparent), + .background(Color.Transparent) + .clip(RoundedCornerShape(10.dp)) + .border(2.dp, MaterialTheme.colorScheme.containerBackground) + .padding(start = 10.dp, end = 10.dp), verticalArrangement = Arrangement.SpaceEvenly, horizontalAlignment = Alignment.CenterHorizontally ) { - Row( - modifier = Modifier.fillMaxWidth().wrapContentHeight() - .background(Color.Transparent) - .clip(RoundedCornerShape(10.dp)) - .border(2.dp, MaterialTheme.colorScheme.containerBackground) - .padding(start = 10.dp, end = 10.dp), + modifier = Modifier.fillMaxWidth().wrapContentHeight(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center ) { @@ -158,49 +148,56 @@ fun EditBookmarkScreen( checked = uiState.isReminderRequested && uiState.alarmPermissionStatus, enabled = true, modifier = Modifier.padding(10.dp).weight(1f), + colors = SwitchDefaults.colors().copy( + checkedTrackColor = MaterialTheme.colorScheme.buttonPurple, + checkedThumbColor = Color.White + ), onCheckedChange = { viewModel.updateReminderOptions(requested = !uiState.isReminderRequested) } ) } + if (uiState.isReminderRequested) { + HorizontalDivider( + modifier = Modifier + .fillMaxWidth() + .padding(PaddingValues(horizontal = 5.dp)), + color = MaterialTheme.colorScheme.containerGray + ) + } + AnimatedVisibility( modifier = Modifier.fillMaxWidth().wrapContentHeight(), visible = uiState.isReminderRequested, enter = slideInVertically(), exit = slideOutVertically() ) { - if (!uiState.alarmPermissionStatus) { + + Row( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .padding(PaddingValues(vertical = 15.dp)), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(3.dp) + ) { Text( - modifier = Modifier.fillMaxWidth() - .wrapContentHeight(), - text = stringResource(R.string.text_schedule_alarm_unavailable_title) - + "\n" - + stringResource(R.string.text_schedule_alarm_unavailable_content), + text = stringResource(R.string.text_set_reminder_date_time), textAlign = TextAlign.Start, - fontSize = 12.sp, - color = Color.Red + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.title, + modifier = Modifier.padding(10.dp).weight(2f) ) - } else { - Surface( - modifier = Modifier.wrapContentSize() - .border( - 2.dp, MaterialTheme.colorScheme.containerBackground, RoundedCornerShape(10.dp) - ) - .background(Color.Transparent), - color = Color.Transparent - ) { - Text( - modifier = Modifier.fillMaxWidth().padding(20.dp) - .clickable { viewModel.updateReminderOptions( - isDatePickerVisible = !uiState.datePickerVisible - ) }, - text = uiState.timeForRemind.toFormattedDate( - SimpleDateFormat("yyyy/MM/dd a HH:mm", Locale.getDefault()) - ), - textAlign = TextAlign.Start, - fontWeight = FontWeight.Normal, - fontSize = 15.sp, - color = MaterialTheme.colorScheme.title - ) + + DatePickerDialog( + initialTime = uiState.timeForRemind + ) { year, month, day -> + viewModel.updateDateInfo(year, month, day) + } + TimePickerDialog( + initialTime = uiState.timeForRemind + ) { hour, min -> + viewModel.updateTimeInfo(hour, min) } } } @@ -309,35 +306,6 @@ fun EditBookmarkScreen( } } - // DateTimePicker - AnimatedVisibility( - modifier = Modifier.fillMaxWidth().wrapContentHeight().imePadding(), - visible = uiState.datePickerVisible, - enter = slideInVertically(initialOffsetY = { it + it / 2 }), - exit = slideOutVertically(targetOffsetY = { it / 2 }) - ) { - Box( - modifier = Modifier.fillMaxSize().padding(start = 5.dp, end = 5.dp) - .clickable { viewModel.updateReminderOptions( - isDatePickerVisible = !uiState.datePickerVisible) - } - ) { - DateTimePicker( - modifier = Modifier.padding(5.dp) - .shadow(5.dp) - .align(Alignment.BottomCenter) - ) { - if (it != null) { - Log.d("EditBookmark", "${it}") - viewModel.updateReminderOptions( - timeForRemind = it, - isDatePickerVisible = !uiState.datePickerVisible - ) - } - } - } - } - if (uiState.isCompleted) { BasicAlertDialog( onDismissRequest = { @@ -350,7 +318,8 @@ fun EditBookmarkScreen( Surface( modifier = Modifier.wrapContentWidth().wrapContentHeight(), shape = MaterialTheme.shapes.large, - tonalElevation = AlertDialogDefaults.TonalElevation + tonalElevation = AlertDialogDefaults.TonalElevation, + color = MaterialTheme.colorScheme.containerBackground ) { Column(modifier = Modifier.padding(30.dp)) { Text( @@ -368,14 +337,13 @@ fun EditBookmarkScreen( }, modifier = Modifier.align(Alignment.End) ) { - Text(stringResource(R.string.btn_confirm)) + Text( + stringResource(R.string.btn_confirm), + color = MaterialTheme.colorScheme.textPurple + ) } } } } } -} - -private fun Long.toFormattedDate(f: SimpleDateFormat): String { - return f.apply { timeZone = TimeZone.getTimeZone(ZoneId.systemDefault()) }.format(Date(this)) } \ No newline at end of file diff --git a/feature/bookmark/src/main/java/com/doyoonkim/bookmark/list/BookmarkListScreen.kt b/feature/bookmark/src/main/java/com/doyoonkim/bookmark/list/BookmarkListScreen.kt index 529b8b87..6d277925 100644 --- a/feature/bookmark/src/main/java/com/doyoonkim/bookmark/list/BookmarkListScreen.kt +++ b/feature/bookmark/src/main/java/com/doyoonkim/bookmark/list/BookmarkListScreen.kt @@ -11,7 +11,6 @@ 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.systemBarsPadding import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -35,7 +34,6 @@ import com.doyoonkim.common.theme.containerBackgroundSolid import com.doyoonkim.common.R import com.doyoonkim.common.theme.subTitle import com.doyoonkim.common.ui.NotificationPreviewCardMarked -import kotlinx.coroutines.delay @Composable fun BookmarkListScreen( @@ -56,7 +54,6 @@ fun BookmarkListScreen( Box( modifier = modifier.fillMaxSize() .background(MaterialTheme.colorScheme.containerBackgroundSolid) - .systemBarsPadding() ) { if (uiState.bookmarks.isEmpty()) { Column( diff --git a/feature/bookmark/src/main/java/com/doyoonkim/bookmark/viewmodel/EditBookmarkViewModel.kt b/feature/bookmark/src/main/java/com/doyoonkim/bookmark/viewmodel/EditBookmarkViewModel.kt index b391ad90..02fb9b43 100644 --- a/feature/bookmark/src/main/java/com/doyoonkim/bookmark/viewmodel/EditBookmarkViewModel.kt +++ b/feature/bookmark/src/main/java/com/doyoonkim/bookmark/viewmodel/EditBookmarkViewModel.kt @@ -1,7 +1,6 @@ package com.doyoonkim.bookmark.viewmodel import android.annotation.SuppressLint -import androidx.annotation.RequiresPermission import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.doyoonkim.common.navigation.BookmarkInfo @@ -11,7 +10,6 @@ import com.doyoonkim.model.BookmarkVO import com.doyoonkim.model.NoticeVO import com.doyoonkim.notification.local.NotificationAlarmScheduler import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest @@ -19,6 +17,7 @@ import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout +import java.util.Calendar import javax.inject.Inject class EditBookmarkViewModel @Inject constructor( @@ -31,6 +30,7 @@ class EditBookmarkViewModel @Inject constructor( val uiState = _uiState.asStateFlow() private var bookmarkNav: BookmarkInfo? = null + private val calendar = Calendar.getInstance() // Functions should be called during initialization init { @@ -58,6 +58,8 @@ class EditBookmarkViewModel @Inject constructor( requireCreation = false, bookmarkInstances = result ) + }.also { + calendar.timeInMillis = result.reminderSchedule } } }.runCatching { @@ -115,14 +117,10 @@ class EditBookmarkViewModel @Inject constructor( fun updateReminderOptions( requested: Boolean = uiState.value.isReminderRequested, - timeForRemind: Long = uiState.value.timeForRemind, - isDatePickerVisible: Boolean = uiState.value.datePickerVisible ) { _uiState.update { it.copy( - isReminderRequested = requested, - timeForRemind = timeForRemind, - datePickerVisible = isDatePickerVisible + isReminderRequested = requested ) } } @@ -137,6 +135,27 @@ class EditBookmarkViewModel @Inject constructor( } } + fun updateDateInfo( + year: Int, + month: Int, + date: Int + ) { + calendar.set(Calendar.YEAR, year) + calendar.set(Calendar.MONTH, month - 1) + calendar.set(Calendar.DATE, date) + } + + fun updateTimeInfo( + hour: Int, + minute: Int + ) { + calendar.set(Calendar.HOUR_OF_DAY, hour) + calendar.set(Calendar.MINUTE, minute) + calendar.set(Calendar.SECOND, 0) + } + + + @SuppressLint("android.permission.SCHEDULE_EXACT_ALARM") fun submitBookmark() = viewModelScope.launch { @@ -147,7 +166,7 @@ class EditBookmarkViewModel @Inject constructor( BookmarkVO( targetNoticeNttId = targetNoticeId, isScheduled = isReminderRequested, - reminderSchedule = timeForRemind, + reminderSchedule = calendar.timeInMillis, bookmarkNote = bookmarkNote ) } else { @@ -156,7 +175,7 @@ class EditBookmarkViewModel @Inject constructor( bookmarkId = bookmarkId, targetNoticeNttId = targetNoticeId, isScheduled = isReminderRequested, - reminderSchedule = timeForRemind, + reminderSchedule = calendar.timeInMillis, bookmarkNote = bookmarkNote ) } @@ -189,7 +208,7 @@ class EditBookmarkViewModel @Inject constructor( bookmarkId = bookmarkId, targetNoticeNttId = targetNoticeId, isScheduled = isReminderRequested, - reminderSchedule = timeForRemind, + reminderSchedule = timeForRemind ?: calendar.timeInMillis, bookmarkNote = bookmarkNote ) } @@ -230,12 +249,13 @@ data class EditBookmarkState( val bookmarkId: Int = 0, val targetNoticeId: Int = 0, val isReminderRequested: Boolean = false, - val timeForRemind: Long = 0, + val timeForRemind: Long? = null, val bookmarkNote: String = "", val requireCreation: Boolean = true, val bookmarkInstances: BookmarkVO? = null, val targetNotice: NoticeVO? = null, val datePickerVisible: Boolean = false, + val timePickerVisible: Boolean = false, val isSuccessful: Boolean = false, val isCompleted: Boolean = false, val alarmPermissionStatus: Boolean = true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b980c224..356a2d5e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -33,6 +33,7 @@ material = "1.12.0" dagger = "2.56.2" splashScreen = "1.0.1" jetbrainsKotlinJvm = "2.1.20" +uiTooling = "1.8.3" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -76,6 +77,7 @@ dagger-compiler = { module = "com.google.dagger:dagger-compiler", version.ref = dagger-android = { module = "com.google.dagger:dagger-android", version.ref = "dagger" } dagger-android-support = { module = "com.google.dagger:dagger-android-support", version.ref = "dagger" } dagger-android-processor = { module = "com.google.dagger:dagger-android-processor", version.ref = "dagger" } +ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version.ref = "uiTooling" } [plugins]