From 88164425468ca7150698cc888cdb10611f3df115 Mon Sep 17 00:00:00 2001 From: Doyoon Kim Date: Fri, 20 Jun 2025 20:50:48 +0900 Subject: [PATCH 01/15] [CHORE] Add missing dependency - Add missing dependency (UI Tooling for Jetpack Compose) --- common/build.gradle.kts | 1 + gradle/libs.versions.toml | 2 ++ 2 files changed, 3 insertions(+) diff --git a/common/build.gradle.kts b/common/build.gradle.kts index 12cf86d8..27bbfb25 100644 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -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/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] From b833873e96a37cf04fcff6f13101638fbe8e9bd2 Mon Sep 17 00:00:00 2001 From: Doyoon Kim Date: Fri, 20 Jun 2025 21:18:17 +0900 Subject: [PATCH 02/15] [REFACTOR] Remove Duplicated icon from the Push Notification - Remove duplicated icon from the Push notification sent via FCM (Remove optional Large Icon) --- .../com/doyoonkim/notification/fcm/PushNotificationHandler.kt | 1 - 1 file changed, 1 deletion(-) 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..e34cd61b 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 @@ -82,7 +82,6 @@ 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.") setContentIntent(pendingIntent) From e63b06c907f0c5087e20e374d20b1d5f9aba3fbc Mon Sep 17 00:00:00 2001 From: Doyoon Kim Date: Fri, 20 Jun 2025 21:44:07 +0900 Subject: [PATCH 03/15] [CHORE] Add Necessary String Resources for Localization - Add necessary string resources for localizing title text to be shown over Push Notification. --- common/src/main/res/values-ja/strings.xml | 3 +++ common/src/main/res/values-ko-rKR/strings.xml | 3 +++ common/src/main/res/values/strings.xml | 3 +++ 3 files changed, 9 insertions(+) diff --git a/common/src/main/res/values-ja/strings.xml b/common/src/main/res/values-ja/strings.xml index f3dc24c7..ae3d967d 100644 --- a/common/src/main/res/values-ja/strings.xml +++ b/common/src/main/res/values-ja/strings.xml @@ -65,4 +65,7 @@ 確認不可能 リマインダーを設定することは出来ません スマホ設定の「アラームとリマインダー」を確認してください + 新しい + お知らせがあります + 就職 \ 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..9ad52efe 100644 --- a/common/src/main/res/values-ko-rKR/strings.xml +++ b/common/src/main/res/values-ko-rKR/strings.xml @@ -65,4 +65,7 @@ 저장하는 중 문제가 생겼어요. 현재 리마인더를 설정할 수 없어요. 설정의 알람 및 리마인더 메뉴에서 권한을 확인 해 주세요. + 새로운 + 알림이 도착했어요 + 취업 \ 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..e5e324a5 100644 --- a/common/src/main/res/values/strings.xml +++ b/common/src/main/res/values/strings.xml @@ -75,4 +75,7 @@ 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 \ No newline at end of file From 6f61a5ecca27326b7c9b3008ea7f9fed9ab5f5ec Mon Sep 17 00:00:00 2001 From: Doyoon Kim Date: Fri, 20 Jun 2025 21:46:22 +0900 Subject: [PATCH 04/15] [REFACTOR] Change Format of Title Text to be shown on Push Notification. - Change the format of title text on the Push notification, alongside with a proper localization. --- .../fcm/PushNotificationHandler.kt | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) 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 e34cd61b..1ed80202 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,8 +85,8 @@ class PushNotificationHandler @Inject constructor( context, context.getString(R.string.inapp_notification_channel_id) ).apply { setSmallIcon(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) @@ -145,4 +148,20 @@ class PushNotificationHandler @Inject constructor( } } } + + private fun localizedTitle(noticeCategory: String): String { + return with(context) { + when (noticeCategory) { + NoticeCategory.GENERAL_NEWS.name -> getString(R.string.general_news) + NoticeCategory.ACADEMIC_NEWS.name -> getString(R.string.academic_news) + NoticeCategory.SCHOLARSHIP_NEWS.name -> getString(R.string.scholarship_news) + NoticeCategory.EVENT_NEWS.name -> 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 From 9e171fa4e60b5b6bb53f4e64ab6ff10534d39a8b Mon Sep 17 00:00:00 2001 From: Doyoon Kim Date: Sat, 21 Jun 2025 00:55:51 +0900 Subject: [PATCH 05/15] [FEAT] TimePicker Composable - Custom-defined WheelPicker based TimePicker Composable. --- common/build.gradle.kts | 2 +- .../doyoonkim/common/ui/TimePickerWheel.kt | 292 ++++++++++++++++++ 2 files changed, 293 insertions(+), 1 deletion(-) create mode 100644 common/src/main/java/com/doyoonkim/common/ui/TimePickerWheel.kt diff --git a/common/build.gradle.kts b/common/build.gradle.kts index 27bbfb25..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) 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..87fce43c --- /dev/null +++ b/common/src/main/java/com/doyoonkim/common/ui/TimePickerWheel.kt @@ -0,0 +1,292 @@ +package com.doyoonkim.common.ui + +import android.content.res.Configuration +import android.util.Log +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.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +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.width +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.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +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.rememberDatePickerState +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.platform.LocalWindowInfo +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 androidx.compose.ui.window.DialogProperties +import com.doyoonkim.common.R +import com.doyoonkim.common.theme.containerBackground +import com.doyoonkim.common.theme.subTitle +import com.doyoonkim.common.theme.textPurple +import com.doyoonkim.common.theme.title +import kotlinx.coroutines.flow.filter + +/** + * @author kimdoyoon + * Created 6/20/25 at 9:54 PM + */ + +@Composable +fun TimePickerWheel( + modifier: Modifier, + initialHourSelected: Int, + initialMinuteSelected: Int, + onSelected: (String) -> Unit +) { + 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(initialHourSelected.coerceIn(0, 23)) } + var minuteSelected by remember { mutableIntStateOf(initialMinuteSelected.coerceIn(0, 59)) } + + Surface( + modifier = modifier + .clip(RoundedCornerShape(10.dp)) + .background(Color.Transparent), + color = MaterialTheme.colorScheme.containerBackground + ) { + Column( + modifier = Modifier.wrapContentSize() + .padding(10.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 = initialHourSelected, + ) { + + } + + 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 = initialMinuteSelected + ) { + + } + } + } + } +} + +@Composable +internal fun WheelPicker( + modifier: Modifier = Modifier, + elements: List, + initialItemIndex: Int = 0, + numberOfVisibleItem: Int = 3, + onSelected: (String) -> 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(elements[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(30.dp)) } + + itemsIndexed(elements) { index, item -> + WheelPickerItem( + content = item, + isHighlighted = index == selectedItem + ) + } + + items(spacerCount) { Spacer(Modifier.height(30.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) + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(showBackground = true, + uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL +) +@Composable +fun TimePickerWheel_Preview() { + /* + var state by remember { mutableStateOf(false) } + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.End + ) { + Column( + modifier = Modifier.wrapContentHeight() + .padding(end = 20.dp), + verticalArrangement = Arrangement.SpaceEvenly, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Date", + modifier = Modifier.clickable { + state = !state + } + ) + + if (state) { + DatePickerDialog( + onDismissRequest = { }, + confirmButton = { + TextButton( + onClick = { } + ) { + Text("Confirm") + } + } + ) { + + + DatePicker(state = rememberDatePickerState()) + } + } + } + } + */ + + Box( + modifier = Modifier.fillMaxSize() + ) { + val density = LocalDensity.current + val window = LocalWindowInfo.current + + val height = with(window) { this.containerSize / 7 }.height + val width = with(window) { this.containerSize / 3 }.width + + Dialog( + onDismissRequest = { }, + properties = DialogProperties( + usePlatformDefaultWidth = true + ) + ) { + TimePickerWheel( + modifier = Modifier.width(width.dp).height(height.dp), + initialHourSelected = 13, + initialMinuteSelected = 20 + ) { } + } + } + + + + +} \ No newline at end of file From 87f95042af6d5a714a10d40293cb83942a0cd02e Mon Sep 17 00:00:00 2001 From: Doyoon Kim Date: Sun, 22 Jun 2025 17:58:23 +0900 Subject: [PATCH 06/15] [CHORE] Add necessary resources - Add necessary string resources for localization - Add Theme value for Gray-based container color. --- common/src/main/java/com/doyoonkim/common/theme/Theme.kt | 4 ++++ common/src/main/res/values-ja/strings.xml | 2 ++ common/src/main/res/values-ko-rKR/strings.xml | 2 ++ common/src/main/res/values/strings.xml | 2 ++ 4 files changed, 10 insertions(+) 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/res/values-ja/strings.xml b/common/src/main/res/values-ja/strings.xml index ae3d967d..3dc69e5b 100644 --- a/common/src/main/res/values-ja/strings.xml +++ b/common/src/main/res/values-ja/strings.xml @@ -68,4 +68,6 @@ 新しい お知らせがあります 就職 + 時間設定 + 時間設定 \ 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 9ad52efe..17329c24 100644 --- a/common/src/main/res/values-ko-rKR/strings.xml +++ b/common/src/main/res/values-ko-rKR/strings.xml @@ -68,4 +68,6 @@ 새로운 알림이 도착했어요 취업 + 시간 선택 + 알림 시간 \ 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 e5e324a5..74859db4 100644 --- a/common/src/main/res/values/strings.xml +++ b/common/src/main/res/values/strings.xml @@ -78,4 +78,6 @@ New notice has been delivered Career + Select Time + Select time for reminder \ No newline at end of file From 30f7017dea5496cd063a408841ca8a9b1fc43dc5 Mon Sep 17 00:00:00 2001 From: Doyoon Kim Date: Sun, 22 Jun 2025 18:00:03 +0900 Subject: [PATCH 07/15] [FEAT] Custom-defined Wheel Picker based Time Picker - Wheel Spinner based Time Picker has been defined. - This component has been applied to the EditBookmarkScreen to enhance user experiences in selecting time for reminder options. --- .../doyoonkim/common/ui/TimePickerWheel.kt | 218 ++++++++---------- 1 file changed, 96 insertions(+), 122 deletions(-) diff --git a/common/src/main/java/com/doyoonkim/common/ui/TimePickerWheel.kt b/common/src/main/java/com/doyoonkim/common/ui/TimePickerWheel.kt index 87fce43c..5144cb4b 100644 --- a/common/src/main/java/com/doyoonkim/common/ui/TimePickerWheel.kt +++ b/common/src/main/java/com/doyoonkim/common/ui/TimePickerWheel.kt @@ -1,36 +1,26 @@ package com.doyoonkim.common.ui import android.content.res.Configuration -import android.util.Log 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.fillMaxHeight -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.width 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.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.DatePicker -import androidx.compose.material3.DatePickerDialog -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.rememberDatePickerState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -44,7 +34,6 @@ 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.platform.LocalWindowInfo import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight @@ -53,13 +42,14 @@ 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 androidx.compose.ui.window.DialogProperties 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 @@ -67,59 +57,108 @@ import kotlinx.coroutines.flow.filter */ @Composable -fun TimePickerWheel( - modifier: Modifier, - initialHourSelected: Int, - initialMinuteSelected: Int, - onSelected: (String) -> Unit +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(initialHourSelected.coerceIn(0, 23)) } - var minuteSelected by remember { mutableIntStateOf(initialMinuteSelected.coerceIn(0, 59)) } + var hourSelected by remember { + mutableIntStateOf(calendar[Calendar.HOUR_OF_DAY]) + } + var minuteSelected by remember { + mutableIntStateOf(calendar[Calendar.MINUTE]) + } - Surface( - modifier = modifier - .clip(RoundedCornerShape(10.dp)) - .background(Color.Transparent), - color = MaterialTheme.colorScheme.containerBackground + var pickerVisible by remember { mutableStateOf(false) } + + Box( + modifier = modifier.wrapContentSize() ) { - Column( + Surface( modifier = Modifier.wrapContentSize() - .padding(10.dp) + .background(Color.Transparent) + .clip(RoundedCornerShape(10.dp)) + .clickable { pickerVisible = !pickerVisible }, + color = MaterialTheme.colorScheme.containerGray ) { - 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 = initialHourSelected, - ) { + 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 } - - 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 = initialMinuteSelected + ) { + 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, + 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 + } + } + } } } + } } } @@ -130,7 +169,7 @@ internal fun WheelPicker( elements: List, initialItemIndex: Int = 0, numberOfVisibleItem: Int = 3, - onSelected: (String) -> Unit + onSelected: (Int) -> Unit ) { // List State val listState = rememberLazyListState( @@ -166,7 +205,7 @@ internal fun WheelPicker( LaunchedEffect(selectedItem) { listState.animateScrollToItem(selectedItem) - onSelected(elements[selectedItem]) + onSelected(selectedItem) } LaunchedEffect(initialItemIndex) { @@ -217,76 +256,11 @@ internal fun WheelPickerItem( ) } -@OptIn(ExperimentalMaterial3Api::class) + @Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL ) @Composable fun TimePickerWheel_Preview() { - /* - var state by remember { mutableStateOf(false) } - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.End - ) { - Column( - modifier = Modifier.wrapContentHeight() - .padding(end = 20.dp), - verticalArrangement = Arrangement.SpaceEvenly, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = "Date", - modifier = Modifier.clickable { - state = !state - } - ) - - if (state) { - DatePickerDialog( - onDismissRequest = { }, - confirmButton = { - TextButton( - onClick = { } - ) { - Text("Confirm") - } - } - ) { - - DatePicker(state = rememberDatePickerState()) - } - } - } - } - */ - - Box( - modifier = Modifier.fillMaxSize() - ) { - val density = LocalDensity.current - val window = LocalWindowInfo.current - - val height = with(window) { this.containerSize / 7 }.height - val width = with(window) { this.containerSize / 3 }.width - - Dialog( - onDismissRequest = { }, - properties = DialogProperties( - usePlatformDefaultWidth = true - ) - ) { - TimePickerWheel( - modifier = Modifier.width(width.dp).height(height.dp), - initialHourSelected = 13, - initialMinuteSelected = 20 - ) { } - } - } - - - - -} \ No newline at end of file +} From 4618dba80851996250e278bbd5080b898c24af62 Mon Sep 17 00:00:00 2001 From: Doyoon Kim Date: Sun, 22 Jun 2025 18:00:54 +0900 Subject: [PATCH 08/15] [CHORE] Resolve Cosmetic Issue on UI - Remove redundant bottom paddings. --- .../java/com/doyoonkim/bookmark/list/BookmarkListScreen.kt | 3 --- 1 file changed, 3 deletions(-) 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( From b6ef5183f794c95c7e6acc398b49a789eda62326 Mon Sep 17 00:00:00 2001 From: Doyoon Kim Date: Sun, 22 Jun 2025 18:02:34 +0900 Subject: [PATCH 09/15] [REFACTOR] Isolate Time Picker from the DateTimePicker - Isolate Time Picker from the DateTimePicker to enhance user experience. - Migrated from type-based date selection to selection based date selection. --- .../com/doyoonkim/common/ui/DateTimePicker.kt | 151 +++++++----------- 1 file changed, 62 insertions(+), 89 deletions(-) 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 From d832e5f4396d78cb64641577877d2e6900388903 Mon Sep 17 00:00:00 2001 From: Doyoon Kim Date: Sun, 22 Jun 2025 18:04:32 +0900 Subject: [PATCH 10/15] [REFACTOR] Modify Date Time handling logic for Reminder Option - Modify logic for handling date and time information for reminder option. --- .../viewmodel/EditBookmarkViewModel.kt | 42 ++++++++++++++----- 1 file changed, 31 insertions(+), 11 deletions(-) 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 From 6e558c547a141c4014a681c3562f33cd1c4e669d Mon Sep 17 00:00:00 2001 From: Doyoon Kim Date: Sun, 22 Jun 2025 18:08:14 +0900 Subject: [PATCH 11/15] [REFACTOR] Replace legacy DateTimePicker to Improved DatePicker and TimePicker - Replace legacy DateTimePicker (type-based selection) to Improved DatePicker and TimePicker. * Each DatePicker and TimePicker would be displayed over Dialog that covers entire composable behind it. * Wheel-spinner based time selection has been applied. --- .../bookmark/edit/EditBookmarkScreen.kt | 142 +++++++----------- 1 file changed, 55 insertions(+), 87 deletions(-) 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 From e0df77804115ba3f2280da570c8407157cba2984 Mon Sep 17 00:00:00 2001 From: Doyoon Kim Date: Sun, 22 Jun 2025 18:14:18 +0900 Subject: [PATCH 12/15] [REACTOR] Resolve Dialog Logic - Change manual dialog popup to Dialog composable. --- app/src/main/java/com/doyoonkim/knutice/MainActivity.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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) From b08dd953b1f92deaf521415b9fc77c5570f5fcc0 Mon Sep 17 00:00:00 2001 From: Doyoon Kim Date: Sun, 22 Jun 2025 18:21:14 +0900 Subject: [PATCH 13/15] [REACTOR] Resolve Cosmetic Issue on UI - Fix height value for Top and Bottom spacer at the Wheel Spinner, - Add Spacer at the bottom of the Dialog for better appearance. --- .../main/java/com/doyoonkim/common/ui/TimePickerWheel.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/common/src/main/java/com/doyoonkim/common/ui/TimePickerWheel.kt b/common/src/main/java/com/doyoonkim/common/ui/TimePickerWheel.kt index 5144cb4b..c1d79b68 100644 --- a/common/src/main/java/com/doyoonkim/common/ui/TimePickerWheel.kt +++ b/common/src/main/java/com/doyoonkim/common/ui/TimePickerWheel.kt @@ -9,6 +9,7 @@ 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.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentHeight @@ -155,6 +156,7 @@ fun TimePickerDialog( minuteSelected = it } } + Spacer(Modifier.height(30.dp)) } } } @@ -221,7 +223,7 @@ internal fun WheelPicker( ) { // 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(30.dp)) } + items(spacerCount) { Spacer(Modifier.height(40.dp)) } itemsIndexed(elements) { index, item -> WheelPickerItem( @@ -230,7 +232,7 @@ internal fun WheelPicker( ) } - items(spacerCount) { Spacer(Modifier.height(30.dp)) } + items(spacerCount) { Spacer(Modifier.height(40.dp)) } } } From 4e3b6043ee7197dc91d9db3bd5623769f0b8e9e6 Mon Sep 17 00:00:00 2001 From: Doyoon Kim Date: Sun, 22 Jun 2025 18:35:19 +0900 Subject: [PATCH 14/15] [REACTOR] Change the condition for determining notice name for the notification title - Change the condition for determining localized notice name. * Received strings have been identified as Korean title. --- .../doyoonkim/notification/fcm/PushNotificationHandler.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 1ed80202..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 @@ -152,10 +152,10 @@ class PushNotificationHandler @Inject constructor( private fun localizedTitle(noticeCategory: String): String { return with(context) { when (noticeCategory) { - NoticeCategory.GENERAL_NEWS.name -> getString(R.string.general_news) - NoticeCategory.ACADEMIC_NEWS.name -> getString(R.string.academic_news) - NoticeCategory.SCHOLARSHIP_NEWS.name -> getString(R.string.scholarship_news) - NoticeCategory.EVENT_NEWS.name -> getString(R.string.event_news) + "일반소식" -> 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 { From 8efc063e8df0e1c620938144d72727929792251a Mon Sep 17 00:00:00 2001 From: Doyoon Kim Date: Sun, 22 Jun 2025 18:44:13 +0900 Subject: [PATCH 15/15] [REACTOR] Resolve Cosmetic Issue - Add missing color properties to the text under TimePicker Dialog --- common/src/main/java/com/doyoonkim/common/ui/TimePickerWheel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/src/main/java/com/doyoonkim/common/ui/TimePickerWheel.kt b/common/src/main/java/com/doyoonkim/common/ui/TimePickerWheel.kt index c1d79b68..8835fa66 100644 --- a/common/src/main/java/com/doyoonkim/common/ui/TimePickerWheel.kt +++ b/common/src/main/java/com/doyoonkim/common/ui/TimePickerWheel.kt @@ -9,7 +9,6 @@ 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.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentHeight @@ -122,6 +121,7 @@ fun TimePickerDialog( fontSize = 15.sp, fontWeight = FontWeight.Normal, textAlign = TextAlign.Start, + color = MaterialTheme.colorScheme.title, modifier = Modifier.padding(15.dp) ) Row(