RepeatRow is @Composable

pull/3739/head
hady 5 months ago
parent 877a2cd6a5
commit 6fd987a055

@ -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(

@ -276,7 +276,17 @@ fun TaskEditScreen(
FilesControlSet.TAG -> AndroidFragment<FilesControlSet>()
TimerControlSet.TAG -> AndroidFragment<TimerControlSet>()
TagsControlSet.TAG -> AndroidFragment<TagsControlSet>()
RepeatControlSet.TAG -> AndroidFragment<RepeatControlSet>()
RepeatControlSet.TAG -> {
//AndroidFragment<RepeatControlSet>()
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<SubtaskControlSet>()
else -> throw IllegalArgumentException("Unknown row: $tag")
}

@ -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<String> =
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))) }
}

@ -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<DayOfWeek>,
selected: List<DayOfWeek>,
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<String>().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 = {},
)
}
}

@ -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<Recur.Frequency> = FREQ_ALL,
val daysOfWeek: List<DayOfWeek> = Locale.getDefault().daysOfWeek(),
val selectedDays: List<DayOfWeek> = 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<DayOfWeek> {
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<DayOfWeek> = 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<DayOfWeek>.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<CustomRecurrenceEditState, Bundle> = 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) }
}
}
}

@ -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 }
)
}
}
Loading…
Cancel
Save