diff --git a/.idea/androidTestResultsUserPreferences.xml b/.idea/androidTestResultsUserPreferences.xml new file mode 100644 index 0000000..7752686 --- /dev/null +++ b/.idea/androidTestResultsUserPreferences.xml @@ -0,0 +1,22 @@ + + + + + + \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 93752fd..0007cff 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -139,6 +139,7 @@ dependencies { implementation(libs.androidx.lifecycle.viewmodel.navigation3) implementation(libs.androidx.adaptive.navigation3) implementation(libs.androidx.appcompat) + implementation(libs.kotlinx.datetime) implementation(libs.lyricist) ksp(libs.lyricist.processor) diff --git a/app/src/main/java/com/timilehinaregbesola/mathalarm/framework/app/di/AppModule.kt b/app/src/main/java/com/timilehinaregbesola/mathalarm/framework/app/di/AppModule.kt index f983703..449fad1 100644 --- a/app/src/main/java/com/timilehinaregbesola/mathalarm/framework/app/di/AppModule.kt +++ b/app/src/main/java/com/timilehinaregbesola/mathalarm/framework/app/di/AppModule.kt @@ -28,8 +28,8 @@ import com.timilehinaregbesola.mathalarm.notification.AlarmNotificationScheduler import com.timilehinaregbesola.mathalarm.notification.MathAlarmNotification import com.timilehinaregbesola.mathalarm.notification.MathAlarmNotificationChannel import com.timilehinaregbesola.mathalarm.presentation.appsettings.AppThemeOptionsMapper -import com.timilehinaregbesola.mathalarm.provider.CalendarProvider -import com.timilehinaregbesola.mathalarm.provider.CalendarProviderImpl +import com.timilehinaregbesola.mathalarm.provider.DateTimeProvider +import com.timilehinaregbesola.mathalarm.provider.DateTimeProviderImpl import com.timilehinaregbesola.mathalarm.usecases.AddAlarm import com.timilehinaregbesola.mathalarm.usecases.CancelAlarm import com.timilehinaregbesola.mathalarm.usecases.ClearAlarms @@ -89,8 +89,8 @@ object AppModule { @Provides @Singleton - fun provideCalenderProvider(): CalendarProvider { - return CalendarProviderImpl() + fun provideCalenderProvider(): DateTimeProvider { + return DateTimeProviderImpl() } @Provides @@ -123,7 +123,7 @@ object AppModule { repository: AlarmRepository, alarmInteractor: AlarmInteractor, notificationInteractor: NotificationInteractor, - calendarProvider: CalendarProvider, + calendarProvider: DateTimeProvider, scheduleNextAlarm: ScheduleNextAlarm ): Usecases { return Usecases( diff --git a/app/src/main/java/com/timilehinaregbesola/mathalarm/interactors/AlarmInteractorImpl.kt b/app/src/main/java/com/timilehinaregbesola/mathalarm/interactors/AlarmInteractorImpl.kt index 193c7c6..cf3b3a3 100644 --- a/app/src/main/java/com/timilehinaregbesola/mathalarm/interactors/AlarmInteractorImpl.kt +++ b/app/src/main/java/com/timilehinaregbesola/mathalarm/interactors/AlarmInteractorImpl.kt @@ -8,12 +8,15 @@ class AlarmInteractorImpl(private val alarmManager: AlarmNotificationScheduler) AlarmInteractor { override fun schedule(alarm: Alarm, reschedule: Boolean): Boolean { - Timber.d("schedule - alarmId = ${alarm.alarmId}") - return alarmManager.scheduleAlarm(alarm, reschedule) + Timber.d("AlarmInteractorImpl.schedule called: alarmId=${alarm.alarmId}, time=${alarm.hour}:${alarm.minute}, repeat=${alarm.repeat}, repeatDays=${alarm.repeatDays}, reschedule=$reschedule") + val result = alarmManager.scheduleAlarm(alarm, reschedule) + Timber.d("AlarmInteractorImpl.schedule result for alarmId=${alarm.alarmId}: $result") + return result } override fun cancel(alarm: Alarm) { - Timber.d("cancel - alarmId = ${alarm.alarmId}") + Timber.d("AlarmInteractorImpl.cancel called: alarmId=${alarm.alarmId}, time=${alarm.hour}:${alarm.minute}, repeat=${alarm.repeat}, repeatDays=${alarm.repeatDays}") alarmManager.cancelAlarm(alarm) + Timber.d("AlarmInteractorImpl.cancel completed for alarmId=${alarm.alarmId}") } } diff --git a/app/src/main/java/com/timilehinaregbesola/mathalarm/notification/AlarmNotificationScheduler.kt b/app/src/main/java/com/timilehinaregbesola/mathalarm/notification/AlarmNotificationScheduler.kt index 51ff72d..08c705b 100644 --- a/app/src/main/java/com/timilehinaregbesola/mathalarm/notification/AlarmNotificationScheduler.kt +++ b/app/src/main/java/com/timilehinaregbesola/mathalarm/notification/AlarmNotificationScheduler.kt @@ -11,9 +11,25 @@ import com.timilehinaregbesola.mathalarm.AlarmReceiver import com.timilehinaregbesola.mathalarm.AlarmReceiver.Companion.ALARM_ACTION import com.timilehinaregbesola.mathalarm.AlarmReceiver.Companion.EXTRA_TASK import com.timilehinaregbesola.mathalarm.domain.model.Alarm -import com.timilehinaregbesola.mathalarm.utils.* +import com.timilehinaregbesola.mathalarm.utils.SAT +import com.timilehinaregbesola.mathalarm.utils.SUN +import com.timilehinaregbesola.mathalarm.utils.cancelAlarm +import com.timilehinaregbesola.mathalarm.utils.fullDays +import com.timilehinaregbesola.mathalarm.utils.initLocalDateTimeInSystemZone +import com.timilehinaregbesola.mathalarm.utils.setExactAlarm +import com.timilehinaregbesola.mathalarm.utils.toIndex +import kotlinx.datetime.DatePeriod +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.LocalTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.plus +import kotlinx.datetime.toInstant +import kotlinx.datetime.toLocalDateTime import timber.log.Timber -import java.util.* +import kotlin.time.Clock +import kotlin.time.ExperimentalTime +import kotlin.time.Instant /** * Alarm manager to schedule an event based on the time from a Alarm. @@ -26,23 +42,35 @@ class AlarmNotificationScheduler(private val context: Context) { * @param passedAlarm alarm to be scheduled * @param reschedule whether alarm is repeating */ + @OptIn(ExperimentalTime::class) @SuppressLint("UnspecifiedImmutableFlag") fun scheduleAlarm(passedAlarm: Alarm, reschedule: Boolean): Boolean { - Timber.d("Schedule alarm..") + Timber.d("Schedule alarm for id=${passedAlarm.alarmId}, time=${passedAlarm.hour}:${passedAlarm.minute}, repeat=${passedAlarm.repeat}, repeatDays=${passedAlarm.repeatDays}, reschedule=$reschedule") val alarmIntent = Intent(context, AlarmReceiver::class.java).apply { action = ALARM_ACTION putExtra(EXTRA_TASK, passedAlarm.alarmId) } val alarmIntentList: MutableList = ArrayList() - val time: MutableList = ArrayList() + val timeInstants: MutableList = ArrayList() + val tz = TimeZone.currentSystemDefault() + var hasExistingAlarms = false + // If there is no day set, set the alarm on the closest possible date if (passedAlarm.repeatDays == "FFFFFFF") { - val cal = passedAlarm.initCalendar() - var dayOfTheWeek = getDayOfWeek(cal[Calendar.DAY_OF_WEEK]) - if (cal.timeInMillis > System.currentTimeMillis()) { // set it today + Timber.d("No repeat days set, determining closest possible date") + val dateTime = passedAlarm.initLocalDateTimeInSystemZone() + val instant = dateTime.toInstant(tz) + val nowInstant = Clock.System.now() + Timber.d("Alarm datetime: $dateTime, instant: $instant, now: $nowInstant") + + var dayOfTheWeek = dateTime.date.dayOfWeek.toIndex() + Timber.d("Current day of week: $dayOfTheWeek") + + if (instant > nowInstant) { // set it today val sb = StringBuilder("FFFFFFF") sb.setCharAt(dayOfTheWeek, 'T') passedAlarm.repeatDays = sb.toString() + Timber.d("Alarm time is in the future, setting for today. New repeatDays: ${passedAlarm.repeatDays}") } else { // alarm time already passed for the day so set it tomorrow val sb = StringBuilder("FFFFFFF") if (dayOfTheWeek == SAT) { // if it is saturday @@ -52,35 +80,62 @@ class AlarmNotificationScheduler(private val context: Context) { } sb.setCharAt(dayOfTheWeek, 'T') passedAlarm.repeatDays = sb.toString() + Timber.d("Alarm time already passed, setting for tomorrow (day $dayOfTheWeek). New repeatDays: ${passedAlarm.repeatDays}") } } + for (i in SUN..SAT) { if (passedAlarm.repeatDays[i] == 'T') { + Timber.d("Processing day $i (${fullDays[i]}) which is set to true") + val nowInstant = Clock.System.now() + val localNow = nowInstant.toLocalDateTime(tz) + val todayDate = localNow.date + + val currentDay = todayDate.dayOfWeek.toIndex() + + Timber.d("Current day: $currentDay (${fullDays[currentDay]})") + val daysUntilAlarm: Int - val cal = passedAlarm.initCalendar() - val currentDay = getDayOfWeek(cal[Calendar.DAY_OF_WEEK]) - Timber.d("current day: $currentDay") - if (currentDay > i || - (currentDay == i && cal.timeInMillis < System.currentTimeMillis()) - ) { + val targetDate: LocalDate + + val alarmTimeToday = passedAlarm.initLocalDateTimeInSystemZone() + val alarmInstantToday = alarmTimeToday.toInstant(tz) + Timber.d("Alarm time today would be: $alarmTimeToday (${alarmInstantToday})") + Timber.d("Current time is: $localNow (${nowInstant})") + + val isPastToday = alarmInstantToday < nowInstant + Timber.d("Is alarm time past for today? $isPastToday") + + if (currentDay > i || (currentDay == i && isPastToday)) { // days left till end of week(sat) + the day of the week of the alarm // EX: alarm = i = tues = 2; current = wed = 3; end of week = sat = 6 // end - current = 6 - 3 = 3 -> 3 days till saturday/end of week // end of week + 1 (to sunday) + day of week alarm is on = 3 + 1 + 2 = 6 daysUntilAlarm = SAT - currentDay + 1 + i - cal.add(Calendar.DAY_OF_YEAR, daysUntilAlarm) - Timber.d("days until alarm: $daysUntilAlarm") + targetDate = todayDate.plus(DatePeriod(days = daysUntilAlarm)) + Timber.d("Current day ($currentDay) > alarm day ($i) or same day but time passed, scheduling for next week") + Timber.d("Days until alarm: $daysUntilAlarm, target date: $targetDate") } else { daysUntilAlarm = i - currentDay - cal.add(Calendar.DAY_OF_YEAR, daysUntilAlarm) - Timber.d("days until alarm: $daysUntilAlarm") + targetDate = todayDate.plus(DatePeriod(days = daysUntilAlarm)) + Timber.d("Current day ($currentDay) <= alarm day ($i) and time not passed, scheduling for this week") + Timber.d("Days until alarm: $daysUntilAlarm, target date: $targetDate") } + + val targetDateTime = LocalDateTime( + date = targetDate, + time = LocalTime(passedAlarm.hour, passedAlarm.minute, 0) + ) + val targetInstant = targetDateTime.toInstant(tz) + val stringId: StringBuilder = StringBuilder().append(passedAlarm.alarmId).append(i) .append(passedAlarm.hour).append(passedAlarm.minute) val id = stringId.toString().split("-").joinToString("") val intentId = id.toInt() - Timber.d("intent id: $intentId") - // check if a previous alarm has been set + Timber.d("Generated intent ID: $intentId for alarm ID: ${passedAlarm.alarmId}, day: $i, time: ${passedAlarm.hour}:${passedAlarm.minute}") + + // Check if a previous alarm has been set + Timber.d("Checking if a previous alarm with this ID already exists") val isSet = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { PendingIntent.getBroadcast( context, @@ -91,37 +146,63 @@ class AlarmNotificationScheduler(private val context: Context) { } else { PendingIntent.getBroadcast(context, intentId, alarmIntent, PendingIntent.FLAG_NO_CREATE) } + if (isSet != null) { + Timber.d("An alarm with ID $intentId already exists") + hasExistingAlarms = true if (!reschedule) { + Timber.d("Not rescheduling because reschedule flag is false") // context.showToast(R.string.alarm_duplicate_toast_text) + } else { + // If reschedule is true, cancel the existing alarm and create a new one + Timber.d("Canceling existing alarm because reschedule flag is true") + context.cancelAlarm(isSet) + isSet.cancel() } - return false } - val pendingIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - PendingIntent.getBroadcast( - context, - intentId, - alarmIntent, - PendingIntent.FLAG_CANCEL_CURRENT or FLAG_MUTABLE, - ) - } else { - PendingIntent.getBroadcast( - context, - intentId, - alarmIntent, - PendingIntent.FLAG_CANCEL_CURRENT, - ) + + // If reschedule is true or no existing alarm was found, create a new one + if (isSet == null || reschedule) { + Timber.d("Proceeding to create new alarm") + + val pendingIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + PendingIntent.getBroadcast( + context, + intentId, + alarmIntent, + PendingIntent.FLAG_CANCEL_CURRENT or FLAG_MUTABLE, + ) + } else { + PendingIntent.getBroadcast( + context, + intentId, + alarmIntent, + PendingIntent.FLAG_CANCEL_CURRENT, + ) + } + + alarmIntentList.add(pendingIntent) + timeInstants.add(targetInstant) } - alarmIntentList.add(pendingIntent) - time.add(cal) } } + + // Return true if we scheduled new alarms OR if there were existing alarms + if (alarmIntentList.isEmpty() && !hasExistingAlarms) { + Timber.w("No alarms were scheduled and no existing alarms found") + return false + } + + Timber.d("Scheduling ${alarmIntentList.size} alarms") for (i in alarmIntentList.indices) { val pendingIntent = alarmIntentList[i] - val cal = time[i] - context.setExactAlarm(cal.timeInMillis, pendingIntent) - Timber.d("scheduled new alarm") + val instant = timeInstants[i] + Timber.d("Scheduling alarm #${i+1}/${alarmIntentList.size} for time: ${instant}") + context.setExactAlarm(instant.toEpochMilliseconds(), pendingIntent) + Timber.d("Alarm #${i+1} scheduled successfully") } + + Timber.d("All ${alarmIntentList.size} alarms scheduled successfully, returning true") return true } @@ -131,15 +212,23 @@ class AlarmNotificationScheduler(private val context: Context) { * @param alarm alarm to be canceled */ fun cancelAlarm(alarm: Alarm) { + Timber.d("AlarmNotificationScheduler.cancelAlarm called: alarmId=${alarm.alarmId}, time=${alarm.hour}:${alarm.minute}, repeat=${alarm.repeat}, repeatDays=${alarm.repeatDays}") + val receiverIntent = Intent(context, AlarmReceiver::class.java) receiverIntent.action = ALARM_ACTION receiverIntent.putExtra(EXTRA_TASK, alarm.alarmId) + + var canceledCount = 0 for (i in 0..6) { // For each day of the week - if (alarm.repeatDays[i] == 'T') { + if (alarm.repeatDays.getOrNull(i) == 'T') { + Timber.d("Canceling alarm for day $i (${fullDays[i]})") + val stringId: StringBuilder = StringBuilder().append(alarm.alarmId).append(i) .append(alarm.hour).append(alarm.minute) val id = stringId.toString().split("-").joinToString("") val intentId = id.toInt() + Timber.d("Generated intent ID: $intentId for alarm ID: ${alarm.alarmId}, day: $i, time: ${alarm.hour}:${alarm.minute}") + val cancelPendingIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { PendingIntent.getBroadcast( context, @@ -155,9 +244,15 @@ class AlarmNotificationScheduler(private val context: Context) { FLAG_UPDATE_CURRENT, ) } + + Timber.d("Calling context.cancelAlarm for intent ID: $intentId") context.cancelAlarm(cancelPendingIntent) cancelPendingIntent.cancel() + Timber.d("Alarm canceled for day $i (${fullDays[i]})") + canceledCount++ } } + + Timber.d("AlarmNotificationScheduler.cancelAlarm completed for alarmId=${alarm.alarmId}, canceled $canceledCount alarms") } } diff --git a/app/src/main/java/com/timilehinaregbesola/mathalarm/presentation/alarmlist/AlarmListEvent.kt b/app/src/main/java/com/timilehinaregbesola/mathalarm/presentation/alarmlist/AlarmListEvent.kt index 797d06c..3737dd2 100644 --- a/app/src/main/java/com/timilehinaregbesola/mathalarm/presentation/alarmlist/AlarmListEvent.kt +++ b/app/src/main/java/com/timilehinaregbesola/mathalarm/presentation/alarmlist/AlarmListEvent.kt @@ -9,5 +9,6 @@ sealed class AlarmListEvent { data class OnEditAlarmClick(val alarm: Alarm) : AlarmListEvent() object OnAddAlarmClick : AlarmListEvent() object OnClearAlarmsClick : AlarmListEvent() + object OnClearEmptyAlarmsClick : AlarmListEvent() data class DeleteTestAlarm(val alarmId: Long) : AlarmListEvent() } diff --git a/app/src/main/java/com/timilehinaregbesola/mathalarm/presentation/alarmlist/AlarmListViewModel.kt b/app/src/main/java/com/timilehinaregbesola/mathalarm/presentation/alarmlist/AlarmListViewModel.kt index b5d3e7b..35f8b4f 100644 --- a/app/src/main/java/com/timilehinaregbesola/mathalarm/presentation/alarmlist/AlarmListViewModel.kt +++ b/app/src/main/java/com/timilehinaregbesola/mathalarm/presentation/alarmlist/AlarmListViewModel.kt @@ -1,13 +1,13 @@ package com.timilehinaregbesola.mathalarm.presentation.alarmlist -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.timilehinaregbesola.mathalarm.domain.model.Alarm import com.timilehinaregbesola.mathalarm.framework.Usecases import com.timilehinaregbesola.mathalarm.framework.app.permission.AlarmPermission -import com.timilehinaregbesola.mathalarm.provider.CalendarProvider import com.timilehinaregbesola.mathalarm.utils.UiEvent +import com.timilehinaregbesola.mathalarm.utils.UiEvent.Navigate +import com.timilehinaregbesola.mathalarm.utils.UiEvent.ShowSnackbar import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.receiveAsFlow @@ -19,9 +19,7 @@ import javax.inject.Inject @HiltViewModel class AlarmListViewModel @Inject constructor( private val usecases: Usecases, - val calender: CalendarProvider, val permission: AlarmPermission, - val savedStateHandle: SavedStateHandle ) : ViewModel() { var alarms = usecases.getSavedAlarms() @@ -35,21 +33,25 @@ class AlarmListViewModel @Inject constructor( when (event) { is AlarmListEvent.OnEditAlarmClick -> { // Navigate to bottom sheet - sendUiEvent(UiEvent.Navigate(event.alarm)) + sendUiEvent(Navigate(event.alarm)) } is AlarmListEvent.OnAlarmOnChange -> { + Timber.d("OnAlarmOnChange event received: alarmId=${event.alarm.alarmId}, isOn=${event.isOn}") viewModelScope.launch { + Timber.d("Updating alarm in database: alarmId=${event.alarm.alarmId}, setting isOn=${event.isOn}") usecases.addAlarm(event.alarm.copy(isOn = event.isOn)) if (event.isOn) { + Timber.d("Alarm is being turned ON, scheduling: alarmId=${event.alarm.alarmId}") usecases.scheduleAlarm(event.alarm, false) } else { + Timber.d("Alarm is being turned OFF: alarmId=${event.alarm.alarmId}") // Cancel } } } is AlarmListEvent.OnAddAlarmClick -> { // Navigate to bottom sheet - sendUiEvent(UiEvent.Navigate(Alarm())) + sendUiEvent(Navigate(Alarm())) } is AlarmListEvent.OnUndoDeleteClick -> { viewModelScope.launch { @@ -61,7 +63,7 @@ class AlarmListViewModel @Inject constructor( viewModelScope.launch { usecases.deleteAlarm(event.alarm) recentlyDeletedAlarm = event.alarm - sendUiEvent(UiEvent.ShowSnackbar(message = "Alarm Deleted", action = "Undo")) + sendUiEvent(ShowSnackbar(message = "Alarm Deleted", action = "Undo")) } } is AlarmListEvent.DeleteTestAlarm -> { @@ -79,6 +81,10 @@ class AlarmListViewModel @Inject constructor( } } } + + AlarmListEvent.OnClearEmptyAlarmsClick -> { + sendUiEvent(ShowSnackbar(message = "There are no alarms to clear")) + } } } @@ -95,15 +101,21 @@ class AlarmListViewModel @Inject constructor( } fun scheduleAlarm(alarm: Alarm, reschedule: Boolean, message: String) { + Timber.d("scheduleAlarm called: alarmId=${alarm.alarmId}, time=${alarm.hour}:${alarm.minute}, repeat=${alarm.repeat}, repeatDays=${alarm.repeatDays}, reschedule=$reschedule") viewModelScope.launch { + Timber.d("Calling usecases.scheduleAlarm for alarmId=${alarm.alarmId}") usecases.scheduleAlarm(alarm, reschedule) - sendUiEvent(UiEvent.ShowSnackbar(message = message)) + Timber.d("scheduleAlarm completed for alarmId=${alarm.alarmId}, showing snackbar message: $message") + sendUiEvent(ShowSnackbar(message = message)) } } fun cancelAlarm(alarm: Alarm) { + Timber.d("cancelAlarm called: alarmId=${alarm.alarmId}, time=${alarm.hour}:${alarm.minute}, repeat=${alarm.repeat}, repeatDays=${alarm.repeatDays}") viewModelScope.launch { + Timber.d("Calling usecases.cancelAlarm for alarmId=${alarm.alarmId}") usecases.cancelAlarm(alarm) + Timber.d("cancelAlarm completed for alarmId=${alarm.alarmId}") } } } diff --git a/app/src/main/java/com/timilehinaregbesola/mathalarm/presentation/alarmlist/components/AlarmListHeader.kt b/app/src/main/java/com/timilehinaregbesola/mathalarm/presentation/alarmlist/components/AlarmListHeader.kt index 77bdae6..079d7b5 100644 --- a/app/src/main/java/com/timilehinaregbesola/mathalarm/presentation/alarmlist/components/AlarmListHeader.kt +++ b/app/src/main/java/com/timilehinaregbesola/mathalarm/presentation/alarmlist/components/AlarmListHeader.kt @@ -17,36 +17,34 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import cafe.adriel.lyricist.strings import com.timilehinaregbesola.mathalarm.domain.model.Alarm -import com.timilehinaregbesola.mathalarm.presentation.alarmlist.components.AlarmListHeader.LIST_HEADER_ALPHA import com.timilehinaregbesola.mathalarm.presentation.alarmlist.components.AlarmListHeader.LIST_HEADER_ELEVATION import com.timilehinaregbesola.mathalarm.presentation.alarmlist.components.AlarmListHeader.LIST_HEADER_FONT_SIZE -import com.timilehinaregbesola.mathalarm.presentation.alarmlist.components.AlarmListHeader.ONE_WEEK_IN_MILLISECONDS +import com.timilehinaregbesola.mathalarm.presentation.alarmlist.components.AlarmListHeader.ListHeaderAlpha import com.timilehinaregbesola.mathalarm.presentation.ui.darkPrimaryLight import com.timilehinaregbesola.mathalarm.presentation.ui.spacing -import com.timilehinaregbesola.mathalarm.utils.getCalendarFromAlarm +import com.timilehinaregbesola.mathalarm.utils.calculateNextAlarmTime import com.timilehinaregbesola.mathalarm.utils.getTimeLeft -import timber.log.Timber -import java.util.Calendar +import kotlinx.datetime.TimeZone +import kotlin.time.ExperimentalTime +import kotlin.time.Instant +@OptIn(ExperimentalTime::class) @Composable fun ListHeader( modifier: Modifier = Modifier, enabled: Boolean, - calendar: Calendar, + timeZone: TimeZone = TimeZone.currentSystemDefault(), alarmList: List, isDark: Boolean ) { val (nearestTime, nearestIndex) = buildNearestTime( alarmList = alarmList, - calendar = calendar + timeZone = timeZone ) val nearestAlarmMessage by remember(nearestTime, nearestIndex) { derivedStateOf { - nearestTime?.let { time -> - alarmList[nearestIndex].getTimeLeft( - time, - calendar - ) + nearestTime?.let { + alarmList.getOrNull(nearestIndex)?.getTimeLeft() } } } @@ -55,7 +53,7 @@ fun ListHeader( .padding(top = MaterialTheme.spacing.small) .fillMaxWidth() .background( - color = if (isDark) darkPrimaryLight else LightGray.copy(alpha = LIST_HEADER_ALPHA), + color = if (isDark) darkPrimaryLight else LightGray.copy(alpha = ListHeaderAlpha), ) .then(modifier), tonalElevation = LIST_HEADER_ELEVATION, @@ -79,28 +77,25 @@ fun ListHeader( } } +@OptIn(ExperimentalTime::class) private fun buildNearestTime( alarmList: List, - calendar: Calendar -): Pair { - var nearestTime: Long? = null + timeZone: TimeZone +): Pair { + var nearestTime: Instant? = null var nearestIndex = -1 + if (alarmList.isNotEmpty()) { alarmList - .filter { it.isOn } - .forEachIndexed { index, alarm -> - val cal = getCalendarFromAlarm(alarm, calendar) - var alarmTime = cal.timeInMillis + .forEachIndexed { originalIndex, alarm -> + if (alarm.isOn) { + val alarmInstant = calculateNextAlarmTime(alarm, timeZone) - val now = System.currentTimeMillis() - Timber.d("time = $alarmTime") - if (alarmTime < now) { - alarmTime += ONE_WEEK_IN_MILLISECONDS - } - val timeToAlarm = alarmTime - now - if (nearestTime == null || timeToAlarm < nearestTime!!) { - nearestTime = timeToAlarm - nearestIndex = index + // If a valid future time was found and it's sooner than the current nearest, update + if (alarmInstant != null && (nearestTime == null || alarmInstant < nearestTime)) { + nearestTime = alarmInstant + nearestIndex = originalIndex + } } } } @@ -113,7 +108,6 @@ private fun ListHeaderPreview() { MaterialTheme { ListHeader( enabled = false, - calendar = Calendar.getInstance(), alarmList = emptyList(), isDark = true ) @@ -121,7 +115,7 @@ private fun ListHeaderPreview() { } private object AlarmListHeader { - const val LIST_HEADER_ALPHA = 0.1f + const val ListHeaderAlpha = 0.1f const val ONE_WEEK_IN_MILLISECONDS = 7 * 24 * 60 * 60 * 1000 val LIST_HEADER_ELEVATION = 4.dp val LIST_HEADER_FONT_SIZE = 16.sp diff --git a/app/src/main/java/com/timilehinaregbesola/mathalarm/presentation/alarmlist/components/AlarmListScreen.kt b/app/src/main/java/com/timilehinaregbesola/mathalarm/presentation/alarmlist/components/AlarmListScreen.kt index 69b9fd0..b5fb73e 100644 --- a/app/src/main/java/com/timilehinaregbesola/mathalarm/presentation/alarmlist/components/AlarmListScreen.kt +++ b/app/src/main/java/com/timilehinaregbesola/mathalarm/presentation/alarmlist/components/AlarmListScreen.kt @@ -46,6 +46,7 @@ import com.timilehinaregbesola.mathalarm.domain.model.Alarm import com.timilehinaregbesola.mathalarm.framework.database.AlarmMapper import com.timilehinaregbesola.mathalarm.presentation.alarmlist.AlarmListEvent.OnAddAlarmClick import com.timilehinaregbesola.mathalarm.presentation.alarmlist.AlarmListEvent.OnClearAlarmsClick +import com.timilehinaregbesola.mathalarm.presentation.alarmlist.AlarmListEvent.OnClearEmptyAlarmsClick import com.timilehinaregbesola.mathalarm.presentation.alarmlist.AlarmListEvent.OnDeleteAlarmClick import com.timilehinaregbesola.mathalarm.presentation.alarmlist.AlarmListEvent.OnEditAlarmClick import com.timilehinaregbesola.mathalarm.presentation.alarmlist.AlarmListEvent.OnUndoDeleteClick @@ -58,10 +59,8 @@ import com.timilehinaregbesola.mathalarm.utils.Destinations.AppSettings import com.timilehinaregbesola.mathalarm.utils.Destinations.SettingsSheet import com.timilehinaregbesola.mathalarm.utils.UiEvent.Navigate import com.timilehinaregbesola.mathalarm.utils.UiEvent.ShowSnackbar -import com.timilehinaregbesola.mathalarm.utils.getCalendarFromAlarm import com.timilehinaregbesola.mathalarm.utils.getTimeLeft import kotlinx.serialization.json.Json -import java.util.Calendar @SuppressLint("UnrememberedMutableState") @ExperimentalAnimationApi @@ -116,7 +115,13 @@ fun ListDisplayScreen( Scaffold( topBar = { ListTopAppBar( - openDialog = { deleteAllAlarmsDialog = true }, + openDialog = { + if (alarmList.isNotEmpty()) { + deleteAllAlarmsDialog = true + } else { + viewModel.onEvent(OnClearEmptyAlarmsClick) + } + }, onSettingsClick = { backstack.add(AppSettings) }, @@ -155,7 +160,6 @@ fun ListDisplayScreen( val alarmSetText = strings.alarmSet AlarmListContent( alarmList = alarmList, - calendar = viewModel.calender.getCurrentCalendar(), darkTheme = darkTheme, onEditAlarm = { isLoading = true @@ -180,19 +184,10 @@ fun ListDisplayScreen( checkPermissionAndPerformAction( value = alarmPermission.hasExactAlarmPermission(), action = { - val calender = viewModel.calender.getCurrentCalendar() viewModel.scheduleAlarm( alarm = curAlarm, reschedule = b, - message = "$alarmSetText ${ - curAlarm.getTimeLeft( - getCalendarFromAlarm( - curAlarm, - calender - ).timeInMillis, - calender, - ) - }", + message = "$alarmSetText ${curAlarm.getTimeLeft()}", ) }, onPermissionAbsent = { showPermissionDialog = true }, @@ -233,7 +228,6 @@ fun ListDisplayScreen( @Composable private fun AlarmListContent( alarmList: List, - calendar: Calendar, darkTheme: Boolean, onEditAlarm: (Alarm) -> Unit, onUpdateAlarm: (Alarm) -> Unit, @@ -254,8 +248,7 @@ private fun AlarmListContent( ListHeader( enabled = alarmList.any { it.isOn }, alarmList = alarmList, - calendar = calendar, - isDark = darkTheme, + isDark = darkTheme ) } items( @@ -325,7 +318,6 @@ private fun AlarmListScreenPreview() { MathAlarmTheme { AlarmListContent( alarmList = listOf(Alarm(), Alarm(alarmId = 1L)), - calendar = Calendar.getInstance(), darkTheme = false, onEditAlarm = {}, onUpdateAlarm = {}, diff --git a/app/src/main/java/com/timilehinaregbesola/mathalarm/presentation/alarmmath/AlarmMathViewModel.kt b/app/src/main/java/com/timilehinaregbesola/mathalarm/presentation/alarmmath/AlarmMathViewModel.kt index 7614396..6a8e6dd 100644 --- a/app/src/main/java/com/timilehinaregbesola/mathalarm/presentation/alarmmath/AlarmMathViewModel.kt +++ b/app/src/main/java/com/timilehinaregbesola/mathalarm/presentation/alarmmath/AlarmMathViewModel.kt @@ -2,8 +2,6 @@ package com.timilehinaregbesola.mathalarm.presentation.alarmmath import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.timilehinaregbesola.mathalarm.domain.model.Alarm @@ -13,7 +11,10 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.* import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.flow import javax.inject.Inject @@ -22,8 +23,8 @@ class AlarmMathViewModel @Inject constructor( private val usecases: Usecases, val audioPlayer: AudioPlayer, ) : ViewModel() { - private val _state = MutableLiveData(ToneState.Stopped()) - val state: LiveData = _state + private val _state = MutableStateFlow(ToneState.Stopped()) + val state: StateFlow = _state.asStateFlow() private val _answerText = mutableStateOf("") val answerText: State = _answerText private val _eventFlow = MutableSharedFlow() diff --git a/app/src/main/java/com/timilehinaregbesola/mathalarm/presentation/alarmmath/components/MathScreen.kt b/app/src/main/java/com/timilehinaregbesola/mathalarm/presentation/alarmmath/components/MathScreen.kt index cb1368f..79e2170 100644 --- a/app/src/main/java/com/timilehinaregbesola/mathalarm/presentation/alarmmath/components/MathScreen.kt +++ b/app/src/main/java/com/timilehinaregbesola/mathalarm/presentation/alarmmath/components/MathScreen.kt @@ -37,7 +37,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -123,7 +123,7 @@ fun MathScreen( SnackbarHostState() } val keyboardController = LocalSoftwareKeyboardController.current - val toneState by viewModel.state.observeAsState() + val toneState by viewModel.state.collectAsState() val progress by remember(viewModel.audioPlayer.currentPosition) { mutableFloatStateOf( if (toneState is Countdown) { diff --git a/app/src/main/java/com/timilehinaregbesola/mathalarm/presentation/alarmsettings/AlarmSettingsViewModel.kt b/app/src/main/java/com/timilehinaregbesola/mathalarm/presentation/alarmsettings/AlarmSettingsViewModel.kt index 91fd18a..ce9c8d1 100644 --- a/app/src/main/java/com/timilehinaregbesola/mathalarm/presentation/alarmsettings/AlarmSettingsViewModel.kt +++ b/app/src/main/java/com/timilehinaregbesola/mathalarm/presentation/alarmsettings/AlarmSettingsViewModel.kt @@ -1,26 +1,29 @@ package com.timilehinaregbesola.mathalarm.presentation.alarmsettings import android.media.RingtoneManager -import androidx.compose.runtime.* +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.text.input.TextFieldValue -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.timilehinaregbesola.mathalarm.domain.model.Alarm import com.timilehinaregbesola.mathalarm.framework.Usecases -import com.timilehinaregbesola.mathalarm.utils.getDayOfWeek import com.timilehinaregbesola.mathalarm.utils.getFormatTime +import com.timilehinaregbesola.mathalarm.utils.initLocalDateTimeInSystemZone +import com.timilehinaregbesola.mathalarm.utils.toIndex import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.* import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow -import java.util.* +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.datetime.LocalDateTime import javax.inject.Inject @HiltViewModel class AlarmSettingsViewModel @Inject constructor( private val usecases: Usecases, - savedStateHandle: SavedStateHandle, ) : ViewModel() { private var isNewAlarm: Boolean? = null @@ -74,9 +77,6 @@ class AlarmSettingsViewModel @Inject constructor( } } is AddEditAlarmEvent.OnTestClick -> { -// runBlocking { -// usecases.addAlarm(createAlarm()) -// } viewModelScope.launch { _eventFlow.emit(UiEvent.TestAlarm(createAlarm())) } @@ -106,7 +106,7 @@ class AlarmSettingsViewModel @Inject constructor( _dayChooser.value = event.value } is AddEditAlarmEvent.OnDifficultyChange -> { - _difficulty.value = event.value + _difficulty.intValue = event.value } is AddEditAlarmEvent.OnToneChange -> { _tone.value = event.value @@ -135,13 +135,7 @@ class AlarmSettingsViewModel @Inject constructor( isSaved = _isSaved.value, ) - private fun initCalendar(alarm: Alarm): Calendar { - val cal = Calendar.getInstance() - cal[Calendar.HOUR_OF_DAY] = alarm.hour - cal[Calendar.MINUTE] = alarm.minute - cal[Calendar.SECOND] = 0 - return cal - } + private fun initDateTime(alarm: Alarm): LocalDateTime = alarm.initLocalDateTimeInSystemZone() fun setAlarm(curAlarm: Alarm) { if (currentAlarmId == null) { @@ -155,9 +149,8 @@ class AlarmSettingsViewModel @Inject constructor( if (alarm.repeatDays == "FFFFFFF") { isNewAlarm = true val sb = StringBuilder("FFFFFFF") - val cal = initCalendar(alarm) - val dayOfTheWeek = - getDayOfWeek(cal[Calendar.DAY_OF_WEEK]) + val dateTime = initDateTime(alarm) + val dayOfTheWeek = dateTime.date.dayOfWeek.toIndex() sb.setCharAt(dayOfTheWeek, 'T') _dayChooser.value = sb.toString() } else { @@ -166,7 +159,7 @@ class AlarmSettingsViewModel @Inject constructor( } _repeatWeekly.value = alarm.repeat _vibrate.value = alarm.vibrate - _difficulty.value = alarm.difficulty + _difficulty.intValue = alarm.difficulty if (alarm.alarmTone == "") { _tone.value = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM).toString() } else { diff --git a/app/src/main/java/com/timilehinaregbesola/mathalarm/presentation/alarmsettings/components/AlarmBottomSheet.kt b/app/src/main/java/com/timilehinaregbesola/mathalarm/presentation/alarmsettings/components/AlarmBottomSheet.kt index 4afac68..06b7c3f 100644 --- a/app/src/main/java/com/timilehinaregbesola/mathalarm/presentation/alarmsettings/components/AlarmBottomSheet.kt +++ b/app/src/main/java/com/timilehinaregbesola/mathalarm/presentation/alarmsettings/components/AlarmBottomSheet.kt @@ -84,7 +84,6 @@ import com.timilehinaregbesola.mathalarm.presentation.alarmsettings.components.A import com.timilehinaregbesola.mathalarm.presentation.alarmsettings.components.AlarmBottomSheet.TEST_BUTTON_FONT_SIZE import com.timilehinaregbesola.mathalarm.presentation.alarmsettings.components.AlarmBottomSheet.TIME_CARD_CORNER_SIZE import com.timilehinaregbesola.mathalarm.presentation.alarmsettings.components.AlarmBottomSheet.TIME_CARD_HEIGHT -import com.timilehinaregbesola.mathalarm.presentation.alarmsettings.components.AlarmBottomSheet.TIME_PATTERN import com.timilehinaregbesola.mathalarm.presentation.alarmsettings.components.AlarmBottomSheet.TIME_TEXT_FONT_SIZE import com.timilehinaregbesola.mathalarm.presentation.alarmsettings.components.AlarmBottomSheet.TIME_TEXT_PADDING import com.timilehinaregbesola.mathalarm.presentation.ui.MathAlarmTheme @@ -101,9 +100,11 @@ import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.datetime.LocalTime +import kotlinx.datetime.format +import kotlinx.datetime.format.char import kotlinx.serialization.json.Json import timber.log.Timber -import java.time.format.DateTimeFormatter @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -113,7 +114,9 @@ fun AlarmBottomSheet( darkTheme: Boolean, alarm: AlarmEntity, ) { - viewModel.setAlarm(AlarmMapper().mapToDomainModel(alarm)) + LaunchedEffect(Unit) { + viewModel.setAlarm(AlarmMapper().mapToDomainModel(alarm)) + } val scaffoldState = rememberBottomSheetScaffoldState() var showTimePickerDialog by remember { mutableStateOf(false) } var showConfirmationDialog by remember { mutableStateOf(false) } @@ -264,13 +267,19 @@ fun AlarmBottomSheet( showTimePickerDialog = false }, onConfirm = { newTime -> - val dtf = DateTimeFormatter.ofPattern(TIME_PATTERN) + val tf = LocalTime.Format { + amPmHour() + char(':') + minute() + char(' ') + amPmMarker("AM", "PM") + } viewModel.onEvent( AddEditAlarmEvent.ChangeTime( TimeState( hour = newTime.hour, minute = newTime.minute, - formattedTime = newTime.format(dtf).toString(), + formattedTime = newTime.format(tf) ), ), ) @@ -461,9 +470,7 @@ private fun SheetActionButtons( modifier = Modifier .padding(top = MaterialTheme.spacing.large) .fillMaxWidth(), - onClick = { - onTestClick() - }, + onClick = onTestClick, colors = buttonColors( containerColor = unSelectedDay, contentColor = Black, @@ -478,9 +485,7 @@ private fun SheetActionButtons( modifier = Modifier .padding(top = SAVE_BUTTON_TOP_PADDING) .fillMaxWidth(), - onClick = { - onSaveClick() - }, + onClick = onSaveClick, colors = buttonColors(containerColor = MaterialTheme.colorScheme.secondary), ) { Text( diff --git a/app/src/main/java/com/timilehinaregbesola/mathalarm/presentation/alarmsettings/components/TimePickerDialog.kt b/app/src/main/java/com/timilehinaregbesola/mathalarm/presentation/alarmsettings/components/TimePickerDialog.kt index e3895a9..f12bb71 100644 --- a/app/src/main/java/com/timilehinaregbesola/mathalarm/presentation/alarmsettings/components/TimePickerDialog.kt +++ b/app/src/main/java/com/timilehinaregbesola/mathalarm/presentation/alarmsettings/components/TimePickerDialog.kt @@ -47,8 +47,7 @@ import androidx.compose.ui.window.DialogProperties import cafe.adriel.lyricist.strings import com.timilehinaregbesola.mathalarm.presentation.ui.MathAlarmTheme import com.timilehinaregbesola.mathalarm.presentation.ui.darkPrimary -import java.time.LocalTime -import java.util.Calendar +import kotlinx.datetime.LocalTime @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -60,13 +59,10 @@ fun TimePickerDialog( darkTheme: Boolean = false, ) { - val time = Calendar.getInstance() - time.timeInMillis = System.currentTimeMillis() - var mode: DisplayMode by remember { mutableStateOf(DisplayMode.Picker) } fun onConfirmClicked() { - val currentTime = LocalTime.of(timeState.hour, timeState.minute) + val currentTime = LocalTime(timeState.hour, timeState.minute) onConfirm(currentTime) } diff --git a/app/src/main/java/com/timilehinaregbesola/mathalarm/utils/AlarmUtil.kt b/app/src/main/java/com/timilehinaregbesola/mathalarm/utils/AlarmUtil.kt index e18ab00..a977bdd 100644 --- a/app/src/main/java/com/timilehinaregbesola/mathalarm/utils/AlarmUtil.kt +++ b/app/src/main/java/com/timilehinaregbesola/mathalarm/utils/AlarmUtil.kt @@ -1,8 +1,17 @@ package com.timilehinaregbesola.mathalarm.utils -import android.text.format.DateFormat import com.timilehinaregbesola.mathalarm.domain.model.Alarm -import java.util.* +import kotlinx.datetime.DatePeriod +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.LocalTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.plus +import kotlinx.datetime.toInstant +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Clock +import kotlin.time.ExperimentalTime +import kotlin.time.Instant const val SUN = 0 const val MON = 1 @@ -16,104 +25,170 @@ const val MEDIUM = 1 const val HARD = 2 val days = listOf("S", "M", "T", "W", "T", "F", "S") -val fullDays = listOf("Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday") - -// Get the formatted time (example: 12:00 AM) -fun Alarm.getFormatTime(): CharSequence? { - val cal = Calendar.getInstance() - cal[Calendar.HOUR_OF_DAY] = hour - cal[Calendar.MINUTE] = minute - return DateFormat.format("hh:mm a", cal) +val fullDays = listOf( + "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday" +) + +/** + * Returns a 12-hour "hh:mm AM/PM" string for this Alarm's hour/minute. + */ +fun Alarm.getFormatTime(): String { + val isAm = hour < 12 + val hour12 = when { + hour == 0 -> 12 + hour == 12 -> 12 + hour > 12 -> hour - 12 + else -> hour + } + val minutePadded = minute.toString().padStart(2, '0') + val amPm = if (isAm) "AM" else "PM" + return "%02d:%s %s".format(hour12, minutePadded, amPm) } -fun Alarm.getTime(): Calendar { - val cal = Calendar.getInstance() - cal[Calendar.HOUR_OF_DAY] = hour - cal[Calendar.MINUTE] = minute - return cal +/** + * Extension function for [DayOfWeek] to get its corresponding index (0-6). + */ +fun DayOfWeek.toIndex(): Int = when (this) { + DayOfWeek.SUNDAY -> SUN + DayOfWeek.MONDAY -> MON + DayOfWeek.TUESDAY -> TUE + DayOfWeek.WEDNESDAY -> WED + DayOfWeek.THURSDAY -> THU + DayOfWeek.FRIDAY -> FRI + DayOfWeek.SATURDAY -> SAT } -fun Alarm.initCalendar(): Calendar { - val cal = Calendar.getInstance() - cal[Calendar.HOUR_OF_DAY] = hour - cal[Calendar.MINUTE] = minute - cal[Calendar.SECOND] = 0 - return cal +/** + * Like getTodayDateTimeInSystemZone(), but enforces second=0. + */ +@OptIn(ExperimentalTime::class) +fun Alarm.initLocalDateTimeInSystemZone(): LocalDateTime { + val nowInstant = Clock.System.now() + val tz = TimeZone.currentSystemDefault() + val today = nowInstant.toLocalDateTime(tz).date + return LocalDateTime( + date = today, + time = LocalTime(hour, minute, 0) + ) } -fun Alarm.getTimeLeft(time: Long, cal: Calendar): String { - val message: String - val calender = getCalendarFromAlarm(alarm = this, cal = cal) - val today = getDayOfWeek(calender[Calendar.DAY_OF_WEEK]) - var i: Int - val lastAlarmDay: Int - val nextAlarmDay: Int - if (System.currentTimeMillis() > time) { - nextAlarmDay = if (today + 1 == 7) 0 else today + 1 - lastAlarmDay = today - } else { - nextAlarmDay = today - lastAlarmDay = if (today - 1 == -1) 6 else today - 1 - } - i = nextAlarmDay - while (i != lastAlarmDay) { - if (i == 7) { - i = 0 - } - if (repeatDays[i] == 'T') { - break +/** + * Calculate the next time an alarm will go off. + * + * @param alarm The alarm to calculate the next time for + * @param timeZone The timezone to use for the calculation + * @return The next time the alarm will go off as an Instant, or null if the alarm has no repeat days set + */ +@OptIn(ExperimentalTime::class) +fun calculateNextAlarmTime(alarm: Alarm, timeZone: TimeZone = TimeZone.currentSystemDefault()): Instant? { + val nowInstant = Clock.System.now() + val localNow = nowInstant.toLocalDateTime(timeZone) + val todayDate = localNow.date + + // Check if the alarm has any repeat days set + val hasRepeatDays = alarm.repeatDays.contains('T') + + if (!hasRepeatDays) { + // If alarm doesn't have any repeat days set, just check today + val alarmDateTime = LocalDateTime( + date = todayDate, + time = LocalTime(alarm.hour, alarm.minute, 0) + ) + var candidateInstant = alarmDateTime.toInstant(timeZone) + + // If the alarm time is in the past, add one week (7 days) + if (candidateInstant < nowInstant) { + val oneWeek = DatePeriod(days = 7) + candidateInstant = candidateInstant.plus(oneWeek, timeZone) } - i++ - } - if (i < today || i == today && calender.timeInMillis < System.currentTimeMillis()) { - val daysUntilAlarm: Int = SAT - today + 1 + i - calender.add(Calendar.DAY_OF_YEAR, daysUntilAlarm) + + return candidateInstant } else { - val daysUntilAlarm = i - today - calender.add(Calendar.DAY_OF_YEAR, daysUntilAlarm) - } - val alarmTime = calender.timeInMillis - val remainderTime = alarmTime - System.currentTimeMillis() - val minutes = (remainderTime / (1000 * 60) % 60).toInt() - val hours = (remainderTime / (1000 * 60 * 60) % 24).toInt() - val days = (remainderTime / (1000 * 60 * 60 * 24)).toInt() - val mString = if (minutes == 1) "minute" else "minutes" - val hString = if (hours == 1) "hour" else "hours" - val dString = if (days == 1) "day" else "days" - message = if (days == 0) { - if (hours == 0) { - ("$minutes $mString") + // For alarms with repeat days, find the next occurrence based on repeat days + + // Get the current day of week (0..6) + val currentDayIndex = todayDate.dayOfWeek.toIndex() + + // Check if the alarm is set for the current day + val isSetForToday = alarm.repeatDays.getOrNull(currentDayIndex) == 'T' + + // Create a LocalDateTime for the alarm time today + val alarmTimeToday = LocalDateTime( + date = todayDate, + time = LocalTime(alarm.hour, alarm.minute, 0) + ) + val alarmInstantToday = alarmTimeToday.toInstant(timeZone) + + // If the alarm is set for today and the time hasn't passed yet, use today's time + if (isSetForToday && alarmInstantToday > nowInstant) { + return alarmInstantToday } else { - ("$hours $hString $minutes $mString") + // Otherwise, find the next occurrence based on repeat days + // Find the earliest day such that: + // - repeatDays[dayIndex] == 'T' + // - The alarm time for that day is in the future + + // First, try to find the next occurrence within the next 7 days + for (offset in 1..7) { + val nextDate = todayDate.plus(DatePeriod(days = offset)) + val nextDayIndex = nextDate.dayOfWeek.toIndex() + + if (alarm.repeatDays.getOrNull(nextDayIndex) == 'T') { + val candidateDateTime = LocalDateTime( + date = nextDate, + time = LocalTime(alarm.hour, alarm.minute, 0) + ) + return candidateDateTime.toInstant(timeZone) + } + } + + // If no future occurrence was found, and the alarm is set for today but the time has passed, + // use today's time + 1 week + if (isSetForToday) { + val oneWeek = DatePeriod(days = 7) + return alarmInstantToday.plus(oneWeek, timeZone) + } } - } else { - ( - " $days $dString $hours $hString $minutes $mString " - ) } - return message -} -fun getCalendarFromAlarm(alarm: Alarm, cal: Calendar): Calendar { - cal[Calendar.DAY_OF_WEEK] = alarm.repeatDays.toList().indexOfFirst { it == 'T' } + 1 - cal[Calendar.HOUR_OF_DAY] = alarm.hour - cal[Calendar.MINUTE] = alarm.minute - cal[Calendar.SECOND] = 0 - return cal + // If no future day matched (repeatDays all 'F'), return null + return null } -fun getDayOfWeek(day: Int): Int { - val dayOfWeek: Int - val errorValue = 8 - dayOfWeek = when (day) { - Calendar.SUNDAY -> SUN - Calendar.MONDAY -> MON - Calendar.TUESDAY -> TUE - Calendar.WEDNESDAY -> WED - Calendar.THURSDAY -> THU - Calendar.FRIDAY -> FRI - Calendar.SATURDAY -> SAT - else -> return errorValue +/** + * Compute "time left until next alarm" exactly as your old Calendar‐based function did, + * but using kotlinx-datetime under the hood. Returns a string like: + * + * • "5 hours 3 minutes" + * • "1 day 2 hours 15 minutes" + * • "45 minutes" + * • "0 minutes" (if repeatDays is all 'F') + * + * We interpret repeatDays[0]=='T' as Sunday, [1]=='T' as Monday, …, [6]=='T' as Saturday. + */ +@OptIn(ExperimentalTime::class) +fun Alarm.getTimeLeft(): String { + val nowInstant = Clock.System.now() + val chosenInstant = calculateNextAlarmTime(this) + + // If no future day matched (repeatDays all 'F'), show "0 minutes" + if (chosenInstant == null) { + return "0 minutes" + } + + // Compute the duration between "now" and "chosenInstant" in seconds: + val totalSeconds = (chosenInstant - nowInstant).inWholeSeconds + val daysPart = (totalSeconds / (60 * 60 * 24)).toInt() + val hoursPart = ((totalSeconds % (60 * 60 * 24)) / (60 * 60)).toInt() + val minutesPart = ((totalSeconds % (60 * 60)) / 60).toInt() + + val dString = if (daysPart == 1) "day" else "days" + val hString = if (hoursPart == 1) "hour" else "hours" + val mString = if (minutesPart == 1) "minute" else "minutes" + + return when { + daysPart > 0 -> "$daysPart $dString $hoursPart $hString $minutesPart $mString" + hoursPart > 0 -> "$hoursPart $hString $minutesPart $mString" + else -> "$minutesPart $mString" } - return dayOfWeek } diff --git a/app/src/main/java/com/timilehinaregbesola/mathalarm/utils/ContextExtensions.kt b/app/src/main/java/com/timilehinaregbesola/mathalarm/utils/ContextExtensions.kt index 63bdf95..ae7c471 100644 --- a/app/src/main/java/com/timilehinaregbesola/mathalarm/utils/ContextExtensions.kt +++ b/app/src/main/java/com/timilehinaregbesola/mathalarm/utils/ContextExtensions.kt @@ -6,20 +6,17 @@ import android.app.Activity import android.app.AlarmManager import android.app.PendingIntent import android.content.Context -import android.content.ContextWrapper import android.content.Intent import android.content.pm.PackageManager import android.net.Uri import android.os.Build import android.provider.Settings -import android.view.ContextThemeWrapper -import android.widget.Toast -import androidx.annotation.StringRes import androidx.core.app.ActivityCompat import androidx.core.app.AlarmManagerCompat import androidx.core.content.ContextCompat import timber.log.Timber -import java.util.Calendar +import kotlin.time.Clock +import kotlin.time.ExperimentalTime /** * Sets a alarm using [AlarmManagerCompat] to be triggered based on the given parameter. @@ -29,27 +26,48 @@ import java.util.Calendar * @param operation action to perform when the alarm goes off * @param type type to define how the alarm will behave */ +@OptIn(ExperimentalTime::class) fun Context.setExactAlarm( triggerAtMillis: Long, operation: PendingIntent?, type: Int = AlarmManager.RTC_WAKEUP, ) { - val currentTime = Calendar.getInstance().timeInMillis - if (triggerAtMillis <= currentTime) { - Timber.w("It is not possible to set alarm in the past") - return + var adjustedTriggerTime = triggerAtMillis + val currentTime = Clock.System.now().toEpochMilliseconds() + + if (adjustedTriggerTime <= currentTime) { + // If the alarm time is in the past, add one week (7 days) to the trigger time + Timber.w("Alarm time is in the past, scheduling for next week") + val oneWeekInMillis = 7 * 24 * 60 * 60 * 1000L + adjustedTriggerTime += oneWeekInMillis } if (operation == null) { - Timber.e("PendingIntent is null") + Timber.e("PendingIntent is null, cannot schedule alarm") return } val manager = getAlarmManager() - manager?.let { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S || it.canScheduleExactAlarms()) { - AlarmManagerCompat.setExactAndAllowWhileIdle(it, type, triggerAtMillis, operation) - } + if (manager == null) { + Timber.e("AlarmManager is null, cannot schedule alarm") + return + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !manager.canScheduleExactAlarms()) { + Timber.e("Cannot schedule exact alarms - permission not granted on Android S+") + return + } + + try { + AlarmManagerCompat.setExactAndAllowWhileIdle( + manager, + type, + adjustedTriggerTime, + operation + ) + Timber.d("Alarm scheduled successfully") + } catch (e: Exception) { + Timber.e(e, "Failed to schedule alarm") } } @@ -59,23 +77,26 @@ fun Context.setExactAlarm( * @param operation action to be canceled */ fun Context.cancelAlarm(operation: PendingIntent?) { + Timber.d("cancelAlarm called with operation=${operation?.hashCode()}") + if (operation == null) { - Timber.e("PendingIntent is null") + Timber.e("PendingIntent is null, cannot cancel alarm") return } val manager = getAlarmManager() - manager?.let { manager.cancel(operation) } -} + if (manager == null) { + Timber.e("AlarmManager is null, cannot cancel alarm") + return + } -/** - * Shows a [Toast] with the given message. - * - * @param messageId the message String resource id - * @param duration the Toast duration, if not provided will be set to [Toast.LENGTH_SHORT] - */ -fun Context.showToast(@StringRes messageId: Int, duration: Int = Toast.LENGTH_SHORT) { - Toast.makeText(this, messageId, duration).show() + try { + Timber.d("Canceling alarm with AlarmManager.cancel") + manager.cancel(operation) + Timber.d("Alarm canceled successfully") + } catch (e: Exception) { + Timber.e(e, "Failed to cancel alarm") + } } fun Context.email( diff --git a/app/src/test/java/com/timilehinaregbesola/mathalarm/fake/AlarmInteractorFake.kt b/app/src/test/java/com/timilehinaregbesola/mathalarm/fake/AlarmInteractorFake.kt index 19ec2d1..d470666 100644 --- a/app/src/test/java/com/timilehinaregbesola/mathalarm/fake/AlarmInteractorFake.kt +++ b/app/src/test/java/com/timilehinaregbesola/mathalarm/fake/AlarmInteractorFake.kt @@ -2,13 +2,13 @@ package com.timilehinaregbesola.mathalarm.fake import com.timilehinaregbesola.mathalarm.domain.model.Alarm import com.timilehinaregbesola.mathalarm.interactors.AlarmInteractor -import com.timilehinaregbesola.mathalarm.utils.getTime -import java.util.Calendar +import com.timilehinaregbesola.mathalarm.utils.initLocalDateTimeInSystemZone +import kotlinx.datetime.LocalDateTime class AlarmInteractorFake : AlarmInteractor { private val alarmMap: MutableMap = mutableMapOf() override fun schedule(alarm: Alarm, reschedule: Boolean): Boolean { - alarmMap[alarm.alarmId] = FakeData(reschedule, alarm.getTime()) + alarmMap[alarm.alarmId] = FakeData(reschedule, alarm.initLocalDateTimeInSystemZone()) return true } @@ -20,8 +20,8 @@ class AlarmInteractorFake : AlarmInteractor { fun clear() = alarmMap.clear() - fun getAlarmTime(alarmId: Long): Calendar? = + fun getAlarmTime(alarmId: Long): LocalDateTime? = alarmMap[alarmId]?.time } -data class FakeData(val reschedule: Boolean, val time: Calendar) +data class FakeData(val reschedule: Boolean, val time: LocalDateTime) diff --git a/app/src/test/java/com/timilehinaregbesola/mathalarm/fake/AlarmRepositoryFake.kt b/app/src/test/java/com/timilehinaregbesola/mathalarm/fake/AlarmRepositoryFake.kt index fc84127..4c28d6b 100644 --- a/app/src/test/java/com/timilehinaregbesola/mathalarm/fake/AlarmRepositoryFake.kt +++ b/app/src/test/java/com/timilehinaregbesola/mathalarm/fake/AlarmRepositoryFake.kt @@ -4,15 +4,14 @@ import com.timilehinaregbesola.mathalarm.data.AlarmDataSource import com.timilehinaregbesola.mathalarm.domain.model.Alarm import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf -import java.util.TreeMap class AlarmRepositoryFake : AlarmDataSource { - private val alarmMap: TreeMap = TreeMap() + private val alarmMap: MutableMap = mutableMapOf() override suspend fun addAlarm(alarm: Alarm): Long { val id = if (alarm.alarmId == 0L) { - alarmMap.lastKey() + 1 + (alarmMap.keys.maxOrNull() ?: 0L) + 1 } else { alarm.alarmId } @@ -37,7 +36,7 @@ class AlarmRepositoryFake : AlarmDataSource { override fun getSavedAlarms(): Flow> = flowOf(alarmMap.values.toList().filter { it.isSaved }) - override suspend fun getLatestAlarmFromDatabase(): Alarm? = alarmMap[alarmMap.lastKey()] + override suspend fun getLatestAlarmFromDatabase(): Alarm? = alarmMap.values.maxByOrNull { it.alarmId } override suspend fun findAlarm(id: Long): Alarm? = alarmMap[id] diff --git a/app/src/test/java/com/timilehinaregbesola/mathalarm/notification/AlarmNotificationSchedulerTest.kt b/app/src/test/java/com/timilehinaregbesola/mathalarm/notification/AlarmNotificationSchedulerTest.kt index 6e84ef9..fb25041 100644 --- a/app/src/test/java/com/timilehinaregbesola/mathalarm/notification/AlarmNotificationSchedulerTest.kt +++ b/app/src/test/java/com/timilehinaregbesola/mathalarm/notification/AlarmNotificationSchedulerTest.kt @@ -5,8 +5,8 @@ import com.timilehinaregbesola.mathalarm.domain.model.Alarm import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic +import kotlinx.datetime.LocalDateTime import org.junit.Before -import java.util.Calendar class AlarmNotificationSchedulerTest { @@ -18,20 +18,20 @@ class AlarmNotificationSchedulerTest { @Before fun setUp() { - val cal = Calendar.getInstance() + val now = LocalDateTime(2023, 1, 1, 10, 30) // Example LocalDateTime mockkStatic("com.timilehinaregbesola.mathalarm.utils.extension-context") every { mockAlarm.repeatDays } returns "FFFTFFF" every { mockAlarm.alarmId } returns 22L every { mockAlarm.repeat } returns false - every { mockAlarm.newCal } returns cal - every { mockAlarm.hour } returns cal[Calendar.HOUR_OF_DAY] - every { mockAlarm.minute } returns cal[Calendar.MINUTE] + every { mockAlarm.newDateTime } returns now + every { mockAlarm.hour } returns now.hour + every { mockAlarm.minute } returns now.minute } // @Test // fun `check if alarm scheduled is valid`() { // scheduler.scheduleAlarm(mockAlarm, false) -// verify { mockContext.setAlarm(mockAlarm.newCal.time.time, any()) } +// verify { mockContext.setExactAlarm(mockAlarm.newDateTime.time.toNanosecondOfDay(), any()) } // } // // @Test diff --git a/app/src/test/java/com/timilehinaregbesola/mathalarm/usecases/SnoozeAlarmTest.kt b/app/src/test/java/com/timilehinaregbesola/mathalarm/usecases/SnoozeAlarmTest.kt index 707712b..77d8fb8 100644 --- a/app/src/test/java/com/timilehinaregbesola/mathalarm/usecases/SnoozeAlarmTest.kt +++ b/app/src/test/java/com/timilehinaregbesola/mathalarm/usecases/SnoozeAlarmTest.kt @@ -5,7 +5,7 @@ import com.timilehinaregbesola.mathalarm.domain.model.Alarm import com.timilehinaregbesola.mathalarm.fake.AlarmInteractorFake import com.timilehinaregbesola.mathalarm.fake.AlarmRepositoryFake import com.timilehinaregbesola.mathalarm.fake.NotificationInteractorFake -import com.timilehinaregbesola.mathalarm.provider.CalendarProviderImpl +import com.timilehinaregbesola.mathalarm.provider.DateTimeProviderImpl import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.Before @@ -21,7 +21,7 @@ class SnoozeAlarmTest { private val notificationInteractor = NotificationInteractorFake() - private val calendarProvider = CalendarProviderImpl() + private val calendarProvider = DateTimeProviderImpl() private val addAlarmUseCase = AddAlarm(alarmRepository) diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 14b17a7..1d3a975 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -12,4 +12,5 @@ dependencies { implementation(kotlin("stdlib-jdk8")) implementation(libs.coroutines.android) implementation(libs.timber) + implementation(libs.kotlinx.datetime) } diff --git a/core/src/main/java/com/timilehinaregbesola/mathalarm/domain/model/Alarm.kt b/core/src/main/java/com/timilehinaregbesola/mathalarm/domain/model/Alarm.kt index bed0768..b61d468 100644 --- a/core/src/main/java/com/timilehinaregbesola/mathalarm/domain/model/Alarm.kt +++ b/core/src/main/java/com/timilehinaregbesola/mathalarm/domain/model/Alarm.kt @@ -1,15 +1,19 @@ package com.timilehinaregbesola.mathalarm.domain.model -import java.util.* +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Clock +import kotlin.time.ExperimentalTime -data class Alarm( +data class Alarm @OptIn(ExperimentalTime::class) constructor( var alarmId: Long = 0L, - val newCal: Calendar = Calendar.getInstance(), + val newDateTime: LocalDateTime = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()), - val newHour: Int = newCal[Calendar.HOUR_OF_DAY], + val newHour: Int = newDateTime.hour, - val newMinute: Int = newCal[Calendar.MINUTE], + val newMinute: Int = newDateTime.minute, var hour: Int = newHour, diff --git a/core/src/main/java/com/timilehinaregbesola/mathalarm/provider/CalendarProvider.kt b/core/src/main/java/com/timilehinaregbesola/mathalarm/provider/CalendarProvider.kt index 918a492..486bf77 100644 --- a/core/src/main/java/com/timilehinaregbesola/mathalarm/provider/CalendarProvider.kt +++ b/core/src/main/java/com/timilehinaregbesola/mathalarm/provider/CalendarProvider.kt @@ -1,16 +1,16 @@ package com.timilehinaregbesola.mathalarm.provider -import java.util.Calendar +import kotlinx.datetime.LocalDateTime /** * Provide the date and time to be used on the alarm use cases, respecting the Inversion of Control. */ -interface CalendarProvider { +interface DateTimeProvider { /** - * Gets the current [Calendar]. + * Gets the current [LocalDateTime] in the system default time zone. * - * @return the current [Calendar] + * @return the current [LocalDateTime] */ - fun getCurrentCalendar(): Calendar + fun getCurrentDateTime(): LocalDateTime } diff --git a/core/src/main/java/com/timilehinaregbesola/mathalarm/provider/CalendarProviderImpl.kt b/core/src/main/java/com/timilehinaregbesola/mathalarm/provider/CalendarProviderImpl.kt index 0fb2256..020e583 100644 --- a/core/src/main/java/com/timilehinaregbesola/mathalarm/provider/CalendarProviderImpl.kt +++ b/core/src/main/java/com/timilehinaregbesola/mathalarm/provider/CalendarProviderImpl.kt @@ -1,16 +1,23 @@ package com.timilehinaregbesola.mathalarm.provider -import java.util.Calendar +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Clock +import kotlin.time.ExperimentalTime + /** * Provide the date and time to be used on the alarm use cases, respecting the Inversion of Control. */ -class CalendarProviderImpl : CalendarProvider { +class DateTimeProviderImpl : DateTimeProvider { /** - * Gets the current [Calendar]. + * Gets the current [LocalDateTime] in the system default time zone. * - * @return the current [Calendar] + * @return the current [LocalDateTime] */ - override fun getCurrentCalendar(): Calendar = Calendar.getInstance() + @OptIn(ExperimentalTime::class) + override fun getCurrentDateTime(): LocalDateTime = + Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()) } diff --git a/core/src/main/java/com/timilehinaregbesola/mathalarm/usecases/SnoozeAlarm.kt b/core/src/main/java/com/timilehinaregbesola/mathalarm/usecases/SnoozeAlarm.kt index 7d5f9f8..cb427ed 100644 --- a/core/src/main/java/com/timilehinaregbesola/mathalarm/usecases/SnoozeAlarm.kt +++ b/core/src/main/java/com/timilehinaregbesola/mathalarm/usecases/SnoozeAlarm.kt @@ -3,14 +3,20 @@ package com.timilehinaregbesola.mathalarm.usecases import com.timilehinaregbesola.mathalarm.data.AlarmRepository import com.timilehinaregbesola.mathalarm.interactors.AlarmInteractor import com.timilehinaregbesola.mathalarm.interactors.NotificationInteractor -import com.timilehinaregbesola.mathalarm.provider.CalendarProvider -import java.util.* +import com.timilehinaregbesola.mathalarm.provider.DateTimeProvider +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.plus +import kotlinx.datetime.toInstant +import kotlinx.datetime.toLocalDateTime +import kotlin.time.ExperimentalTime /** * Use case to snooze an alarm. */ class SnoozeAlarm( - private val calendarProvider: CalendarProvider, + private val dateTimeProvider: DateTimeProvider, private val notificationInteractor: NotificationInteractor, private val alarmInteractor: AlarmInteractor, private val alarmRepository: AlarmRepository @@ -28,18 +34,22 @@ class SnoozeAlarm( require(minutes > 0) { "The delay minutes must be positive" } val alarm = alarmRepository.findAlarm(alarmId) ?: return - val snoozedTime = getSnoozedAlarm(calendarProvider.getCurrentCalendar(), minutes) + val snoozedTime = getSnoozedDateTime(dateTimeProvider.getCurrentDateTime(), minutes) alarm.apply { - hour = snoozedTime[Calendar.HOUR_OF_DAY] - minute = snoozedTime[Calendar.MINUTE] + hour = snoozedTime.hour + minute = snoozedTime.minute } alarmInteractor.schedule(alarm, alarm.repeat) notificationInteractor.dismiss(alarmId) -// logger.debug("Task snoozed in $minutes minutes") } - private fun getSnoozedAlarm(calendar: Calendar, minutes: Int): Calendar = - calendar.apply { add(Calendar.MINUTE, minutes) } + @OptIn(ExperimentalTime::class) + private fun getSnoozedDateTime(dateTime: LocalDateTime, minutes: Int): LocalDateTime { + val tz = TimeZone.currentSystemDefault() + val instant = dateTime.toInstant(tz) + val newInstant = instant.plus(minutes.toLong(), DateTimeUnit.MINUTE) + return newInstant.toLocalDateTime(tz) + } companion object { private const val DEFAULT_SNOOZE = 5 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d0b35af..4777b85 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -37,6 +37,7 @@ navigation3 = "1.0.0-alpha08" nav3Material = "1.0.0-SNAPSHOT" lifecycleViewmodel = "1.0.0-SNAPSHOT" appcompat = "1.7.1" +datetime = "0.7.1" [libraries] android-material = { module = "com.google.android.material:material", version.ref = "material" } @@ -85,6 +86,7 @@ lyricist-processor = { module = "cafe.adriel.lyricist:lyricist-processor", versi mockk = { module = "io.mockk:mockk", version.ref = "test_mockk" } plugin-ksp = { module = "com.google.devtools.ksp:com.google.devtools.ksp.gradle.plugin", version.ref = "ksp" } timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" } +kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" } [plugins] compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }