From 6fd987a0554fc3ab01a7c64b9e0df9f5d41dd8d3 Mon Sep 17 00:00:00 2001 From: hady Date: Sat, 19 Jul 2025 10:36:22 +0300 Subject: [PATCH] RepeatRow is @Composable --- .../java/org/tasks/compose/edit/RepeatRow.kt | 64 ++ .../org/tasks/compose/edit/TaskEditScreen.kt | 12 +- .../tasks/repeats/BasicReccurrencePicker.kt | 263 ++++++++ .../org/tasks/repeats/CustomRecurrenceEdit.kt | 581 ++++++++++++++++++ .../repeats/CustomRecurrenceEditState.kt | 271 ++++++++ .../tasks/repeats/RecurrencePickerDialog.kt | 63 ++ 6 files changed, 1253 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/org/tasks/repeats/BasicReccurrencePicker.kt create mode 100644 app/src/main/java/org/tasks/repeats/CustomRecurrenceEdit.kt create mode 100644 app/src/main/java/org/tasks/repeats/CustomRecurrenceEditState.kt create mode 100644 app/src/main/java/org/tasks/repeats/RecurrencePickerDialog.kt diff --git a/app/src/main/java/org/tasks/compose/edit/RepeatRow.kt b/app/src/main/java/org/tasks/compose/edit/RepeatRow.kt index 73e77d81f..af3e562fd 100644 --- a/app/src/main/java/org/tasks/compose/edit/RepeatRow.kt +++ b/app/src/main/java/org/tasks/compose/edit/RepeatRow.kt @@ -15,6 +15,7 @@ import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text 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 @@ -25,11 +26,74 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import net.fortuna.ical4j.model.Recur +import net.fortuna.ical4j.model.WeekDay import org.tasks.R import org.tasks.compose.DisabledText import org.tasks.compose.TaskEditRow import org.tasks.data.entity.Task +import org.tasks.repeats.RecurrencePickerDialog +import org.tasks.repeats.RecurrenceUtils.newRecur +import org.tasks.repeats.rememberRepeatRuleToString import org.tasks.themes.TasksTheme +import org.tasks.time.DateTime +import org.tasks.time.DateTimeUtils2.currentTimeMillis + +@Composable +fun RepeatRow( + recurrence: String?, + onRecurrenceChanged: (String?) -> Unit, + repeatFrom: @Task.RepeatFrom Int, + onRepeatFromChanged: (@Task.RepeatFrom Int) -> Unit, + dueDate: Long, + accountType: Int +) { + val showPicker = remember { mutableStateOf(false) } + + RepeatRow( + recurrence = rememberRepeatRuleToString().toString(recurrence), + repeatFrom = repeatFrom, + onClick = { showPicker.value = true }, + onRepeatFromChanged = onRepeatFromChanged + ) + + if (showPicker.value) { + RecurrencePickerDialog( + dismiss = { showPicker.value = false }, + recurrence = recurrence, + onRecurrenceChanged = onRecurrenceChanged, + repeatFrom = repeatFrom, + onRepeatFromChanged = onRepeatFromChanged, + accountType = accountType, + ) + } + + fun onDueDateChanged() { + // TODO: move to view model + recurrence?.takeIf { it.isNotBlank() }?.let { recurrence -> + val recur = newRecur(recurrence) + if (recur.frequency == Recur.Frequency.MONTHLY && recur.dayList.isNotEmpty()) { + val weekdayNum = recur.dayList[0] + val dateTime = + DateTime(dueDate.let { if (it > 0) it else currentTimeMillis() }) + val num: Int + val dayOfWeekInMonth = dateTime.dayOfWeekInMonth + num = if (weekdayNum.offset == -1 || dayOfWeekInMonth == 5) { + if (dayOfWeekInMonth == dateTime.maxDayOfWeekInMonth) -1 else dayOfWeekInMonth + } else { + dayOfWeekInMonth + } + recur.dayList.let { + it.clear() + it.add(WeekDay(dateTime.weekDay, num)) + } + onRecurrenceChanged(recur.toString()) + } + } + } + + LaunchedEffect(dueDate) { onDueDateChanged() } +} @Composable fun RepeatRow( diff --git a/app/src/main/java/org/tasks/compose/edit/TaskEditScreen.kt b/app/src/main/java/org/tasks/compose/edit/TaskEditScreen.kt index d00c15887..b0c360bb3 100644 --- a/app/src/main/java/org/tasks/compose/edit/TaskEditScreen.kt +++ b/app/src/main/java/org/tasks/compose/edit/TaskEditScreen.kt @@ -276,7 +276,17 @@ fun TaskEditScreen( FilesControlSet.TAG -> AndroidFragment() TimerControlSet.TAG -> AndroidFragment() TagsControlSet.TAG -> AndroidFragment() - RepeatControlSet.TAG -> AndroidFragment() + RepeatControlSet.TAG -> { + //AndroidFragment() + RepeatRow( + recurrence = viewState.task.recurrence, + onRecurrenceChanged = { editViewModel.setRecurrence(it) }, + repeatFrom = viewState.task.repeatFrom, + onRepeatFromChanged = { editViewModel.setRepeatFrom(it) }, + dueDate = editViewModel.dueDate.collectAsStateWithLifecycle().value, + accountType = viewState.list.account.accountType + ) + } SubtaskControlSet.TAG -> AndroidFragment() else -> throw IllegalArgumentException("Unknown row: $tag") } diff --git a/app/src/main/java/org/tasks/repeats/BasicReccurrencePicker.kt b/app/src/main/java/org/tasks/repeats/BasicReccurrencePicker.kt new file mode 100644 index 000000000..80d5f695f --- /dev/null +++ b/app/src/main/java/org/tasks/repeats/BasicReccurrencePicker.kt @@ -0,0 +1,263 @@ +package org.tasks.repeats + +/* This is mostly a copy of the BasicRecurrenceDialog with UI made @Composable */ + +import android.content.Context +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import com.google.common.collect.Lists +import net.fortuna.ical4j.model.Recur +import org.tasks.R +import org.tasks.analytics.Firebase +import org.tasks.compose.Constants.TextButton +import org.tasks.data.entity.Task +import org.tasks.preferences.Preferences +import org.tasks.repeats.RecurrenceUtils.newRecur +import timber.log.Timber +import java.util.Locale + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BasicRecurrencePicker ( + dismiss: () -> Unit, + recurrence: String?, + setRecurrence: (String?) -> Unit, + peekCustomRecurrence: () -> Unit, + repeatFrom: @Task.RepeatFrom Int = Task.RepeatFrom.COMPLETION_DATE, + onRepeatFromChanged: ((@Task.RepeatFrom Int) -> Unit)? = null, +) { + + val context = LocalContext.current + val helper = remember { RecurrenceHelper(context) } + helper.setRecurrence(recurrence) + + fun setSelection(index: Int) { + when (index) { + 0 -> setRecurrence(null) + 5 -> { + peekCustomRecurrence() + return // to avoid dismiss() call + } + 6 -> Unit + else -> { + setRecurrence( + newRecur().apply { + interval = 1 + setFrequency(helper.selectedFrequency(index).name) + }.toString() + ) + } + } + dismiss() + } + + BasicAlertDialog( + onDismissRequest = dismiss, + ) { + Card { + Column (modifier = Modifier.padding(16.dp)) { + onRepeatFromChanged?.let { + Spacer(modifier = Modifier.height(16.dp)) + Row (modifier = Modifier.padding(start = 12.dp, top = 12.dp, bottom = 12.dp)){ + Text( + text = stringResource(id = R.string.repeats_from), + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(modifier = Modifier.width(4.dp)) + var expanded by remember { mutableStateOf(false) } + Text( + text = stringResource( + id = if (repeatFrom == Task.RepeatFrom.COMPLETION_DATE) + R.string.repeat_type_completion + else + R.string.repeat_type_due + ), + style = MaterialTheme.typography.bodyLarge.copy( + textDecoration = TextDecoration.Underline, + ), + modifier = Modifier.clickable { expanded = true }, + color = MaterialTheme.colorScheme.onSurface, + ) + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + DropdownMenuItem( + onClick = { + expanded = false + onRepeatFromChanged(Task.RepeatFrom.DUE_DATE) + }, + text = { + Text( + text = stringResource(id = R.string.repeat_type_due), + color = MaterialTheme.colorScheme.onSurface, + ) + } + ) + DropdownMenuItem( + onClick = { + expanded = false + onRepeatFromChanged(Task.RepeatFrom.COMPLETION_DATE) + }, + text = { + Text( + text = stringResource(id = R.string.repeat_type_completion), + color = MaterialTheme.colorScheme.onSurface, + ) + } + ) + } + } + } + Spacer(modifier = Modifier.height(16.dp)) + if (helper.isCustomValue()) { + SelectableText( + text = helper.repeatRuleToString.toString(recurrence)!!, + index = 6, + selected = 6, + setSelection = { setSelection(6) } + ) + } + for (i in 0..5) { + SelectableText( + text = helper.title(i), + index = i, + selected = helper.selectionIndex(), + setSelection = { setSelection(i) } + ) + } + Row ( + modifier = Modifier.padding(bottom = 12.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + TextButton(R.string.ok, dismiss) + } + } + } + } +} + +@Composable +fun SelectableText ( + text: String, + index: Int, + selected: Int, + setSelection: (Int) -> Unit +) { + Row ( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable{ setSelection(index) } + ) { + RadioButton( + selected = index == selected, + onClick = { setSelection(index) } + ) + Spacer(modifier = Modifier.width(4.dp)) + Text(text = text, textDecoration = TextDecoration.Underline) + } +} + +/** + * Helper object over recurrence string, to share basic access to rrule and to avoid reloading + * R.array.repeat_options during each recomposition + * Intended use: + * + * val helper = remember { RecurrenceHelper(context) } + * helper.setRecurrence(recurrence) + * ... + * Text(text = helper.title(selectedIndex), ...) + */ +class RecurrenceHelper ( + context: Context, +) { + val repeatRuleToString = RepeatRuleToString(context,Locale.getDefault(),Firebase(context, Preferences(context))) + private var _recurrence: String? = null + val recurrence: String? get() = _recurrence + + private var _rrule: Recur? = null + val rrule: Recur? get() = _rrule + + private val titles: MutableList = + Lists.newArrayList(*context.resources.getStringArray(R.array.repeat_options)) + private val ruleTitle = if (isCustomValue()) repeatRuleToString.toString(recurrence)!! else titles[5] + + fun title(index: Int): String = + if (index < 5) titles[index] + else ruleTitle + + fun isCustomValue(): Boolean { + if (rrule == null) { + return false + } + val frequency = rrule!!.frequency + return (frequency == Recur.Frequency.WEEKLY || frequency == Recur.Frequency.MONTHLY) && !rrule!!.dayList.isEmpty() + || frequency == Recur.Frequency.HOURLY + || frequency == Recur.Frequency.MINUTELY + || rrule!!.until != null + || rrule!!.interval > 1 + || rrule!!.count > 0 + } + + fun selectionIndex(): Int = + when { + rrule == null -> 0 + isCustomValue() -> 6 + rrule!!.frequency == Recur.Frequency.DAILY -> 1 + rrule!!.frequency == Recur.Frequency.WEEKLY -> 2 + rrule!!.frequency == Recur.Frequency.MONTHLY -> 3 + rrule!!.frequency == Recur.Frequency.YEARLY -> 4 + else -> 0 + } + + fun selectedFrequency(index: Int): Recur.Frequency = + when (index) { + 1 -> Recur.Frequency.DAILY + 2 -> Recur.Frequency.WEEKLY + 3 -> Recur.Frequency.MONTHLY + 4 -> Recur.Frequency.YEARLY + else -> throw IllegalArgumentException() + } + + fun setRecurrence(recurrence: String?) { + _recurrence = recurrence + _rrule = recurrence + .takeIf { !it.isNullOrBlank() } + ?.let { + try { + newRecur(it) + } catch (e: Exception) { + Timber.e(e) + null + } + } + } +} + +@Composable +fun rememberRepeatRuleToString(): RepeatRuleToString { + val context = LocalContext.current + return remember { RepeatRuleToString(context,Locale.getDefault(),Firebase(context, Preferences(context))) } +} diff --git a/app/src/main/java/org/tasks/repeats/CustomRecurrenceEdit.kt b/app/src/main/java/org/tasks/repeats/CustomRecurrenceEdit.kt new file mode 100644 index 000000000..0f8fe3c7b --- /dev/null +++ b/app/src/main/java/org/tasks/repeats/CustomRecurrenceEdit.kt @@ -0,0 +1,581 @@ +package org.tasks.repeats + +/* +* This file is a copy of the CustomRecurrence.kt +* The function CustomRecurrence is renamed to CustomRecurrenceEdit to avoid name conflicts, and +* CustomRecurrenceEditState is used instead of the CustomRecurrenceViewModel +*/ + +import android.content.res.Configuration +import androidx.activity.compose.BackHandler +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.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material3.DisplayMode +import androidx.compose.material3.Divider +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +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.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.core.os.ConfigurationCompat +import kotlinx.coroutines.runBlocking +import net.fortuna.ical4j.model.Recur +import net.fortuna.ical4j.model.WeekDay +import org.tasks.R +import org.tasks.compose.OutlinedBox +import org.tasks.compose.OutlinedNumberInput +import org.tasks.compose.OutlinedSpinner +import org.tasks.compose.border +import org.tasks.compose.pickers.DatePickerDialog +import org.tasks.kmp.org.tasks.time.getRelativeDay +import org.tasks.themes.TasksTheme +import java.time.DayOfWeek +import java.time.format.TextStyle +import java.util.Locale + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CustomRecurrenceEdit( + state: CustomRecurrenceEditState.ViewState, + save: () -> Unit, + discard: () -> Unit, + setInterval: (Int) -> Unit, + setSelectedFrequency: (Recur.Frequency) -> Unit, + setEndDate: (Long) -> Unit, + setSelectedEndType: (Int) -> Unit, + setOccurrences: (Int) -> Unit, + toggleDay: (DayOfWeek) -> Unit, + setMonthSelection: (Int) -> Unit, + calendarDisplayMode: DisplayMode, + setDisplayMode: (DisplayMode) -> Unit, +) { + Dialog( + onDismissRequest = { discard() }, + properties = DialogProperties( + dismissOnBackPress = true, + usePlatformDefaultWidth = false, + decorFitsSystemWindows = false + ) + ) { + BackHandler { save() } + Scaffold( + topBar = { + TopAppBar( + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + titleContentColor = MaterialTheme.colorScheme.onSurface, + navigationIconContentColor = MaterialTheme.colorScheme.onSurface, + actionIconContentColor = MaterialTheme.colorScheme.onSurface, + ), + title = { + Text( + text = stringResource(id = R.string.repeats_custom_recurrence), + ) + }, + navigationIcon = { + IconButton(onClick = save) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = stringResource(id = R.string.save), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + }, + actions = { + TextButton(onClick = discard) { + Text( + text = stringResource(id = R.string.cancel), + style = MaterialTheme.typography.bodyLarge.copy( + fontFeatureSettings = "c2sc, smcp" + ) + ) + } + }, + ) + } + ) { padding -> + Surface( + color = MaterialTheme.colorScheme.surface, + modifier = Modifier + .fillMaxSize() + .padding(padding), + ) { + Column { + Spacer(modifier = Modifier.height(16.dp)) + Header(R.string.repeats_every) + Spacer(modifier = Modifier.height(16.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.padding(horizontal = 16.dp), + ) { + OutlinedNumberInput( + number = state.interval, + onTextChanged = setInterval, + ) + val context = LocalContext.current + val options by remember(state.interval, state.frequency) { + derivedStateOf { + state.frequencyOptions.map { + context.resources.getQuantityString( + it.plural, + state.interval, + state.interval, + ) + } + } + } + OutlinedSpinner( + text = pluralStringResource( + id = state.frequency.plural, + count = state.interval + ), + options = options, + onSelected = { setSelectedFrequency(state.frequencyOptions[it]) }, + ) + } + if (state.frequency == Recur.Frequency.WEEKLY) { + WeekdayPicker( + daysOfWeek = state.daysOfWeek, + selected = state.selectedDays, + toggle = toggleDay, + ) + } else if (state.frequency == Recur.Frequency.MONTHLY && !state.isMicrosoftTask) { + MonthlyPicker( + monthDay = state.monthDay, + dayNumber = state.dueDayOfMonth, + dayOfWeek = state.dueDayOfWeek, + nthWeek = state.nthWeek, + isLastWeek = state.lastWeekDayOfMonth, + locale = state.locale, + onSelected = setMonthSelection, + ) + } + if (!state.isMicrosoftTask) { + Divider( + modifier = Modifier.padding(vertical = if (state.frequency == Recur.Frequency.WEEKLY) 11.dp else 16.dp), + color = border() + ) + EndsPicker( + selection = state.endSelection, + endDate = state.endDate, + endOccurrences = state.endCount, + setEndDate = setEndDate, + setSelection = setSelectedEndType, + setOccurrences = setOccurrences, + calendarDisplayMode = calendarDisplayMode, + setDisplayMode = setDisplayMode, + ) + } + } + } + } + } +} + +@Composable +private fun Header(resId: Int) { + Text( + text = stringResource(id = resId), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(horizontal = 16.dp), + ) +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun WeekdayPicker( + daysOfWeek: List, + selected: List, + toggle: (DayOfWeek) -> Unit, +) { + val context = LocalContext.current + val locale = remember { + ConfigurationCompat + .getLocales(context.resources.configuration) + .get(0) + ?: Locale.getDefault() + } + Divider( + modifier = Modifier.padding(vertical = 16.dp), + color = border() + ) + Header(R.string.repeats_weekly_on) + Spacer(modifier = Modifier.height(16.dp)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.padding(horizontal = 16.dp), + ) { + daysOfWeek.forEach { dayOfWeek -> + val string = remember(dayOfWeek) { + dayOfWeek.getDisplayName(TextStyle.NARROW, locale) + } + Box( + modifier = Modifier + .padding(bottom = 5.dp) // hack until compose 1.5 + .size(36.dp) + .let { + if (selected.contains(dayOfWeek)) { + it.background(MaterialTheme.colorScheme.secondary, shape = CircleShape) + } else { + it.border(1.dp, border(), shape = CircleShape) + } + } + .clickable { toggle(dayOfWeek) }, + contentAlignment = Alignment.Center + ) { + Text( + text = string, + style = MaterialTheme.typography.bodyMedium, + color = if (selected.contains(dayOfWeek)) MaterialTheme.colorScheme.onSecondary else MaterialTheme.colorScheme.onSurface + ) + } + } + } +} + +@Composable +private fun MonthlyPicker( + monthDay: WeekDay?, + dayNumber: Int, + dayOfWeek: DayOfWeek, + nthWeek: Int, + isLastWeek: Boolean, + locale: Locale, + onSelected: (Int) -> Unit, +) { + val selection = remember(monthDay) { + when (monthDay?.offset) { + null -> 0 + -1 -> 2 + else -> 1 + } + } + Divider( + modifier = Modifier.padding(vertical = 16.dp), + color = border() + ) + val context = LocalContext.current + val options = remember(dayNumber, dayOfWeek, nthWeek, isLastWeek, locale) { + ArrayList().apply { + add(context.getString(R.string.repeat_monthly_on_day_number, dayNumber)) + val nth = context.getString( + when (nthWeek - 1) { + 0 -> R.string.repeat_monthly_first_week + 1 -> R.string.repeat_monthly_second_week + 2 -> R.string.repeat_monthly_third_week + 3 -> R.string.repeat_monthly_fourth_week + 4 -> R.string.repeat_monthly_fifth_week + else -> throw IllegalArgumentException() + } + ) + val dayOfWeekDisplayName = dayOfWeek.getDisplayName(TextStyle.FULL, locale) + add( + context.getString( + R.string.repeat_monthly_on_the_nth_weekday, + nth, + dayOfWeekDisplayName + ) + ) + if (isLastWeek) { + add( + context.getString( + R.string.repeat_monthly_on_the_nth_weekday, + context.getString(R.string.repeat_monthly_last_week), + dayOfWeekDisplayName + ) + ) + } + } + } + Row( + modifier = Modifier.padding(horizontal = 16.dp), + ) { + OutlinedSpinner( + text = options[selection], + options = options, + onSelected = onSelected, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun EndsPicker( + selection: Int, + endDate: Long, + endOccurrences: Int, + calendarDisplayMode: DisplayMode, + setDisplayMode: (DisplayMode) -> Unit, + setOccurrences: (Int) -> Unit, + setEndDate: (Long) -> Unit, + setSelection: (Int) -> Unit, +) { + Header(R.string.repeats_ends) + Spacer(modifier = Modifier.height(8.dp)) + RadioRow(selected = selection == 0, onClick = { setSelection(0) }) { + Text(text = stringResource(id = R.string.repeats_never)) + } + Divider( + modifier = Modifier.padding(start = 50.dp, end = 16.dp, top = 8.dp, bottom = 8.dp), + color = border() + ) + RadioRow(selected = selection == 1, onClick = { setSelection(1) }) { + Text(text = stringResource(id = R.string.repeats_on)) + Spacer(modifier = Modifier.width(8.dp)) + val context = LocalContext.current + val endDateString by remember(context, endDate) { + derivedStateOf { + runBlocking { + getRelativeDay(endDate) + } + } + } + var showDatePicker by remember { mutableStateOf(false) } + if (showDatePicker) { + DatePickerDialog( + initialDate = endDate, + displayMode = calendarDisplayMode, + setDisplayMode = setDisplayMode, + selected = { + setEndDate(it) + showDatePicker = false + }, + dismiss = { showDatePicker = false }, + ) + } + OutlinedBox( + modifier = Modifier.clickable { + setSelection(1) + showDatePicker = true + } + ) { + Text(text = endDateString) + Spacer(modifier = Modifier.width(4.dp)) + } + } + Divider( + modifier = Modifier.padding(start = 50.dp, end = 16.dp, top = 8.dp, bottom = 8.dp), + color = border() + ) + RadioRow(selected = selection == 2, onClick = { setSelection(2) }) { + Text(text = stringResource(id = R.string.repeats_after)) + Spacer(modifier = Modifier.width(8.dp)) + OutlinedNumberInput( + number = endOccurrences, + onTextChanged = setOccurrences, + onFocus = { setSelection(2) }, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = pluralStringResource(id = R.plurals.repeat_occurrence, endOccurrences)) + } +} + +@Composable +private fun RadioRow( + selected: Boolean, + onClick: () -> Unit, + content: @Composable RowScope.() -> Unit +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { onClick() }, + ) { + RadioButton(selected = selected, onClick = onClick) + Row( + verticalAlignment = Alignment.CenterVertically, + content = content, + ) + } +} + +private val Recur.Frequency.plural: Int + get() = when (this) { + Recur.Frequency.MINUTELY -> R.plurals.repeat_minutes + Recur.Frequency.HOURLY -> R.plurals.repeat_hours + Recur.Frequency.DAILY -> R.plurals.repeat_days + Recur.Frequency.WEEKLY -> R.plurals.repeat_weeks + Recur.Frequency.MONTHLY -> R.plurals.repeat_months + Recur.Frequency.YEARLY -> R.plurals.repeat_years + else -> throw RuntimeException() + } + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun WeeklyPreview() { + TasksTheme { + CustomRecurrenceEdit( + state = CustomRecurrenceEditState.ViewState(frequency = Recur.Frequency.WEEKLY), + save = {}, + discard = {}, + setSelectedFrequency = {}, + setSelectedEndType = {}, + setEndDate = {}, + setInterval = {}, + setOccurrences = {}, + toggleDay = {}, + setMonthSelection = {}, + calendarDisplayMode = DisplayMode.Picker, + setDisplayMode = {}, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun MonthlyPreview() { + TasksTheme { + CustomRecurrenceEdit ( + state = CustomRecurrenceEditState.ViewState(frequency = Recur.Frequency.MONTHLY), + save = {}, + discard = {}, + setSelectedFrequency = {}, + setSelectedEndType = {}, + setEndDate = {}, + setInterval = {}, + setOccurrences = {}, + toggleDay = {}, + setMonthSelection = {}, + calendarDisplayMode = DisplayMode.Picker, + setDisplayMode = {}, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun MinutelyPreview() { + TasksTheme { + CustomRecurrenceEdit( + state = CustomRecurrenceEditState.ViewState(frequency = Recur.Frequency.MINUTELY), + save = {}, + discard = {}, + setSelectedFrequency = {}, + setSelectedEndType = {}, + setEndDate = {}, + setInterval = {}, + setOccurrences = {}, + toggleDay = {}, + setMonthSelection = {}, + calendarDisplayMode = DisplayMode.Picker, + setDisplayMode = {}, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun HourlyPreview() { + TasksTheme { + CustomRecurrenceEdit( + state = CustomRecurrenceEditState.ViewState(frequency = Recur.Frequency.HOURLY), + save = {}, + discard = {}, + setSelectedFrequency = {}, + setSelectedEndType = {}, + setEndDate = {}, + setInterval = {}, + setOccurrences = {}, + toggleDay = {}, + setMonthSelection = {}, + calendarDisplayMode = DisplayMode.Picker, + setDisplayMode = {}, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun DailyPreview() { + TasksTheme { + CustomRecurrenceEdit( + state = CustomRecurrenceEditState.ViewState(frequency = Recur.Frequency.DAILY), + save = {}, + discard = {}, + setSelectedFrequency = {}, + setSelectedEndType = {}, + setEndDate = {}, + setInterval = {}, + setOccurrences = {}, + toggleDay = {}, + setMonthSelection = {}, + calendarDisplayMode = DisplayMode.Picker, + setDisplayMode = {}, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun YearlyPreview() { + TasksTheme { + CustomRecurrenceEdit( + state = CustomRecurrenceEditState.ViewState(frequency = Recur.Frequency.YEARLY), + save = {}, + discard = {}, + setSelectedFrequency = {}, + setSelectedEndType = {}, + setEndDate = {}, + setInterval = {}, + setOccurrences = {}, + toggleDay = {}, + setMonthSelection = {}, + calendarDisplayMode = DisplayMode.Picker, + setDisplayMode = {}, + ) + } +} diff --git a/app/src/main/java/org/tasks/repeats/CustomRecurrenceEditState.kt b/app/src/main/java/org/tasks/repeats/CustomRecurrenceEditState.kt new file mode 100644 index 000000000..39a7d24fe --- /dev/null +++ b/app/src/main/java/org/tasks/repeats/CustomRecurrenceEditState.kt @@ -0,0 +1,271 @@ +package org.tasks.repeats + +/* +* This is essentially a copy of the CustomRecurrenceViewModel, changed to a saveable independent +* class to fit to @Composable environment +* */ + +import android.os.Bundle +import androidx.compose.runtime.Composable +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import net.fortuna.ical4j.model.Date +import net.fortuna.ical4j.model.Recur +import net.fortuna.ical4j.model.Recur.Frequency.DAILY +import net.fortuna.ical4j.model.Recur.Frequency.HOURLY +import net.fortuna.ical4j.model.Recur.Frequency.MINUTELY +import net.fortuna.ical4j.model.Recur.Frequency.MONTHLY +import net.fortuna.ical4j.model.Recur.Frequency.WEEKLY +import net.fortuna.ical4j.model.Recur.Frequency.YEARLY +import net.fortuna.ical4j.model.WeekDay +import net.fortuna.ical4j.model.WeekDayList +import net.fortuna.ical4j.model.property.RRule +import org.tasks.data.entity.CaldavAccount.Companion.TYPE_MICROSOFT +import org.tasks.date.DateTimeUtils.toDateTime +import org.tasks.time.DateTime +import org.tasks.time.DateTimeUtils2.currentTimeMillis +import org.tasks.time.startOfDay +import java.time.DayOfWeek +import java.time.Instant +import java.time.ZoneId +import java.time.temporal.WeekFields +import java.util.Calendar +import java.util.Calendar.DAY_OF_WEEK_IN_MONTH +import java.util.Locale + +class CustomRecurrenceEditState( + rrule: String?, + dueDate: Long?, + val accountType: Int, + locale: Locale = Locale.getDefault() +) { + data class ViewState( + val interval: Int = 1, + val frequency: Recur.Frequency = WEEKLY, + val dueDate: Long = currentTimeMillis().startOfDay(), + val endSelection: Int = 0, + val endDate: Long = dueDate.toDateTime().plusMonths(1).startOfDay().millis, + val endCount: Int = 1, + val frequencyOptions: List = FREQ_ALL, + val daysOfWeek: List = Locale.getDefault().daysOfWeek(), + val selectedDays: List = emptyList(), + val locale: Locale = Locale.getDefault(), + val monthDay: WeekDay? = null, + val isMicrosoftTask: Boolean = false, + ) { + val dueDayOfWeek: DayOfWeek + get() = Instant.ofEpochMilli(dueDate).atZone(ZoneId.systemDefault()).dayOfWeek + + val dueDayOfMonth: Int + get() = DateTime(dueDate).dayOfMonth + + val nthWeek: Int + get() = + Calendar.getInstance(locale) + .apply { timeInMillis = dueDate } + .get(DAY_OF_WEEK_IN_MONTH) + + val lastWeekDayOfMonth: Boolean + get() = + Calendar.getInstance(locale) + .apply { timeInMillis = dueDate } + .let { it[DAY_OF_WEEK_IN_MONTH] == it.getActualMaximum(DAY_OF_WEEK_IN_MONTH) } + } + + private val _state = MutableStateFlow(ViewState()) + val state = _state.asStateFlow() + + init { + val daysOfWeek = locale.daysOfWeek() + val recur = rrule + ?.takeIf { it.isNotBlank() } + ?.let { RRule(it) } + ?.recur + val dueDate = dueDate + ?.takeIf { it > 0 } + ?: currentTimeMillis().startOfDay() + val isMicrosoftTask = accountType == TYPE_MICROSOFT + val frequencies = if (isMicrosoftTask) FREQ_MICROSOFT else FREQ_ALL + _state.update { state -> + state.copy( + interval = recur?.interval?.takeIf { it > 0 } ?: 1, + frequency = recur?.frequency?.takeIf { frequencies.contains(it) } ?: WEEKLY, + dueDate = dueDate, + endSelection = when { + isMicrosoftTask -> 0 + recur == null -> 0 + recur.until != null -> 1 + recur.count >= 0 -> 2 + else -> 0 + }, + endDate = DateTime(dueDate).plusMonths(1).startOfDay().millis, + endCount = recur?.count?.takeIf { it >= 0 } ?: 1, + daysOfWeek = daysOfWeek, + selectedDays = recur + ?.dayList + ?.takeIf { recur.frequency == WEEKLY } + ?.toDaysOfWeek() + ?: emptyList(), + locale = locale, + monthDay = recur + ?.dayList + ?.takeIf { recur.frequency == MONTHLY && !isMicrosoftTask } + ?.firstOrNull(), + isMicrosoftTask = isMicrosoftTask, + frequencyOptions = frequencies, + ) + } + } + + fun setEndType(endType: Int) { + _state.update { + it.copy(endSelection = endType) + } + } + + fun setFrequency(frequency: Recur.Frequency) { + _state.update { + it.copy(frequency = frequency) + } + } + + fun setEndDate(endDate: Long) { + _state.update { + it.copy(endDate = endDate) + } + } + + fun setInterval(interval: Int) { + _state.update { + it.copy(interval = interval) + } + } + + fun setOccurrences(occurrences: Int) { + _state.update { + it.copy(endCount = occurrences) + } + } + + fun toggleDay(dayOfWeek: DayOfWeek) { + _state.update { state -> + state.copy( + selectedDays = state.selectedDays.toMutableList().also { + if (!it.remove(dayOfWeek)) { + it.add(dayOfWeek) + } + } + ) + } + } + + fun getRecur(): String { + val state = _state.value + val builder = Recur.Builder().frequency(state.frequency) + if (state.frequency == WEEKLY) { + builder.dayList(state.selectedDays.toWeekDayList()) + } else if (state.frequency == MONTHLY) { + state.monthDay?.let { builder.dayList(WeekDayList(it)) } + } + if (state.interval > 1) { + builder.interval(state.interval) + } + when (state.endSelection) { + // 1 -> builder.until(Date(state.endDate)) + // builder.until expects that Date() is in local timezone and strips it, which effectively + // equivalent to decrementing the "endDate" value by TimeZone.offset. This changes the date + // to the previous day in timezones to the East of GMT, so this value shall be pre-shifted + 1 -> builder.until(Date(DateTime(state.endDate).let { it.millis + it.offset })) + 2 -> builder.count(state.endCount.coerceAtLeast(1)) + } + return builder.build().toString() + } + + fun setMonthSelection(selection: Int) { + _state.update { + it.copy( + monthDay = when (selection) { + 0 -> null + 1 -> WeekDay(it.dueDayOfWeek.weekDay, it.nthWeek) + 2 -> WeekDay(it.dueDayOfWeek.weekDay, -1) + else -> throw IllegalArgumentException() + }, + ) + } + } + + companion object { + val FREQ_ALL = listOf(MINUTELY, HOURLY, DAILY, WEEKLY, MONTHLY, YEARLY) + val FREQ_MICROSOFT = listOf(DAILY, WEEKLY, MONTHLY, YEARLY) + + private fun Locale.daysOfWeek(): List { + val values = DayOfWeek.values() + val weekFields = WeekFields.of(this) + var index = values.indexOf(weekFields.firstDayOfWeek) + return (0..6).map { + values[index].also { index = (index + 1) % 7 } + } + } + + private fun WeekDayList.toDaysOfWeek(): List = map { + when (it) { + WeekDay.SU -> DayOfWeek.SUNDAY + WeekDay.MO -> DayOfWeek.MONDAY + WeekDay.TU -> DayOfWeek.TUESDAY + WeekDay.WE -> DayOfWeek.WEDNESDAY + WeekDay.TH -> DayOfWeek.THURSDAY + WeekDay.FR -> DayOfWeek.FRIDAY + WeekDay.SA -> DayOfWeek.SATURDAY + else -> throw IllegalArgumentException() + } + } + + private fun List.toWeekDayList(): WeekDayList = + WeekDayList(*sortedBy { it.value }.map { it.weekDay }.toTypedArray()) + + private val DayOfWeek.weekDay: WeekDay + get() = when (this) { + DayOfWeek.SUNDAY -> WeekDay.SU + DayOfWeek.MONDAY -> WeekDay.MO + DayOfWeek.TUESDAY -> WeekDay.TU + DayOfWeek.WEDNESDAY -> WeekDay.WE + DayOfWeek.THURSDAY -> WeekDay.TH + DayOfWeek.FRIDAY -> WeekDay.FR + DayOfWeek.SATURDAY -> WeekDay.SA + else -> throw IllegalArgumentException() + } + + val Saver: Saver = Saver( + save = { original: CustomRecurrenceEditState -> + Bundle().apply { + putString("rrule", original.getRecur()) + putLong("dueDate", original.state.value.dueDate) + putInt("accountType", original.accountType) + putString("locale", original.state.value.locale.toLanguageTag()) + } + }, + restore = { bundle -> + CustomRecurrenceEditState( + rrule = bundle.getString("rrule"), + dueDate = bundle.getLong("dueDate"), + accountType = bundle.getInt("accountType"), + locale = Locale.forLanguageTag(bundle.getString("locale")) + ) + } + ) + + @Composable + fun rememberCustomRecurrencePickerState( + rrule: String?, + dueDate: Long?, + accountType: Int, + locale: Locale = Locale.getDefault() + ): CustomRecurrenceEditState { + return rememberSaveable(saver = Saver) { CustomRecurrenceEditState(rrule, dueDate, accountType, locale) } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/tasks/repeats/RecurrencePickerDialog.kt b/app/src/main/java/org/tasks/repeats/RecurrencePickerDialog.kt new file mode 100644 index 000000000..614f87e5a --- /dev/null +++ b/app/src/main/java/org/tasks/repeats/RecurrencePickerDialog.kt @@ -0,0 +1,63 @@ +package org.tasks.repeats + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.tasks.data.entity.Task +import org.tasks.preferences.Preferences +import java.util.Locale + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RecurrencePickerDialog ( + dismiss: () -> Unit, + recurrence: String?, + onRecurrenceChanged: (String?) -> Unit, + repeatFrom: @Task.RepeatFrom Int, + onRepeatFromChanged: (@Task.RepeatFrom Int) -> Unit, + accountType: Int, +) { + val context = LocalContext.current + val preferences = remember { Preferences(context) } + + val basicDialog = remember { mutableStateOf(true) } + if (basicDialog.value) { + BasicRecurrencePicker( + dismiss = dismiss, + recurrence = recurrence, + setRecurrence = onRecurrenceChanged, + repeatFrom = repeatFrom, + onRepeatFromChanged = { onRepeatFromChanged(it) }, + peekCustomRecurrence = { basicDialog.value = false }, + ) + } else { + val state = CustomRecurrenceEditState.Companion + .rememberCustomRecurrencePickerState( + rrule = recurrence, + dueDate = null, + accountType = accountType, + locale = Locale.getDefault() + ) + + CustomRecurrenceEdit( + state = state.state.collectAsStateWithLifecycle().value, + save = { + onRecurrenceChanged(state.getRecur()) + dismiss() + }, + discard = dismiss, + setInterval = { state.setInterval(it) }, + setSelectedFrequency = { state.setFrequency(it) }, + setEndDate = { state.setEndDate(it) }, + setSelectedEndType = { state.setEndType(it) }, + setOccurrences = { state.setOccurrences(it) }, + toggleDay = { state.toggleDay(it) }, + setMonthSelection = { state.setMonthSelection(it) }, + calendarDisplayMode = preferences.calendarDisplayMode, + setDisplayMode = { preferences.calendarDisplayMode = it } + ) + } +} \ No newline at end of file