Implement editing of recurring transactions

Signed-off-by: William Brawner <me@wbrawner.com>
This commit is contained in:
William Brawner 2023-02-06 16:25:59 -07:00
parent badafaffc7
commit 6e32b5f6a3
8 changed files with 480 additions and 33 deletions

View file

@ -78,7 +78,7 @@ fun RecurringTransactionDetailsScreen(store: Store) {
budget = budget,
createdBy = createdBy
)
if (state.editingTransaction) {
if (state.editingRecurringTransaction) {
RecurringTransactionFormDialog(store = store)
}
}

View file

@ -32,15 +32,12 @@ import com.wbrawner.budget.ui.base.TwigsApp
import com.wbrawner.budget.ui.transaction.toDecimalString
import com.wbrawner.budget.ui.util.DatePicker
import com.wbrawner.budget.ui.util.FrequencyPicker
import com.wbrawner.budget.ui.util.TimePicker
import com.wbrawner.twigs.shared.Store
import com.wbrawner.twigs.shared.category.Category
import com.wbrawner.twigs.shared.recurringtransaction.Frequency
import com.wbrawner.twigs.shared.recurringtransaction.RecurringTransaction
import com.wbrawner.twigs.shared.recurringtransaction.RecurringTransactionAction
import com.wbrawner.twigs.shared.recurringtransaction.Time
import com.wbrawner.twigs.shared.recurringtransaction.time
import com.wbrawner.twigs.shared.transaction.TransactionAction
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import java.util.*
@ -49,7 +46,7 @@ import java.util.*
@Composable
fun RecurringTransactionFormDialog(store: Store) {
Dialog(
onDismissRequest = { store.dispatch(TransactionAction.CancelEditTransaction) },
onDismissRequest = { store.dispatch(RecurringTransactionAction.CancelEditRecurringTransaction) },
properties = DialogProperties(
usePlatformDefaultWidth = false,
decorFitsSystemWindows = false
@ -94,7 +91,7 @@ fun RecurringTransactionForm(store: Store) {
topBar = {
TopAppBar(
navigationIcon = {
IconButton(onClick = { store.dispatch(TransactionAction.CancelEditTransaction) }) {
IconButton(onClick = { store.dispatch(RecurringTransactionAction.CancelEditRecurringTransaction) }) {
Icon(Icons.Default.Close, "Cancel")
}
},
@ -267,24 +264,66 @@ fun RecurringTransactionForm(
}),
)
FrequencyPicker(frequency, setFrequency)
val (datePickerVisible, setDatePickerVisible) = remember { mutableStateOf(false) }
val (startPickerVisible, setStartPickerVisible) = remember { mutableStateOf(false) }
DatePicker(
modifier = Modifier.fillMaxWidth(),
date = start,
setDate = setStart,
dialogVisible = datePickerVisible,
setDialogVisible = setDatePickerVisible
label = "Start Date",
dialogVisible = startPickerVisible,
setDialogVisible = setStartPickerVisible
)
val (timePickerVisible, setTimePickerVisible) = remember { mutableStateOf(false) }
TimePicker(
modifier = Modifier.fillMaxWidth(),
time = start.time(),
setTime = {
val (endExpanded, setEndExpanded) = remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
modifier = Modifier
.fillMaxWidth(),
expanded = endExpanded,
onExpandedChange = setEndExpanded,
) {
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
value = end?.let { "On Date" } ?: "Never",
onValueChange = {},
readOnly = true,
label = {
Text("End Criteria")
},
dialogVisible = timePickerVisible,
setDialogVisible = setTimePickerVisible
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = endExpanded)
}
)
ExposedDropdownMenu(expanded = endExpanded, onDismissRequest = {
setEndExpanded(false)
}) {
DropdownMenuItem(
text = { Text("Never") },
onClick = {
setEnd(null)
setEndExpanded(false)
}
)
DropdownMenuItem(
text = { Text("On Date") },
onClick = {
setEnd(Clock.System.now())
setEndExpanded(false)
}
)
}
}
end?.let {
val (endPickerVisible, setEndPickerVisible) = remember { mutableStateOf(false) }
DatePicker(
modifier = Modifier.fillMaxWidth(),
date = end,
setDate = setEnd,
label = "End Date",
dialogVisible = endPickerVisible,
setDialogVisible = setEndPickerVisible
)
}
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = spacedBy(8.dp)) {
Row(
modifier = Modifier.clickable {

View file

@ -37,10 +37,9 @@ fun RecurringTransactionsScreen(store: Store) {
TwigsScaffold(
store = store,
title = "Recurring Transactions",
// TODO: Implement RecurringTransaction creation/editing
// onClickFab = {
// store.dispatch(RecurringTransactionAction.NewRecurringTransactionClicked)
// }
onClickFab = {
store.dispatch(RecurringTransactionAction.NewRecurringTransactionClicked)
}
) {
val state by store.state.collectAsState()
state.recurringTransactions?.let { transactions ->
@ -78,9 +77,9 @@ fun RecurringTransactionsScreen(store: Store) {
} ?: Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
// if (state.editingTransaction) {
// RecurringTransactionFormDialog(store = store)
// }
if (state.editingRecurringTransaction) {
RecurringTransactionFormDialog(store = store)
}
}
}

View file

@ -33,6 +33,7 @@ fun DatePicker(
modifier: Modifier,
date: Instant,
setDate: (Instant) -> Unit,
label: String = "Date",
dialogVisible: Boolean,
setDialogVisible: (Boolean) -> Unit
) {
@ -52,7 +53,7 @@ fun DatePicker(
onValueChange = {},
readOnly = true,
label = {
Text("Date")
Text(label)
}
)
val dialog = remember {

View file

@ -1,9 +1,398 @@
package com.wbrawner.budget.ui.util
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.InputChip
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.wbrawner.twigs.shared.recurringtransaction.DayOfMonth
import com.wbrawner.twigs.shared.recurringtransaction.DayOfYear
import com.wbrawner.twigs.shared.recurringtransaction.Frequency
import com.wbrawner.twigs.shared.recurringtransaction.Ordinal
import com.wbrawner.twigs.shared.recurringtransaction.capitalizedName
import com.wbrawner.twigs.shared.recurringtransaction.toMonth
import kotlinx.datetime.Clock
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.Month
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import java.time.format.TextStyle
import java.util.Locale
import kotlin.math.min
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FrequencyPicker(frequency: Frequency, setFrequency: (Frequency) -> Unit) {
Column(modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(
modifier = Modifier
.fillMaxWidth(),
value = frequency.count.toString(),
onValueChange = { setFrequency(frequency.update(count = it.toInt())) },
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Next
),
label = { Text("Repeat Every") },
)
val (unitExpanded, setUnitExpanded) = remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
modifier = Modifier
.fillMaxWidth(),
expanded = unitExpanded,
onExpandedChange = setUnitExpanded,
) {
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
value = frequency.name,
onValueChange = {},
readOnly = true,
label = {
Text("Time Unit")
},
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = unitExpanded)
}
)
ExposedDropdownMenu(expanded = unitExpanded, onDismissRequest = {
setUnitExpanded(false)
}) {
DropdownMenuItem(
text = { Text("Daily") },
onClick = {
setFrequency(Frequency.Daily(frequency.count, frequency.time))
setUnitExpanded(false)
}
)
DropdownMenuItem(
text = { Text("Weekly") },
onClick = {
setFrequency(Frequency.Weekly(frequency.count, setOf(), frequency.time))
setUnitExpanded(false)
}
)
DropdownMenuItem(
text = { Text("Monthly") },
onClick = {
setFrequency(
Frequency.Monthly(
frequency.count,
DayOfMonth.FixedDayOfMonth(
Clock.System.now().toLocalDateTime(
TimeZone.UTC
).dayOfMonth
),
frequency.time
)
)
setUnitExpanded(false)
}
)
DropdownMenuItem(
text = { Text("Yearly") },
onClick = {
val today = Clock.System.now().toLocalDateTime(
TimeZone.UTC
)
setFrequency(
Frequency.Yearly(
frequency.count,
DayOfYear.of(today.monthNumber, today.dayOfMonth),
frequency.time
)
)
setUnitExpanded(false)
}
)
}
}
}
when (frequency) {
is Frequency.Daily -> {
// No additional config needed
}
is Frequency.Weekly -> {
WeeklyFrequencyPicker(frequency, setFrequency)
}
is Frequency.Monthly -> {
MonthlyFrequencyPicker(frequency, setFrequency)
}
is Frequency.Yearly -> {
YearlyFrequencyPicker(frequency, setFrequency)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun WeeklyFrequencyPicker(frequency: Frequency.Weekly, setFrequency: (Frequency) -> Unit) {
val daysOfWeek = remember { DayOfWeek.values() }
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = spacedBy(8.dp, Alignment.CenterHorizontally)
) {
daysOfWeek.forEach {
val label = remember(it) { it.getDisplayName(TextStyle.SHORT, Locale.getDefault()) }
InputChip(
selected = frequency.daysOfWeek.contains(it),
onClick = {
val selection = frequency.daysOfWeek.toMutableSet()
if (selection.contains(it)) {
selection.remove(it)
} else {
selection.add(it)
}
setFrequency(frequency.copy(daysOfWeek = selection))
},
label = { Text(label) }
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MonthlyFrequencyPicker(frequency: Frequency.Monthly, setFrequency: (Frequency) -> Unit) {
val (fixedDay, setFixedDay) = remember {
mutableStateOf(
(frequency.dayOfMonth as? DayOfMonth.FixedDayOfMonth)?.day ?: 1
)
}
val (ordinal, setOrdinal) = remember { mutableStateOf((frequency.dayOfMonth as? DayOfMonth.OrdinalDayOfMonth)?.ordinal) }
val (dayOfWeek, setDayOfWeek) = remember {
mutableStateOf(
(frequency.dayOfMonth as? DayOfMonth.OrdinalDayOfMonth)?.dayOfWeek ?: DayOfWeek.SUNDAY
)
}
Row(modifier = Modifier.fillMaxWidth()) {
Box(modifier = Modifier.weight(1f)) {
val (ordinalExpanded, setOrdinalExpanded) = remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
modifier = Modifier
.fillMaxWidth(),
expanded = ordinalExpanded,
onExpandedChange = setOrdinalExpanded,
) {
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
value = ordinal?.capitalizedName ?: "Day",
onValueChange = {},
readOnly = true,
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = ordinalExpanded)
}
)
ExposedDropdownMenu(expanded = ordinalExpanded, onDismissRequest = {
setOrdinalExpanded(false)
}) {
DropdownMenuItem(
text = { Text("Day") },
onClick = {
setOrdinal(null)
setFrequency(
frequency.copy(
dayOfMonth = DayOfMonth.FixedDayOfMonth(
fixedDay
)
)
)
setOrdinalExpanded(false)
}
)
Ordinal.values().forEach { ordinal ->
DropdownMenuItem(
text = { Text(ordinal.capitalizedName) },
onClick = {
setOrdinal(ordinal)
setFrequency(
frequency.copy(
dayOfMonth = DayOfMonth.OrdinalDayOfMonth(
ordinal,
dayOfWeek
)
)
)
setOrdinalExpanded(false)
}
)
}
}
}
}
Spacer(modifier = Modifier.width(8.dp))
Box(modifier = Modifier.weight(1f)) {
val (dayExpanded, setDayExpanded) = remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
modifier = Modifier
.fillMaxWidth(),
expanded = dayExpanded,
onExpandedChange = setDayExpanded,
) {
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
value = ordinal?.let { dayOfWeek.capitalizedName } ?: fixedDay.toString(),
onValueChange = {},
readOnly = true,
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = dayExpanded)
}
)
ExposedDropdownMenu(expanded = dayExpanded, onDismissRequest = {
setDayExpanded(false)
}) {
if (ordinal == null) {
for (day in 1..31) {
DropdownMenuItem(
text = { Text(day.toString()) },
onClick = {
setFixedDay(day)
setFrequency(
frequency.copy(
dayOfMonth = DayOfMonth.FixedDayOfMonth(
day
)
)
)
setDayExpanded(false)
}
)
}
} else {
DayOfWeek.values().forEach { dayOfWeek ->
DropdownMenuItem(
text = { Text(dayOfWeek.capitalizedName) },
onClick = {
setDayOfWeek(dayOfWeek)
setFrequency(
frequency.copy(
dayOfMonth = (frequency.dayOfMonth as DayOfMonth.OrdinalDayOfMonth).copy(
dayOfWeek = dayOfWeek
)
)
)
setDayExpanded(false)
}
)
}
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun YearlyFrequencyPicker(frequency: Frequency.Yearly, setFrequency: (Frequency) -> Unit) {
val (month, setMonth) = remember { mutableStateOf(frequency.dayOfYear.month) }
val (day, setDay) = remember { mutableStateOf(frequency.dayOfYear.day) }
Row(modifier = Modifier.fillMaxWidth()) {
Box(modifier = Modifier.weight(1f)) {
val (monthExpanded, setMonthExpanded) = remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
modifier = Modifier
.fillMaxWidth(),
expanded = monthExpanded,
onExpandedChange = setMonthExpanded,
) {
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
value = Month.of(month).getDisplayName(TextStyle.FULL, Locale.getDefault()),
onValueChange = {},
readOnly = true,
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = monthExpanded)
}
)
ExposedDropdownMenu(expanded = monthExpanded, onDismissRequest = {
setMonthExpanded(false)
}) {
Month.values().forEach { m ->
DropdownMenuItem(
text = { Text(m.getDisplayName(TextStyle.FULL, Locale.getDefault())) },
onClick = {
setMonth(m.value)
setFrequency(
frequency.copy(
dayOfYear = DayOfYear.of(m.value, min(day, m.maxLength()))
)
)
setMonthExpanded(false)
}
)
}
}
}
}
Box(modifier = Modifier.weight(1f)) {
val (dayExpanded, setDayExpanded) = remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
modifier = Modifier
.fillMaxWidth(),
expanded = dayExpanded,
onExpandedChange = setDayExpanded,
) {
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
value = day.toString(),
onValueChange = {},
readOnly = true,
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = dayExpanded)
}
)
ExposedDropdownMenu(expanded = dayExpanded, onDismissRequest = {
setDayExpanded(false)
}) {
for (d in 1..month.toMonth().maxLength()) {
DropdownMenuItem(
text = { Text(d.toString()) },
onClick = {
setDay(d)
setFrequency(frequency.copy(dayOfYear = DayOfYear.of(month, d)))
setDayExpanded(false)
}
)
}
}
}
}
}
}

View file

@ -16,7 +16,7 @@ kotlinx-datetime = "0.4.0"
ktor = "2.1.2"
material = "1.3.0"
maxSdk = "33"
minSdk = "23"
minSdk = "26"
navigation = "2.4.1"
okhttp = "4.2.2"
settings = "0.8.1"

View file

@ -4,6 +4,7 @@ import com.wbrawner.twigs.shared.startOfMonth
import kotlinx.datetime.Clock
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.Instant
import kotlinx.datetime.Month
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlinx.serialization.KSerializer
@ -33,9 +34,11 @@ data class RecurringTransaction(
sealed class Frequency {
abstract val count: Int
abstract val time: Time
abstract val name: String
data class Daily(override val count: Int, override val time: Time) : Frequency() {
override fun toString(): String = "D;$count;$time"
override val name: String = "Daily"
companion object {
fun parse(s: String): Daily {
@ -56,6 +59,7 @@ sealed class Frequency {
override val time: Time
) : Frequency() {
override fun toString(): String = "W;$count;${daysOfWeek.joinToString(",")};$time"
override val name: String = "Weekly"
companion object {
fun parse(s: String): Weekly {
@ -77,6 +81,7 @@ sealed class Frequency {
override val time: Time
) : Frequency() {
override fun toString(): String = "M;$count;$dayOfMonth;$time"
override val name: String = "Monthly"
companion object {
fun parse(s: String): Monthly {
@ -97,6 +102,8 @@ sealed class Frequency {
override fun toString(): String =
"Y;$count;${dayOfYear.month.padStart(2, '0')}-${dayOfYear.day.padStart(2, '0')};$time"
override val name: String = "Yearly"
companion object {
fun parse(s: String): Yearly {
require(s[0] == 'Y') { "Invalid format for Yearly: $s" }
@ -114,6 +121,13 @@ sealed class Frequency {
fun instant(now: Instant): Instant =
Instant.parse(now.toString().split("T")[0] + "T" + time.toString() + "Z")
fun update(count: Int = this.count, time: Time = this.time): Frequency = when (this) {
is Daily -> copy(count = count, time = time)
is Weekly -> copy(count = count, time = time)
is Monthly -> copy(count = count, time = time)
is Yearly -> copy(count = count, time = time)
}
companion object {
fun parse(s: String): Frequency = when (s[0]) {
'D' -> Daily.parse(s)
@ -160,6 +174,9 @@ enum class Ordinal {
LAST
}
val Enum<*>.capitalizedName: String
get() = name.lowercase().replaceFirstChar { it.uppercaseChar() }
class DayOfYear private constructor(val month: Int, val day: Int) {
override fun toString(): String {
@ -186,6 +203,8 @@ class DayOfYear private constructor(val month: Int, val day: Int) {
}
}
fun Int.toMonth(): Month = Month(this)
data class Time(val hours: Int, val minutes: Int, val seconds: Int) {
override fun toString(): String {
val s = StringBuilder()

View file

@ -81,7 +81,7 @@ class RecurringTransactionReducer(
is Action.Back -> {
val currentState = state()
currentState.copy(
editingTransaction = false,
editingRecurringTransaction = false,
selectedRecurringTransaction = if (currentState.editingRecurringTransaction) currentState.selectedRecurringTransaction else null,
route = if (currentState.route is Route.RecurringTransactions && !currentState.route.selected.isNullOrBlank() && !currentState.editingRecurringTransaction) {
Route.RecurringTransactions()
@ -113,7 +113,7 @@ class RecurringTransactionReducer(
is RecurringTransactionAction.CancelEditRecurringTransaction -> {
val currentState = state()
currentState.copy(
editingTransaction = false,
editingRecurringTransaction = false,
selectedRecurringTransaction = if (currentState.route is Route.RecurringTransactions && !currentState.route.selected.isNullOrBlank()) {
currentState.selectedRecurringTransaction
} else {
@ -178,7 +178,7 @@ class RecurringTransactionReducer(
recurringTransactions = transactions.toList(),
selectedRecurringTransaction = action.transaction.id,
selectedRecurringTransactionCreatedBy = currentState.user,
editingTransaction = false
editingRecurringTransaction = false
)
}
@ -202,11 +202,11 @@ class RecurringTransactionReducer(
is ConfigAction.Logout -> state().copy(
transactions = null,
selectedRecurringTransaction = null,
editingTransaction = false
editingRecurringTransaction = false
)
is RecurringTransactionAction.EditRecurringTransaction -> state().copy(
editingTransaction = true,
editingRecurringTransaction = true,
selectedRecurringTransaction = action.id
)