From 40961dad87d227281bc3072243bf54990200c154 Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Sun, 9 Nov 2025 11:11:47 -0600 Subject: [PATCH] Refactor custom and random reminder dialogs --- .../org/tasks/compose/AddReminderDialog.kt | 484 +++++++++++------- .../java/org/tasks/compose/edit/AlarmRow.kt | 36 +- 2 files changed, 310 insertions(+), 210 deletions(-) diff --git a/app/src/main/java/org/tasks/compose/AddReminderDialog.kt b/app/src/main/java/org/tasks/compose/AddReminderDialog.kt index 56e1b3bfa..97a8c5161 100644 --- a/app/src/main/java/org/tasks/compose/AddReminderDialog.kt +++ b/app/src/main/java/org/tasks/compose/AddReminderDialog.kt @@ -25,10 +25,11 @@ import androidx.compose.material3.RadioButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier @@ -62,141 +63,99 @@ import java.util.concurrent.TimeUnit @ExperimentalComposeUiApi object AddReminderDialog { + // Helper functions for converting between Alarm properties and UI state + private fun unitIndexToMillis(unitIndex: Int): Long = when (unitIndex) { + 1 -> TimeUnit.HOURS.toMillis(1) + 2 -> TimeUnit.DAYS.toMillis(1) + 3 -> TimeUnit.DAYS.toMillis(7) + else -> TimeUnit.MINUTES.toMillis(1) + } + + private fun timeToAmountAndUnit(time: Long): Pair { + val absTime = kotlin.math.abs(time) + return when { + absTime == 0L -> 0 to 0 // Default to minutes when time is 0 + absTime % TimeUnit.DAYS.toMillis(7) == 0L -> + (absTime / TimeUnit.DAYS.toMillis(7)).toInt() to 3 + absTime % TimeUnit.DAYS.toMillis(1) == 0L -> + (absTime / TimeUnit.DAYS.toMillis(1)).toInt() to 2 + absTime % TimeUnit.HOURS.toMillis(1) == 0L -> + (absTime / TimeUnit.HOURS.toMillis(1)).toInt() to 1 + else -> + (absTime / TimeUnit.MINUTES.toMillis(1)).toInt() to 0 + } + } + @Composable fun AddRandomReminderDialog( - viewState: ViewState, - addAlarm: (Alarm) -> Unit, + alarm: Alarm?, + updateAlarm: (Alarm) -> Unit, closeDialog: () -> Unit, ) { - val time = rememberSaveable { mutableStateOf(15) } - val units = rememberSaveable { mutableStateOf(0) } - if (viewState.showRandomDialog) { - AlertDialog( - onDismissRequest = closeDialog, - text = { AddRandomReminder(time, units) }, - confirmButton = { - Constants.TextButton(text = R.string.ok, onClick = { - time.value.takeIf { it > 0 }?.let { i -> - addAlarm(Alarm(time = i * units.millis, type = TYPE_RANDOM)) - closeDialog() - } - }) - }, - dismissButton = { - Constants.TextButton( - text = R.string.cancel, - onClick = closeDialog - ) - }, - ) - } else { - time.value = 15 - units.value = 0 + // Create working copy from alarm or use defaults + var workingCopy by rememberSaveable { + mutableStateOf(alarm ?: Alarm(time = 15 * TimeUnit.MINUTES.toMillis(1), type = TYPE_RANDOM)) } + + AlertDialog( + onDismissRequest = closeDialog, + text = { + AddRandomReminder( + alarm = workingCopy, + updateAlarm = { workingCopy = it } + ) + }, + confirmButton = { + Constants.TextButton(text = R.string.ok, onClick = { + val (amount, _) = timeToAmountAndUnit(workingCopy.time) + if (amount > 0) { + updateAlarm(workingCopy) + closeDialog() + } + }) + }, + dismissButton = { + Constants.TextButton( + text = R.string.cancel, + onClick = closeDialog + ) + }, + ) } @Composable fun AddCustomReminderDialog( - viewState: ViewState, - addAlarm: (Alarm) -> Unit, + alarm: Alarm?, + updateAlarm: (Alarm) -> Unit, closeDialog: () -> Unit, ) { - val openDialog = viewState.showCustomDialog - val time = rememberSaveable { mutableStateOf(15) } - val units = rememberSaveable { mutableStateOf(0) } - val openRecurringDialog = rememberSaveable { mutableStateOf(false) } - val interval = rememberSaveable { mutableStateOf(0) } - val recurringUnits = rememberSaveable { mutableStateOf(0) } - val repeat = rememberSaveable { mutableStateOf(0) } - if (openDialog) { - if (!openRecurringDialog.value) { - AlertDialog( - onDismissRequest = closeDialog, - text = { - AddCustomReminder( - time, - units, - interval, - recurringUnits, - repeat, - showRecurring = { - openRecurringDialog.value = true - } - ) - }, - confirmButton = { - Constants.TextButton(text = R.string.ok, onClick = { - time.value.takeIf { it >= 0 }?.let { i -> - addAlarm( - Alarm( - time = -1 * i * units.millis, - type = TYPE_REL_END, - repeat = repeat.value, - interval = interval.value * recurringUnits.millis - ) - ) - closeDialog() - } - }) - }, - dismissButton = { - Constants.TextButton( - text = R.string.cancel, - onClick = closeDialog - ) - }, + // Create working copy from alarm or use defaults + var workingCopy by rememberSaveable { + mutableStateOf( + alarm ?: Alarm( + time = -1 * 15 * TimeUnit.MINUTES.toMillis(1), + type = TYPE_REL_END ) - } - AddRepeatReminderDialog( - openDialog = openRecurringDialog, - initialInterval = interval.value, - initialUnits = recurringUnits.value, - initialRepeat = repeat.value, - selected = { i, u, r -> - interval.value = i - recurringUnits.value = u - repeat.value = r - } ) - } else { - time.value = 15 - units.value = 0 - interval.value = 0 - recurringUnits.value = 0 - repeat.value = 0 } - } + var showRecurringDialog by rememberSaveable { mutableStateOf(false) } - @Composable - fun AddRepeatReminderDialog( - openDialog: MutableState, - initialInterval: Int, - initialUnits: Int, - initialRepeat: Int, - selected: (Int, Int, Int) -> Unit, - ) { - val interval = rememberSaveable { mutableStateOf(initialInterval) } - val units = rememberSaveable { mutableStateOf(initialUnits) } - val repeat = rememberSaveable { mutableStateOf(initialRepeat) } - val closeDialog = { - openDialog.value = false - } - if (openDialog.value) { + if (!showRecurringDialog) { AlertDialog( onDismissRequest = closeDialog, text = { - AddRecurringReminder( - openDialog.value, - interval, - units, - repeat, + AddCustomReminder( + alarm = workingCopy, + updateAlarm = { workingCopy = it }, + showRecurring = { showRecurringDialog = true } ) }, confirmButton = { Constants.TextButton(text = R.string.ok, onClick = { - if (interval.value > 0 && repeat.value > 0) { - selected(interval.value, units.value, repeat.value) - openDialog.value = false + val (amount, _) = timeToAmountAndUnit(workingCopy.time) + if (amount >= 0) { + updateAlarm(workingCopy) + closeDialog() } }) }, @@ -207,19 +166,74 @@ object AddReminderDialog { ) }, ) - } else { - interval.value = initialInterval.takeIf { it > 0 } ?: 15 - units.value = initialUnits - repeat.value = initialRepeat.takeIf { it > 0 } ?: 4 } + + if (showRecurringDialog) { + AddRepeatReminderDialog( + alarm = workingCopy, + updateAlarm = { workingCopy = it }, + closeDialog = { showRecurringDialog = false } + ) + } + } + + @Composable + fun AddRepeatReminderDialog( + alarm: Alarm, + updateAlarm: (Alarm) -> Unit, + closeDialog: () -> Unit, + ) { + // Create working copy with defaults if no recurrence set + var workingCopy by rememberSaveable { + mutableStateOf( + if (alarm.interval == 0L && alarm.repeat == 0) { + // Default to 15 minutes, 4 times + alarm.copy( + interval = 15 * TimeUnit.MINUTES.toMillis(1), + repeat = 4 + ) + } else { + alarm + } + ) + } + + AlertDialog( + onDismissRequest = closeDialog, + text = { + AddRecurringReminder( + alarm = workingCopy, + updateAlarm = { workingCopy = it } + ) + }, + confirmButton = { + Constants.TextButton(text = R.string.ok, onClick = { + val (intervalAmount, _) = timeToAmountAndUnit(workingCopy.interval) + if (intervalAmount > 0 && workingCopy.repeat > 0) { + updateAlarm(workingCopy) + closeDialog() + } + }) + }, + dismissButton = { + Constants.TextButton( + text = R.string.cancel, + onClick = closeDialog + ) + }, + ) } @Composable fun AddRandomReminder( - time: MutableState, - units: MutableState, + alarm: Alarm, + updateAlarm: (Alarm) -> Unit, ) { + val (initialAmount, initialUnit) = timeToAmountAndUnit(alarm.time) + var selectedUnit by rememberSaveable { mutableStateOf(initialUnit) } + val amount = if (alarm.time == 0L) 0 else (alarm.time / unitIndexToMillis(selectedUnit)).toInt() val scrollState = rememberScrollState() + Column( modifier = Modifier .fillMaxWidth() @@ -228,14 +242,27 @@ object AddReminderDialog { CenteredH6(text = stringResource(id = R.string.randomly_every, "").trim()) val focusRequester = remember { FocusRequester() } OutlinedIntInput( - time, + value = amount, + onValueChange = { newAmount -> + val amt = newAmount ?: 0 + updateAlarm(alarm.copy(time = amt * unitIndexToMillis(selectedUnit))) + }, modifier = Modifier .fillMaxWidth() .focusRequester(focusRequester) ) Spacer(modifier = Modifier.height(16.dp)) options.forEachIndexed { index, option -> - RadioRow(index, option, time, units) + RadioRow( + index = index, + option = option, + timeAmount = amount, + unitIndex = selectedUnit, + onUnitSelected = { newUnit -> + selectedUnit = newUnit + updateAlarm(alarm.copy(time = amount * unitIndexToMillis(newUnit))) + } + ) } ShowKeyboard(true, focusRequester) } @@ -243,14 +270,19 @@ object AddReminderDialog { @Composable fun AddCustomReminder( - time: MutableState, - units: MutableState, - interval: MutableState, - recurringUnits: MutableState, - repeat: MutableState, + alarm: Alarm, + updateAlarm: (Alarm) -> Unit, showRecurring: () -> Unit, ) { + val (initialAmount, initialUnit) = timeToAmountAndUnit(alarm.time) + var selectedUnit by rememberSaveable { mutableStateOf(initialUnit) } + val amount = if (alarm.time == 0L) 0 else kotlin.math.abs(alarm.time / unitIndexToMillis(selectedUnit)).toInt() + + val (initialIntervalAmount, initialIntervalUnit) = timeToAmountAndUnit(alarm.interval) + val intervalAmount = if (alarm.interval == 0L) 0 else (alarm.interval / unitIndexToMillis(initialIntervalUnit)).toInt() + val scrollState = rememberScrollState() + Column( modifier = Modifier .fillMaxWidth() @@ -259,7 +291,11 @@ object AddReminderDialog { CenteredH6(resId = R.string.custom_notification) val focusRequester = remember { FocusRequester() } OutlinedIntInput( - time, + value = amount, + onValueChange = { newAmount -> + val amt = newAmount ?: 0 + updateAlarm(alarm.copy(time = -1 * amt * unitIndexToMillis(selectedUnit))) + }, minValue = 0, modifier = Modifier .fillMaxWidth() @@ -267,7 +303,17 @@ object AddReminderDialog { ) Spacer(modifier = Modifier.height(16.dp)) options.forEachIndexed { index, option -> - RadioRow(index, option, time, units, R.string.alarm_before_due) + RadioRow( + index = index, + option = option, + timeAmount = amount, + unitIndex = selectedUnit, + onUnitSelected = { newUnit -> + selectedUnit = newUnit + updateAlarm(alarm.copy(time = -1 * amount * unitIndexToMillis(newUnit))) + }, + formatString = R.string.alarm_before_due + ) } Divider(modifier = Modifier.padding(vertical = 4.dp), thickness = 1.dp) Row(modifier = Modifier @@ -288,11 +334,11 @@ object AddReminderDialog { ), ) } - val repeating = repeat.value > 0 && interval.value > 0 + val repeating = alarm.repeat > 0 && intervalAmount > 0 val text = if (repeating) { LocalContext.current.resources.getRepeatString( - repeat.value, - interval.value * recurringUnits.millis + alarm.repeat, + alarm.interval ) } else { stringResource(id = R.string.repeat_option_does_not_repeat) @@ -305,11 +351,9 @@ object AddReminderDialog { .align(CenterVertically) ) if (repeating) { - ClearButton { - repeat.value = 0 - interval.value = 0 - recurringUnits.value = 0 - } + ClearButton(onClick = { + updateAlarm(alarm.copy(repeat = 0, interval = 0)) + }) } } ShowKeyboard(true, focusRequester) @@ -318,12 +362,14 @@ object AddReminderDialog { @Composable fun AddRecurringReminder( - openDialog: Boolean, - interval: MutableState, - units: MutableState, - repeat: MutableState + alarm: Alarm, + updateAlarm: (Alarm) -> Unit, ) { + val (initialIntervalAmount, initialIntervalUnit) = timeToAmountAndUnit(alarm.interval) + var selectedUnit by rememberSaveable { mutableStateOf(initialIntervalUnit) } + val intervalAmount = if (alarm.interval == 0L) 0 else (alarm.interval / unitIndexToMillis(selectedUnit)).toInt() val scrollState = rememberScrollState() + Column( modifier = Modifier .fillMaxWidth() @@ -332,24 +378,40 @@ object AddReminderDialog { CenteredH6(text = stringResource(id = R.string.repeats_plural, "").trim()) val focusRequester = remember { FocusRequester() } OutlinedIntInput( - time = interval, + value = intervalAmount, + onValueChange = { newAmount -> + val amt = newAmount ?: 0 + updateAlarm(alarm.copy(interval = amt * unitIndexToMillis(selectedUnit))) + }, modifier = Modifier.focusRequester(focusRequester), ) Spacer(modifier = Modifier.height(16.dp)) options.forEachIndexed { index, option -> - RadioRow(index, option, interval, units) + RadioRow( + index = index, + option = option, + timeAmount = intervalAmount, + unitIndex = selectedUnit, + onUnitSelected = { newUnit -> + selectedUnit = newUnit + updateAlarm(alarm.copy(interval = intervalAmount * unitIndexToMillis(newUnit))) + } + ) } Divider(modifier = Modifier.padding(vertical = 4.dp), thickness = 1.dp) Row(modifier = Modifier.fillMaxWidth()) { OutlinedIntInput( - time = repeat, + value = alarm.repeat, + onValueChange = { newRepeat -> + updateAlarm(alarm.copy(repeat = newRepeat ?: 0)) + }, modifier = Modifier.weight(0.5f), autoSelect = false, ) BodyText( text = LocalContext.current.resources.getQuantityString( R.plurals.repeat_times, - repeat.value + alarm.repeat ), modifier = Modifier .weight(0.5f) @@ -357,7 +419,7 @@ object AddReminderDialog { ) } - ShowKeyboard(openDialog, focusRequester) + ShowKeyboard(true, focusRequester) } } @@ -367,14 +429,6 @@ object AddReminderDialog { R.plurals.reminder_days, R.plurals.reminder_week, ) - - private val MutableState.millis: Long - get() = when (value) { - 1 -> TimeUnit.HOURS.toMillis(1) - 2 -> TimeUnit.DAYS.toMillis(1) - 3 -> TimeUnit.DAYS.toMillis(7) - else -> TimeUnit.MINUTES.toMillis(1) - } } @ExperimentalComposeUiApi @@ -391,25 +445,48 @@ fun ShowKeyboard(visible: Boolean, focusRequester: FocusRequester) { @Composable fun OutlinedIntInput( - time: MutableState, + value: Int?, + onValueChange: (Int?) -> Unit, modifier: Modifier = Modifier, minValue: Int = 1, autoSelect: Boolean = true, ) { - val value = rememberSaveable(stateSaver = TextFieldValue.Saver) { - val text = time.value.toString() + var textFieldValue by remember { mutableStateOf( TextFieldValue( - text = text, - selection = TextRange(0, if (autoSelect) text.length else 0) + text = value?.toString() ?: "", + selection = if (autoSelect) { + TextRange(0, value?.toString()?.length ?: 0) + } else { + TextRange.Zero + } ) ) } + + // Sync when external value changes, but don't interfere with user editing + LaunchedEffect(value) { + val currentParsedValue = textFieldValue.text.toIntOrNull() + // Only sync if the new value is different from what we currently parse to, + // and don't sync if the text field is empty (user is actively deleting) + if (currentParsedValue != value && textFieldValue.text.isNotEmpty()) { + val newText = value?.toString() ?: "" + textFieldValue = TextFieldValue( + text = newText, + selection = if (autoSelect) { + TextRange(0, newText.length) + } else { + textFieldValue.selection + } + ) + } + } + OutlinedTextField( - value = value.value, + value = textFieldValue, onValueChange = { - value.value = it.copy(text = it.text.filter { t -> t.isDigit() }) - time.value = value.value.text.toIntOrNull() ?: 0 + textFieldValue = it.copy(text = it.text.filter { t -> t.isDigit() }) + onValueChange(textFieldValue.text.toIntOrNull()) }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), modifier = modifier.padding(horizontal = 16.dp), @@ -419,7 +496,7 @@ fun OutlinedIntInput( focusedBorderColor = MaterialTheme.colorScheme.onSurface, unfocusedBorderColor = MaterialTheme.colorScheme.onSurface, ), - isError = value.value.text.toIntOrNull()?.let { it < minValue } ?: true, + isError = textFieldValue.text.toIntOrNull()?.let { it < minValue } ?: true, ) } @@ -445,23 +522,24 @@ fun CenteredH6(text: String) { fun RadioRow( index: Int, option: Int, - time: MutableState, - units: MutableState, + timeAmount: Int, + unitIndex: Int, + onUnitSelected: (Int) -> Unit, formatString: Int? = null, ) { - val optionString = LocalContext.current.resources.getQuantityString(option, time.value) + val optionString = LocalContext.current.resources.getQuantityString(option, timeAmount) Row( modifier = Modifier .fillMaxWidth() - .clickable { units.value = index } + .clickable { onUnitSelected(index) } ) { RadioButton( - selected = index == units.value, - onClick = { units.value = index }, + selected = index == unitIndex, + onClick = { onUnitSelected(index) }, modifier = Modifier.align(CenterVertically) ) BodyText( - text = if (index == units.value) { + text = if (index == unitIndex) { formatString ?.let { stringResource(id = formatString, optionString) } ?: optionString @@ -506,8 +584,14 @@ fun AddAlarmDialog( dismiss() return } - // TODO: if replacing custom alarm show custom picker - // TODO: prepopulate pickers with existing values + TYPE_REL_END -> { + if (viewState.replace.time < 0) { + // Custom reminder (before due) + addCustom() + dismiss() + return + } + } } } CustomDialog(visible = viewState.showAddAlarm, onDismiss = dismiss) { @@ -555,11 +639,11 @@ fun AddAlarmDialog( fun AddCustomReminderOne() = TasksTheme { AddReminderDialog.AddCustomReminder( - time = remember { mutableStateOf(1) }, - units = remember { mutableStateOf(0) }, - interval = remember { mutableStateOf(0) }, - recurringUnits = remember { mutableStateOf(0) }, - repeat = remember { mutableStateOf(0) }, + alarm = Alarm( + time = -1 * TimeUnit.MINUTES.toMillis(1), + type = TYPE_REL_END + ), + updateAlarm = {}, showRecurring = {}, ) } @@ -571,11 +655,11 @@ fun AddCustomReminderOne() = fun AddCustomReminder() = TasksTheme { AddReminderDialog.AddCustomReminder( - time = remember { mutableStateOf(15) }, - units = remember { mutableStateOf(1) }, - interval = remember { mutableStateOf(0) }, - recurringUnits = remember { mutableStateOf(0) }, - repeat = remember { mutableStateOf(0) }, + alarm = Alarm( + time = -15 * TimeUnit.HOURS.toMillis(1), + type = TYPE_REL_END + ), + updateAlarm = {}, showRecurring = {}, ) } @@ -587,10 +671,13 @@ fun AddCustomReminder() = fun AddRepeatingReminderOne() = TasksTheme { AddReminderDialog.AddRecurringReminder( - openDialog = true, - interval = remember { mutableStateOf(1) }, - units = remember { mutableStateOf(0) }, - repeat = remember { mutableStateOf(1) }, + alarm = Alarm( + time = -1 * TimeUnit.MINUTES.toMillis(1), + type = TYPE_REL_END, + interval = TimeUnit.MINUTES.toMillis(1), + repeat = 1 + ), + updateAlarm = {}, ) } @@ -601,10 +688,13 @@ fun AddRepeatingReminderOne() = fun AddRepeatingReminder() = TasksTheme { AddReminderDialog.AddRecurringReminder( - openDialog = true, - interval = remember { mutableStateOf(15) }, - units = remember { mutableStateOf(1) }, - repeat = remember { mutableStateOf(4) }, + alarm = Alarm( + time = -15 * TimeUnit.HOURS.toMillis(1), + type = TYPE_REL_END, + interval = 15 * TimeUnit.HOURS.toMillis(1), + repeat = 4 + ), + updateAlarm = {}, ) } @@ -615,8 +705,11 @@ fun AddRepeatingReminder() = fun AddRandomReminderOne() = TasksTheme { AddReminderDialog.AddRandomReminder( - time = remember { mutableStateOf(1) }, - units = remember { mutableStateOf(0) } + alarm = Alarm( + time = TimeUnit.MINUTES.toMillis(1), + type = TYPE_RANDOM + ), + updateAlarm = {} ) } @@ -627,8 +720,11 @@ fun AddRandomReminderOne() = fun AddRandomReminder() = TasksTheme { AddReminderDialog.AddRandomReminder( - time = remember { mutableStateOf(15) }, - units = remember { mutableStateOf(1) } + alarm = Alarm( + time = 15 * TimeUnit.HOURS.toMillis(1), + type = TYPE_RANDOM + ), + updateAlarm = {} ) } diff --git a/app/src/main/java/org/tasks/compose/edit/AlarmRow.kt b/app/src/main/java/org/tasks/compose/edit/AlarmRow.kt index 6b4e97661..eecf5eb0e 100644 --- a/app/src/main/java/org/tasks/compose/edit/AlarmRow.kt +++ b/app/src/main/java/org/tasks/compose/edit/AlarmRow.kt @@ -106,23 +106,27 @@ fun AlarmRow( dismiss = { vm.showAddAlarm(visible = false) }, ) - AddReminderDialog.AddCustomReminderDialog( - viewState = viewState, - addAlarm = { - viewState.replace?.let(deleteAlarm) - addAlarm(it) - }, - closeDialog = { vm.showCustomDialog(visible = false) } - ) + if (viewState.showCustomDialog) { + AddReminderDialog.AddCustomReminderDialog( + alarm = viewState.replace, + updateAlarm = { + viewState.replace?.let(deleteAlarm) + addAlarm(it) + }, + closeDialog = { vm.showCustomDialog(visible = false) } + ) + } - AddReminderDialog.AddRandomReminderDialog( - viewState = viewState, - addAlarm = { - viewState.replace?.let(deleteAlarm) - addAlarm(it) - }, - closeDialog = { vm.showRandomDialog(visible = false) } - ) + if (viewState.showRandomDialog) { + AddReminderDialog.AddRandomReminderDialog( + alarm = viewState.replace, + updateAlarm = { + viewState.replace?.let(deleteAlarm) + addAlarm(it) + }, + closeDialog = { vm.showRandomDialog(visible = false) } + ) + } }, ) }