diff --git a/core/data/src/commonMain/kotlin/de/tabmates/core/data/networking/HttpClientExt.kt b/core/data/src/commonMain/kotlin/de/tabmates/core/data/networking/HttpClientExt.kt index 74bee778..fec275fe 100644 --- a/core/data/src/commonMain/kotlin/de/tabmates/core/data/networking/HttpClientExt.kt +++ b/core/data/src/commonMain/kotlin/de/tabmates/core/data/networking/HttpClientExt.kt @@ -100,9 +100,11 @@ suspend inline fun HttpClient.patch( route: String, body: Request, queryParams: Map = mapOf(), + // See post: per-call error mapper inspected before the generic status handling. + noinline mapKnownError: (suspend (HttpResponse) -> DataError.Remote?)? = null, crossinline builder: HttpRequestBuilder.() -> Unit = {}, ): Result { - return safeCall { + return safeCall(mapKnownError = mapKnownError) { patch { url(routeForRequest(route)) queryParams.forEach { (key, value) -> diff --git a/core/domain/src/commonMain/kotlin/de/tabmates/core/domain/util/DataError.kt b/core/domain/src/commonMain/kotlin/de/tabmates/core/domain/util/DataError.kt index 289420f2..638b5790 100644 --- a/core/domain/src/commonMain/kotlin/de/tabmates/core/domain/util/DataError.kt +++ b/core/domain/src/commonMain/kotlin/de/tabmates/core/domain/util/DataError.kt @@ -32,6 +32,16 @@ sealed interface DataError : Error { // 403 with body { "code": "CANNOT_REMOVE_GROUP_CREATOR" }: the group's creator can only // leave voluntarily. Distinguished from FORBIDDEN, which means the caller is not a member. CANNOT_REMOVE_GROUP_CREATOR, + + // 400 with body { "code": "INVALID_RECURRING_RULE" }: the recurring template or schedule + // is not something the server will store. Covers both a malformed template and the cap on + // how many active schedules one member may own, which the server states under this code. + INVALID_RECURRING_RULE, + + // 503 with body { "code": "RECURRING_ENTRIES_DISABLED" }: the feature is switched off in + // this environment. Distinguished from SERVICE_UNAVAILABLE, which means the server is + // struggling — here the request was fine and only a config change makes it work. + RECURRING_ENTRIES_DISABLED, UNKNOWN, } diff --git a/core/presentation/src/commonMain/composeResources/values-de/string.xml b/core/presentation/src/commonMain/composeResources/values-de/string.xml index 05823832..31dd5731 100644 --- a/core/presentation/src/commonMain/composeResources/values-de/string.xml +++ b/core/presentation/src/commonMain/composeResources/values-de/string.xml @@ -17,6 +17,8 @@ Nachricht konnte nicht gesendet werden Verifizierung fehlgeschlagen. Bitte versuche es erneut. Diese TabMates-Version wird nicht mehr unterstützt. Bitte aktualisiere die App. + Diese Wiederholung kann nicht gespeichert werden. Prüfe Datum und Betrag, oder beende eine andere Wiederholung, wenn du viele hast. + Wiederholende Einträge sind derzeit deaktiviert. Bitte versuche es später erneut. Du kannst dich nicht selbst entfernen. Verlasse die Gruppe stattdessen in den Einstellungen. Die Person, die diese Gruppe erstellt hat, kann nicht entfernt werden. diff --git a/core/presentation/src/commonMain/composeResources/values/string.xml b/core/presentation/src/commonMain/composeResources/values/string.xml index aff1410b..88a910ed 100644 --- a/core/presentation/src/commonMain/composeResources/values/string.xml +++ b/core/presentation/src/commonMain/composeResources/values/string.xml @@ -17,6 +17,8 @@ Failed to send message Couldn't verify you're human. Please try again. This version of TabMates is no longer supported. Please update to continue. + That repeat schedule can't be saved. Check the dates and amounts, or end another schedule if you have a lot of them. + Repeating entries are switched off right now. Try again later. You cannot remove yourself. Leave the group from its settings instead. The person who created this group cannot be removed. \ No newline at end of file diff --git a/core/presentation/src/commonMain/kotlin/de/tabmates/core/presentation/util/DataErrorToUiText.kt b/core/presentation/src/commonMain/kotlin/de/tabmates/core/presentation/util/DataErrorToUiText.kt index 50633e02..d5df851d 100644 --- a/core/presentation/src/commonMain/kotlin/de/tabmates/core/presentation/util/DataErrorToUiText.kt +++ b/core/presentation/src/commonMain/kotlin/de/tabmates/core/presentation/util/DataErrorToUiText.kt @@ -8,10 +8,12 @@ import tabmatesapp.core.presentation.generated.resources.error_cannot_remove_sel import tabmatesapp.core.presentation.generated.resources.error_conflict import tabmatesapp.core.presentation.generated.resources.error_disk_full import tabmatesapp.core.presentation.generated.resources.error_forbidden +import tabmatesapp.core.presentation.generated.resources.error_invalid_recurring_rule import tabmatesapp.core.presentation.generated.resources.error_message_send_failed import tabmatesapp.core.presentation.generated.resources.error_no_internet import tabmatesapp.core.presentation.generated.resources.error_not_found import tabmatesapp.core.presentation.generated.resources.error_payload_too_large +import tabmatesapp.core.presentation.generated.resources.error_recurring_entries_disabled import tabmatesapp.core.presentation.generated.resources.error_request_timeout import tabmatesapp.core.presentation.generated.resources.error_serialization import tabmatesapp.core.presentation.generated.resources.error_server @@ -44,6 +46,8 @@ fun DataError.toUiText(): UiText { DataError.Remote.UPGRADE_REQUIRED -> Res.string.error_upgrade_required DataError.Remote.CANNOT_REMOVE_SELF -> Res.string.error_cannot_remove_self DataError.Remote.CANNOT_REMOVE_GROUP_CREATOR -> Res.string.error_cannot_remove_group_creator + DataError.Remote.INVALID_RECURRING_RULE -> Res.string.error_invalid_recurring_rule + DataError.Remote.RECURRING_ENTRIES_DISABLED -> Res.string.error_recurring_entries_disabled DataError.Remote.UNKNOWN -> Res.string.error_unknown DataError.Connection.NOT_CONNECTED -> Res.string.error_no_internet DataError.Connection.MESSAGE_SEND_FAILED -> Res.string.error_message_send_failed diff --git a/features/tabgroup/data/build.gradle.kts b/features/tabgroup/data/build.gradle.kts index 2970b67e..bfa01948 100644 --- a/features/tabgroup/data/build.gradle.kts +++ b/features/tabgroup/data/build.gradle.kts @@ -30,6 +30,7 @@ kotlin { implementation(libs.kotlin.test) implementation(libs.kotlinx.coroutines.test) implementation(libs.ktor.client.mock) + implementation(libs.turbine) } } diff --git a/features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/di/TabgroupDataModule.kt b/features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/di/TabgroupDataModule.kt index a764a008..f0a13d21 100644 --- a/features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/di/TabgroupDataModule.kt +++ b/features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/di/TabgroupDataModule.kt @@ -5,6 +5,7 @@ import org.koin.core.annotation.ComponentScan import org.koin.core.annotation.Configuration import org.koin.core.annotation.Module import org.koin.core.annotation.Single +import kotlin.time.Clock @Module @Configuration @@ -14,4 +15,9 @@ class TabgroupDataModule { // UpgradeRequiredNotifier, which CoreDataModule provides for the same reason. @Single fun provideGroupRemovalNotifier(): GroupRemovalNotifier = GroupRemovalNotifier() + + // Bound rather than read statically so anything whose behaviour turns on the date — the + // scheduled ledger's day boundary above all — can be tested without waiting for midnight. + @Single + fun provideClock(): Clock = Clock.System } diff --git a/features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/dto/RecurringSeriesDto.kt b/features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/dto/RecurringSeriesDto.kt new file mode 100644 index 00000000..d6f9e5de --- /dev/null +++ b/features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/dto/RecurringSeriesDto.kt @@ -0,0 +1,106 @@ +package de.tabmates.features.tabgroup.data.dto + +import de.tabmates.features.tabgroup.data.network.dto.WsSplitDto +import kotlinx.datetime.LocalDate +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonClassDiscriminator +import kotlin.time.Instant + +/** A recurring schedule as the server reports it: identity, state, and its current template. */ +@Serializable +data class RecurringSeriesDto( + val id: String, + val groupId: String, + val entryType: RecurringEntryTypeDto, + val isActive: Boolean, + /** + * The template names somebody who has left the group, so nothing is being generated until a + * member repairs it. The only state that needs a human — surface it. + */ + val needsAttention: Boolean, + val createdAt: Instant, + val createdBy: GroupParticipantDto, + val updatedAt: Instant, + val rule: RecurringRuleDto, + /** + * Future occurrences a member skipped. Defaulted so a server predating the field still parses — + * at the cost, until it ships, of a skipped date rendering as a placeholder that never resolves. + */ + val skippedOccurrenceDates: List = emptyList(), +) + +@Serializable +data class RecurringRuleDto( + val id: String, + val title: String, + val description: String, + val amount: Double, + val currency: String, + val exchangeRate: Double? = null, + val paidBy: GroupParticipantDto, + /** Set for `SETTLEMENT` schedules only. */ + val receivedBy: GroupParticipantDto? = null, + /** Empty for `SETTLEMENT` schedules. */ + val splits: List = emptyList(), + val frequency: RecurrenceFrequencyDto, + val interval: Int, + val startDate: LocalDate, + val end: RecurringEndDto, +) + +@Serializable +data class RecurringTemplateSplitDto( + val id: String? = null, + val participantId: String, + /** + * Null when the participant is not one the payload otherwise names — a template can outlive the + * membership of the people in it. [participantId] is always present. + */ + val participant: GroupParticipantDto? = null, + val split: WsSplitDto, + val resolvedAmount: Double, +) + +@Serializable +enum class RecurringEntryTypeDto { + EXPENSE, + INCOME, + SETTLEMENT, +} + +@Serializable +enum class RecurrenceFrequencyDto { + DAILY, + WEEKLY, + MONTHLY, + YEARLY, +} + +/** + * How a schedule stops. A sealed shape on the wire, discriminated by `type` — the server writes + * `{"type":"NEVER"}`, `{"type":"UNTIL","date":...}`, `{"type":"COUNT","count":...}`. + */ +@OptIn(ExperimentalSerializationApi::class) +@Serializable +@JsonClassDiscriminator("type") +sealed class RecurringEndDto { + @Serializable + @SerialName("NEVER") + data object Never : RecurringEndDto() + + /** Inclusive: an occurrence landing exactly on [date] is still produced. */ + @Serializable + @SerialName("UNTIL") + data class Until( + val date: LocalDate, + ) : RecurringEndDto() + + /** Total occurrences, counting ones a skip left empty. */ + @Serializable + @SerialName("COUNT") + data class Count( + val count: Int, + ) : RecurringEndDto() +} diff --git a/features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/dto/RecurringSeriesRequestDtos.kt b/features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/dto/RecurringSeriesRequestDtos.kt new file mode 100644 index 00000000..9fbe5b5e --- /dev/null +++ b/features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/dto/RecurringSeriesRequestDtos.kt @@ -0,0 +1,119 @@ +package de.tabmates.features.tabgroup.data.dto + +import de.tabmates.features.tabgroup.data.network.dto.WsSplitDto +import kotlinx.datetime.LocalDate +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonClassDiscriminator + +/** + * The template half of a create or edit. + * + * Mirrors the outgoing entry payload's shape and discriminator deliberately: this is the same entry + * the client would otherwise create by hand, plus when to repeat it. + */ +@OptIn(ExperimentalSerializationApi::class) +@Serializable +@JsonClassDiscriminator("entryType") +sealed class RecurringTemplateDto { + abstract val paidByUserId: String + abstract val title: String + abstract val description: String + abstract val amount: Double + abstract val currency: String + + /** Fallback rate only; the server resolves each occurrence's own rate when it writes one. */ + abstract val exchangeRate: Double? + abstract val frequency: RecurrenceFrequencyDto + abstract val interval: Int + abstract val startDate: LocalDate + abstract val end: RecurringEndDto + + @Serializable + @SerialName("EXPENSE") + data class Expense( + override val paidByUserId: String, + override val title: String, + override val description: String, + override val amount: Double, + override val currency: String, + override val exchangeRate: Double? = null, + override val frequency: RecurrenceFrequencyDto, + override val interval: Int = 1, + override val startDate: LocalDate, + override val end: RecurringEndDto = RecurringEndDto.Never, + val splits: List, + ) : RecurringTemplateDto() + + @Serializable + @SerialName("INCOME") + data class Income( + override val paidByUserId: String, + override val title: String, + override val description: String, + override val amount: Double, + override val currency: String, + override val exchangeRate: Double? = null, + override val frequency: RecurrenceFrequencyDto, + override val interval: Int = 1, + override val startDate: LocalDate, + override val end: RecurringEndDto = RecurringEndDto.Never, + val splits: List, + ) : RecurringTemplateDto() + + @Serializable + @SerialName("SETTLEMENT") + data class Settlement( + override val paidByUserId: String, + override val title: String, + override val description: String, + override val amount: Double, + override val currency: String, + override val exchangeRate: Double? = null, + override val frequency: RecurrenceFrequencyDto, + override val interval: Int = 1, + override val startDate: LocalDate, + override val end: RecurringEndDto = RecurringEndDto.Never, + val receivedByUserId: String, + ) : RecurringTemplateDto() +} + +@Serializable +data class NewRecurringTemplateSplitDto( + val participantId: String, + val split: WsSplitDto, + val resolvedAmount: Double, +) + +@Serializable +data class CreateRecurringSeriesRequestDto( + val groupId: String, + /** Client-generated, so a create retried after a dropped response cannot make a second series. */ + val id: String, + val template: RecurringTemplateDto, +) + +/** + * Applies a new template from [effectiveFrom] onwards. + * + * [effectiveFrom] must be a future date the current schedule actually produces, and must equal the + * new template's `startDate` — otherwise the server rejects it rather than silently re-anchoring + * the rhythm to whichever day the edit happened to be made. + */ +@Serializable +data class UpdateRecurringSeriesRequestDto( + val effectiveFrom: LocalDate, + val template: RecurringTemplateDto, +) + +@Serializable +data class SkipRecurringOccurrenceRequestDto( + val occurrenceDate: LocalDate, +) + +/** Error bodies from the schedule endpoints, which state their refusals by code. */ +@Serializable +internal data class RecurringSeriesErrorDto( + val code: String? = null, +) diff --git a/features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/dto/SyncResponseDto.kt b/features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/dto/SyncResponseDto.kt index 2c9ecf97..3bdaa582 100644 --- a/features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/dto/SyncResponseDto.kt +++ b/features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/dto/SyncResponseDto.kt @@ -9,4 +9,10 @@ data class SyncResponseDto( val groups: List, val activeGroupIds: List, val tabEntries: List, + /** + * Schedules created or changed since the cursor, active and ended alike. The entries they + * produce need no separate treatment — they are ordinary rows in [tabEntries]. Defaulted so a + * server predating the feature still parses. + */ + val recurringSeries: List = emptyList(), ) diff --git a/features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/dto/TabEntryDto.kt b/features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/dto/TabEntryDto.kt index 7b4d7448..82aac09a 100644 --- a/features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/dto/TabEntryDto.kt +++ b/features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/dto/TabEntryDto.kt @@ -34,6 +34,17 @@ sealed class TabEntryDto { abstract val deletedAt: Instant? abstract val deletedBy: GroupParticipantDto? + /** + * The recurring series that produced this entry, and the slot it filled. Both null for a + * hand-created entry, both set for a generated one; defaulted so a server predating the feature + * still parses. + * + * The slot is [recurringOccurrenceDate], not [entryDate] — the latter stays editable once the + * entry exists, and the slot must not move with it. + */ + abstract val recurringSeriesId: String? + abstract val recurringOccurrenceDate: LocalDate? + @Serializable @SerialName("EXPENSE") data class Expense( @@ -54,6 +65,8 @@ sealed class TabEntryDto { override val version: Int, override val deletedAt: Instant?, override val deletedBy: GroupParticipantDto?, + override val recurringSeriesId: String? = null, + override val recurringOccurrenceDate: LocalDate? = null, ) : TabEntryDto() @Serializable @@ -76,6 +89,8 @@ sealed class TabEntryDto { override val version: Int, override val deletedAt: Instant?, override val deletedBy: GroupParticipantDto?, + override val recurringSeriesId: String? = null, + override val recurringOccurrenceDate: LocalDate? = null, ) : TabEntryDto() @Serializable @@ -98,5 +113,7 @@ sealed class TabEntryDto { override val version: Int, override val deletedAt: Instant?, override val deletedBy: GroupParticipantDto?, + override val recurringSeriesId: String? = null, + override val recurringOccurrenceDate: LocalDate? = null, ) : TabEntryDto() } diff --git a/features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/mappers/RecurringSeriesMappers.kt b/features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/mappers/RecurringSeriesMappers.kt new file mode 100644 index 00000000..811242b5 --- /dev/null +++ b/features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/mappers/RecurringSeriesMappers.kt @@ -0,0 +1,349 @@ +package de.tabmates.features.tabgroup.data.mappers + +import de.tabmates.features.tabgroup.data.dto.GroupParticipantDto +import de.tabmates.features.tabgroup.data.dto.NewRecurringTemplateSplitDto +import de.tabmates.features.tabgroup.data.dto.RecurrenceFrequencyDto +import de.tabmates.features.tabgroup.data.dto.RecurringEndDto +import de.tabmates.features.tabgroup.data.dto.RecurringEntryTypeDto +import de.tabmates.features.tabgroup.data.dto.RecurringSeriesDto +import de.tabmates.features.tabgroup.data.dto.RecurringTemplateDto +import de.tabmates.features.tabgroup.database.entities.RecurringExceptionEntity +import de.tabmates.features.tabgroup.database.entities.RecurringSeriesEntity +import de.tabmates.features.tabgroup.database.entities.RecurringSeriesWithDetails +import de.tabmates.features.tabgroup.database.entities.RecurringTemplateSplitEntity +import de.tabmates.features.tabgroup.database.entities.types.RecurrenceFrequencyDatabase +import de.tabmates.features.tabgroup.database.entities.types.RecurringEndTypeDatabase +import de.tabmates.features.tabgroup.database.entities.types.TabEntryTypeDatabase +import de.tabmates.features.tabgroup.domain.models.GroupParticipant +import de.tabmates.features.tabgroup.domain.models.ParticipantType +import de.tabmates.features.tabgroup.domain.recurring.NewRecurringTemplateSplit +import de.tabmates.features.tabgroup.domain.recurring.RecurrenceFrequency +import de.tabmates.features.tabgroup.domain.recurring.RecurringEnd +import de.tabmates.features.tabgroup.domain.recurring.RecurringEntryType +import de.tabmates.features.tabgroup.domain.recurring.RecurringRule +import de.tabmates.features.tabgroup.domain.recurring.RecurringSeries +import de.tabmates.features.tabgroup.domain.recurring.RecurringTemplate +import de.tabmates.features.tabgroup.domain.recurring.RecurringTemplateSplit +import kotlinx.datetime.LocalDate +import kotlin.time.Instant + +// region wire -> domain + +fun RecurringSeriesDto.toDomain(): RecurringSeries = + RecurringSeries( + seriesId = id, + groupId = groupId, + entryType = entryType.toDomain(), + isActive = isActive, + needsAttention = needsAttention, + createdAt = createdAt, + createdBy = createdBy.toDomain(), + updatedAt = updatedAt, + rule = + RecurringRule( + ruleId = rule.id, + title = rule.title, + description = rule.description, + amount = rule.amount, + currencyCode = rule.currency, + exchangeRate = rule.exchangeRate, + paidByUserId = rule.paidBy.userId, + receivedByUserId = rule.receivedBy?.userId, + splits = + rule.splits.map { split -> + val (type, value) = split.split.toSplitTypeAndValue() + RecurringTemplateSplit( + splitId = split.id, + participantId = split.participantId, + splitType = type, + value = value, + resolvedAmount = split.resolvedAmount, + ) + }, + frequency = rule.frequency.toDomain(), + interval = rule.interval, + startDate = rule.startDate, + end = rule.end.toDomain(), + ), + skippedOccurrenceDates = skippedOccurrenceDates.toSet(), + ) + +/** + * Every participant the series names, for the same foreign-key reason entries have one: a template + * can outlive the membership of the people in it, so the payload's own participant lists are not + * enough to keep the split and creator references valid. + */ +fun RecurringSeriesDto.referencedParticipants(): List = + buildList { + add(createdBy) + add(rule.paidBy) + rule.receivedBy?.let(::add) + rule.splits.forEach { split -> split.participant?.let(::add) } + } + +fun RecurringEntryTypeDto.toDomain(): RecurringEntryType = + when (this) { + RecurringEntryTypeDto.EXPENSE -> RecurringEntryType.EXPENSE + RecurringEntryTypeDto.INCOME -> RecurringEntryType.INCOME + RecurringEntryTypeDto.SETTLEMENT -> RecurringEntryType.SETTLEMENT + } + +fun RecurrenceFrequencyDto.toDomain(): RecurrenceFrequency = + when (this) { + RecurrenceFrequencyDto.DAILY -> RecurrenceFrequency.DAILY + RecurrenceFrequencyDto.WEEKLY -> RecurrenceFrequency.WEEKLY + RecurrenceFrequencyDto.MONTHLY -> RecurrenceFrequency.MONTHLY + RecurrenceFrequencyDto.YEARLY -> RecurrenceFrequency.YEARLY + } + +fun RecurringEndDto.toDomain(): RecurringEnd = + when (this) { + RecurringEndDto.Never -> RecurringEnd.Never + is RecurringEndDto.Until -> RecurringEnd.Until(date) + is RecurringEndDto.Count -> RecurringEnd.Count(count) + } + +// endregion + +// region domain -> wire + +fun RecurringTemplate.toDto(): RecurringTemplateDto = + when (entryType) { + RecurringEntryType.EXPENSE -> { + RecurringTemplateDto.Expense( + paidByUserId = paidByUserId, + title = title, + description = description, + amount = amount, + currency = currencyCode, + exchangeRate = exchangeRate, + frequency = frequency.toDto(), + interval = interval, + startDate = startDate, + end = end.toDto(), + splits = splits.map { it.toDto() }, + ) + } + + RecurringEntryType.INCOME -> { + RecurringTemplateDto.Income( + paidByUserId = paidByUserId, + title = title, + description = description, + amount = amount, + currency = currencyCode, + exchangeRate = exchangeRate, + frequency = frequency.toDto(), + interval = interval, + startDate = startDate, + end = end.toDto(), + splits = splits.map { it.toDto() }, + ) + } + + RecurringEntryType.SETTLEMENT -> { + RecurringTemplateDto.Settlement( + paidByUserId = paidByUserId, + title = title, + description = description, + amount = amount, + currency = currencyCode, + exchangeRate = exchangeRate, + frequency = frequency.toDto(), + interval = interval, + startDate = startDate, + end = end.toDto(), + // A settlement template without a receiver is not something the server will store, + // and the form cannot produce one; failing loudly beats posting a request that + // comes back as an opaque 400. + receivedByUserId = + requireNotNull(receivedByUserId) { + "a SETTLEMENT recurring template requires receivedByUserId" + }, + ) + } + } + +private fun NewRecurringTemplateSplit.toDto() = + NewRecurringTemplateSplitDto( + participantId = participantId, + split = toWsSplit(splitType, value), + resolvedAmount = resolvedAmount, + ) + +fun RecurrenceFrequency.toDto(): RecurrenceFrequencyDto = + when (this) { + RecurrenceFrequency.DAILY -> RecurrenceFrequencyDto.DAILY + RecurrenceFrequency.WEEKLY -> RecurrenceFrequencyDto.WEEKLY + RecurrenceFrequency.MONTHLY -> RecurrenceFrequencyDto.MONTHLY + RecurrenceFrequency.YEARLY -> RecurrenceFrequencyDto.YEARLY + } + +fun RecurringEnd.toDto(): RecurringEndDto = + when (this) { + RecurringEnd.Never -> RecurringEndDto.Never + is RecurringEnd.Until -> RecurringEndDto.Until(date) + is RecurringEnd.Count -> RecurringEndDto.Count(count) + } + +// endregion + +// region domain -> entity + +fun RecurringSeries.toEntity(): RecurringSeriesEntity = + RecurringSeriesEntity( + seriesId = seriesId, + groupId = groupId, + entryType = entryType.toDatabase(), + isActive = isActive, + needsAttention = needsAttention, + createdAt = createdAt.toEpochMilliseconds(), + createdByUserId = createdBy.userId, + updatedAt = updatedAt.toEpochMilliseconds(), + ruleId = rule.ruleId, + title = rule.title, + description = rule.description, + amount = rule.amount, + currencyCode = rule.currencyCode, + exchangeRate = rule.exchangeRate, + paidByUserId = rule.paidByUserId, + receivedByUserId = rule.receivedByUserId, + frequency = rule.frequency.toDatabase(), + intervalCount = rule.interval, + startDate = rule.startDate.toString(), + endType = rule.end.toDatabaseType(), + endUntilDate = (rule.end as? RecurringEnd.Until)?.date?.toString(), + endCount = (rule.end as? RecurringEnd.Count)?.count, + ) + +fun RecurringSeries.toSplitEntities(): List = + rule.splits.map { split -> + RecurringTemplateSplitEntity( + // The server omits an id on a template split it has not persisted separately; deriving + // one from the slot keeps the local primary key stable across re-syncs of the same rule. + splitId = split.splitId ?: "${rule.ruleId}:${split.participantId}", + seriesId = seriesId, + participantId = split.participantId, + splitType = split.splitType.toDatabase(), + value = split.value, + resolvedAmount = split.resolvedAmount, + ) + } + +fun RecurringSeries.toExceptionEntities(): List = + skippedOccurrenceDates.map { date -> + RecurringExceptionEntity(seriesId = seriesId, occurrenceDate = date.toString()) + } + +fun RecurringEntryType.toDatabase(): TabEntryTypeDatabase = + when (this) { + RecurringEntryType.EXPENSE -> TabEntryTypeDatabase.EXPENSE + RecurringEntryType.INCOME -> TabEntryTypeDatabase.INCOME + RecurringEntryType.SETTLEMENT -> TabEntryTypeDatabase.SETTLEMENT + } + +fun RecurrenceFrequency.toDatabase(): RecurrenceFrequencyDatabase = + when (this) { + RecurrenceFrequency.DAILY -> RecurrenceFrequencyDatabase.DAILY + RecurrenceFrequency.WEEKLY -> RecurrenceFrequencyDatabase.WEEKLY + RecurrenceFrequency.MONTHLY -> RecurrenceFrequencyDatabase.MONTHLY + RecurrenceFrequency.YEARLY -> RecurrenceFrequencyDatabase.YEARLY + } + +private fun RecurringEnd.toDatabaseType(): RecurringEndTypeDatabase = + when (this) { + RecurringEnd.Never -> RecurringEndTypeDatabase.NEVER + is RecurringEnd.Until -> RecurringEndTypeDatabase.UNTIL + is RecurringEnd.Count -> RecurringEndTypeDatabase.COUNT + } + +// endregion + +// region entity -> domain + +fun RecurringSeriesWithDetails.toDomain(): RecurringSeries = + RecurringSeries( + seriesId = series.seriesId, + groupId = series.groupId, + entryType = series.entryType.toRecurringEntryType(), + isActive = series.isActive, + needsAttention = series.needsAttention, + createdAt = Instant.fromEpochMilliseconds(series.createdAt), + createdBy = + createdBy?.toDomain() + // A series always names its creator, but the participant row can be missing on a + // device that has not synced them yet. A stand-in keeps the schedule renderable + // rather than dropping it from the list entirely. + ?: GroupParticipant(series.createdByUserId, "", ParticipantType.PLACEHOLDER), + updatedAt = Instant.fromEpochMilliseconds(series.updatedAt), + rule = + RecurringRule( + ruleId = series.ruleId, + title = series.title, + description = series.description, + amount = series.amount, + currencyCode = series.currencyCode, + exchangeRate = series.exchangeRate, + paidByUserId = series.paidByUserId, + receivedByUserId = series.receivedByUserId, + splits = + splits.map { split -> + RecurringTemplateSplit( + splitId = split.splitId, + participantId = split.participantId, + splitType = split.splitType.toDomain(), + value = split.value, + resolvedAmount = split.resolvedAmount, + ) + }, + frequency = series.frequency.toDomain(), + interval = series.intervalCount, + startDate = LocalDate.parse(series.startDate), + end = + when (series.endType) { + RecurringEndTypeDatabase.NEVER -> { + RecurringEnd.Never + } + + RecurringEndTypeDatabase.UNTIL -> { + series.endUntilDate + ?.let { RecurringEnd.Until(LocalDate.parse(it)) } + ?: STOP_GENERATING + } + + RecurringEndTypeDatabase.COUNT -> { + series.endCount + ?.let { RecurringEnd.Count(it) } + ?: STOP_GENERATING + } + }, + ), + skippedOccurrenceDates = exceptions.mapTo(mutableSetOf()) { LocalDate.parse(it.occurrenceDate) }, + ) + +/** + * The end rule an `UNTIL`/`COUNT` row missing its bound falls back to. + * + * [toEntity] always writes the bound alongside the type, so only a corrupted row reaches this. The + * direction of the failure is what matters: [RecurringEnd.Never] would turn a schedule that should + * have stopped into one that projects placeholders forever and moves everybody's balance, while a + * zero count consumes no slots and produces nothing until the row is re-synced from the server. + */ +private val STOP_GENERATING = RecurringEnd.Count(0) + +fun TabEntryTypeDatabase.toRecurringEntryType(): RecurringEntryType = + when (this) { + TabEntryTypeDatabase.EXPENSE -> RecurringEntryType.EXPENSE + TabEntryTypeDatabase.INCOME -> RecurringEntryType.INCOME + TabEntryTypeDatabase.SETTLEMENT -> RecurringEntryType.SETTLEMENT + } + +fun RecurrenceFrequencyDatabase.toDomain(): RecurrenceFrequency = + when (this) { + RecurrenceFrequencyDatabase.DAILY -> RecurrenceFrequency.DAILY + RecurrenceFrequencyDatabase.WEEKLY -> RecurrenceFrequency.WEEKLY + RecurrenceFrequencyDatabase.MONTHLY -> RecurrenceFrequency.MONTHLY + RecurrenceFrequencyDatabase.YEARLY -> RecurrenceFrequency.YEARLY + } + +// endregion diff --git a/features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/mappers/SyncMappers.kt b/features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/mappers/SyncMappers.kt index e1a72471..bd8fe1e1 100644 --- a/features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/mappers/SyncMappers.kt +++ b/features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/mappers/SyncMappers.kt @@ -9,9 +9,13 @@ fun SyncResponseDto.toDomain(): SyncSnapshot = groups = groups.map { it.toDomain() }, activeGroupIds = activeGroupIds, tabEntries = tabEntries.map { it.toDomain() }, + // Series participants join the same bucket as entry participants: both can name people + // who are no longer members of any group, and both need a local row for their foreign keys. referencedParticipants = - tabEntries - .flatMap { it.referencedParticipants() } - .distinctBy { it.userId } + ( + tabEntries.flatMap { it.referencedParticipants() } + + recurringSeries.flatMap { it.referencedParticipants() } + ).distinctBy { it.userId } .map { it.toDomain() }, + recurringSeries = recurringSeries.map { it.toDomain() }, ) diff --git a/features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/mappers/TabEntryMappers.kt b/features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/mappers/TabEntryMappers.kt index 33238cb9..97ba0545 100644 --- a/features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/mappers/TabEntryMappers.kt +++ b/features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/mappers/TabEntryMappers.kt @@ -3,6 +3,7 @@ package de.tabmates.features.tabgroup.data.mappers import de.tabmates.features.tabgroup.data.dto.GroupParticipantDto import de.tabmates.features.tabgroup.data.dto.TabEntryDto import de.tabmates.features.tabgroup.database.entities.LastTabEntryWithSplits +import de.tabmates.features.tabgroup.database.entities.RecurringSlotClaimEntity import de.tabmates.features.tabgroup.database.entities.TabEntryEntity import de.tabmates.features.tabgroup.database.entities.TabEntrySplitEntity import de.tabmates.features.tabgroup.database.entities.TabEntryWithSplits @@ -32,6 +33,8 @@ fun TabEntryDto.toDomain(): TabEntry = deletedAt = deletedAt, deletedByUserId = deletedBy?.userId, splits = splits.map { it.toDomain(tabEntryId = id) }, + recurringSeriesId = recurringSeriesId, + recurringOccurrenceDate = recurringOccurrenceDate, ) } @@ -54,6 +57,8 @@ fun TabEntryDto.toDomain(): TabEntry = deletedAt = deletedAt, deletedByUserId = deletedBy?.userId, splits = splits.map { it.toDomain(tabEntryId = id) }, + recurringSeriesId = recurringSeriesId, + recurringOccurrenceDate = recurringOccurrenceDate, ) } @@ -76,10 +81,29 @@ fun TabEntryDto.toDomain(): TabEntry = deletedAt = deletedAt, deletedByUserId = deletedBy?.userId, receivedByUserId = receivedBy.userId, + recurringSeriesId = recurringSeriesId, + recurringOccurrenceDate = recurringOccurrenceDate, ) } } +/** + * The recurring slot this entry filled, or null for a hand-created one. + * + * Recorded separately from the entry itself and never removed: a soft-deleted entry is dropped from + * the local table outright, but the server keeps its slot claimed forever, so the claim is the only + * thing that stops a deliberately deleted occurrence being projected as a placeholder again. + */ +fun TabEntryDto.recurringSlotClaim(): RecurringSlotClaimEntity? { + val seriesId = recurringSeriesId ?: return null + val occurrenceDate = recurringOccurrenceDate ?: return null + return RecurringSlotClaimEntity( + seriesId = seriesId, + occurrenceDate = occurrenceDate.toString(), + groupId = groupId, + ) +} + /** * Every participant this entry references. These may include users who are no longer group * members (left, removed, or deleted account) and are therefore absent from the group's @@ -119,6 +143,8 @@ fun TabEntry.toEntity(pendingSync: Boolean = false): TabEntryEntity = deletedAt = deletedAt?.toEpochMilliseconds(), deletedByUserId = deletedByUserId, pendingSync = pendingSync, + recurringSeriesId = recurringSeriesId, + recurringOccurrenceDate = recurringOccurrenceDate?.toString(), ) fun TabEntry.toSplitEntities(): List = @@ -150,6 +176,8 @@ fun TabEntryWithSplits.toDomain(): TabEntry = deletedByUserId = tabEntry.deletedByUserId, splits = splits.map { it.toDomain() }, isPendingSync = tabEntry.pendingSync, + recurringSeriesId = tabEntry.recurringSeriesId, + recurringOccurrenceDate = tabEntry.recurringOccurrenceDate?.let(LocalDate::parse), ) } @@ -173,6 +201,8 @@ fun TabEntryWithSplits.toDomain(): TabEntry = deletedByUserId = tabEntry.deletedByUserId, splits = splits.map { it.toDomain() }, isPendingSync = tabEntry.pendingSync, + recurringSeriesId = tabEntry.recurringSeriesId, + recurringOccurrenceDate = tabEntry.recurringOccurrenceDate?.let(LocalDate::parse), ) } @@ -199,6 +229,8 @@ fun TabEntryWithSplits.toDomain(): TabEntry = "Settlement TabEntry ${tabEntry.tabEntryId} has null receivedByUserId" }, isPendingSync = tabEntry.pendingSync, + recurringSeriesId = tabEntry.recurringSeriesId, + recurringOccurrenceDate = tabEntry.recurringOccurrenceDate?.let(LocalDate::parse), ) } } diff --git a/features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/network/dto/TabEntryWsMessages.kt b/features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/network/dto/TabEntryWsMessages.kt index 07798c8e..f1300ae2 100644 --- a/features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/network/dto/TabEntryWsMessages.kt +++ b/features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/network/dto/TabEntryWsMessages.kt @@ -35,6 +35,16 @@ object WsMessageType { */ const val REMOVED_FROM_GROUP = "REMOVED_FROM_GROUP" const val ACTIVITY_EVENT = "ACTIVITY_EVENT" + + /** + * A recurring schedule was created, edited, skipped or ended. Carries the canonical + * `RecurringSeriesDto` and no `requestId` — schedules are managed over REST, so this is a + * broadcast to the group rather than an answer to anybody's request. + * + * It is what keeps an open group screen's projected occurrences honest between syncs; without + * it a schedule change would only land on the next reconnect. + */ + const val RECURRING_SERIES_CHANGED = "RECURRING_SERIES_CHANGED" const val ERROR = "ERROR" } diff --git a/features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/recurring/DefaultScheduledLedger.kt b/features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/recurring/DefaultScheduledLedger.kt new file mode 100644 index 00000000..bbe4a05f --- /dev/null +++ b/features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/recurring/DefaultScheduledLedger.kt @@ -0,0 +1,66 @@ +package de.tabmates.features.tabgroup.data.recurring + +import de.tabmates.features.tabgroup.domain.models.TabEntry +import de.tabmates.features.tabgroup.domain.recurring.RecurringSeriesRepository +import de.tabmates.features.tabgroup.domain.recurring.ScheduledEntryProjector +import de.tabmates.features.tabgroup.domain.recurring.ScheduledLedger +import de.tabmates.features.tabgroup.domain.tabentry.TabEntryRepository +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flow +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.plus +import kotlinx.datetime.toLocalDateTime +import org.koin.core.annotation.Single +import kotlin.time.Clock + +@Single(binds = [ScheduledLedger::class]) +class DefaultScheduledLedger( + private val tabEntryRepository: TabEntryRepository, + private val recurringSeriesRepository: RecurringSeriesRepository, + private val clock: Clock, +) : ScheduledLedger { + override fun observeEntriesForGroup(groupId: String): Flow> = + combine( + tabEntryRepository.getTabEntriesForGroup(groupId), + recurringSeriesRepository.getSeriesForGroup(groupId), + recurringSeriesRepository.getClaimedSlotsForGroup(groupId), + utcDates(), + ) { entries, series, claimedSlots, today -> + if (series.isEmpty()) { + entries + } else { + entries + + ScheduledEntryProjector.project( + series = series, + // Unfiltered on purpose: a soft-deleted occurrence still occupies its slot. + existingEntries = entries, + claimedSlots = claimedSlots, + today = today, + ) + } + } + + /** + * The current UTC day, re-emitted as each one ends. + * + * The projection is a function of "today", so reading the clock inside the combine would pin it + * to whenever a repository last emitted — a session left open overnight would keep showing + * yesterday's due set until something unrelated happened to change. UTC because that is the day + * the server's sweep measures against, and the two have to agree on which occurrences are owed + * at the edges of a day. + */ + private fun utcDates(): Flow = + flow { + while (true) { + val now = clock.now() + val today = now.toLocalDateTime(TimeZone.UTC).date + emit(today) + delay(today.plus(1, DateTimeUnit.DAY).atStartOfDayIn(TimeZone.UTC) - now) + } + } +} diff --git a/features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/recurring/KtorRecurringSeriesService.kt b/features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/recurring/KtorRecurringSeriesService.kt new file mode 100644 index 00000000..23a72e83 --- /dev/null +++ b/features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/recurring/KtorRecurringSeriesService.kt @@ -0,0 +1,123 @@ +package de.tabmates.features.tabgroup.data.recurring + +import de.tabmates.core.data.networking.delete +import de.tabmates.core.data.networking.get +import de.tabmates.core.data.networking.patch +import de.tabmates.core.data.networking.post +import de.tabmates.core.domain.util.DataError +import de.tabmates.core.domain.util.EmptyResult +import de.tabmates.core.domain.util.Result +import de.tabmates.core.domain.util.asEmptyResult +import de.tabmates.features.tabgroup.data.dto.CreateRecurringSeriesRequestDto +import de.tabmates.features.tabgroup.data.dto.RecurringSeriesDto +import de.tabmates.features.tabgroup.data.dto.RecurringSeriesErrorDto +import de.tabmates.features.tabgroup.data.dto.RecurringTemplateDto +import de.tabmates.features.tabgroup.data.dto.SkipRecurringOccurrenceRequestDto +import de.tabmates.features.tabgroup.data.dto.UpdateRecurringSeriesRequestDto +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.statement.HttpResponse +import kotlinx.coroutines.CancellationException +import kotlinx.datetime.LocalDate +import org.koin.core.annotation.Single + +/** + * The schedule endpoints. + * + * Deliberately plain REST with no outbox behind it, mirroring the server's own split: entries are + * high-volume writes that need the websocket ack contract, whereas a schedule is a rare, deliberate + * act. Queueing one offline would leave a standing instruction to write into other people's ledgers + * pending on a device nobody is watching. + */ +@Single(binds = [RecurringSeriesService::class]) +class KtorRecurringSeriesService( + private val httpClient: HttpClient, +) : RecurringSeriesService { + override suspend fun getSeriesForGroup(groupId: String): Result, DataError.Remote> = + httpClient.get>(route = "/api/group/$groupId/recurring-series") + + override suspend fun createSeries( + seriesId: String, + groupId: String, + template: RecurringTemplateDto, + ): Result = + httpClient.post( + route = "/api/recurring-series", + body = + CreateRecurringSeriesRequestDto( + groupId = groupId, + id = seriesId, + template = template, + ), + mapKnownError = { it.recurringErrorOrNull() }, + ) + + override suspend fun updateSeries( + seriesId: String, + effectiveFrom: LocalDate, + template: RecurringTemplateDto, + ): Result = + httpClient.patch( + route = "/api/recurring-series/$seriesId", + body = + UpdateRecurringSeriesRequestDto( + effectiveFrom = effectiveFrom, + template = template, + ), + mapKnownError = { it.recurringErrorOrNull() }, + ) + + override suspend fun skipOccurrence( + seriesId: String, + occurrenceDate: LocalDate, + ): EmptyResult = + httpClient + .post( + route = "/api/recurring-series/$seriesId/exceptions", + body = SkipRecurringOccurrenceRequestDto(occurrenceDate), + mapKnownError = { it.recurringErrorOrNull() }, + ).asEmptyResult() + + override suspend fun unskipOccurrence( + seriesId: String, + occurrenceDate: LocalDate, + ): EmptyResult = + httpClient + .delete( + route = "/api/recurring-series/$seriesId/exceptions/$occurrenceDate", + mapKnownError = { it.recurringErrorOrNull() }, + ).asEmptyResult() + + override suspend fun endSeries(seriesId: String): EmptyResult = + httpClient + .delete( + route = "/api/recurring-series/$seriesId", + mapKnownError = { it.recurringErrorOrNull() }, + ).asEmptyResult() +} + +/** + * Maps the two refusals the schedule endpoints state by code. + * + * The `503` is the one worth separating: it does not mean the server is struggling, it means the + * feature is switched off in this environment, so the same request can succeed later untouched. + * Anything else returns null and falls through to the generic status handling. The catch tolerates + * non-JSON bodies but lets cancellation through, which has to reach the caller rather than be + * answered with a plain failure. + */ +private suspend fun HttpResponse.recurringErrorOrNull(): DataError.Remote? { + if (status.value != 400 && status.value != 503) return null + val code = + try { + body().code + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + null + } + return when (code) { + "INVALID_RECURRING_RULE" -> DataError.Remote.INVALID_RECURRING_RULE + "RECURRING_ENTRIES_DISABLED" -> DataError.Remote.RECURRING_ENTRIES_DISABLED + else -> null + } +} diff --git a/features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/recurring/OfflineFirstRecurringSeriesRepository.kt b/features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/recurring/OfflineFirstRecurringSeriesRepository.kt new file mode 100644 index 00000000..c89ac9d2 --- /dev/null +++ b/features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/recurring/OfflineFirstRecurringSeriesRepository.kt @@ -0,0 +1,143 @@ +package de.tabmates.features.tabgroup.data.recurring + +import de.tabmates.core.domain.util.DataError +import de.tabmates.core.domain.util.EmptyResult +import de.tabmates.core.domain.util.Result +import de.tabmates.core.domain.util.asEmptyResult +import de.tabmates.core.domain.util.map +import de.tabmates.core.domain.util.onSuccess +import de.tabmates.features.tabgroup.data.mappers.toDomain +import de.tabmates.features.tabgroup.data.mappers.toDto +import de.tabmates.features.tabgroup.data.sync.RecurringSeriesLocalWriter +import de.tabmates.features.tabgroup.database.TabMatesDatabase +import de.tabmates.features.tabgroup.database.entities.RecurringExceptionEntity +import de.tabmates.features.tabgroup.domain.recurring.RecurringSeries +import de.tabmates.features.tabgroup.domain.recurring.RecurringSeriesRepository +import de.tabmates.features.tabgroup.domain.recurring.RecurringSlot +import de.tabmates.features.tabgroup.domain.recurring.RecurringTemplate +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.datetime.LocalDate +import org.koin.core.annotation.Single +import kotlin.time.Clock + +/** + * Reads schedules from the local mirror, writes them straight to the server. + * + * The asymmetry is deliberate and is the one place this repository departs from how tab entries + * work. Reads have to survive offline, because a schedule is what lets the group screen project the + * occurrences the server has not written yet. Writes must not be queued: a schedule is a standing + * instruction to write into other people's shared ledgers, and one sitting in an outbox on a device + * nobody is watching would fire days later against a group that has moved on. Every write here + * needs a connection and reports its own failure. + */ +@Single(binds = [RecurringSeriesRepository::class]) +class OfflineFirstRecurringSeriesRepository( + private val service: RecurringSeriesService, + private val database: TabMatesDatabase, + private val localWriter: RecurringSeriesLocalWriter, +) : RecurringSeriesRepository { + override fun getSeriesForGroup(groupId: String): Flow> = + database.recurringSeriesDao + .observeSeriesByGroupId(groupId) + .map { rows -> rows.map { it.toDomain() } } + + override fun getSeriesById(seriesId: String): Flow = + database.recurringSeriesDao + .observeSeriesById(seriesId) + .map { it?.toDomain() } + + override fun getClaimedSlotsForGroup(groupId: String): Flow> = + database.recurringSlotClaimDao + .observeClaimsForGroup(groupId) + .map { claims -> + claims.mapTo(mutableSetOf()) { + RecurringSlot(it.seriesId, LocalDate.parse(it.occurrenceDate)) + } + } + + override suspend fun createSeries( + seriesId: String, + groupId: String, + template: RecurringTemplate, + ): Result = + service + .createSeries(seriesId = seriesId, groupId = groupId, template = template.toDto()) + .onSuccess { localWriter.persist(listOf(it)) } + .map { it.toDomain() } + + override suspend fun updateSeries( + seriesId: String, + effectiveFrom: LocalDate, + template: RecurringTemplate, + ): Result = + service + .updateSeries(seriesId = seriesId, effectiveFrom = effectiveFrom, template = template.toDto()) + .onSuccess { localWriter.persist(listOf(it)) } + .map { it.toDomain() } + + /** + * The three endpoints below answer with no body, so the local mirror is nudged by hand and then + * reconciled from the server. Applying the change locally first is what keeps the screen from + * snapping back for the length of the refresh round trip; the refresh is what makes the local + * guess authoritative, including anything another member changed in the meantime. + * + * A failed refresh is not a failed write — the write already succeeded, and the next sync will + * carry the schedule anyway — so its result is deliberately discarded. + */ + override suspend fun skipOccurrence( + seriesId: String, + occurrenceDate: LocalDate, + ): EmptyResult = + service + .skipOccurrence(seriesId, occurrenceDate) + .onSuccess { + database.recurringSeriesDao.upsertExceptions( + listOf(RecurringExceptionEntity(seriesId, occurrenceDate.toString())), + ) + refreshSeriesOfSameGroup(seriesId) + }.asEmptyResult() + + override suspend fun unskipOccurrence( + seriesId: String, + occurrenceDate: LocalDate, + ): EmptyResult = + service + .unskipOccurrence(seriesId, occurrenceDate) + .onSuccess { + database.recurringSeriesDao.deleteException(seriesId, occurrenceDate.toString()) + refreshSeriesOfSameGroup(seriesId) + }.asEmptyResult() + + override suspend fun endSeries(seriesId: String): EmptyResult = + service + .endSeries(seriesId) + .onSuccess { + database.recurringSeriesDao.markEnded(seriesId, Clock.System.now().toEpochMilliseconds()) + refreshSeriesOfSameGroup(seriesId) + }.asEmptyResult() + + /** + * Replaces the group's schedules with what the server currently has. + * + * Needed on top of the account-wide sync, which only carries series changed since the cursor: a + * group that just became visible arrives without the schedules it already had. Prunes anything + * local the payload does not mention, because unlike a delta this response is complete. + */ + override suspend fun refreshSeriesForGroup(groupId: String): EmptyResult = + service + .getSeriesForGroup(groupId) + .onSuccess { series -> + val serverIds = series.mapTo(mutableSetOf()) { it.id } + val stale = + database.recurringSeriesDao + .getSeriesIdsForGroup(groupId) + .filterNot { it in serverIds } + localWriter.persist(series, staleSeriesIds = stale) + }.asEmptyResult() + + private suspend fun refreshSeriesOfSameGroup(seriesId: String) { + val groupId = database.recurringSeriesDao.getGroupIdForSeries(seriesId) ?: return + refreshSeriesForGroup(groupId) + } +} diff --git a/features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/recurring/RecurringSeriesService.kt b/features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/recurring/RecurringSeriesService.kt new file mode 100644 index 00000000..1e36ea0b --- /dev/null +++ b/features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/recurring/RecurringSeriesService.kt @@ -0,0 +1,42 @@ +package de.tabmates.features.tabgroup.data.recurring + +import de.tabmates.core.domain.util.DataError +import de.tabmates.core.domain.util.EmptyResult +import de.tabmates.core.domain.util.Result +import de.tabmates.features.tabgroup.data.dto.RecurringSeriesDto +import de.tabmates.features.tabgroup.data.dto.RecurringTemplateDto +import kotlinx.datetime.LocalDate + +/** + * The remote contract for recurring schedules. + * + * Lives in the data layer rather than the domain because it trades in DTOs: the repository owns the + * mapping, and every response has to reach the local mirror in the same shape the sync path writes. + */ +interface RecurringSeriesService { + suspend fun getSeriesForGroup(groupId: String): Result, DataError.Remote> + + suspend fun createSeries( + seriesId: String, + groupId: String, + template: RecurringTemplateDto, + ): Result + + suspend fun updateSeries( + seriesId: String, + effectiveFrom: LocalDate, + template: RecurringTemplateDto, + ): Result + + suspend fun skipOccurrence( + seriesId: String, + occurrenceDate: LocalDate, + ): EmptyResult + + suspend fun unskipOccurrence( + seriesId: String, + occurrenceDate: LocalDate, + ): EmptyResult + + suspend fun endSeries(seriesId: String): EmptyResult +} diff --git a/features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/sync/OfflineFirstSyncRepository.kt b/features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/sync/OfflineFirstSyncRepository.kt index c2296660..2fe938f5 100644 --- a/features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/sync/OfflineFirstSyncRepository.kt +++ b/features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/sync/OfflineFirstSyncRepository.kt @@ -12,8 +12,11 @@ import de.tabmates.features.tabgroup.data.mappers.toSplitEntities import de.tabmates.features.tabgroup.database.TabMatesDatabase import de.tabmates.features.tabgroup.database.entities.GroupParticipantEntity import de.tabmates.features.tabgroup.database.entities.GroupWithParticipants +import de.tabmates.features.tabgroup.database.entities.RecurringSlotClaimEntity import de.tabmates.features.tabgroup.database.entities.types.ParticipantTypeDatabase import de.tabmates.features.tabgroup.domain.models.SyncSnapshot +import de.tabmates.features.tabgroup.domain.models.TabEntry +import de.tabmates.features.tabgroup.domain.recurring.RecurringSeriesRepository import de.tabmates.features.tabgroup.domain.sync.SyncRepository import de.tabmates.features.tabgroup.domain.sync.SyncService import kotlinx.coroutines.sync.Mutex @@ -28,6 +31,8 @@ class OfflineFirstSyncRepository( private val lastServerContactStore: LastServerContactStore, private val tabEntryBackfiller: GroupTabEntryBackfiller, private val pendingBackfillStore: PendingTabEntryBackfillStore, + private val recurringSeriesLocalWriter: RecurringSeriesLocalWriter, + private val recurringSeriesRepository: RecurringSeriesRepository, ) : SyncRepository { // Serializes the login and reconnect triggers so their sync runs can't interleave and race // on the shared cursor / local DB. @@ -84,7 +89,12 @@ class OfflineFirstSyncRepository( (newlyKnownGroupIds + pendingBackfillStore.getAll()) .toSet() - .forEach { groupId -> tabEntryBackfiller.backfill(groupId) } + .forEach { groupId -> + tabEntryBackfiller.backfill(groupId) + // Schedules have the same gap as entries: the delta filters them by the cursor, so + // a group that just became visible arrives without the ones it already had. + recurringSeriesRepository.refreshSeriesForGroup(groupId) + } } private suspend fun applySnapshot( @@ -146,6 +156,26 @@ class OfflineFirstSyncRepository( database.groupParticipantDao.insertParticipantsIgnoringConflicts(orphanSplitParticipants) } + // Series before entries: a generated entry names its series, and the projector reads both. + // Only a full sync may prune — a delta says nothing about the series it omits, and a series + // is never deleted server-side, only deactivated. + recurringSeriesLocalWriter.persist( + series = snapshot.recurringSeries, + namedParticipants = snapshot.referencedParticipants, + staleSeriesIds = + if (isFullSync) { + val serverIds = snapshot.recurringSeries.mapTo(mutableSetOf()) { it.seriesId } + database.recurringSeriesDao.getAllSeriesIds().filterNot { it in serverIds } + } else { + emptyList() + }, + ) + + // Recorded from alive and soft-deleted entries alike, and before the merge below hard- + // deletes the latter. A claimed slot is never regenerated by the server, so losing the + // record is what would make a deliberately deleted occurrence reappear as a placeholder. + recordRecurringSlotClaims(snapshot.tabEntries) + database.tabEntryDao.applySyncedTabEntries( aliveEntries = alive.map { it.toEntity() }, splitsByEntryId = splitsByEntryId, @@ -153,5 +183,25 @@ class OfflineFirstSyncRepository( allServerIds = allServerIds, splitDao = database.tabEntrySplitDao, ) + + // The table carries no foreign key — a claim can arrive before its series does — so nothing + // else removes the rows of a group the user has left. + database.recurringSlotClaimDao.deleteClaimsForRemovedGroups() + } + + private suspend fun recordRecurringSlotClaims(entries: List) { + val claims = + entries.mapNotNull { entry -> + val seriesId = entry.recurringSeriesId ?: return@mapNotNull null + val occurrenceDate = entry.recurringOccurrenceDate ?: return@mapNotNull null + RecurringSlotClaimEntity( + seriesId = seriesId, + occurrenceDate = occurrenceDate.toString(), + groupId = entry.groupId, + ) + } + if (claims.isNotEmpty()) { + database.recurringSlotClaimDao.recordClaims(claims) + } } } diff --git a/features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/sync/RecurringSeriesLocalWriter.kt b/features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/sync/RecurringSeriesLocalWriter.kt new file mode 100644 index 00000000..7e51ac17 --- /dev/null +++ b/features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/sync/RecurringSeriesLocalWriter.kt @@ -0,0 +1,111 @@ +package de.tabmates.features.tabgroup.data.sync + +import de.tabmates.features.tabgroup.data.dto.RecurringSeriesDto +import de.tabmates.features.tabgroup.data.mappers.referencedParticipants +import de.tabmates.features.tabgroup.data.mappers.toDomain +import de.tabmates.features.tabgroup.data.mappers.toEntity +import de.tabmates.features.tabgroup.data.mappers.toExceptionEntities +import de.tabmates.features.tabgroup.data.mappers.toSplitEntities +import de.tabmates.features.tabgroup.database.TabMatesDatabase +import de.tabmates.features.tabgroup.database.entities.GroupParticipantEntity +import de.tabmates.features.tabgroup.database.entities.types.ParticipantTypeDatabase +import de.tabmates.features.tabgroup.domain.models.GroupParticipant +import de.tabmates.features.tabgroup.domain.recurring.RecurringSeries +import org.koin.core.annotation.Single + +/** + * The single place recurring schedules are written to the local mirror. + * + * Shared by the sync path, the per-group refresh and every schedule write, because all three have + * the same foreign-key problem to solve first: a series' creator, payer, receiver and split + * participants each need a `group_participants` row to exist, and a template routinely outlives the + * membership of the people in it. Leaving that to each caller is how one of them ends up being the + * one that crashes a sync on a constraint. + */ +@Single +class RecurringSeriesLocalWriter( + private val database: TabMatesDatabase, +) { + /** + * Persists [series] and prunes [staleSeriesIds]. + * + * [namedParticipants] are the participants the payload described in full; anyone a series + * references without one gets an insert-ignore placeholder so the foreign keys hold. + * + * Pass stale ids only when the payload is complete — a full sync, or a per-group refresh. A + * delta carries only what changed, and a series is never deleted server-side (only deactivated), + * so pruning what a delta omits would wipe every schedule that simply had a quiet week. + */ + suspend fun persist( + series: List, + namedParticipants: List = emptyList(), + staleSeriesIds: List = emptyList(), + ) { + if (series.isEmpty() && staleSeriesIds.isEmpty()) return + + ensureParticipantsExist(series, namedParticipants) + + database.recurringSeriesDao.applySyncedSeries( + series = series.map { it.toEntity() }, + splitsBySeriesId = series.associate { it.seriesId to it.toSplitEntities() }, + exceptionsBySeriesId = series.associate { it.seriesId to it.toExceptionEntities() }, + staleSeriesIds = staleSeriesIds, + ) + } + + /** Convenience for the write paths, which hold the server's own response. */ + suspend fun persist( + series: List, + staleSeriesIds: List = emptyList(), + ) = persist( + series = series.map { it.toDomain() }, + namedParticipants = + series + .flatMap { it.referencedParticipants() } + .distinctBy { it.userId } + .map { it.toDomain() }, + staleSeriesIds = staleSeriesIds, + ) + + /** + * Makes every participant a series references resolvable before the series is written. + * + * Named participants are upserted with what the payload knows. Everyone else a series names — + * split participants are the only ones the server may leave unnamed — gets an insert-ignore + * placeholder, which never overwrites a real row but does keep the foreign keys satisfiable. + * Same last-resort guard the entry sync path uses, for the same reason. + */ + private suspend fun ensureParticipantsExist( + series: List, + namedParticipants: List, + ) { + val named = namedParticipants.distinctBy { it.userId } + if (named.isNotEmpty()) { + database.groupParticipantDao.upsertParticipants(named.map { it.toEntity() }) + } + + val namedIds = named.mapTo(mutableSetOf()) { it.userId } + val unresolved = + series + .flatMap { candidate -> + buildList { + add(candidate.createdBy.userId) + add(candidate.rule.paidByUserId) + candidate.rule.receivedByUserId?.let(::add) + candidate.rule.splits.mapTo(this) { it.participantId } + } + }.distinct() + .filterNot { it in namedIds } + if (unresolved.isNotEmpty()) { + database.groupParticipantDao.insertParticipantsIgnoringConflicts( + unresolved.map { + GroupParticipantEntity( + userId = it, + username = "Unknown", + participantType = ParticipantTypeDatabase.PLACEHOLDER, + ) + }, + ) + } + } +} diff --git a/features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/tabentry/TabEntryOutbox.kt b/features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/tabentry/TabEntryOutbox.kt index 8391f7b9..c3f4c205 100644 --- a/features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/tabentry/TabEntryOutbox.kt +++ b/features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/tabentry/TabEntryOutbox.kt @@ -661,10 +661,13 @@ class TabEntryOutbox( DataError.Remote.SERIALIZATION, // Turnstile only gates the auth endpoints, never this delete; classify as // permanent for exhaustiveness (it would never clear on retry anyway). The - // same goes for the two participant-removal refusals. + // same goes for the two participant-removal refusals and the two recurring + // ones, which only the schedule endpoints raise — and those have no outbox. DataError.Remote.TURNSTILE_FAILED, DataError.Remote.CANNOT_REMOVE_SELF, DataError.Remote.CANNOT_REMOVE_GROUP_CREATOR, + DataError.Remote.INVALID_RECURRING_RULE, + DataError.Remote.RECURRING_ENTRIES_DISABLED, -> DispatchResult.Permanent(result.error.name.lowercase()) } } diff --git a/features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/tabentry/TabEntryRealtimeSync.kt b/features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/tabentry/TabEntryRealtimeSync.kt index a4e41dde..683bad07 100644 --- a/features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/tabentry/TabEntryRealtimeSync.kt +++ b/features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/tabentry/TabEntryRealtimeSync.kt @@ -3,7 +3,9 @@ package de.tabmates.features.tabgroup.data.tabentry import de.tabmates.core.data.di.APPLICATION_SCOPE import de.tabmates.core.domain.logging.TabMatesLogger import de.tabmates.core.domain.util.onFailure +import de.tabmates.features.tabgroup.data.dto.RecurringSeriesDto import de.tabmates.features.tabgroup.data.dto.TabEntryDto +import de.tabmates.features.tabgroup.data.mappers.recurringSlotClaim import de.tabmates.features.tabgroup.data.mappers.referencedParticipants import de.tabmates.features.tabgroup.data.mappers.toDomain import de.tabmates.features.tabgroup.data.mappers.toEntity @@ -14,6 +16,7 @@ import de.tabmates.features.tabgroup.data.network.dto.TabEntryDeletedWsPayload import de.tabmates.features.tabgroup.data.network.dto.WebSocketMessageDto import de.tabmates.features.tabgroup.data.network.dto.WsErrorPayload import de.tabmates.features.tabgroup.data.network.dto.WsMessageType +import de.tabmates.features.tabgroup.data.sync.RecurringSeriesLocalWriter import de.tabmates.features.tabgroup.database.TabMatesDatabase import de.tabmates.features.tabgroup.domain.group.GroupRemovalNotifier import de.tabmates.features.tabgroup.domain.group.GroupRepository @@ -39,6 +42,7 @@ class TabEntryRealtimeSync( private val database: TabMatesDatabase, private val groupRepository: GroupRepository, private val groupRemovalNotifier: GroupRemovalNotifier, + private val recurringSeriesLocalWriter: RecurringSeriesLocalWriter, private val json: Json, private val logger: TabMatesLogger, @Named(APPLICATION_SCOPE) private val applicationScope: CoroutineScope, @@ -69,6 +73,11 @@ class TabEntryRealtimeSync( WsMessageType.REMOVED_FROM_GROUP -> handleRemovedFromGroup(message.payload) + // A schedule someone created, edited, skipped or ended. Schedules are managed over + // REST, so this frame is the only thing that keeps an open group screen's projected + // occurrences honest between syncs. + WsMessageType.RECURRING_SERIES_CHANGED -> handleRecurringSeriesChanged(message.payload) + // Owned by ActivityRealtimeSync; named here only to keep it out of the unknown-type log. WsMessageType.ACTIVITY_EVENT -> Unit @@ -89,6 +98,10 @@ class TabEntryRealtimeSync( val dto = json.decodeFromString(TabEntryDto.serializer(), payload) val entry = dto.toDomain() logger.debug(TAG, "WS echo received id=${entry.tabEntryId}") + // Recorded before the soft-delete branch below, and never removed afterwards. The server + // keeps a recurring slot claimed whatever happens to the entry in it, so this is what stops + // a deleted occurrence being projected as a placeholder and regenerated on screen forever. + dto.recurringSlotClaim()?.let { database.recurringSlotClaimDao.recordClaims(listOf(it)) } // A soft-deleted entry is gone as far as this client is concerned, and local queries do not // filter on deletedAt — upserting one would put it back on screen. Reached via a replayed // ACK: the server answers a retry of a write whose entry has since been deleted with the @@ -123,6 +136,13 @@ class TabEntryRealtimeSync( ) } + private suspend fun handleRecurringSeriesChanged(payload: String) { + val dto = json.decodeFromString(RecurringSeriesDto.serializer(), payload) + logger.debug(TAG, "WS recurring series changed id=${dto.id}") + // No pruning: this frame describes one series and says nothing about the others. + recurringSeriesLocalWriter.persist(listOf(dto)) + } + private suspend fun handleDeleted(payload: String) { val event = json.decodeFromString(TabEntryDeletedWsPayload.serializer(), payload) database.tabEntryDao.deleteTabEntryAndSplits( diff --git a/features/tabgroup/data/src/desktopTest/kotlin/de/tabmates/features/tabgroup/data/recurring/DefaultScheduledLedgerTest.kt b/features/tabgroup/data/src/desktopTest/kotlin/de/tabmates/features/tabgroup/data/recurring/DefaultScheduledLedgerTest.kt new file mode 100644 index 00000000..498a30e1 --- /dev/null +++ b/features/tabgroup/data/src/desktopTest/kotlin/de/tabmates/features/tabgroup/data/recurring/DefaultScheduledLedgerTest.kt @@ -0,0 +1,264 @@ +package de.tabmates.features.tabgroup.data.recurring + +import app.cash.turbine.test +import de.tabmates.core.domain.util.DataError +import de.tabmates.core.domain.util.EmptyResult +import de.tabmates.core.domain.util.Result +import de.tabmates.features.tabgroup.domain.models.GroupParticipant +import de.tabmates.features.tabgroup.domain.models.ParticipantType +import de.tabmates.features.tabgroup.domain.models.SplitType +import de.tabmates.features.tabgroup.domain.models.TabEntry +import de.tabmates.features.tabgroup.domain.recurring.RecurrenceFrequency +import de.tabmates.features.tabgroup.domain.recurring.RecurringEnd +import de.tabmates.features.tabgroup.domain.recurring.RecurringEntryType +import de.tabmates.features.tabgroup.domain.recurring.RecurringRule +import de.tabmates.features.tabgroup.domain.recurring.RecurringSeries +import de.tabmates.features.tabgroup.domain.recurring.RecurringSeriesRepository +import de.tabmates.features.tabgroup.domain.recurring.RecurringSlot +import de.tabmates.features.tabgroup.domain.recurring.RecurringTemplate +import de.tabmates.features.tabgroup.domain.recurring.RecurringTemplateSplit +import de.tabmates.features.tabgroup.domain.tabentry.NewTabEntrySplit +import de.tabmates.features.tabgroup.domain.tabentry.TabEntryRepository +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.plus +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Clock +import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant + +/** + * The ledger's day boundary. + * + * An occurrence falls due at a calendar boundary, not at anything a repository emits, so the + * projection has to advance on its own. Without that, a session left open overnight keeps showing + * yesterday's due set — and the balance that goes with it — until something unrelated happens to + * change. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class DefaultScheduledLedgerTest { + private val today = LocalDate(2026, 8, 11) + private val tomorrow = today.plus(1, DateTimeUnit.DAY) + + @Test + fun `an occurrence due tomorrow appears once the UTC day turns over`() = + runTest { + // Six hours before midnight UTC, so the test crosses the boundary rather than starting + // on it. + val clock = MutableClock(tomorrow.atStartOfDayIn(TimeZone.UTC) - 6.hours) + val ledger = + DefaultScheduledLedger( + tabEntryRepository = FakeTabEntryRepository(), + recurringSeriesRepository = + FakeRecurringSeriesRepository(listOf(seriesStartingOn(tomorrow))), + clock = clock, + ) + + ledger.observeEntriesForGroup("g1").test { + assertTrue( + awaitItem().none { it.isScheduledPlaceholder }, + "an occurrence dated tomorrow is not owed yet", + ) + + // A second past the boundary: `advanceTimeBy` stops short of tasks scheduled on it. + val pastMidnight = 6.hours + 1.seconds + clock.advanceBy(pastMidnight) + advanceTimeBy(pastMidnight) + + val projected = awaitItem().filter { it.isScheduledPlaceholder } + assertEquals(1, projected.size) + assertEquals(tomorrow, projected.single().entryDate) + cancelAndIgnoreRemainingEvents() + } + } + + private fun seriesStartingOn(startDate: LocalDate) = + RecurringSeries( + seriesId = "series-1", + groupId = "g1", + entryType = RecurringEntryType.EXPENSE, + isActive = true, + needsAttention = false, + createdAt = Instant.fromEpochMilliseconds(0), + createdBy = GroupParticipant("user-1", "Alice", ParticipantType.REGISTERED), + updatedAt = Instant.fromEpochMilliseconds(0), + rule = + RecurringRule( + ruleId = "rule-1", + title = "Rent", + description = "", + amount = 100.0, + currencyCode = "EUR", + exchangeRate = null, + paidByUserId = "user-1", + receivedByUserId = null, + splits = + listOf( + RecurringTemplateSplit( + splitId = "split-1", + participantId = "user-1", + splitType = SplitType.EQUAL, + value = 1.0, + resolvedAmount = 100.0, + ), + ), + frequency = RecurrenceFrequency.MONTHLY, + interval = 1, + startDate = startDate, + end = RecurringEnd.Never, + ), + ) + + /** A clock the test moves by hand, so the day can turn over without waiting for it to. */ + private class MutableClock( + private var now: Instant, + ) : Clock { + override fun now(): Instant = now + + fun advanceBy(duration: Duration) { + now += duration + } + } + + private class FakeTabEntryRepository : TabEntryRepository { + override fun getTabEntriesForGroup(groupId: String): Flow> = flowOf(emptyList()) + + override fun getTabEntryById(tabEntryId: String): Flow = flowOf(null) + + override suspend fun createExpense( + groupId: String, + title: String, + description: String, + amount: Double, + currencyCode: String, + exchangeRate: Double?, + paidByUserId: String, + entryDate: LocalDate, + splits: List, + ) = unexpected() + + override suspend fun updateExpense( + tabEntryId: String, + groupId: String, + title: String, + description: String, + amount: Double, + currencyCode: String, + exchangeRate: Double?, + paidByUserId: String, + entryDate: LocalDate, + splits: List, + ) = unexpected() + + override suspend fun createIncome( + groupId: String, + title: String, + description: String, + amount: Double, + currencyCode: String, + exchangeRate: Double?, + paidByUserId: String, + entryDate: LocalDate, + splits: List, + ) = unexpected() + + override suspend fun updateIncome( + tabEntryId: String, + groupId: String, + title: String, + description: String, + amount: Double, + currencyCode: String, + exchangeRate: Double?, + paidByUserId: String, + entryDate: LocalDate, + splits: List, + ) = unexpected() + + override suspend fun createSettlement( + groupId: String, + title: String, + description: String, + amount: Double, + currencyCode: String, + exchangeRate: Double?, + paidByUserId: String, + receivedByUserId: String, + entryDate: LocalDate, + ) = unexpected() + + override suspend fun updateSettlement( + tabEntryId: String, + groupId: String, + title: String, + description: String, + amount: Double, + currencyCode: String, + exchangeRate: Double?, + paidByUserId: String, + receivedByUserId: String, + entryDate: LocalDate, + ) = unexpected() + + override suspend fun deleteTabEntry(tabEntryId: String): EmptyResult = unexpected() + + private fun unexpected(): Nothing = error("unexpected write in a ledger test") + } + + private class FakeRecurringSeriesRepository( + private val series: List, + ) : RecurringSeriesRepository { + // StateFlows, so the ledger's combine has something that stays open rather than completing + // and settling the whole projection on its first value. + private val seriesFlow = MutableStateFlow(series) + private val claimsFlow = MutableStateFlow(emptySet()) + + override fun getSeriesForGroup(groupId: String): Flow> = seriesFlow + + override fun getSeriesById(seriesId: String): Flow = + MutableStateFlow(series.firstOrNull { it.seriesId == seriesId }) + + override fun getClaimedSlotsForGroup(groupId: String): Flow> = claimsFlow + + override suspend fun createSeries( + seriesId: String, + groupId: String, + template: RecurringTemplate, + ): Result = unexpected() + + override suspend fun updateSeries( + seriesId: String, + effectiveFrom: LocalDate, + template: RecurringTemplate, + ): Result = unexpected() + + override suspend fun skipOccurrence( + seriesId: String, + occurrenceDate: LocalDate, + ): EmptyResult = unexpected() + + override suspend fun unskipOccurrence( + seriesId: String, + occurrenceDate: LocalDate, + ): EmptyResult = unexpected() + + override suspend fun endSeries(seriesId: String): EmptyResult = unexpected() + + override suspend fun refreshSeriesForGroup(groupId: String): EmptyResult = + Result.Success(Unit) + + private fun unexpected(): Nothing = error("unexpected write in a ledger test") + } +} diff --git a/features/tabgroup/data/src/desktopTest/kotlin/de/tabmates/features/tabgroup/data/sync/FakeRecurringSeriesRepository.kt b/features/tabgroup/data/src/desktopTest/kotlin/de/tabmates/features/tabgroup/data/sync/FakeRecurringSeriesRepository.kt new file mode 100644 index 00000000..2cb2eeba --- /dev/null +++ b/features/tabgroup/data/src/desktopTest/kotlin/de/tabmates/features/tabgroup/data/sync/FakeRecurringSeriesRepository.kt @@ -0,0 +1,58 @@ +package de.tabmates.features.tabgroup.data.sync + +import de.tabmates.core.domain.util.DataError +import de.tabmates.core.domain.util.EmptyResult +import de.tabmates.core.domain.util.Result +import de.tabmates.features.tabgroup.domain.recurring.RecurringSeries +import de.tabmates.features.tabgroup.domain.recurring.RecurringSeriesRepository +import de.tabmates.features.tabgroup.domain.recurring.RecurringSlot +import de.tabmates.features.tabgroup.domain.recurring.RecurringTemplate +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.datetime.LocalDate + +/** + * Records the per-group refreshes the sync path asks for, and does nothing else. + * + * Every write method fails loudly: the sync path has no business creating or editing schedules, so + * a call reaching one of them is a wiring mistake worth a red test rather than a silent success. + */ +class FakeRecurringSeriesRepository : RecurringSeriesRepository { + val refreshedGroupIds = mutableListOf() + + override fun getSeriesForGroup(groupId: String): Flow> = flowOf(emptyList()) + + override fun getSeriesById(seriesId: String): Flow = flowOf(null) + + override fun getClaimedSlotsForGroup(groupId: String): Flow> = flowOf(emptySet()) + + override suspend fun createSeries( + seriesId: String, + groupId: String, + template: RecurringTemplate, + ): Result = error("unexpected createSeries in a sync test") + + override suspend fun updateSeries( + seriesId: String, + effectiveFrom: LocalDate, + template: RecurringTemplate, + ): Result = error("unexpected updateSeries in a sync test") + + override suspend fun skipOccurrence( + seriesId: String, + occurrenceDate: LocalDate, + ): EmptyResult = error("unexpected skipOccurrence in a sync test") + + override suspend fun unskipOccurrence( + seriesId: String, + occurrenceDate: LocalDate, + ): EmptyResult = error("unexpected unskipOccurrence in a sync test") + + override suspend fun endSeries(seriesId: String): EmptyResult = + error("unexpected endSeries in a sync test") + + override suspend fun refreshSeriesForGroup(groupId: String): EmptyResult { + refreshedGroupIds += groupId + return Result.Success(Unit) + } +} diff --git a/features/tabgroup/data/src/desktopTest/kotlin/de/tabmates/features/tabgroup/data/sync/OfflineFirstSyncRepositoryTest.kt b/features/tabgroup/data/src/desktopTest/kotlin/de/tabmates/features/tabgroup/data/sync/OfflineFirstSyncRepositoryTest.kt index b3f01409..48dcf723 100644 --- a/features/tabgroup/data/src/desktopTest/kotlin/de/tabmates/features/tabgroup/data/sync/OfflineFirstSyncRepositoryTest.kt +++ b/features/tabgroup/data/src/desktopTest/kotlin/de/tabmates/features/tabgroup/data/sync/OfflineFirstSyncRepositoryTest.kt @@ -34,6 +34,7 @@ class OfflineFirstSyncRepositoryTest { tabEntryService: FakeTabEntryService = FakeTabEntryService(), pendingBackfillStore: FakePendingTabEntryBackfillStore = FakePendingTabEntryBackfillStore(), lastServerContactStore: FakeLastServerContactStore = FakeLastServerContactStore(), + recurringSeriesRepository: FakeRecurringSeriesRepository = FakeRecurringSeriesRepository(), ): OfflineFirstSyncRepository = OfflineFirstSyncRepository( syncService = service, @@ -43,6 +44,8 @@ class OfflineFirstSyncRepositoryTest { tabEntryBackfiller = GroupTabEntryBackfiller(tabEntryService, database, pendingBackfillStore, NoopLogger), pendingBackfillStore = pendingBackfillStore, + recurringSeriesLocalWriter = RecurringSeriesLocalWriter(database), + recurringSeriesRepository = recurringSeriesRepository, ) private suspend fun localGroupIds() = database.groupDao.getAllGroupIds().toSet() @@ -399,12 +402,16 @@ class OfflineFirstSyncRepositoryTest { val tabEntryService = FakeTabEntryService(Result.Success(history(listOf(expense("e9", "g2"))))) val pendingStore = FakePendingTabEntryBackfillStore() + val recurring = FakeRecurringSeriesRepository() - repository(service, cursorStore, tabEntryService, pendingStore).sync() + repository(service, cursorStore, tabEntryService, pendingStore, recurringSeriesRepository = recurring) + .sync() assertEquals(listOf("g2"), tabEntryService.receivedGroupIds) assertEquals(setOf("e9"), localEntryIds()) assertTrue(pendingStore.getAll().isEmpty()) + // Schedules have the same cursor gap entries do, so every backfilled group is refreshed. + assertEquals(setOf("g2"), recurring.refreshedGroupIds.toSet()) } @Test @@ -440,7 +447,15 @@ class OfflineFirstSyncRepositoryTest { ) val tabEntryService = FakeTabEntryService() val pendingStore = FakePendingTabEntryBackfillStore() - val repository = repository(service, cursorStore, tabEntryService, pendingStore) + val recurring = FakeRecurringSeriesRepository() + val repository = + repository( + service, + cursorStore, + tabEntryService, + pendingStore, + recurringSeriesRepository = recurring, + ) repository.sync() // Simulates a join whose entries fetch failed: group already local, only the marker left. @@ -453,6 +468,8 @@ class OfflineFirstSyncRepositoryTest { assertEquals(listOf("g1"), tabEntryService.receivedGroupIds) assertEquals(setOf("e1"), localEntryIds()) assertTrue(pendingStore.getAll().isEmpty()) + // A retried group's schedules are refreshed alongside its entries. + assertEquals(listOf("g1"), recurring.refreshedGroupIds) } @Test diff --git a/features/tabgroup/data/src/desktopTest/kotlin/de/tabmates/features/tabgroup/data/tabentry/TabEntryRealtimeSyncTest.kt b/features/tabgroup/data/src/desktopTest/kotlin/de/tabmates/features/tabgroup/data/tabentry/TabEntryRealtimeSyncTest.kt index a4d2f573..8bf59cc4 100644 --- a/features/tabgroup/data/src/desktopTest/kotlin/de/tabmates/features/tabgroup/data/tabentry/TabEntryRealtimeSyncTest.kt +++ b/features/tabgroup/data/src/desktopTest/kotlin/de/tabmates/features/tabgroup/data/tabentry/TabEntryRealtimeSyncTest.kt @@ -5,16 +5,25 @@ import de.tabmates.core.domain.util.EmptyResult import de.tabmates.core.domain.util.Result import de.tabmates.features.tabgroup.data.dto.GroupParticipantDto import de.tabmates.features.tabgroup.data.dto.ParticipantTypeDto +import de.tabmates.features.tabgroup.data.dto.RecurrenceFrequencyDto +import de.tabmates.features.tabgroup.data.dto.RecurringEndDto +import de.tabmates.features.tabgroup.data.dto.RecurringEntryTypeDto +import de.tabmates.features.tabgroup.data.dto.RecurringRuleDto +import de.tabmates.features.tabgroup.data.dto.RecurringSeriesDto +import de.tabmates.features.tabgroup.data.dto.RecurringTemplateSplitDto import de.tabmates.features.tabgroup.data.dto.TabEntryDto import de.tabmates.features.tabgroup.data.mappers.toEntity import de.tabmates.features.tabgroup.data.network.dto.WebSocketMessageDto import de.tabmates.features.tabgroup.data.network.dto.WsMessageType +import de.tabmates.features.tabgroup.data.network.dto.WsSplitDto import de.tabmates.features.tabgroup.data.sync.NoopLogger +import de.tabmates.features.tabgroup.data.sync.RecurringSeriesLocalWriter import de.tabmates.features.tabgroup.data.sync.createInMemoryDatabase import de.tabmates.features.tabgroup.data.sync.expense import de.tabmates.features.tabgroup.data.sync.group import de.tabmates.features.tabgroup.data.sync.insertGroup import de.tabmates.features.tabgroup.database.TabMatesDatabase +import de.tabmates.features.tabgroup.database.entities.RecurringSlotClaimEntity import de.tabmates.features.tabgroup.domain.group.GroupRemovalNotifier import de.tabmates.features.tabgroup.domain.group.GroupRepository import de.tabmates.features.tabgroup.domain.group.RemovedFromGroup @@ -26,6 +35,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch import kotlinx.coroutines.test.TestScope @@ -81,6 +91,45 @@ class TabEntryRealtimeSyncTest { ) } + @Test + fun `a soft-deleted generated entry keeps its slot claim`() = + syncTest { database, channel -> + database.insertGroup(group(id = GROUP_ID)) + database.tabEntryDao.upsertTabEntry(expense(id = ENTRY_ID, groupId = GROUP_ID).toEntity()) + + channel.emit(ack(deletedAt = Instant.fromEpochMilliseconds(1), fromSeries = SERIES_ID)) + + awaitCondition("the deleted entry should be gone") { + database.tabEntryDao.getTabEntryById(ENTRY_ID) == null + } + // The claim is what stops the projector handing back a placeholder for an occurrence + // somebody deleted on purpose, so removing the entry must not take it with it. + assertEquals( + listOf(RecurringSlotClaimEntity(SERIES_ID, OCCURRENCE_DATE.toString(), GROUP_ID)), + database.recurringSlotClaimDao.observeClaimsForGroup(GROUP_ID).first(), + ) + } + + @Test + fun `a series-changed frame mirrors the schedule locally`() = + syncTest { database, channel -> + database.insertGroup(group(id = GROUP_ID)) + + channel.emit( + WebSocketMessageDto( + type = WsMessageType.RECURRING_SERIES_CHANGED, + payload = json.encodeToString(RecurringSeriesDto.serializer(), seriesDto()), + ), + ) + + awaitCondition("the schedule should have been mirrored") { + database.recurringSeriesDao.observeSeriesById(SERIES_ID).first() != null + } + val stored = database.recurringSeriesDao.observeSeriesById(SERIES_ID).first() + assertNotNull(stored) + assertEquals("Rent", stored.series.title) + } + @Test fun `being removed reports the group by its local name and then deletes it`() = syncTest(groupRepository = StubGroupRepository(listOf(group(id = GROUP_ID)))) { database, channel -> @@ -121,16 +170,60 @@ class TabEntryRealtimeSyncTest { // region helpers - private fun ack(deletedAt: Instant?): WebSocketMessageDto = + private fun ack( + deletedAt: Instant?, + fromSeries: String? = null, + ): WebSocketMessageDto = WebSocketMessageDto( type = WsMessageType.ACK, - payload = json.encodeToString(TabEntryDto.serializer(), entryDto(deletedAt)), + payload = json.encodeToString(TabEntryDto.serializer(), entryDto(deletedAt, fromSeries)), requestId = "req-1", ) - private fun entryDto(deletedAt: Instant?): TabEntryDto { - val participant = - GroupParticipantDto(userId = "u1", username = "u1", userType = ParticipantTypeDto.REGISTERED) + private fun seriesDto(): RecurringSeriesDto { + val participant = participantDto() + return RecurringSeriesDto( + id = SERIES_ID, + groupId = GROUP_ID, + entryType = RecurringEntryTypeDto.EXPENSE, + isActive = true, + needsAttention = false, + createdAt = Instant.fromEpochMilliseconds(0), + createdBy = participant, + updatedAt = Instant.fromEpochMilliseconds(0), + rule = + RecurringRuleDto( + id = "rule-1", + title = "Rent", + description = "", + amount = 100.0, + currency = "EUR", + paidBy = participant, + splits = + listOf( + RecurringTemplateSplitDto( + participantId = participant.userId, + participant = participant, + split = WsSplitDto.Equal, + resolvedAmount = 100.0, + ), + ), + frequency = RecurrenceFrequencyDto.MONTHLY, + interval = 1, + startDate = OCCURRENCE_DATE, + end = RecurringEndDto.Never, + ), + ) + } + + private fun participantDto() = + GroupParticipantDto(userId = "u1", username = "u1", userType = ParticipantTypeDto.REGISTERED) + + private fun entryDto( + deletedAt: Instant?, + fromSeries: String? = null, + ): TabEntryDto { + val participant = participantDto() return TabEntryDto.Expense( id = ENTRY_ID, groupId = GROUP_ID, @@ -148,6 +241,8 @@ class TabEntryRealtimeSyncTest { version = 0, deletedAt = deletedAt, deletedBy = deletedAt?.let { participant }, + recurringSeriesId = fromSeries, + recurringOccurrenceDate = fromSeries?.let { OCCURRENCE_DATE }, ) } @@ -176,6 +271,7 @@ class TabEntryRealtimeSyncTest { database = database, groupRepository = groupRepository, groupRemovalNotifier = removalNotifier, + recurringSeriesLocalWriter = RecurringSeriesLocalWriter(database), json = json, logger = NoopLogger, applicationScope = @@ -192,6 +288,8 @@ class TabEntryRealtimeSyncTest { private companion object { const val GROUP_ID = "g1" const val ENTRY_ID = "e1" + const val SERIES_ID = "series-1" + val OCCURRENCE_DATE = LocalDate(2026, 8, 1) const val AWAIT_TIMEOUT_MS = 5_000L } diff --git a/features/tabgroup/database/schemas/de.tabmates.features.tabgroup.database.TabMatesDatabase/8.json b/features/tabgroup/database/schemas/de.tabmates.features.tabgroup.database.TabMatesDatabase/8.json new file mode 100644 index 00000000..8ec6de3c --- /dev/null +++ b/features/tabgroup/database/schemas/de.tabmates.features.tabgroup.database.TabMatesDatabase/8.json @@ -0,0 +1,1161 @@ +{ + "formatVersion": 1, + "database": { + "version": 8, + "identityHash": "8e0ea453c312b472502da51ce458e43e", + "entities": [ + { + "tableName": "GroupEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` TEXT NOT NULL, `title` TEXT NOT NULL, `description` TEXT, `defaultCurrencyCode` TEXT NOT NULL, `inviteToken` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastModifiedAt` INTEGER NOT NULL, `creator_userId` TEXT NOT NULL, `creator_username` TEXT NOT NULL, `creator_participantType` TEXT NOT NULL, PRIMARY KEY(`groupId`))", + "fields": [ + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "defaultCurrencyCode", + "columnName": "defaultCurrencyCode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "inviteToken", + "columnName": "inviteToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastModifiedAt", + "columnName": "lastModifiedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "creator.userId", + "columnName": "creator_userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creator.username", + "columnName": "creator_username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creator.participantType", + "columnName": "creator_participantType", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "groupId" + ] + } + }, + { + "tableName": "GroupParticipantCrossRef", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` TEXT NOT NULL, `userId` TEXT NOT NULL, `isActive` INTEGER NOT NULL, PRIMARY KEY(`groupId`, `userId`), FOREIGN KEY(`groupId`) REFERENCES `GroupEntity`(`groupId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`) REFERENCES `GroupParticipantEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "groupId", + "userId" + ] + }, + "indices": [ + { + "name": "index_GroupParticipantCrossRef_groupId", + "unique": false, + "columnNames": [ + "groupId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_GroupParticipantCrossRef_groupId` ON `${TABLE_NAME}` (`groupId`)" + }, + { + "name": "index_GroupParticipantCrossRef_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_GroupParticipantCrossRef_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "GroupEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "groupId" + ], + "referencedColumns": [ + "groupId" + ] + }, + { + "table": "GroupParticipantEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "GroupParticipantEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `username` TEXT NOT NULL, `participantType` TEXT NOT NULL, PRIMARY KEY(`userId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "participantType", + "columnName": "participantType", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + } + }, + { + "tableName": "TabEntryEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tabEntryId` TEXT NOT NULL, `title` TEXT NOT NULL, `description` TEXT NOT NULL, `amount` REAL NOT NULL, `currencyCode` TEXT NOT NULL, `exchangeRate` REAL, `entryType` TEXT NOT NULL, `groupId` TEXT NOT NULL, `creatorId` TEXT NOT NULL, `paidByUserId` TEXT NOT NULL, `receivedByUserId` TEXT, `entryDate` TEXT NOT NULL DEFAULT '1970-01-01', `createdAt` INTEGER NOT NULL, `lastModifiedAt` INTEGER NOT NULL, `lastModifiedByUserId` TEXT NOT NULL, `version` INTEGER NOT NULL, `deletedAt` INTEGER, `deletedByUserId` TEXT, `pendingSync` INTEGER NOT NULL DEFAULT 0, `recurringSeriesId` TEXT, `recurringOccurrenceDate` TEXT, PRIMARY KEY(`tabEntryId`), FOREIGN KEY(`groupId`) REFERENCES `GroupEntity`(`groupId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "tabEntryId", + "columnName": "tabEntryId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "currencyCode", + "columnName": "currencyCode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "exchangeRate", + "columnName": "exchangeRate", + "affinity": "REAL" + }, + { + "fieldPath": "entryType", + "columnName": "entryType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creatorId", + "columnName": "creatorId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paidByUserId", + "columnName": "paidByUserId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "receivedByUserId", + "columnName": "receivedByUserId", + "affinity": "TEXT" + }, + { + "fieldPath": "entryDate", + "columnName": "entryDate", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'1970-01-01'" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastModifiedAt", + "columnName": "lastModifiedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastModifiedByUserId", + "columnName": "lastModifiedByUserId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedByUserId", + "columnName": "deletedByUserId", + "affinity": "TEXT" + }, + { + "fieldPath": "pendingSync", + "columnName": "pendingSync", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "recurringSeriesId", + "columnName": "recurringSeriesId", + "affinity": "TEXT" + }, + { + "fieldPath": "recurringOccurrenceDate", + "columnName": "recurringOccurrenceDate", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "tabEntryId" + ] + }, + "indices": [ + { + "name": "index_TabEntryEntity_groupId", + "unique": false, + "columnNames": [ + "groupId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TabEntryEntity_groupId` ON `${TABLE_NAME}` (`groupId`)" + } + ], + "foreignKeys": [ + { + "table": "GroupEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "groupId" + ], + "referencedColumns": [ + "groupId" + ] + } + ] + }, + { + "tableName": "TabEntrySplitEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`splitId` TEXT NOT NULL, `tabEntryId` TEXT NOT NULL, `participantId` TEXT NOT NULL, `splitType` TEXT NOT NULL, `value` REAL NOT NULL, `resolvedAmount` REAL NOT NULL, PRIMARY KEY(`splitId`), FOREIGN KEY(`tabEntryId`) REFERENCES `TabEntryEntity`(`tabEntryId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`participantId`) REFERENCES `GroupParticipantEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "splitId", + "columnName": "splitId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tabEntryId", + "columnName": "tabEntryId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "participantId", + "columnName": "participantId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "splitType", + "columnName": "splitType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "resolvedAmount", + "columnName": "resolvedAmount", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "splitId" + ] + }, + "indices": [ + { + "name": "index_TabEntrySplitEntity_tabEntryId", + "unique": false, + "columnNames": [ + "tabEntryId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TabEntrySplitEntity_tabEntryId` ON `${TABLE_NAME}` (`tabEntryId`)" + }, + { + "name": "index_TabEntrySplitEntity_participantId", + "unique": false, + "columnNames": [ + "participantId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TabEntrySplitEntity_participantId` ON `${TABLE_NAME}` (`participantId`)" + } + ], + "foreignKeys": [ + { + "table": "TabEntryEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "tabEntryId" + ], + "referencedColumns": [ + "tabEntryId" + ] + }, + { + "table": "GroupParticipantEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "participantId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "CurrencyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`code` TEXT NOT NULL, `name` TEXT NOT NULL, `nativeSymbol` TEXT NOT NULL, `decimalDigits` INTEGER NOT NULL, `type` TEXT NOT NULL, `countries` TEXT NOT NULL, PRIMARY KEY(`code`))", + "fields": [ + { + "fieldPath": "code", + "columnName": "code", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nativeSymbol", + "columnName": "nativeSymbol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "decimalDigits", + "columnName": "decimalDigits", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "countries", + "columnName": "countries", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "code" + ] + } + }, + { + "tableName": "ExchangeRateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`currencyCode` TEXT NOT NULL, `rateToBase` REAL NOT NULL, `baseCurrency` TEXT NOT NULL, `lastUpdatedAtEpochMs` INTEGER NOT NULL, PRIMARY KEY(`currencyCode`))", + "fields": [ + { + "fieldPath": "currencyCode", + "columnName": "currencyCode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rateToBase", + "columnName": "rateToBase", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "baseCurrency", + "columnName": "baseCurrency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastUpdatedAtEpochMs", + "columnName": "lastUpdatedAtEpochMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "currencyCode" + ] + } + }, + { + "tableName": "PendingOutboxEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `type` TEXT NOT NULL, `payload` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `attemptCount` INTEGER NOT NULL, `lastAttemptAt` INTEGER, `lastError` TEXT, `expectedVersion` INTEGER, `requestId` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attemptCount", + "columnName": "attemptCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAttemptAt", + "columnName": "lastAttemptAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastError", + "columnName": "lastError", + "affinity": "TEXT" + }, + { + "fieldPath": "expectedVersion", + "columnName": "expectedVersion", + "affinity": "INTEGER" + }, + { + "fieldPath": "requestId", + "columnName": "requestId", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "ActivityEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `seq` INTEGER NOT NULL, `groupId` TEXT NOT NULL, `occurredAt` INTEGER NOT NULL, `actorUserId` TEXT NOT NULL, `type` TEXT NOT NULL, `tabEntryId` TEXT, `entryType` TEXT, `entryTitle` TEXT, `amount` REAL, `currencyCode` TEXT, `targetUserId` TEXT, `targetUsername` TEXT, `entryVersion` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`groupId`) REFERENCES `GroupEntity`(`groupId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "seq", + "columnName": "seq", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "occurredAt", + "columnName": "occurredAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "actorUserId", + "columnName": "actorUserId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tabEntryId", + "columnName": "tabEntryId", + "affinity": "TEXT" + }, + { + "fieldPath": "entryType", + "columnName": "entryType", + "affinity": "TEXT" + }, + { + "fieldPath": "entryTitle", + "columnName": "entryTitle", + "affinity": "TEXT" + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL" + }, + { + "fieldPath": "currencyCode", + "columnName": "currencyCode", + "affinity": "TEXT" + }, + { + "fieldPath": "targetUserId", + "columnName": "targetUserId", + "affinity": "TEXT" + }, + { + "fieldPath": "targetUsername", + "columnName": "targetUsername", + "affinity": "TEXT" + }, + { + "fieldPath": "entryVersion", + "columnName": "entryVersion", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_ActivityEventEntity_groupId", + "unique": false, + "columnNames": [ + "groupId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ActivityEventEntity_groupId` ON `${TABLE_NAME}` (`groupId`)" + }, + { + "name": "index_ActivityEventEntity_seq", + "unique": true, + "columnNames": [ + "seq" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ActivityEventEntity_seq` ON `${TABLE_NAME}` (`seq`)" + }, + { + "name": "index_ActivityEventEntity_tabEntryId", + "unique": false, + "columnNames": [ + "tabEntryId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ActivityEventEntity_tabEntryId` ON `${TABLE_NAME}` (`tabEntryId`)" + } + ], + "foreignKeys": [ + { + "table": "GroupEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "groupId" + ], + "referencedColumns": [ + "groupId" + ] + } + ] + }, + { + "tableName": "ActivityFieldChangeEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`changeId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `activityEventId` TEXT NOT NULL, `field` TEXT NOT NULL, `oldValue` TEXT, `newValue` TEXT, FOREIGN KEY(`activityEventId`) REFERENCES `ActivityEventEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "changeId", + "columnName": "changeId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "activityEventId", + "columnName": "activityEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "field", + "columnName": "field", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "oldValue", + "columnName": "oldValue", + "affinity": "TEXT" + }, + { + "fieldPath": "newValue", + "columnName": "newValue", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "changeId" + ] + }, + "indices": [ + { + "name": "index_ActivityFieldChangeEntity_activityEventId", + "unique": false, + "columnNames": [ + "activityEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ActivityFieldChangeEntity_activityEventId` ON `${TABLE_NAME}` (`activityEventId`)" + } + ], + "foreignKeys": [ + { + "table": "ActivityEventEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "activityEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "RecurringSeriesEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`seriesId` TEXT NOT NULL, `groupId` TEXT NOT NULL, `entryType` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `needsAttention` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `createdByUserId` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, `ruleId` TEXT NOT NULL, `title` TEXT NOT NULL, `description` TEXT NOT NULL, `amount` REAL NOT NULL, `currencyCode` TEXT NOT NULL, `exchangeRate` REAL, `paidByUserId` TEXT NOT NULL, `receivedByUserId` TEXT, `frequency` TEXT NOT NULL, `intervalCount` INTEGER NOT NULL, `startDate` TEXT NOT NULL, `endType` TEXT NOT NULL, `endUntilDate` TEXT, `endCount` INTEGER, PRIMARY KEY(`seriesId`), FOREIGN KEY(`groupId`) REFERENCES `GroupEntity`(`groupId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`createdByUserId`) REFERENCES `GroupParticipantEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "seriesId", + "columnName": "seriesId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "entryType", + "columnName": "entryType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "needsAttention", + "columnName": "needsAttention", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdByUserId", + "columnName": "createdByUserId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ruleId", + "columnName": "ruleId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "currencyCode", + "columnName": "currencyCode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "exchangeRate", + "columnName": "exchangeRate", + "affinity": "REAL" + }, + { + "fieldPath": "paidByUserId", + "columnName": "paidByUserId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "receivedByUserId", + "columnName": "receivedByUserId", + "affinity": "TEXT" + }, + { + "fieldPath": "frequency", + "columnName": "frequency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "intervalCount", + "columnName": "intervalCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "startDate", + "columnName": "startDate", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "endType", + "columnName": "endType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "endUntilDate", + "columnName": "endUntilDate", + "affinity": "TEXT" + }, + { + "fieldPath": "endCount", + "columnName": "endCount", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "seriesId" + ] + }, + "indices": [ + { + "name": "index_RecurringSeriesEntity_groupId", + "unique": false, + "columnNames": [ + "groupId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_RecurringSeriesEntity_groupId` ON `${TABLE_NAME}` (`groupId`)" + }, + { + "name": "index_RecurringSeriesEntity_createdByUserId", + "unique": false, + "columnNames": [ + "createdByUserId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_RecurringSeriesEntity_createdByUserId` ON `${TABLE_NAME}` (`createdByUserId`)" + } + ], + "foreignKeys": [ + { + "table": "GroupEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "groupId" + ], + "referencedColumns": [ + "groupId" + ] + }, + { + "table": "GroupParticipantEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "createdByUserId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "RecurringTemplateSplitEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`splitId` TEXT NOT NULL, `seriesId` TEXT NOT NULL, `participantId` TEXT NOT NULL, `splitType` TEXT NOT NULL, `value` REAL NOT NULL, `resolvedAmount` REAL NOT NULL, PRIMARY KEY(`splitId`), FOREIGN KEY(`seriesId`) REFERENCES `RecurringSeriesEntity`(`seriesId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`participantId`) REFERENCES `GroupParticipantEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "splitId", + "columnName": "splitId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "seriesId", + "columnName": "seriesId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "participantId", + "columnName": "participantId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "splitType", + "columnName": "splitType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "resolvedAmount", + "columnName": "resolvedAmount", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "splitId" + ] + }, + "indices": [ + { + "name": "index_RecurringTemplateSplitEntity_seriesId", + "unique": false, + "columnNames": [ + "seriesId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_RecurringTemplateSplitEntity_seriesId` ON `${TABLE_NAME}` (`seriesId`)" + }, + { + "name": "index_RecurringTemplateSplitEntity_participantId", + "unique": false, + "columnNames": [ + "participantId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_RecurringTemplateSplitEntity_participantId` ON `${TABLE_NAME}` (`participantId`)" + } + ], + "foreignKeys": [ + { + "table": "RecurringSeriesEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "seriesId" + ], + "referencedColumns": [ + "seriesId" + ] + }, + { + "table": "GroupParticipantEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "participantId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "RecurringExceptionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`seriesId` TEXT NOT NULL, `occurrenceDate` TEXT NOT NULL, PRIMARY KEY(`seriesId`, `occurrenceDate`), FOREIGN KEY(`seriesId`) REFERENCES `RecurringSeriesEntity`(`seriesId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "seriesId", + "columnName": "seriesId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "occurrenceDate", + "columnName": "occurrenceDate", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "seriesId", + "occurrenceDate" + ] + }, + "indices": [ + { + "name": "index_RecurringExceptionEntity_seriesId", + "unique": false, + "columnNames": [ + "seriesId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_RecurringExceptionEntity_seriesId` ON `${TABLE_NAME}` (`seriesId`)" + } + ], + "foreignKeys": [ + { + "table": "RecurringSeriesEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "seriesId" + ], + "referencedColumns": [ + "seriesId" + ] + } + ] + }, + { + "tableName": "RecurringSlotClaimEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`seriesId` TEXT NOT NULL, `occurrenceDate` TEXT NOT NULL, `groupId` TEXT NOT NULL, PRIMARY KEY(`seriesId`, `occurrenceDate`))", + "fields": [ + { + "fieldPath": "seriesId", + "columnName": "seriesId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "occurrenceDate", + "columnName": "occurrenceDate", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "seriesId", + "occurrenceDate" + ] + }, + "indices": [ + { + "name": "index_RecurringSlotClaimEntity_seriesId", + "unique": false, + "columnNames": [ + "seriesId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_RecurringSlotClaimEntity_seriesId` ON `${TABLE_NAME}` (`seriesId`)" + }, + { + "name": "index_RecurringSlotClaimEntity_groupId", + "unique": false, + "columnNames": [ + "groupId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_RecurringSlotClaimEntity_groupId` ON `${TABLE_NAME}` (`groupId`)" + } + ] + } + ], + "views": [ + { + "viewName": "last_tab_entry_per_group", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT te1.*\n FROM tabentryentity te1\n WHERE te1.deletedAt IS NULL\n AND te1.createdAt = (\n SELECT te2.createdAt\n FROM tabentryentity te2\n WHERE te2.groupId = te1.groupId AND te2.deletedAt IS NULL\n ORDER BY te2.entryDate DESC, te2.createdAt DESC\n LIMIT 1\n )" + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '8e0ea453c312b472502da51ce458e43e')" + ] + } +} \ No newline at end of file diff --git a/features/tabgroup/database/src/commonMain/kotlin/de/tabmates/features/tabgroup/database/TabMatesDatabase.kt b/features/tabgroup/database/src/commonMain/kotlin/de/tabmates/features/tabgroup/database/TabMatesDatabase.kt index d1716c45..181c2ab2 100644 --- a/features/tabgroup/database/src/commonMain/kotlin/de/tabmates/features/tabgroup/database/TabMatesDatabase.kt +++ b/features/tabgroup/database/src/commonMain/kotlin/de/tabmates/features/tabgroup/database/TabMatesDatabase.kt @@ -11,6 +11,8 @@ import de.tabmates.features.tabgroup.database.dao.GroupDao import de.tabmates.features.tabgroup.database.dao.GroupParticipantCrossRefDao import de.tabmates.features.tabgroup.database.dao.GroupParticipantDao import de.tabmates.features.tabgroup.database.dao.PendingOutboxDao +import de.tabmates.features.tabgroup.database.dao.RecurringSeriesDao +import de.tabmates.features.tabgroup.database.dao.RecurringSlotClaimDao import de.tabmates.features.tabgroup.database.dao.TabEntryDao import de.tabmates.features.tabgroup.database.dao.TabEntrySplitDao import de.tabmates.features.tabgroup.database.entities.ActivityEventEntity @@ -21,6 +23,10 @@ import de.tabmates.features.tabgroup.database.entities.GroupEntity import de.tabmates.features.tabgroup.database.entities.GroupParticipantCrossRef import de.tabmates.features.tabgroup.database.entities.GroupParticipantEntity import de.tabmates.features.tabgroup.database.entities.PendingOutboxEntity +import de.tabmates.features.tabgroup.database.entities.RecurringExceptionEntity +import de.tabmates.features.tabgroup.database.entities.RecurringSeriesEntity +import de.tabmates.features.tabgroup.database.entities.RecurringSlotClaimEntity +import de.tabmates.features.tabgroup.database.entities.RecurringTemplateSplitEntity import de.tabmates.features.tabgroup.database.entities.TabEntryEntity import de.tabmates.features.tabgroup.database.entities.TabEntrySplitEntity import de.tabmates.features.tabgroup.database.migrations.TabEntryEntryDateBackfill @@ -38,11 +44,15 @@ import de.tabmates.features.tabgroup.database.view.LastTabEntryView PendingOutboxEntity::class, ActivityEventEntity::class, ActivityFieldChangeEntity::class, + RecurringSeriesEntity::class, + RecurringTemplateSplitEntity::class, + RecurringExceptionEntity::class, + RecurringSlotClaimEntity::class, ], views = [ LastTabEntryView::class, ], - version = 7, + version = 8, exportSchema = true, autoMigrations = [ AutoMigration(from = 2, to = 3), @@ -50,6 +60,9 @@ import de.tabmates.features.tabgroup.database.view.LastTabEntryView AutoMigration(from = 4, to = 5), AutoMigration(from = 5, to = 6), AutoMigration(from = 6, to = 7), + // Recurring entries: four new tables plus two nullable columns on tab entries, all + // additive, so Room derives the whole migration. + AutoMigration(from = 7, to = 8), ], ) @ConstructedBy(TabMatesDatabaseConstructor::class) @@ -63,6 +76,8 @@ abstract class TabMatesDatabase : RoomDatabase() { abstract val exchangeRateDao: ExchangeRateDao abstract val pendingOutboxDao: PendingOutboxDao abstract val activityEventDao: ActivityEventDao + abstract val recurringSeriesDao: RecurringSeriesDao + abstract val recurringSlotClaimDao: RecurringSlotClaimDao companion object { const val DATABASE_NAME = "tabmates.db" diff --git a/features/tabgroup/database/src/commonMain/kotlin/de/tabmates/features/tabgroup/database/dao/RecurringSeriesDao.kt b/features/tabgroup/database/src/commonMain/kotlin/de/tabmates/features/tabgroup/database/dao/RecurringSeriesDao.kt new file mode 100644 index 00000000..2e424393 --- /dev/null +++ b/features/tabgroup/database/src/commonMain/kotlin/de/tabmates/features/tabgroup/database/dao/RecurringSeriesDao.kt @@ -0,0 +1,115 @@ +package de.tabmates.features.tabgroup.database.dao + +import androidx.room3.Dao +import androidx.room3.Query +import androidx.room3.Transaction +import androidx.room3.Upsert +import de.tabmates.features.tabgroup.database.entities.RecurringExceptionEntity +import de.tabmates.features.tabgroup.database.entities.RecurringSeriesEntity +import de.tabmates.features.tabgroup.database.entities.RecurringSeriesWithDetails +import de.tabmates.features.tabgroup.database.entities.RecurringTemplateSplitEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface RecurringSeriesDao { + @Transaction + @Query("SELECT * FROM recurringseriesentity WHERE groupId = :groupId ORDER BY createdAt DESC") + fun observeSeriesByGroupId(groupId: String): Flow> + + @Transaction + @Query("SELECT * FROM recurringseriesentity WHERE seriesId = :seriesId") + fun observeSeriesById(seriesId: String): Flow + + @Upsert + suspend fun upsertSeries(series: List) + + @Upsert + suspend fun upsertSplits(splits: List) + + @Upsert + suspend fun upsertExceptions(exceptions: List) + + @Query("DELETE FROM recurringtemplatesplitentity WHERE seriesId IN (:seriesIds)") + suspend fun deleteSplitsBySeriesIds(seriesIds: List) + + @Query("DELETE FROM recurringexceptionentity WHERE seriesId IN (:seriesIds)") + suspend fun deleteExceptionsBySeriesIds(seriesIds: List) + + @Query("DELETE FROM recurringseriesentity WHERE seriesId IN (:seriesIds)") + suspend fun deleteSeriesByIds(seriesIds: List) + + @Query("SELECT seriesId FROM recurringseriesentity") + suspend fun getAllSeriesIds(): List + + @Query("SELECT seriesId FROM recurringseriesentity WHERE groupId = :groupId") + suspend fun getSeriesIdsForGroup(groupId: String): List + + @Query("SELECT groupId FROM recurringseriesentity WHERE seriesId = :seriesId") + suspend fun getGroupIdForSeries(seriesId: String): String? + + @Query("UPDATE recurringseriesentity SET isActive = 0, updatedAt = :updatedAt WHERE seriesId = :seriesId") + suspend fun markEnded( + seriesId: String, + updatedAt: Long, + ) + + @Query("DELETE FROM recurringexceptionentity WHERE seriesId = :seriesId AND occurrenceDate = :occurrenceDate") + suspend fun deleteException( + seriesId: String, + occurrenceDate: String, + ) + + /** + * Replaces one series and its full set of splits and exceptions. + * + * Splits and exceptions are wiped before reinsert rather than upserted: a removed split or an + * un-skipped date has no row in the payload to overwrite the stale one, so an upsert-only merge + * would leave it behind and quietly change what the template means. + */ + @Transaction + suspend fun upsertSeriesWithDetails( + series: RecurringSeriesEntity, + splits: List, + exceptions: List, + ) { + upsertSeries(listOf(series)) + deleteSplitsBySeriesIds(listOf(series.seriesId)) + deleteExceptionsBySeriesIds(listOf(series.seriesId)) + if (splits.isNotEmpty()) upsertSplits(splits) + if (exceptions.isNotEmpty()) upsertExceptions(exceptions) + } + + /** + * Applies a batch of series from `/api/sync` or a per-group refresh. + * + * [staleSeriesIds] are ids to prune — the complete local set minus the payload on a full sync or + * a group refresh, and empty on a delta, where the payload only carries what changed. A series + * is never deleted server-side, only deactivated, so a delta legitimately says nothing about + * the ones it omits. + */ + @Transaction + suspend fun applySyncedSeries( + series: List, + splitsBySeriesId: Map>, + exceptionsBySeriesId: Map>, + staleSeriesIds: List, + ) { + if (staleSeriesIds.isNotEmpty()) { + deleteSplitsBySeriesIds(staleSeriesIds) + deleteExceptionsBySeriesIds(staleSeriesIds) + deleteSeriesByIds(staleSeriesIds) + } + if (series.isEmpty()) return + + upsertSeries(series) + val seriesIds = series.map { it.seriesId } + deleteSplitsBySeriesIds(seriesIds) + deleteExceptionsBySeriesIds(seriesIds) + + val splits = seriesIds.flatMap { splitsBySeriesId[it].orEmpty() } + if (splits.isNotEmpty()) upsertSplits(splits) + + val exceptions = seriesIds.flatMap { exceptionsBySeriesId[it].orEmpty() } + if (exceptions.isNotEmpty()) upsertExceptions(exceptions) + } +} diff --git a/features/tabgroup/database/src/commonMain/kotlin/de/tabmates/features/tabgroup/database/dao/RecurringSlotClaimDao.kt b/features/tabgroup/database/src/commonMain/kotlin/de/tabmates/features/tabgroup/database/dao/RecurringSlotClaimDao.kt new file mode 100644 index 00000000..ea1d5482 --- /dev/null +++ b/features/tabgroup/database/src/commonMain/kotlin/de/tabmates/features/tabgroup/database/dao/RecurringSlotClaimDao.kt @@ -0,0 +1,29 @@ +package de.tabmates.features.tabgroup.database.dao + +import androidx.room3.Dao +import androidx.room3.Insert +import androidx.room3.OnConflictStrategy +import androidx.room3.Query +import de.tabmates.features.tabgroup.database.entities.RecurringSlotClaimEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface RecurringSlotClaimDao { + /** + * Records slots as claimed. Insert-ignore, never upsert: a claim carries no state worth + * refreshing, and seeing the same generated entry twice (a sync after a websocket broadcast) is + * the normal case rather than the exception. + */ + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun recordClaims(claims: List) + + @Query("SELECT * FROM recurringslotclaimentity WHERE groupId = :groupId") + fun observeClaimsForGroup(groupId: String): Flow> + + /** + * Drops the claims of groups that are gone. Called wherever groups are pruned — the table + * carries no foreign key, so nothing removes these rows on its own. + */ + @Query("DELETE FROM recurringslotclaimentity WHERE groupId NOT IN (SELECT groupId FROM groupentity)") + suspend fun deleteClaimsForRemovedGroups() +} diff --git a/features/tabgroup/database/src/commonMain/kotlin/de/tabmates/features/tabgroup/database/entities/RecurringExceptionEntity.kt b/features/tabgroup/database/src/commonMain/kotlin/de/tabmates/features/tabgroup/database/entities/RecurringExceptionEntity.kt new file mode 100644 index 00000000..a32e33ef --- /dev/null +++ b/features/tabgroup/database/src/commonMain/kotlin/de/tabmates/features/tabgroup/database/entities/RecurringExceptionEntity.kt @@ -0,0 +1,30 @@ +package de.tabmates.features.tabgroup.database.entities + +import androidx.room3.Entity +import androidx.room3.ForeignKey +import androidx.room3.Index + +/** + * One future occurrence a member chose to skip. + * + * A skipped slot is still consumed, so this list is what keeps a `COUNT`-limited schedule from + * running a period longer to make up for the skip — and what stops the skipped date being rendered + * as a placeholder that never resolves. + */ +@Entity( + primaryKeys = ["seriesId", "occurrenceDate"], + foreignKeys = [ + ForeignKey( + entity = RecurringSeriesEntity::class, + parentColumns = ["seriesId"], + childColumns = ["seriesId"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [Index("seriesId")], +) +data class RecurringExceptionEntity( + val seriesId: String, + /** ISO "YYYY-MM-DD". */ + val occurrenceDate: String, +) diff --git a/features/tabgroup/database/src/commonMain/kotlin/de/tabmates/features/tabgroup/database/entities/RecurringSeriesEntity.kt b/features/tabgroup/database/src/commonMain/kotlin/de/tabmates/features/tabgroup/database/entities/RecurringSeriesEntity.kt new file mode 100644 index 00000000..0868007e --- /dev/null +++ b/features/tabgroup/database/src/commonMain/kotlin/de/tabmates/features/tabgroup/database/entities/RecurringSeriesEntity.kt @@ -0,0 +1,69 @@ +package de.tabmates.features.tabgroup.database.entities + +import androidx.room3.Entity +import androidx.room3.ForeignKey +import androidx.room3.Index +import androidx.room3.PrimaryKey +import de.tabmates.features.tabgroup.database.entities.types.RecurrenceFrequencyDatabase +import de.tabmates.features.tabgroup.database.entities.types.RecurringEndTypeDatabase +import de.tabmates.features.tabgroup.database.entities.types.TabEntryTypeDatabase + +/** + * A recurring schedule, mirrored from the server. + * + * The server keeps an append-only chain of template revisions but only ever ships the newest one, + * so the current rule is flattened into this row rather than given a table of its own — there is no + * local history to reconcile, and every read wants the series and its template together. + */ +@Entity( + foreignKeys = [ + ForeignKey( + entity = GroupEntity::class, + parentColumns = ["groupId"], + childColumns = ["groupId"], + onDelete = ForeignKey.CASCADE, + ), + ForeignKey( + entity = GroupParticipantEntity::class, + parentColumns = ["userId"], + childColumns = ["createdByUserId"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [ + Index("groupId"), + Index("createdByUserId"), + ], +) +data class RecurringSeriesEntity( + @PrimaryKey + val seriesId: String, + val groupId: String, + val entryType: TabEntryTypeDatabase, + val isActive: Boolean, + /** The template names a former member; the server generates nothing until someone repairs it. */ + val needsAttention: Boolean, + val createdAt: Long, + val createdByUserId: String, + val updatedAt: Long, + // --- current rule revision --- + val ruleId: String, + val title: String, + val description: String, + val amount: Double, + val currencyCode: String, + /** Fallback only — the server resolves a live rate per occurrence when it writes one. */ + val exchangeRate: Double?, + val paidByUserId: String, + /** Set for SETTLEMENT series only. */ + val receivedByUserId: String?, + val frequency: RecurrenceFrequencyDatabase, + val intervalCount: Int, + /** ISO "YYYY-MM-DD". The first occurrence, and the anchor every later date is computed from. */ + val startDate: String, + val endType: RecurringEndTypeDatabase, + /** Set only when [endType] is `UNTIL`. ISO "YYYY-MM-DD", inclusive. */ + val endUntilDate: String?, + /** Set only when [endType] is `COUNT`. Counts occurrences a skip left empty. */ + val endCount: Int?, +) diff --git a/features/tabgroup/database/src/commonMain/kotlin/de/tabmates/features/tabgroup/database/entities/RecurringSeriesWithDetails.kt b/features/tabgroup/database/src/commonMain/kotlin/de/tabmates/features/tabgroup/database/entities/RecurringSeriesWithDetails.kt new file mode 100644 index 00000000..f318bed4 --- /dev/null +++ b/features/tabgroup/database/src/commonMain/kotlin/de/tabmates/features/tabgroup/database/entities/RecurringSeriesWithDetails.kt @@ -0,0 +1,25 @@ +package de.tabmates.features.tabgroup.database.entities + +import androidx.room3.Embedded +import androidx.room3.Relation + +/** A recurring series with everything needed to render it and project its occurrences. */ +data class RecurringSeriesWithDetails( + @Embedded + val series: RecurringSeriesEntity, + @Relation( + parentColumns = ["seriesId"], + entityColumns = ["seriesId"], + ) + val splits: List, + @Relation( + parentColumns = ["seriesId"], + entityColumns = ["seriesId"], + ) + val exceptions: List, + @Relation( + parentColumns = ["createdByUserId"], + entityColumns = ["userId"], + ) + val createdBy: GroupParticipantEntity?, +) diff --git a/features/tabgroup/database/src/commonMain/kotlin/de/tabmates/features/tabgroup/database/entities/RecurringSlotClaimEntity.kt b/features/tabgroup/database/src/commonMain/kotlin/de/tabmates/features/tabgroup/database/entities/RecurringSlotClaimEntity.kt new file mode 100644 index 00000000..81da2032 --- /dev/null +++ b/features/tabgroup/database/src/commonMain/kotlin/de/tabmates/features/tabgroup/database/entities/RecurringSlotClaimEntity.kt @@ -0,0 +1,33 @@ +package de.tabmates.features.tabgroup.database.entities + +import androidx.room3.Entity +import androidx.room3.Index + +/** + * A slot the server has written an entry into at some point. + * + * Deliberately **not** derived from the entries table, and deliberately never deleted when an entry + * is. The server's uniqueness guarantee on `(series, occurrence date)` is not filtered by deletion: + * a slot stays claimed forever, so an occurrence a member deleted on purpose is not regenerated. + * Locally, though, a soft-deleted entry is dropped from the table outright — so without this record + * the projector would see a free slot and render the deleted occurrence as a placeholder again, on + * every projection, with no sync able to clear it. + * + * No foreign key to the series on purpose: a generated entry can reach this device before the + * schedule that produced it does — a websocket broadcast arrives without waiting for a sync, and a + * sync applies groups and entries in one pass. An FK would reject exactly the claim that matters + * most. [groupId] carries the ownership instead, so claims are pruned when their group is. + */ +@Entity( + primaryKeys = ["seriesId", "occurrenceDate"], + indices = [ + Index("seriesId"), + Index("groupId"), + ], +) +data class RecurringSlotClaimEntity( + val seriesId: String, + /** ISO "YYYY-MM-DD". */ + val occurrenceDate: String, + val groupId: String, +) diff --git a/features/tabgroup/database/src/commonMain/kotlin/de/tabmates/features/tabgroup/database/entities/RecurringTemplateSplitEntity.kt b/features/tabgroup/database/src/commonMain/kotlin/de/tabmates/features/tabgroup/database/entities/RecurringTemplateSplitEntity.kt new file mode 100644 index 00000000..1cc70f27 --- /dev/null +++ b/features/tabgroup/database/src/commonMain/kotlin/de/tabmates/features/tabgroup/database/entities/RecurringTemplateSplitEntity.kt @@ -0,0 +1,38 @@ +package de.tabmates.features.tabgroup.database.entities + +import androidx.room3.Entity +import androidx.room3.ForeignKey +import androidx.room3.Index +import androidx.room3.PrimaryKey +import de.tabmates.features.tabgroup.database.entities.types.SplitTypeDatabase + +/** A participant's share in a recurring template, copied verbatim into every occurrence. */ +@Entity( + foreignKeys = [ + ForeignKey( + entity = RecurringSeriesEntity::class, + parentColumns = ["seriesId"], + childColumns = ["seriesId"], + onDelete = ForeignKey.CASCADE, + ), + ForeignKey( + entity = GroupParticipantEntity::class, + parentColumns = ["userId"], + childColumns = ["participantId"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [ + Index("seriesId"), + Index("participantId"), + ], +) +data class RecurringTemplateSplitEntity( + @PrimaryKey + val splitId: String, + val seriesId: String, + val participantId: String, + val splitType: SplitTypeDatabase, + val value: Double, + val resolvedAmount: Double, +) diff --git a/features/tabgroup/database/src/commonMain/kotlin/de/tabmates/features/tabgroup/database/entities/TabEntryEntity.kt b/features/tabgroup/database/src/commonMain/kotlin/de/tabmates/features/tabgroup/database/entities/TabEntryEntity.kt index 1f9d8dc7..82271762 100644 --- a/features/tabgroup/database/src/commonMain/kotlin/de/tabmates/features/tabgroup/database/entities/TabEntryEntity.kt +++ b/features/tabgroup/database/src/commonMain/kotlin/de/tabmates/features/tabgroup/database/entities/TabEntryEntity.kt @@ -47,6 +47,15 @@ data class TabEntryEntity( /** True while this row is an optimistic local write awaiting server confirmation. */ @ColumnInfo(defaultValue = "0") val pendingSync: Boolean = false, + /** + * The recurring series that produced this entry, and the slot it filled (ISO "YYYY-MM-DD"). + * Both null for a hand-created entry, both set for a generated one. + * + * The slot is deliberately not [entryDate]: a generated entry is ordinary once written and its + * date stays editable, but the slot it occupies must not move with it. + */ + val recurringSeriesId: String? = null, + val recurringOccurrenceDate: String? = null, ) { val isDeleted: Boolean get() = deletedAt != null diff --git a/features/tabgroup/database/src/commonMain/kotlin/de/tabmates/features/tabgroup/database/entities/types/RecurrenceFrequencyDatabase.kt b/features/tabgroup/database/src/commonMain/kotlin/de/tabmates/features/tabgroup/database/entities/types/RecurrenceFrequencyDatabase.kt new file mode 100644 index 00000000..2558782f --- /dev/null +++ b/features/tabgroup/database/src/commonMain/kotlin/de/tabmates/features/tabgroup/database/entities/types/RecurrenceFrequencyDatabase.kt @@ -0,0 +1,8 @@ +package de.tabmates.features.tabgroup.database.entities.types + +enum class RecurrenceFrequencyDatabase { + DAILY, + WEEKLY, + MONTHLY, + YEARLY, +} diff --git a/features/tabgroup/database/src/commonMain/kotlin/de/tabmates/features/tabgroup/database/entities/types/RecurringEndTypeDatabase.kt b/features/tabgroup/database/src/commonMain/kotlin/de/tabmates/features/tabgroup/database/entities/types/RecurringEndTypeDatabase.kt new file mode 100644 index 00000000..c5077cef --- /dev/null +++ b/features/tabgroup/database/src/commonMain/kotlin/de/tabmates/features/tabgroup/database/entities/types/RecurringEndTypeDatabase.kt @@ -0,0 +1,13 @@ +package de.tabmates.features.tabgroup.database.entities.types + +/** + * Which of a series' two mutually exclusive end columns is in use. + * + * The wire models this as a sealed type; the table keeps it a discriminator plus two nullable + * columns so both stay queryable. + */ +enum class RecurringEndTypeDatabase { + NEVER, + UNTIL, + COUNT, +} diff --git a/features/tabgroup/domain/src/commonMain/kotlin/de/tabmates/features/tabgroup/domain/models/SyncSnapshot.kt b/features/tabgroup/domain/src/commonMain/kotlin/de/tabmates/features/tabgroup/domain/models/SyncSnapshot.kt index 11758848..c0157834 100644 --- a/features/tabgroup/domain/src/commonMain/kotlin/de/tabmates/features/tabgroup/domain/models/SyncSnapshot.kt +++ b/features/tabgroup/domain/src/commonMain/kotlin/de/tabmates/features/tabgroup/domain/models/SyncSnapshot.kt @@ -1,5 +1,6 @@ package de.tabmates.features.tabgroup.domain.models +import de.tabmates.features.tabgroup.domain.recurring.RecurringSeries import kotlin.time.Instant /** @@ -24,4 +25,10 @@ data class SyncSnapshot( val activeGroupIds: List, val tabEntries: List, val referencedParticipants: List = emptyList(), + /** + * Recurring schedules created or changed since the cursor, active and ended alike. A series is + * never deleted server-side, only deactivated, so a delta says nothing about the ones it omits + * and they must not be pruned. + */ + val recurringSeries: List = emptyList(), ) diff --git a/features/tabgroup/domain/src/commonMain/kotlin/de/tabmates/features/tabgroup/domain/models/TabEntry.kt b/features/tabgroup/domain/src/commonMain/kotlin/de/tabmates/features/tabgroup/domain/models/TabEntry.kt index 14264277..4883eda0 100644 --- a/features/tabgroup/domain/src/commonMain/kotlin/de/tabmates/features/tabgroup/domain/models/TabEntry.kt +++ b/features/tabgroup/domain/src/commonMain/kotlin/de/tabmates/features/tabgroup/domain/models/TabEntry.kt @@ -34,6 +34,27 @@ sealed class TabEntry { /** True while this entry is a local optimistic write not yet confirmed by the server. */ abstract val isPendingSync: Boolean + /** + * The recurring series that produced this entry, and the slot it filled. Both null for a + * hand-created entry, both set for a generated one. + * + * The pair is the slot identity, and the server guarantees exactly one entry per slot. It is + * how a scheduled placeholder is matched against the entry that eventually replaces it — + * [recurringOccurrenceDate] and not [entryDate], which stays editable afterwards. + */ + abstract val recurringSeriesId: String? + abstract val recurringOccurrenceDate: LocalDate? + + /** + * True for a *projected* entry: an occurrence a recurring series owes but the server has not + * written yet, either because its sweep has not run or because this device is offline. + * + * Never persisted and never sent anywhere. It exists so balances stay correct in the window + * between a due date and the entry appearing, and so the UI can mark the row as not-yet-real. + * See `ScheduledEntryProjector`. + */ + abstract val isScheduledPlaceholder: Boolean + val isDeleted: Boolean get() = deletedAt != null @@ -56,6 +77,9 @@ sealed class TabEntry { override val deletedByUserId: String?, val splits: List, override val isPendingSync: Boolean = false, + override val recurringSeriesId: String? = null, + override val recurringOccurrenceDate: LocalDate? = null, + override val isScheduledPlaceholder: Boolean = false, ) : TabEntry() data class Income( @@ -77,6 +101,9 @@ sealed class TabEntry { override val deletedByUserId: String?, val splits: List, override val isPendingSync: Boolean = false, + override val recurringSeriesId: String? = null, + override val recurringOccurrenceDate: LocalDate? = null, + override val isScheduledPlaceholder: Boolean = false, ) : TabEntry() data class Settlement( @@ -98,5 +125,8 @@ sealed class TabEntry { override val deletedByUserId: String?, val receivedByUserId: String, override val isPendingSync: Boolean = false, + override val recurringSeriesId: String? = null, + override val recurringOccurrenceDate: LocalDate? = null, + override val isScheduledPlaceholder: Boolean = false, ) : TabEntry() } diff --git a/features/tabgroup/domain/src/commonMain/kotlin/de/tabmates/features/tabgroup/domain/recurring/RecurrenceFrequency.kt b/features/tabgroup/domain/src/commonMain/kotlin/de/tabmates/features/tabgroup/domain/recurring/RecurrenceFrequency.kt new file mode 100644 index 00000000..4747f4b0 --- /dev/null +++ b/features/tabgroup/domain/src/commonMain/kotlin/de/tabmates/features/tabgroup/domain/recurring/RecurrenceFrequency.kt @@ -0,0 +1,31 @@ +package de.tabmates.features.tabgroup.domain.recurring + +/** + * How often a recurring series repeats. + * + * Deliberately a small closed set rather than an RFC 5545 `RRULE`: combined with + * [RecurringRule.interval] it covers rent, subscriptions and salaries without a calendar library. + * Mirrors the server enum of the same name — the wire carries these names verbatim. + */ +enum class RecurrenceFrequency { + DAILY, + + /** + * Repeats on the same weekday as the rule's start date. There is no multi-weekday set: a + * schedule falling on both Monday and Thursday is two series. + */ + WEEKLY, + + /** + * Repeats on the same day of the month as the rule's start date, clamped to the last day of + * shorter months — a series anchored on the 31st falls on the 30th in April and the 28th or + * 29th in February, then returns to the 31st. + */ + MONTHLY, + + /** + * Repeats on the same month and day as the rule's start date. A series anchored on 29 February + * falls on the 28th in non-leap years. + */ + YEARLY, +} diff --git a/features/tabgroup/domain/src/commonMain/kotlin/de/tabmates/features/tabgroup/domain/recurring/RecurringEnd.kt b/features/tabgroup/domain/src/commonMain/kotlin/de/tabmates/features/tabgroup/domain/recurring/RecurringEnd.kt new file mode 100644 index 00000000..4b65e94d --- /dev/null +++ b/features/tabgroup/domain/src/commonMain/kotlin/de/tabmates/features/tabgroup/domain/recurring/RecurringEnd.kt @@ -0,0 +1,22 @@ +package de.tabmates.features.tabgroup.domain.recurring + +import kotlinx.datetime.LocalDate + +/** How a recurring series stops producing occurrences. */ +sealed class RecurringEnd { + /** The series runs until somebody ends it. */ + data object Never : RecurringEnd() + + /** Inclusive: an occurrence landing exactly on [date] is still produced. */ + data class Until( + val date: LocalDate, + ) : RecurringEnd() + + /** + * Total occurrences the series may produce, **counting ones a skip left empty**. Skipping one + * month of a twelve-occurrence schedule leaves eleven entries; it does not run a month longer. + */ + data class Count( + val count: Int, + ) : RecurringEnd() +} diff --git a/features/tabgroup/domain/src/commonMain/kotlin/de/tabmates/features/tabgroup/domain/recurring/RecurringOccurrenceCalculator.kt b/features/tabgroup/domain/src/commonMain/kotlin/de/tabmates/features/tabgroup/domain/recurring/RecurringOccurrenceCalculator.kt new file mode 100644 index 00000000..a32735c1 --- /dev/null +++ b/features/tabgroup/domain/src/commonMain/kotlin/de/tabmates/features/tabgroup/domain/recurring/RecurringOccurrenceCalculator.kt @@ -0,0 +1,198 @@ +package de.tabmates.features.tabgroup.domain.recurring + +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.LocalDate +import kotlinx.datetime.minus +import kotlinx.datetime.plus + +/** + * Turns a recurrence rule into the calendar dates it produces. + * + * A port of the server's `RecurringOccurrenceGenerator`, and it has to stay one: the server decides + * which occurrences actually get written, and this decides which ones the client renders as + * placeholders in the meantime. A divergence shows up as a placeholder that never resolves, or a + * written entry that was never previewed. + * + * Pure by design — no clock. Callers decide what "today" means, which keeps every timezone question + * out of the date arithmetic. + */ +object RecurringOccurrenceCalculator { + /** + * Slots walked before giving up. Mirrors the server's own bound; years of daily occurrences. + * Without it a daily rule anchored far in the past would spin on a background thread. + */ + private const val MAX_SLOT_SCAN = 10_000 + + private const val MONTHS_PER_YEAR = 12 + + /** + * The date of the zero-based [slotIndex]th occurrence of a rule anchored at [anchorDate]. + * + * Always computed fresh from [anchorDate] rather than by stepping off the previous occurrence. + * That is the whole trick for monthly rules: adding a month clamps to the month it lands on, so + * chaining Jan 31 -> Feb 28 -> Mar 28 would ratchet the day down permanently after the first + * short month and never recover the 31st. Recomputing from the anchor each time means February + * borrows the day and March gives it straight back. + */ + fun occurrenceDateForSlot( + frequency: RecurrenceFrequency, + interval: Int, + anchorDate: LocalDate, + slotIndex: Int, + ): LocalDate { + require(interval > 0) { "interval must be positive, was $interval" } + require(slotIndex >= 0) { "slotIndex must not be negative, was $slotIndex" } + + val steps = interval.toLong() * slotIndex + return when (frequency) { + RecurrenceFrequency.DAILY -> anchorDate.plus(steps, DateTimeUnit.DAY) + RecurrenceFrequency.WEEKLY -> anchorDate.plus(steps, DateTimeUnit.WEEK) + RecurrenceFrequency.MONTHLY -> clampedMonthsFromAnchor(anchorDate, steps) + RecurrenceFrequency.YEARLY -> clampedMonthsFromAnchor(anchorDate, steps * MONTHS_PER_YEAR) + } + } + + /** + * Every occurrence of [rule] that is due on or before [asOf] and has not been accounted for. + * + * "Accounted for" is two different things, and both matter: + * - [claimedDates] — a slot the server has already written an entry into. Includes slots whose + * entry was since deleted: the server keeps such a slot claimed forever, so a deliberately + * deleted occurrence must not come back as a placeholder. + * - [skippedDates] — a slot a member skipped on purpose. Still consumes its slot, so it is + * filtered out of the result but not out of the walk. + */ + fun dueOccurrences( + rule: RecurringRule, + asOf: LocalDate, + claimedDates: Set = emptySet(), + skippedDates: Set = emptySet(), + ): List = + walkSlots(rule) { date -> + when { + date > asOf -> SlotVerdict.Stop + date in claimedDates || date in skippedDates -> SlotVerdict.Consume + else -> SlotVerdict.Take + } + } + + /** + * The next [limit] occurrence dates strictly after [after], for a schedule preview. + * + * Skipped dates are left out — the preview is what the series is *going* to produce. Claimed + * slots are not considered, because everything after [after] is by definition unwritten. + */ + fun upcomingOccurrences( + rule: RecurringRule, + after: LocalDate, + limit: Int, + skippedDates: Set = emptySet(), + ): List { + require(limit > 0) { "limit must be positive, was $limit" } + + var taken = 0 + return walkSlots(rule) { date -> + when { + taken >= limit -> { + SlotVerdict.Stop + } + + date <= after || date in skippedDates -> { + SlotVerdict.Consume + } + + else -> { + taken++ + SlotVerdict.Take + } + } + } + } + + /** Whether [date] is one of the dates [rule] produces — what an edit's `effectiveFrom` must be. */ + fun isOccurrenceDate( + rule: RecurringRule, + date: LocalDate, + ): Boolean = + walkSlots(rule) { slotDate -> + when { + slotDate > date -> SlotVerdict.Stop + slotDate == date -> SlotVerdict.Take + else -> SlotVerdict.Consume + } + }.isNotEmpty() + + /** + * Walks the rule's slots in order, applying [verdict] to each date, until the rule's own end is + * reached, the verdict says stop, or [MAX_SLOT_SCAN] slots have been examined. + * + * [RecurringEnd.Count] is checked against the slot index rather than the number of dates taken, + * because a skipped occurrence consumes its slot without producing a date. + */ + private fun walkSlots( + rule: RecurringRule, + verdict: (LocalDate) -> SlotVerdict, + ): List { + val untilDate = (rule.end as? RecurringEnd.Until)?.date + val occurrenceCount = (rule.end as? RecurringEnd.Count)?.count + + val dates = mutableListOf() + var slotIndex = 0 + while (slotIndex < MAX_SLOT_SCAN) { + if (occurrenceCount != null && slotIndex >= occurrenceCount) break + + val date = + occurrenceDateForSlot( + frequency = rule.frequency, + interval = rule.interval, + anchorDate = rule.startDate, + slotIndex = slotIndex, + ) + if (untilDate != null && date > untilDate) break + + when (verdict(date)) { + SlotVerdict.Stop -> return dates + SlotVerdict.Take -> dates.add(date) + SlotVerdict.Consume -> Unit + } + slotIndex++ + } + return dates + } + + /** What [walkSlots] should do with the slot it is looking at. */ + private enum class SlotVerdict { + /** Include the date and move on. */ + Take, + + /** The slot is used up but produces no date — move on. */ + Consume, + + /** End the walk; nothing later can qualify. */ + Stop, + } + + /** + * [anchorDate] moved [monthsToAdd] months, keeping its day of the month where the target month + * is long enough and clamping to that month's last day where it is not. + * + * Spelled out rather than delegated to `plus(DateTimeUnit.MONTH)` so the clamping rule is + * pinned by this code and its tests, not by whichever behaviour the datetime library happens to + * have. The server clamps via `YearMonth.atDay(min(day, lengthOfMonth))`; this must agree. + */ + private fun clampedMonthsFromAnchor( + anchorDate: LocalDate, + monthsToAdd: Long, + ): LocalDate { + val firstOfTargetMonth = + LocalDate(anchorDate.year, anchorDate.month, 1).plus(monthsToAdd, DateTimeUnit.MONTH) + val lengthOfTargetMonth = + firstOfTargetMonth.plus(1, DateTimeUnit.MONTH).minus(1, DateTimeUnit.DAY).day + + return LocalDate( + firstOfTargetMonth.year, + firstOfTargetMonth.month, + minOf(anchorDate.day, lengthOfTargetMonth), + ) + } +} diff --git a/features/tabgroup/domain/src/commonMain/kotlin/de/tabmates/features/tabgroup/domain/recurring/RecurringSeries.kt b/features/tabgroup/domain/src/commonMain/kotlin/de/tabmates/features/tabgroup/domain/recurring/RecurringSeries.kt new file mode 100644 index 00000000..5d98f222 --- /dev/null +++ b/features/tabgroup/domain/src/commonMain/kotlin/de/tabmates/features/tabgroup/domain/recurring/RecurringSeries.kt @@ -0,0 +1,85 @@ +package de.tabmates.features.tabgroup.domain.recurring + +import de.tabmates.features.tabgroup.domain.models.GroupParticipant +import de.tabmates.features.tabgroup.domain.models.SplitType +import kotlinx.datetime.LocalDate +import kotlin.time.Instant + +/** + * A repeating tab entry: a stored template plus a repetition rule that a server-side sweep turns + * into ordinary entries. + * + * The client never generates entries. It mirrors the schedule so it can render an occurrence that + * is due but not yet written (see `ScheduledEntryProjector`), which is what keeps the ledger + * readable offline and in the window before the sweep runs. + * + * The series is the stable identity; [rule] is the current revision of its template. The server + * appends a revision on every "this and future" edit and only ever ships the newest one, so there + * is no local revision history to keep. + */ +data class RecurringSeries( + val seriesId: String, + val groupId: String, + val entryType: RecurringEntryType, + /** False once ended, either by a member or by the rule reaching its own end. */ + val isActive: Boolean, + /** + * The template names somebody who has left the group, so nothing is being generated until a + * member repairs it. The only series state that needs a human — surface it. + */ + val needsAttention: Boolean, + val createdAt: Instant, + val createdBy: GroupParticipant, + val updatedAt: Instant, + val rule: RecurringRule, + /** + * Future occurrence dates a member chose to skip. A skipped slot is still consumed, so it must + * be excluded from the dates rendered as placeholders **and** counted against + * [RecurringEnd.Count]. + */ + val skippedOccurrenceDates: Set = emptySet(), +) + +/** The template and schedule a series currently repeats. */ +data class RecurringRule( + val ruleId: String, + val title: String, + val description: String, + val amount: Double, + val currencyCode: String, + /** + * Fallback rate only. Each occurrence resolves its own rate when the server writes it, so a + * placeholder rendered from this value can differ slightly from the entry that lands. + */ + val exchangeRate: Double?, + val paidByUserId: String, + /** Set for [RecurringEntryType.SETTLEMENT] only. */ + val receivedByUserId: String?, + /** Empty for [RecurringEntryType.SETTLEMENT], which has no splits. */ + val splits: List, + val frequency: RecurrenceFrequency, + /** Repeat every N periods of [frequency]; 1 means every period. Always positive. */ + val interval: Int, + /** The first occurrence, and the anchor every later occurrence date is computed from. */ + val startDate: LocalDate, + val end: RecurringEnd, +) + +/** A participant's share in a recurring template, copied verbatim into every occurrence. */ +data class RecurringTemplateSplit( + val splitId: String?, + val participantId: String, + val splitType: SplitType, + val value: Double, + val resolvedAmount: Double, +) + +/** + * Which kind of entry a series produces. Fixed for the life of the series — the server rejects an + * edit that changes it. + */ +enum class RecurringEntryType { + EXPENSE, + INCOME, + SETTLEMENT, +} diff --git a/features/tabgroup/domain/src/commonMain/kotlin/de/tabmates/features/tabgroup/domain/recurring/RecurringSeriesRepository.kt b/features/tabgroup/domain/src/commonMain/kotlin/de/tabmates/features/tabgroup/domain/recurring/RecurringSeriesRepository.kt new file mode 100644 index 00000000..562c4edd --- /dev/null +++ b/features/tabgroup/domain/src/commonMain/kotlin/de/tabmates/features/tabgroup/domain/recurring/RecurringSeriesRepository.kt @@ -0,0 +1,108 @@ +package de.tabmates.features.tabgroup.domain.recurring + +import de.tabmates.core.domain.util.DataError +import de.tabmates.core.domain.util.EmptyResult +import de.tabmates.core.domain.util.Result +import de.tabmates.features.tabgroup.domain.models.SplitType +import kotlinx.coroutines.flow.Flow +import kotlinx.datetime.LocalDate + +/** + * Recurring schedules for a group. + * + * Reads are offline-first off the local mirror. **Writes are not** — unlike tab entries there is no + * outbox behind these: creating or changing a schedule is a rare, deliberate act, and queuing one + * offline would leave a standing instruction to write into other people's ledgers pending on a + * device nobody is watching. Every write here needs a connection and reports its own failure. + */ +interface RecurringSeriesRepository { + fun getSeriesForGroup(groupId: String): Flow> + + fun getSeriesById(seriesId: String): Flow + + /** + * Slots in this group the server has written an entry into at some point. + * + * Needed alongside the entries themselves because a soft-deleted entry is dropped locally while + * its slot stays claimed forever server-side. Without this, [ScheduledEntryProjector] would see + * a free slot and keep re-projecting an occurrence somebody deleted on purpose. + */ + fun getClaimedSlotsForGroup(groupId: String): Flow> + + /** + * [seriesId] is client-generated and doubles as the idempotency key, so a create retried after + * a dropped response cannot produce a second schedule writing the same rent twice a month. + */ + suspend fun createSeries( + seriesId: String, + groupId: String, + template: RecurringTemplate, + ): Result + + /** + * Applies [template] from [effectiveFrom] onwards, leaving earlier occurrences untouched. + * + * [effectiveFrom] must be a future date the current schedule actually produces, and must equal + * `template.startDate` — otherwise the server rejects it rather than silently re-anchoring the + * rhythm to whichever day the edit was made. Occurrences between now and [effectiveFrom] are + * deliberately abandoned, not replayed. + * + * This is also the only thing that clears [RecurringSeries.needsAttention]. + */ + suspend fun updateSeries( + seriesId: String, + effectiveFrom: LocalDate, + template: RecurringTemplate, + ): Result + + /** Skips one future occurrence. The slot is still consumed, so the series does not run longer. */ + suspend fun skipOccurrence( + seriesId: String, + occurrenceDate: LocalDate, + ): EmptyResult + + suspend fun unskipOccurrence( + seriesId: String, + occurrenceDate: LocalDate, + ): EmptyResult + + /** Stops the schedule. Entries it already produced stay exactly as they are. */ + suspend fun endSeries(seriesId: String): EmptyResult + + /** + * Pulls a group's schedules from the server and replaces the local mirror for that group. + * + * Needed on top of the account-wide sync because the delta only carries series changed since + * the cursor: a group that just became visible arrives without its existing schedules. + */ + suspend fun refreshSeriesForGroup(groupId: String): EmptyResult +} + +/** The template half of a create or edit — what each occurrence will look like, and how it repeats. */ +data class RecurringTemplate( + val entryType: RecurringEntryType, + val title: String, + val description: String, + val amount: Double, + val currencyCode: String, + /** Fallback only; the server resolves a live rate per occurrence when it writes one. */ + val exchangeRate: Double?, + val paidByUserId: String, + /** Required for [RecurringEntryType.SETTLEMENT], rejected for the other two. */ + val receivedByUserId: String?, + /** Required for expenses and incomes, rejected for settlements. */ + val splits: List, + val frequency: RecurrenceFrequency, + val interval: Int, + /** May not be in the past. For an edit it must equal the request's `effectiveFrom`. */ + val startDate: LocalDate, + val end: RecurringEnd, +) + +/** A split as entered on the form: no id yet, and the resolved amount computed from the total. */ +data class NewRecurringTemplateSplit( + val participantId: String, + val splitType: SplitType, + val value: Double, + val resolvedAmount: Double, +) diff --git a/features/tabgroup/domain/src/commonMain/kotlin/de/tabmates/features/tabgroup/domain/recurring/ScheduledEntryProjector.kt b/features/tabgroup/domain/src/commonMain/kotlin/de/tabmates/features/tabgroup/domain/recurring/ScheduledEntryProjector.kt new file mode 100644 index 00000000..45858a51 --- /dev/null +++ b/features/tabgroup/domain/src/commonMain/kotlin/de/tabmates/features/tabgroup/domain/recurring/ScheduledEntryProjector.kt @@ -0,0 +1,194 @@ +package de.tabmates.features.tabgroup.domain.recurring + +import de.tabmates.features.tabgroup.domain.models.TabEntry +import de.tabmates.features.tabgroup.domain.models.TabEntrySplit +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn + +/** + * Projects the occurrences a group's schedules owe but the server has not written yet. + * + * The server is the only writer of recurring entries — balances are shared, so a ledger that only + * advanced when someone opened the app would leave every other member's numbers stale. That leaves + * a gap between an occurrence falling due and its entry arriving: up to a sweep interval when + * online, indefinitely when offline. This fills the gap with placeholders, so the numbers a member + * sees are the numbers they will still see once the entry lands. + * + * Placeholders are ordinary [TabEntry] values carrying [TabEntry.isScheduledPlaceholder], which is + * what lets every existing balance calculator consume them unchanged. They are never persisted and + * never sent anywhere. + */ +object ScheduledEntryProjector { + /** + * Placeholders for every occurrence due on or before [today] that has no entry. + * + * @param series the group's schedules, as mirrored from the server + * @param existingEntries the group's real entries, used to recognise slots already written + * @param claimedSlots slots the server has written at some point, **including ones whose entry + * was since deleted**. The server keeps such a slot claimed forever, and locally a + * soft-deleted entry is dropped from the table entirely, so without this record a + * deliberately deleted occurrence would come back as a placeholder on every projection. + * @param today the calendar day to measure against. Use the same UTC day the server's sweep + * uses, or the two disagree about which occurrences are owed at the edges of the day. + */ + fun project( + series: List, + existingEntries: List, + claimedSlots: Set, + today: LocalDate, + ): List { + if (series.isEmpty()) return emptyList() + + val claimedBySeries = + buildMap> { + claimedSlots.forEach { slot -> + getOrPut(slot.seriesId) { mutableSetOf() }.add(slot.occurrenceDate) + } + // Entries present locally are claimed whether or not the claim record caught them, + // which keeps a projection correct even on the very first sync of a device. + existingEntries.forEach { entry -> + val seriesId = entry.recurringSeriesId ?: return@forEach + val date = entry.recurringOccurrenceDate ?: return@forEach + getOrPut(seriesId) { mutableSetOf() }.add(date) + } + } + + return series.flatMap { candidate -> + // A parked series is one whose template names somebody who has left the group. The + // server writes nothing for it until a member repairs the template, so previewing its + // occurrences would promise entries that are not coming. + if (!candidate.isActive || candidate.needsAttention) return@flatMap emptyList() + + RecurringOccurrenceCalculator + .dueOccurrences( + rule = candidate.rule, + asOf = today, + claimedDates = claimedBySeries[candidate.seriesId].orEmpty(), + skippedDates = candidate.skippedOccurrenceDates, + ).mapNotNull { occurrenceDate -> candidate.toPlaceholder(occurrenceDate) } + } + } + + /** + * Builds one placeholder from a series' template. + * + * The id is synthetic and derived from the slot, so it is stable across projections — a list + * key that survives recomposition, and one that cannot collide with a server id. + * + * Null when the template could not produce a valid entry, which only a settlement series + * missing its receiver can do. Inventing one would move money to the wrong person. + */ + private fun RecurringSeries.toPlaceholder(occurrenceDate: LocalDate): TabEntry? { + val placeholderId = placeholderId(seriesId, occurrenceDate) + // The occurrence does not exist yet, so there is no creation instant to report. Midnight UTC + // on the day it falls due is the honest answer, and it orders sanely against real entries. + val dueAt = occurrenceDate.atStartOfDayIn(TimeZone.UTC) + + return when (entryType) { + RecurringEntryType.EXPENSE -> { + TabEntry.Expense( + tabEntryId = placeholderId, + groupId = groupId, + title = rule.title, + description = rule.description, + amount = rule.amount, + currencyCode = rule.currencyCode, + exchangeRate = rule.exchangeRate, + creatorId = createdBy.userId, + paidByUserId = rule.paidByUserId, + entryDate = occurrenceDate, + createdAt = dueAt, + lastModifiedAt = dueAt, + lastModifiedByUserId = createdBy.userId, + version = 0, + deletedAt = null, + deletedByUserId = null, + splits = rule.splits.toPlaceholderSplits(placeholderId), + recurringSeriesId = seriesId, + recurringOccurrenceDate = occurrenceDate, + isScheduledPlaceholder = true, + ) + } + + RecurringEntryType.INCOME -> { + TabEntry.Income( + tabEntryId = placeholderId, + groupId = groupId, + title = rule.title, + description = rule.description, + amount = rule.amount, + currencyCode = rule.currencyCode, + exchangeRate = rule.exchangeRate, + creatorId = createdBy.userId, + paidByUserId = rule.paidByUserId, + entryDate = occurrenceDate, + createdAt = dueAt, + lastModifiedAt = dueAt, + lastModifiedByUserId = createdBy.userId, + version = 0, + deletedAt = null, + deletedByUserId = null, + splits = rule.splits.toPlaceholderSplits(placeholderId), + recurringSeriesId = seriesId, + recurringOccurrenceDate = occurrenceDate, + isScheduledPlaceholder = true, + ) + } + + RecurringEntryType.SETTLEMENT -> { + TabEntry.Settlement( + tabEntryId = placeholderId, + groupId = groupId, + title = rule.title, + description = rule.description, + amount = rule.amount, + currencyCode = rule.currencyCode, + exchangeRate = rule.exchangeRate, + creatorId = createdBy.userId, + paidByUserId = rule.paidByUserId, + entryDate = occurrenceDate, + createdAt = dueAt, + lastModifiedAt = dueAt, + lastModifiedByUserId = createdBy.userId, + version = 0, + deletedAt = null, + deletedByUserId = null, + // A settlement series always carries a receiver; the server rejects one without. + // Falling back to the payer would silently move money to the wrong person, so an + // incomplete template produces no placeholder at all instead. + receivedByUserId = rule.receivedByUserId ?: return null, + recurringSeriesId = seriesId, + recurringOccurrenceDate = occurrenceDate, + isScheduledPlaceholder = true, + ) + } + } + } + + private fun List.toPlaceholderSplits(placeholderEntryId: String) = + map { split -> + TabEntrySplit( + splitId = "$placeholderEntryId:${split.participantId}", + tabEntryId = placeholderEntryId, + participantId = split.participantId, + splitType = split.splitType, + value = split.value, + resolvedAmount = split.resolvedAmount, + ) + } + + /** The synthetic id a placeholder for one slot always gets. */ + fun placeholderId( + seriesId: String, + occurrenceDate: LocalDate, + ): String = "$PLACEHOLDER_ID_PREFIX$seriesId:$occurrenceDate" + + private const val PLACEHOLDER_ID_PREFIX = "scheduled:" +} + +/** One slot of a recurring series: the coordinate the server guarantees at most one entry for. */ +data class RecurringSlot( + val seriesId: String, + val occurrenceDate: LocalDate, +) diff --git a/features/tabgroup/domain/src/commonMain/kotlin/de/tabmates/features/tabgroup/domain/recurring/ScheduledLedger.kt b/features/tabgroup/domain/src/commonMain/kotlin/de/tabmates/features/tabgroup/domain/recurring/ScheduledLedger.kt new file mode 100644 index 00000000..f5c6122a --- /dev/null +++ b/features/tabgroup/domain/src/commonMain/kotlin/de/tabmates/features/tabgroup/domain/recurring/ScheduledLedger.kt @@ -0,0 +1,30 @@ +package de.tabmates.features.tabgroup.domain.recurring + +import de.tabmates.features.tabgroup.domain.models.TabEntry +import kotlinx.coroutines.flow.Flow + +/** + * A group's entries as its ledger currently reads: what the server has written, plus the + * occurrences its schedules already owe but nobody has written yet. + * + * This exists so there is exactly one answer to "what is this group's balance". Every screen that + * shows a number for a group reads it from here — the group screen, the home summary, the group + * list, the per-person breakdown. Projecting in some of them and not others is how the same group + * ends up owing two different amounts on two different screens. + * + * The one deliberate exception is settling up, which builds real settlements out of these numbers + * and must only ever act on entries that actually exist. It reads + * [de.tabmates.features.tabgroup.domain.tabentry.TabEntryRepository] directly. + */ +interface ScheduledLedger { + /** + * Every entry of [groupId], followed by a placeholder for each occurrence that is due and + * unwritten. + * + * A soft-deleted entry is not in the result: persistence removes the row outright rather than + * keeping it flagged. Its slot stays claimed regardless — the server never releases one — and an + * implementation has to honour those claims when it projects, or a deliberately deleted + * occurrence comes back as a placeholder on every projection. + */ + fun observeEntriesForGroup(groupId: String): Flow> +} diff --git a/features/tabgroup/domain/src/commonTest/kotlin/de/tabmates/features/tabgroup/domain/recurring/RecurringOccurrenceCalculatorTest.kt b/features/tabgroup/domain/src/commonTest/kotlin/de/tabmates/features/tabgroup/domain/recurring/RecurringOccurrenceCalculatorTest.kt new file mode 100644 index 00000000..5ccc36c0 --- /dev/null +++ b/features/tabgroup/domain/src/commonTest/kotlin/de/tabmates/features/tabgroup/domain/recurring/RecurringOccurrenceCalculatorTest.kt @@ -0,0 +1,390 @@ +package de.tabmates.features.tabgroup.domain.recurring + +import kotlinx.datetime.LocalDate +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * The date arithmetic behind recurring entries, ported case-for-case from the server's + * `RecurringOccurrenceGeneratorTest`. + * + * The server decides which occurrences get written; this decides which ones the client previews as + * placeholders. They have to produce the same dates, so the cases that pin the server's month-length + * behaviour are reproduced here verbatim rather than paraphrased. + * + * The server's per-run caps (`maxOccurrences`, `maxMonths`, the resume cursor) have no counterpart + * here on purpose: those bound one sweep's write batch, while the client renders every due slot. + */ +class RecurringOccurrenceCalculatorTest { + // region monthly and yearly clamping + + @Test + fun `monthly anchored on the 31st borrows a day in February and gives it straight back`() { + val dates = + RecurringOccurrenceCalculator.dueOccurrences( + rule = rule(RecurrenceFrequency.MONTHLY, startDate = LocalDate(2026, 1, 31)), + asOf = LocalDate(2026, 4, 30), + ) + + // The regression the anchor-based helper exists for: stepping off the previous occurrence + // would clamp to Feb 28 and then never recover the 31st, giving Mar 28 and Apr 28. + assertEquals( + listOf( + LocalDate(2026, 1, 31), + LocalDate(2026, 2, 28), + LocalDate(2026, 3, 31), + LocalDate(2026, 4, 30), + ), + dates, + ) + } + + @Test + fun `monthly anchored on the 31st clamps to 29 February in a leap year`() { + val dates = + RecurringOccurrenceCalculator.dueOccurrences( + rule = rule(RecurrenceFrequency.MONTHLY, startDate = LocalDate(2028, 1, 31)), + asOf = LocalDate(2028, 3, 31), + ) + + assertEquals( + listOf( + LocalDate(2028, 1, 31), + LocalDate(2028, 2, 29), + LocalDate(2028, 3, 31), + ), + dates, + ) + } + + @Test + fun `monthly anchored on 29 February falls on the 28th in a non-leap year`() { + val date = + RecurringOccurrenceCalculator.occurrenceDateForSlot( + frequency = RecurrenceFrequency.MONTHLY, + interval = 12, + anchorDate = LocalDate(2028, 2, 29), + slotIndex = 1, + ) + + assertEquals(LocalDate(2029, 2, 28), date) + } + + @Test + fun `yearly anchored on 29 February falls on the 28th in a non-leap year and recovers later`() { + val anchor = LocalDate(2028, 2, 29) + + assertEquals( + LocalDate(2029, 2, 28), + RecurringOccurrenceCalculator.occurrenceDateForSlot(RecurrenceFrequency.YEARLY, 1, anchor, 1), + ) + assertEquals( + LocalDate(2032, 2, 29), + RecurringOccurrenceCalculator.occurrenceDateForSlot(RecurrenceFrequency.YEARLY, 1, anchor, 4), + ) + } + + @Test + fun `monthly honours the interval`() { + val dates = + RecurringOccurrenceCalculator.dueOccurrences( + rule = + rule( + RecurrenceFrequency.MONTHLY, + interval = 3, + startDate = LocalDate(2026, 1, 15), + ), + asOf = LocalDate(2026, 7, 15), + ) + + assertEquals( + listOf( + LocalDate(2026, 1, 15), + LocalDate(2026, 4, 15), + LocalDate(2026, 7, 15), + ), + dates, + ) + } + + @Test + fun `every slot of a long monthly run is recomputed from the anchor`() { + // Guards the clamping helper across a full year of short and long months in one pass: any + // ratcheting bug shows up as a day that never returns to 31. + val anchor = LocalDate(2026, 1, 31) + val dates = + (0..12).map { + RecurringOccurrenceCalculator.occurrenceDateForSlot( + RecurrenceFrequency.MONTHLY, + 1, + anchor, + it, + ) + } + + assertEquals( + listOf(31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31, 31), + dates.map { it.day }, + ) + } + + // endregion + + // region daily and weekly + + @Test + fun `daily with interval one produces consecutive days`() { + val dates = + RecurringOccurrenceCalculator.dueOccurrences( + rule = rule(RecurrenceFrequency.DAILY, startDate = LocalDate(2026, 1, 1)), + asOf = LocalDate(2026, 1, 4), + ) + + assertEquals( + listOf( + LocalDate(2026, 1, 1), + LocalDate(2026, 1, 2), + LocalDate(2026, 1, 3), + LocalDate(2026, 1, 4), + ), + dates, + ) + } + + @Test + fun `daily honours the interval`() { + val dates = + RecurringOccurrenceCalculator.dueOccurrences( + rule = rule(RecurrenceFrequency.DAILY, interval = 3, startDate = LocalDate(2026, 1, 1)), + asOf = LocalDate(2026, 1, 8), + ) + + assertEquals( + listOf( + LocalDate(2026, 1, 1), + LocalDate(2026, 1, 4), + LocalDate(2026, 1, 7), + ), + dates, + ) + } + + @Test + fun `weekly with interval two lands fortnightly on the anchor weekday`() { + val dates = + RecurringOccurrenceCalculator.dueOccurrences( + rule = rule(RecurrenceFrequency.WEEKLY, interval = 2, startDate = LocalDate(2026, 1, 2)), + asOf = LocalDate(2026, 1, 30), + ) + + assertEquals( + listOf( + LocalDate(2026, 1, 2), + LocalDate(2026, 1, 16), + LocalDate(2026, 1, 30), + ), + dates, + ) + } + + // endregion + + // region end conditions + + @Test + fun `until date is inclusive and stops the series`() { + val dates = + RecurringOccurrenceCalculator.dueOccurrences( + rule = + rule( + RecurrenceFrequency.DAILY, + startDate = LocalDate(2026, 1, 1), + end = RecurringEnd.Until(LocalDate(2026, 1, 5)), + ), + asOf = LocalDate(2026, 12, 31), + ) + + assertEquals(LocalDate(2026, 1, 5), dates.last()) + assertEquals(5, dates.size) + } + + @Test + fun `occurrence count produces exactly that many`() { + val dates = + RecurringOccurrenceCalculator.dueOccurrences( + rule = + rule( + RecurrenceFrequency.DAILY, + startDate = LocalDate(2026, 1, 1), + end = RecurringEnd.Count(3), + ), + asOf = LocalDate(2026, 12, 31), + ) + + assertEquals(3, dates.size) + } + + @Test + fun `a series starting in the future yields nothing yet`() { + val dates = + RecurringOccurrenceCalculator.dueOccurrences( + rule = rule(RecurrenceFrequency.MONTHLY, startDate = LocalDate(2026, 9, 1)), + asOf = LocalDate(2026, 8, 10), + ) + + assertTrue(dates.isEmpty()) + } + + // endregion + + // region skipped and claimed slots + + @Test + fun `a skipped date is left out but still consumes its slot`() { + val dates = + RecurringOccurrenceCalculator.dueOccurrences( + rule = + rule( + RecurrenceFrequency.DAILY, + startDate = LocalDate(2026, 1, 1), + end = RecurringEnd.Count(5), + ), + asOf = LocalDate(2026, 12, 31), + skippedDates = setOf(LocalDate(2026, 1, 3)), + ) + + // Four dates, not five — and the series still ends after its fifth slot rather than running + // a day longer to make up for the skip. + assertEquals( + listOf( + LocalDate(2026, 1, 1), + LocalDate(2026, 1, 2), + LocalDate(2026, 1, 4), + LocalDate(2026, 1, 5), + ), + dates, + ) + } + + @Test + fun `a claimed slot is left out and still consumes its slot`() { + // A claimed slot is one the server has already written an entry for. It must not be + // previewed again, and it must not push the count-limited series a day further out. + val dates = + RecurringOccurrenceCalculator.dueOccurrences( + rule = + rule( + RecurrenceFrequency.DAILY, + startDate = LocalDate(2026, 1, 1), + end = RecurringEnd.Count(4), + ), + asOf = LocalDate(2026, 12, 31), + claimedDates = setOf(LocalDate(2026, 1, 1), LocalDate(2026, 1, 2)), + ) + + assertEquals(listOf(LocalDate(2026, 1, 3), LocalDate(2026, 1, 4)), dates) + } + + // endregion + + // region upcoming preview + + @Test + fun `upcoming returns the next dates strictly after the given day`() { + val dates = + RecurringOccurrenceCalculator.upcomingOccurrences( + rule = rule(RecurrenceFrequency.MONTHLY, startDate = LocalDate(2026, 1, 15)), + after = LocalDate(2026, 3, 15), + limit = 3, + ) + + assertEquals( + listOf( + LocalDate(2026, 4, 15), + LocalDate(2026, 5, 15), + LocalDate(2026, 6, 15), + ), + dates, + ) + } + + @Test + fun `upcoming leaves out skipped dates`() { + val dates = + RecurringOccurrenceCalculator.upcomingOccurrences( + rule = rule(RecurrenceFrequency.MONTHLY, startDate = LocalDate(2026, 1, 15)), + after = LocalDate(2026, 1, 20), + limit = 2, + skippedDates = setOf(LocalDate(2026, 2, 15)), + ) + + assertEquals(listOf(LocalDate(2026, 3, 15), LocalDate(2026, 4, 15)), dates) + } + + @Test + fun `upcoming stops at the series end rather than padding to the limit`() { + val dates = + RecurringOccurrenceCalculator.upcomingOccurrences( + rule = + rule( + RecurrenceFrequency.MONTHLY, + startDate = LocalDate(2026, 1, 15), + end = RecurringEnd.Count(3), + ), + after = LocalDate(2026, 1, 20), + limit = 6, + ) + + assertEquals(listOf(LocalDate(2026, 2, 15), LocalDate(2026, 3, 15)), dates) + } + + // endregion + + // region edit anchor + + @Test + fun `isOccurrenceDate accepts a date the schedule produces and rejects one it does not`() { + val monthly = rule(RecurrenceFrequency.MONTHLY, startDate = LocalDate(2026, 1, 15)) + + assertTrue(RecurringOccurrenceCalculator.isOccurrenceDate(monthly, LocalDate(2026, 5, 15))) + assertFalse(RecurringOccurrenceCalculator.isOccurrenceDate(monthly, LocalDate(2026, 5, 14))) + } + + @Test + fun `isOccurrenceDate rejects a date past the series end`() { + val monthly = + rule( + RecurrenceFrequency.MONTHLY, + startDate = LocalDate(2026, 1, 15), + end = RecurringEnd.Until(LocalDate(2026, 3, 31)), + ) + + assertTrue(RecurringOccurrenceCalculator.isOccurrenceDate(monthly, LocalDate(2026, 3, 15))) + assertFalse(RecurringOccurrenceCalculator.isOccurrenceDate(monthly, LocalDate(2026, 4, 15))) + } + + // endregion + + private fun rule( + frequency: RecurrenceFrequency, + interval: Int = 1, + startDate: LocalDate, + end: RecurringEnd = RecurringEnd.Never, + ) = RecurringRule( + ruleId = "rule-1", + title = "Rent", + description = "", + amount = 100.0, + currencyCode = "EUR", + exchangeRate = null, + paidByUserId = "user-1", + receivedByUserId = null, + splits = emptyList(), + frequency = frequency, + interval = interval, + startDate = startDate, + end = end, + ) +} diff --git a/features/tabgroup/domain/src/commonTest/kotlin/de/tabmates/features/tabgroup/domain/recurring/ScheduledEntryProjectorTest.kt b/features/tabgroup/domain/src/commonTest/kotlin/de/tabmates/features/tabgroup/domain/recurring/ScheduledEntryProjectorTest.kt new file mode 100644 index 00000000..192f2457 --- /dev/null +++ b/features/tabgroup/domain/src/commonTest/kotlin/de/tabmates/features/tabgroup/domain/recurring/ScheduledEntryProjectorTest.kt @@ -0,0 +1,252 @@ +package de.tabmates.features.tabgroup.domain.recurring + +import de.tabmates.features.tabgroup.domain.models.GroupParticipant +import de.tabmates.features.tabgroup.domain.models.ParticipantType +import de.tabmates.features.tabgroup.domain.models.SplitType +import de.tabmates.features.tabgroup.domain.models.TabEntry +import kotlinx.datetime.LocalDate +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Instant + +class ScheduledEntryProjectorTest { + private val today = LocalDate(2026, 3, 15) + private val creator = GroupParticipant("user-1", "Ada", ParticipantType.REGISTERED) + + @Test + fun `projects one placeholder per due unwritten occurrence`() { + val projected = + ScheduledEntryProjector.project( + series = listOf(series(startDate = LocalDate(2026, 1, 15))), + existingEntries = emptyList(), + claimedSlots = emptySet(), + today = today, + ) + + assertContentEquals( + listOf(LocalDate(2026, 1, 15), LocalDate(2026, 2, 15), LocalDate(2026, 3, 15)), + projected.map { it.entryDate }, + ) + assertTrue(projected.all { it.isScheduledPlaceholder }) + assertTrue(projected.all { it.recurringSeriesId == "series-1" }) + } + + @Test + fun `a slot with a local entry produces no placeholder`() { + val written = expense(occurrenceDate = LocalDate(2026, 2, 15)) + + val projected = + ScheduledEntryProjector.project( + series = listOf(series(startDate = LocalDate(2026, 1, 15))), + existingEntries = listOf(written), + claimedSlots = emptySet(), + today = today, + ) + + assertContentEquals( + listOf(LocalDate(2026, 1, 15), LocalDate(2026, 3, 15)), + projected.map { it.entryDate }, + ) + } + + @Test + fun `a deleted occurrence stays gone because its slot is still claimed`() { + // The regression the claim record exists for. The server keeps a slot claimed forever, but + // locally a soft-deleted entry is dropped from the table outright — so with only the entry + // list to go on, an occurrence someone deleted on purpose reappears as a placeholder on + // every projection, and no amount of syncing gets rid of it. + val projected = + ScheduledEntryProjector.project( + series = listOf(series(startDate = LocalDate(2026, 1, 15))), + existingEntries = emptyList(), + claimedSlots = setOf(RecurringSlot("series-1", LocalDate(2026, 2, 15))), + today = today, + ) + + assertContentEquals( + listOf(LocalDate(2026, 1, 15), LocalDate(2026, 3, 15)), + projected.map { it.entryDate }, + ) + } + + @Test + fun `a skipped occurrence produces no placeholder`() { + val projected = + ScheduledEntryProjector.project( + series = + listOf( + series( + startDate = LocalDate(2026, 1, 15), + skipped = setOf(LocalDate(2026, 2, 15)), + ), + ), + existingEntries = emptyList(), + claimedSlots = emptySet(), + today = today, + ) + + assertContentEquals( + listOf(LocalDate(2026, 1, 15), LocalDate(2026, 3, 15)), + projected.map { it.entryDate }, + ) + } + + @Test + fun `a parked series produces nothing`() { + // needsAttention means the server is writing nothing until a human repairs the template. + // Previewing occurrences would promise entries that are not coming. + val projected = + ScheduledEntryProjector.project( + series = listOf(series(startDate = LocalDate(2026, 1, 15), needsAttention = true)), + existingEntries = emptyList(), + claimedSlots = emptySet(), + today = today, + ) + + assertTrue(projected.isEmpty()) + } + + @Test + fun `an ended series produces nothing`() { + val projected = + ScheduledEntryProjector.project( + series = listOf(series(startDate = LocalDate(2026, 1, 15), isActive = false)), + existingEntries = emptyList(), + claimedSlots = emptySet(), + today = today, + ) + + assertTrue(projected.isEmpty()) + } + + @Test + fun `future occurrences are not projected`() { + val projected = + ScheduledEntryProjector.project( + series = listOf(series(startDate = LocalDate(2026, 4, 1))), + existingEntries = emptyList(), + claimedSlots = emptySet(), + today = today, + ) + + assertTrue(projected.isEmpty()) + } + + @Test + fun `placeholder carries the template's splits and amount`() { + val projected = + ScheduledEntryProjector.project( + series = listOf(series(startDate = today)), + existingEntries = emptyList(), + claimedSlots = emptySet(), + today = today, + ) + + val placeholder = assertIs(projected.single()) + assertEquals(120.0, placeholder.amount) + assertEquals("EUR", placeholder.currencyCode) + assertContentEquals(listOf("user-1", "user-2"), placeholder.splits.map { it.participantId }) + assertEquals(listOf(60.0, 60.0), placeholder.splits.map { it.resolvedAmount }) + } + + @Test + fun `a settlement series without a receiver produces no placeholder`() { + // The server rejects such a template, so this is unreachable in practice — but defaulting + // the receiver would move money to the wrong person, so it must produce nothing instead. + val broken = + series(startDate = today).let { + it.copy( + entryType = RecurringEntryType.SETTLEMENT, + rule = it.rule.copy(receivedByUserId = null, splits = emptyList()), + ) + } + + val projected = + ScheduledEntryProjector.project( + series = listOf(broken), + existingEntries = emptyList(), + claimedSlots = emptySet(), + today = today, + ) + + assertTrue(projected.isEmpty()) + } + + @Test + fun `placeholder ids are stable across projections`() { + fun projectOnce() = + ScheduledEntryProjector.project( + series = listOf(series(startDate = LocalDate(2026, 1, 15))), + existingEntries = emptyList(), + claimedSlots = emptySet(), + today = today, + ) + + assertContentEquals( + projectOnce().map { it.tabEntryId }, + projectOnce().map { it.tabEntryId }, + ) + } + + private fun series( + startDate: LocalDate, + isActive: Boolean = true, + needsAttention: Boolean = false, + skipped: Set = emptySet(), + ) = RecurringSeries( + seriesId = "series-1", + groupId = "group-1", + entryType = RecurringEntryType.EXPENSE, + isActive = isActive, + needsAttention = needsAttention, + createdAt = Instant.fromEpochMilliseconds(0), + createdBy = creator, + updatedAt = Instant.fromEpochMilliseconds(0), + rule = + RecurringRule( + ruleId = "rule-1", + title = "Rent", + description = "", + amount = 120.0, + currencyCode = "EUR", + exchangeRate = null, + paidByUserId = "user-1", + receivedByUserId = null, + splits = + listOf( + RecurringTemplateSplit(null, "user-1", SplitType.EQUAL, 1.0, 60.0), + RecurringTemplateSplit(null, "user-2", SplitType.EQUAL, 1.0, 60.0), + ), + frequency = RecurrenceFrequency.MONTHLY, + interval = 1, + startDate = startDate, + end = RecurringEnd.Never, + ), + skippedOccurrenceDates = skipped, + ) + + private fun expense(occurrenceDate: LocalDate) = + TabEntry.Expense( + tabEntryId = "entry-1", + groupId = "group-1", + title = "Rent", + description = "", + amount = 120.0, + currencyCode = "EUR", + creatorId = "user-1", + paidByUserId = "user-1", + entryDate = occurrenceDate, + createdAt = Instant.fromEpochMilliseconds(0), + lastModifiedAt = Instant.fromEpochMilliseconds(0), + lastModifiedByUserId = "user-1", + version = 1, + deletedAt = null, + deletedByUserId = null, + splits = emptyList(), + recurringSeriesId = "series-1", + recurringOccurrenceDate = occurrenceDate, + ) +} diff --git a/features/tabgroup/presentation/src/commonMain/composeResources/values-de/string.xml b/features/tabgroup/presentation/src/commonMain/composeResources/values-de/string.xml index 5b51314d..b66d3ddb 100644 --- a/features/tabgroup/presentation/src/commonMain/composeResources/values-de/string.xml +++ b/features/tabgroup/presentation/src/commonMain/composeResources/values-de/string.xml @@ -187,6 +187,7 @@ Beschreibung Standardwährung Personen + Wiederkehrende Einträge GEFAHRENZONE Gruppe verlassen Du verlierst den Zugriff auf die Ausgaben in dieser Gruppe. @@ -250,6 +251,87 @@ Titel hinzufügen Titel darf höchstens 255 Zeichen lang sein Beschreibung darf höchstens 255 Zeichen lang sein + Wähle, wer die Zahlung erhalten hat + Eine Zahlung braucht zwei verschiedene Personen + Wähle, ab wann die Änderung gelten soll + Zahlung + Wiederholung + Wiederholung + WIE OFT + INTERVALL + Alle %1$d %2$s + NÄCHSTE TERMINE + Fertig + Tag + Tage + Woche + Wochen + Monat + Monate + Jahr + Jahre + Nie + Täglich + Wöchentlich + Monatlich + Jährlich + Alle %1$d Tage + Alle %1$d Wochen + Alle %1$d Monate + Alle %1$d Jahre + Beginnt + ENDET + Nie + An einem Datum + Nach einer Anzahl + Mal + Verringern + Erhöhen + Wiederholende Einträge brauchen eine Verbindung. + Wöchentliche Wiederholungen fallen auf denselben Wochentag wie das Startdatum. + Der erste Eintrag wird am Startdatum automatisch erstellt. + Gilt ab + Vorkommen vor diesem Datum bleiben unverändert und werden nicht erstellt. + Zeitplan speichern + Zeitplan bearbeiten + Wiederkehrend + In dieser Gruppe wiederholt sich noch nichts. Aktiviere die Wiederholung beim Anlegen eines Eintrags. + Aktiv + Beendet + Beendet + Aktion nötig + Geplant + Demnächst + Verwalten + Alle anzeigen (%1$d) + Weniger anzeigen + Pausiert — Korrektur nötig + %1$s, wiederkehrender Eintrag + Nächstes am %1$s + Ab %1$s + Endet am %1$s + Endet nach %1$d Mal + NÄCHSTE VORKOMMEN + ÜBERSPRUNGEN + Zeitplan bearbeiten + Zeitplan beenden + %1$s überspringen + Überspringen am %1$s rückgängig machen + Eingerichtet von %1$s + Überspringen + Überspringen rückgängig + Ein übersprungenes Datum verbraucht trotzdem ein Vorkommen, der Zeitplan läuft also nicht länger. + Zeitplan beenden + Diesen Zeitplan beenden? + Es werden keine neuen Einträge mehr erstellt. Bereits erstellte Einträge bleiben unverändert, und das lässt sich nicht rückgängig machen. + Beenden + Abbrechen + Dieser Zeitplan ist beendet und erstellt keine neuen Einträge. + Zum Ändern eines Zeitplans brauchst du eine Verbindung. + Pausiert + %1$s ist nicht mehr in dieser Gruppe, daher wird nichts erstellt. Bearbeite den Zeitplan, um das zu beheben. + Zeitplan reparieren + Dieser Zeitplan ist nicht mehr verfügbar Wähle, wer bezahlt hat Aufteilung muss %1$s ergeben Wähle mindestens eine Person für die Aufteilung diff --git a/features/tabgroup/presentation/src/commonMain/composeResources/values/string.xml b/features/tabgroup/presentation/src/commonMain/composeResources/values/string.xml index 135e2ad0..6609931a 100644 --- a/features/tabgroup/presentation/src/commonMain/composeResources/values/string.xml +++ b/features/tabgroup/presentation/src/commonMain/composeResources/values/string.xml @@ -187,6 +187,7 @@ Description Default currency People + Repeating entries DANGER ZONE Leave group You will lose access to expenses in this group. @@ -250,6 +251,87 @@ Add a title Title must be 255 characters or fewer Description must be 255 characters or fewer + Pick who received the payment + A payment needs two different people + Pick when the change should start + Payment + Repeats + Repeat + HOW OFTEN + INTERVAL + Every %1$d %2$s + NEXT DATES + Done + day + days + week + weeks + month + months + year + years + Never + Daily + Weekly + Monthly + Yearly + Every %1$d days + Every %1$d weeks + Every %1$d months + Every %1$d years + Starts + ENDS + Never + On a date + After a number of times + times + Decrease + Increase + Repeating entries need a connection. + Weekly repeats fall on the same weekday as the start date. + The first entry is created automatically on its start date. + Applies from + Occurrences before this date stay as they are and are not created. + Save schedule + Edit schedule + Repeating + Nothing repeats in this group yet. Turn on Repeats when adding an entry. + Active + Ended + Ended + Needs attention + Scheduled + Upcoming + Manage + Show all (%1$d) + Show less + Paused — needs a fix + %1$s, repeating entry + Next on %1$s + Starts %1$s + Ends %1$s + Ends after %1$d times + NEXT OCCURRENCES + SKIPPED + Edit schedule + End schedule + Skip %1$s + Undo skip on %1$s + Set up by %1$s + Skip + Undo skip + A skipped date still uses up one occurrence, so the schedule does not run longer. + End schedule + End this schedule? + No new entries will be created. Entries it already created stay as they are, and this cannot be undone. + End + Cancel + This schedule has ended and creates no new entries. + Changing a schedule needs a connection. + Paused + %1$s is no longer in this group, so nothing is being created. Edit the schedule to fix it. + Fix schedule + That schedule is no longer available Pick who paid Split must equal %1$s Pick at least one person to split with diff --git a/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/components/DetailHero.kt b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/components/DetailHero.kt new file mode 100644 index 00000000..e8dfb4c7 --- /dev/null +++ b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/components/DetailHero.kt @@ -0,0 +1,93 @@ +package de.tabmates.features.tabgroup.presentation.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.DrawableResource +import org.jetbrains.compose.resources.vectorResource + +/** + * The masthead every "one thing, in detail" screen opens with: a big round badge, the title, the + * amount, and one muted line under it. + * + * Shared rather than copied. Entries, settlements and schedules are three different objects, but a + * member reads all three the same way, and three private copies of this block drifted apart is + * exactly how a screen ends up looking like it came from somewhere else. + * + * @param subtitle the one muted line: a date for an entry or settlement, a cadence for a schedule + * @param description optional free text under the subtitle, centred; blank hides it + */ +@Composable +fun DetailHero( + icon: DrawableResource, + title: String, + amountFormatted: String, + subtitle: String, + modifier: Modifier = Modifier, + description: String = "", + isPendingSync: Boolean = false, +) { + Column( + modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box( + modifier = + Modifier + .size(96.dp) + .background( + color = MaterialTheme.colorScheme.tertiaryContainer, + shape = CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = vectorResource(icon), + contentDescription = null, + tint = MaterialTheme.colorScheme.onTertiaryContainer, + modifier = Modifier.size(40.dp), + ) + } + Text( + text = title, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + ) + if (isPendingSync) { + SyncStatusChip() + } + Text( + text = amountFormatted, + style = MaterialTheme.typography.displaySmall, + fontWeight = FontWeight.Bold, + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (description.isNotBlank()) { + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + } + } +} diff --git a/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/MainGraph.kt b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/MainGraph.kt index bdfc5857..693c62ea 100644 --- a/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/MainGraph.kt +++ b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/MainGraph.kt @@ -2,6 +2,7 @@ package de.tabmates.features.tabgroup.presentation.navigation import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey @@ -12,11 +13,11 @@ import de.tabmates.features.tabgroup.domain.group.GroupRemovalNotifier import de.tabmates.features.tabgroup.presentation.navigation.activity.ActivityRoot import de.tabmates.features.tabgroup.presentation.navigation.addentry.AddEntryRoot import de.tabmates.features.tabgroup.presentation.navigation.creategroup.CreateGroupRoot -import de.tabmates.features.tabgroup.presentation.navigation.editsettlement.EditSettlementRoot import de.tabmates.features.tabgroup.presentation.navigation.entrydetail.EntryDetailRoot import de.tabmates.features.tabgroup.presentation.navigation.groupdetail.GroupDetailRoot import de.tabmates.features.tabgroup.presentation.navigation.groupoverview.GroupOverviewRoot import de.tabmates.features.tabgroup.presentation.navigation.grouppeople.GroupPeopleRoot +import de.tabmates.features.tabgroup.presentation.navigation.groupschedules.GroupSchedulesRoot import de.tabmates.features.tabgroup.presentation.navigation.groupsettings.GroupSettingsRoot import de.tabmates.features.tabgroup.presentation.navigation.home.HomeRoot import de.tabmates.features.tabgroup.presentation.navigation.joingroup.JoinGroupRoot @@ -26,6 +27,7 @@ import de.tabmates.features.tabgroup.presentation.navigation.profile.DeleteAccou import de.tabmates.features.tabgroup.presentation.navigation.profile.EditUsernameRoot import de.tabmates.features.tabgroup.presentation.navigation.profile.ProfileRoot import de.tabmates.features.tabgroup.presentation.navigation.profile.UpgradeAccountRoot +import de.tabmates.features.tabgroup.presentation.navigation.recurringdetail.RecurringSeriesDetailRoot import de.tabmates.features.tabgroup.presentation.navigation.settings.OssLicensesRoot import de.tabmates.features.tabgroup.presentation.navigation.settings.SettingsRoot import de.tabmates.features.tabgroup.presentation.navigation.settlementdetail.SettlementDetailRoot @@ -55,7 +57,11 @@ val mainSerializersModule = subclass(EditEntry::class) subclass(EntryDetail::class) subclass(SettlementDetail::class) + @Suppress("DEPRECATION") subclass(EditSettlement::class) + subclass(GroupSchedules::class) + subclass(RecurringSeriesDetail::class) + subclass(EditRecurringSeries::class) subclass(CreateGroup::class) subclass(GroupDetail::class) subclass(SettleUp::class) @@ -168,6 +174,10 @@ fun EntryProviderScope.mainGraph( onSettlementClick = { settlementId -> backStack.add(SettlementDetail(settlementId = settlementId, groupId = route.groupId)) }, + onRecurringSeriesClick = { seriesId -> + backStack.add(RecurringSeriesDetail(groupId = route.groupId, seriesId = seriesId)) + }, + onManageSchedulesClick = { backStack.add(GroupSchedules(route.groupId)) }, ) } @@ -277,18 +287,51 @@ fun EntryProviderScope.mainGraph( snackbarHostState = snackbarHostState, onBack = { backStack.removeLastOrNull() }, onEdit = { - backStack.add(EditSettlement(groupId = route.groupId, settlementId = route.settlementId)) + backStack.add(EditEntry(groupId = route.groupId, entryId = route.settlementId)) }, ) } + // Retired. Only reachable from a back stack persisted by an older build, so it swaps itself + // for the entry form rather than rendering — removing the key outright would fail to + // deserialize and take the whole restored stack with it. + @Suppress("DEPRECATION") entry { route -> - EditSettlementRoot( + LaunchedEffect(route) { + backStack.removeAll { it is EditSettlement } + backStack.add(EditEntry(groupId = route.groupId, entryId = route.settlementId)) + } + } + + entry { route -> + GroupSchedulesRoot( + groupId = route.groupId, + onSeriesClick = { seriesId -> + backStack.add(RecurringSeriesDetail(groupId = route.groupId, seriesId = seriesId)) + }, + ) + } + + entry { route -> + RecurringSeriesDetailRoot( + groupId = route.groupId, + seriesId = route.seriesId, + navKey = route, + snackbarHostState = snackbarHostState, + onBack = { backStack.removeLastOrNull() }, + onEdit = { seriesId -> + backStack.add(EditRecurringSeries(groupId = route.groupId, seriesId = seriesId)) + }, + ) + } + + entry { route -> + AddEntryRoot( groupId = route.groupId, - settlementId = route.settlementId, navKey = route, + seriesId = route.seriesId, snackbarHostState = snackbarHostState, - onSaved = { backStack.removeAll { it is EditSettlement } }, + onSaved = { backStack.removeAll { it is EditRecurringSeries } }, ) } @@ -315,6 +358,7 @@ fun EntryProviderScope.mainGraph( GroupSettingsRoot( groupId = route.groupId, onPeopleClick = { backStack.add(GroupPeople(route.groupId)) }, + onSchedulesClick = { backStack.add(GroupSchedules(route.groupId)) }, onLeft = { backStack.removeGroupScopedEntries(route.groupId) appScope.launch { snackbarHostState.showSnackbar(leftMessage) } diff --git a/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/MainNavKeys.kt b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/MainNavKeys.kt index bf821746..27d09e34 100644 --- a/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/MainNavKeys.kt +++ b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/MainNavKeys.kt @@ -27,7 +27,6 @@ import tabmatesapp.features.tabgroup.presentation.generated.resources.change_pas import tabmatesapp.features.tabgroup.presentation.generated.resources.create_group_title import tabmatesapp.features.tabgroup.presentation.generated.resources.delete_account_title import tabmatesapp.features.tabgroup.presentation.generated.resources.edit_entry_title -import tabmatesapp.features.tabgroup.presentation.generated.resources.edit_settlement_title import tabmatesapp.features.tabgroup.presentation.generated.resources.edit_username_title import tabmatesapp.features.tabgroup.presentation.generated.resources.group_label import tabmatesapp.features.tabgroup.presentation.generated.resources.group_people_title @@ -38,6 +37,8 @@ import tabmatesapp.features.tabgroup.presentation.generated.resources.ic_home_fi import tabmatesapp.features.tabgroup.presentation.generated.resources.join_group_title import tabmatesapp.features.tabgroup.presentation.generated.resources.oss_licenses_title import tabmatesapp.features.tabgroup.presentation.generated.resources.profile_title +import tabmatesapp.features.tabgroup.presentation.generated.resources.recurring_edit_title +import tabmatesapp.features.tabgroup.presentation.generated.resources.recurring_screen_title import tabmatesapp.features.tabgroup.presentation.generated.resources.settings_label import tabmatesapp.features.tabgroup.presentation.generated.resources.settle_up_title import tabmatesapp.features.tabgroup.presentation.generated.resources.upgrade_account_title @@ -175,12 +176,55 @@ data class SettlementDetail( override val topBarAction: TopBarAction get() = TopBarAction.Back } +/** + * Retired: settlements are edited through [EditEntry] like every other entry, now that the add form + * handles all three kinds. + * + * Kept registered for one release because a persisted back stack may still hold this key, and a + * removed polymorphic subclass fails deserialization rather than degrading. The graph redirects it. + */ @Serializable +@Deprecated("Use EditEntry; kept only so a persisted back stack still deserializes.") data class EditSettlement( override val groupId: String, val settlementId: String, ) : LoggableNavKey(), LoggedIn, ScreenWithTopBar, GroupScoped { - override val topBarTitle: UiText get() = UiText.Resource(Res.string.edit_settlement_title) + override val topBarTitle: UiText get() = UiText.Resource(Res.string.edit_entry_title) + override val topBarAction: TopBarAction get() = TopBarAction.Close +} + +/** + * Every schedule in the group, active and ended. + * + * A screen rather than a tab: the group's own tabs answer "what happened" and "who owes what", and + * the only schedules that bear on either are the ones about to produce something — those get a + * section on the transactions tab. The rest, ended ones included, are settings-shaped and live here. + */ +@Serializable +data class GroupSchedules( + override val groupId: String, +) : LoggableNavKey(), LoggedIn, ScreenWithTopBar, GroupScoped { + override val topBarTitle: UiText get() = UiText.Resource(Res.string.recurring_screen_title) + override val topBarAction: TopBarAction get() = TopBarAction.Back +} + +/** Read-only view of one recurring schedule, mirroring how an entry's detail screen works. */ +@Serializable +data class RecurringSeriesDetail( + override val groupId: String, + val seriesId: String, +) : LoggableNavKey(), LoggedIn, ScreenWithTopBar, GroupScoped { + override val topBarTitle: UiText get() = UiText.DynamicString("") + override val topBarAction: TopBarAction get() = TopBarAction.Back +} + +/** The add/edit form bound to a schedule instead of an entry. */ +@Serializable +data class EditRecurringSeries( + override val groupId: String, + val seriesId: String, +) : LoggableNavKey(), LoggedIn, ScreenWithTopBar, GroupScoped { + override val topBarTitle: UiText get() = UiText.Resource(Res.string.recurring_edit_title) override val topBarAction: TopBarAction get() = TopBarAction.Close } diff --git a/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/addentry/AddEntryScreen.kt b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/addentry/AddEntryScreen.kt index c2c0b4cf..4df16c18 100644 --- a/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/addentry/AddEntryScreen.kt +++ b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/addentry/AddEntryScreen.kt @@ -44,6 +44,7 @@ import androidx.compose.material3.rememberDatePickerState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -77,6 +78,8 @@ import de.tabmates.core.presentation.util.UiText import de.tabmates.features.tabgroup.domain.currency.CurrencyConverter import de.tabmates.features.tabgroup.domain.models.GroupParticipant import de.tabmates.features.tabgroup.domain.models.SplitType +import de.tabmates.features.tabgroup.domain.recurring.RecurrenceFrequency +import de.tabmates.features.tabgroup.domain.recurring.RecurringEnd import de.tabmates.features.tabgroup.presentation.components.formatMoney import de.tabmates.features.tabgroup.presentation.components.formatRate import de.tabmates.features.tabgroup.presentation.components.parseAmount @@ -85,8 +88,10 @@ import de.tabmates.features.tabgroup.presentation.components.rememberAmountInput import de.tabmates.features.tabgroup.presentation.navigation.creategroup.CurrencyPickerBottomSheet import de.tabmates.features.tabgroup.presentation.navigation.creategroup.CurrencyPickerUiState import de.tabmates.features.tabgroup.presentation.navigation.groupoverview.UserAvatar +import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.toLocalDateTime import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.vectorResource @@ -100,8 +105,11 @@ import tabmatesapp.features.tabgroup.presentation.generated.resources.add_entry_ import tabmatesapp.features.tabgroup.presentation.generated.resources.add_entry_date_confirm import tabmatesapp.features.tabgroup.presentation.generated.resources.add_entry_date_label import tabmatesapp.features.tabgroup.presentation.generated.resources.add_entry_description_placeholder +import tabmatesapp.features.tabgroup.presentation.generated.resources.add_entry_effective_from_label +import tabmatesapp.features.tabgroup.presentation.generated.resources.add_entry_effective_from_note import tabmatesapp.features.tabgroup.presentation.generated.resources.add_entry_kind_expense import tabmatesapp.features.tabgroup.presentation.generated.resources.add_entry_kind_income +import tabmatesapp.features.tabgroup.presentation.generated.resources.add_entry_kind_settlement import tabmatesapp.features.tabgroup.presentation.generated.resources.add_entry_paid_by_label import tabmatesapp.features.tabgroup.presentation.generated.resources.add_entry_paid_by_sheet_done import tabmatesapp.features.tabgroup.presentation.generated.resources.add_entry_paid_by_sheet_title @@ -111,6 +119,10 @@ import tabmatesapp.features.tabgroup.presentation.generated.resources.add_entry_ import tabmatesapp.features.tabgroup.presentation.generated.resources.add_entry_rate_unavailable import tabmatesapp.features.tabgroup.presentation.generated.resources.add_entry_received_by_label import tabmatesapp.features.tabgroup.presentation.generated.resources.add_entry_received_by_sheet_title +import tabmatesapp.features.tabgroup.presentation.generated.resources.add_entry_repeat_done +import tabmatesapp.features.tabgroup.presentation.generated.resources.add_entry_repeat_editor_title +import tabmatesapp.features.tabgroup.presentation.generated.resources.add_entry_repeat_label +import tabmatesapp.features.tabgroup.presentation.generated.resources.add_entry_repeat_offline_hint import tabmatesapp.features.tabgroup.presentation.generated.resources.add_entry_save import tabmatesapp.features.tabgroup.presentation.generated.resources.add_entry_split_label import tabmatesapp.features.tabgroup.presentation.generated.resources.add_entry_split_summary_equal @@ -125,6 +137,7 @@ import tabmatesapp.features.tabgroup.presentation.generated.resources.ic_chevron import tabmatesapp.features.tabgroup.presentation.generated.resources.ic_pie_chart import tabmatesapp.features.tabgroup.presentation.generated.resources.split_screen_done import tabmatesapp.features.tabgroup.presentation.generated.resources.split_screen_title +import kotlin.time.Instant @Composable fun AddEntryRoot( @@ -134,10 +147,13 @@ fun AddEntryRoot( onSaved: () -> Unit, modifier: Modifier = Modifier, entryId: String = "", + seriesId: String = "", viewModel: AddEntryViewModel = koinViewModel( - key = entryId.ifBlank { groupId }, - parameters = { parametersOf(groupId, entryId) }, + // Keyed by whatever the screen is bound to, so opening an entry and a schedule in turn + // does not hand the second one the first one's loaded form. + key = seriesId.ifBlank { entryId.ifBlank { groupId } }, + parameters = { parametersOf(groupId, entryId, seriesId) }, ), ) { val state by viewModel.state.collectAsStateWithLifecycle() @@ -165,6 +181,22 @@ fun AddEntryRoot( ) } } + } else if (state.isRepeatEditorVisible) { + // Same deal for the repeat editor. It edits the form state live, so "Done" only closes it — + // there is nothing to commit that backing out would undo. + OverrideTopBar( + key = navKey, + title = UiText.Resource(Res.string.add_entry_repeat_editor_title), + navigationAction = TopBarAction.Back, + onNavigationClick = viewModel::onRepeatDismiss, + ) { + TextButton(onClick = viewModel::onRepeatDismiss) { + Text( + text = stringResource(Res.string.add_entry_repeat_done), + fontWeight = FontWeight.SemiBold, + ) + } + } } else { TopBarActions(navKey) { TextButton( @@ -184,6 +216,22 @@ fun AddEntryRoot( currencyPickerState = currencyPickerState, onKindChange = viewModel::onKindChange, onPaidByClick = viewModel::onPaidByClick, + onReceivedByClick = viewModel::onReceivedByClick, + onReceivedBySelected = viewModel::onReceivedBySelected, + onReceivedByPickerDismiss = viewModel::onReceivedByPickerDismiss, + onRepeatOpen = viewModel::onRepeatOpen, + onRepeatDismiss = viewModel::onRepeatDismiss, + onRepeatFrequencyChange = viewModel::onRepeatFrequencyChange, + onRepeatIntervalChange = viewModel::onRepeatIntervalChange, + onRepeatStartPickerOpen = viewModel::onRepeatStartPickerOpen, + onRepeatStartPickerDismiss = viewModel::onRepeatStartPickerDismiss, + onRepeatStartDateChange = viewModel::onRepeatStartDateChange, + onRepeatEndChange = viewModel::onRepeatEndChange, + onRepeatEndPickerOpen = viewModel::onRepeatEndPickerOpen, + onRepeatEndPickerDismiss = viewModel::onRepeatEndPickerDismiss, + onEffectiveFromClick = viewModel::onEffectiveFromClick, + onEffectiveFromSelected = viewModel::onEffectiveFromSelected, + onEffectiveFromPickerDismiss = viewModel::onEffectiveFromPickerDismiss, onPaidByPickerDismiss = viewModel::onPaidByPickerDismiss, onPaidBySelected = viewModel::onPaidBySelected, onCurrencyClick = viewModel::onCurrencyClick, @@ -207,6 +255,22 @@ internal fun AddEntryScreen( currencyPickerState: CurrencyPickerUiState, onKindChange: (EntryKind) -> Unit, onPaidByClick: () -> Unit, + onReceivedByClick: () -> Unit, + onReceivedBySelected: (String) -> Unit, + onReceivedByPickerDismiss: () -> Unit, + onRepeatOpen: () -> Unit, + onRepeatDismiss: () -> Unit, + onRepeatFrequencyChange: (RecurrenceFrequency?) -> Unit, + onRepeatIntervalChange: (Int) -> Unit, + onRepeatStartPickerOpen: () -> Unit, + onRepeatStartPickerDismiss: () -> Unit, + onRepeatStartDateChange: (LocalDate) -> Unit, + onRepeatEndChange: (RecurringEnd) -> Unit, + onRepeatEndPickerOpen: () -> Unit, + onRepeatEndPickerDismiss: () -> Unit, + onEffectiveFromClick: () -> Unit, + onEffectiveFromSelected: (LocalDate) -> Unit, + onEffectiveFromPickerDismiss: () -> Unit, onPaidByPickerDismiss: () -> Unit, onPaidBySelected: (String) -> Unit, onCurrencyClick: () -> Unit, @@ -230,8 +294,8 @@ internal fun AddEntryScreen( NavigationEventHandler( state = backState, isForwardEnabled = false, - isBackEnabled = state.isSplitEditorVisible, - onBackCompleted = onSplitDismiss, + isBackEnabled = state.isSplitEditorVisible || state.isRepeatEditorVisible, + onBackCompleted = { if (state.isRepeatEditorVisible) onRepeatDismiss() else onSplitDismiss() }, ) Column( @@ -291,18 +355,64 @@ internal fun AddEntryScreen( onClick = onPaidByClick, leadingIcon = null, ) - FieldRow( - label = stringResource(Res.string.add_entry_split_label), - value = splitSummary(state), - onClick = onSplitOpen, - leadingIcon = Res.drawable.ic_pie_chart, - ) + // A settlement moves a fixed amount from one person to another, so it takes a second + // person instead of a split. + if (state.isSettlement) { + FieldRow( + label = stringResource(Res.string.add_entry_received_by_label), + value = participantDisplay(state, state.receivedByUserId), + onClick = onReceivedByClick, + leadingIcon = null, + ) + } else { + FieldRow( + label = stringResource(Res.string.add_entry_split_label), + value = splitSummary(state), + onClick = onSplitOpen, + leadingIcon = Res.drawable.ic_pie_chart, + ) + } FieldRow( label = stringResource(Res.string.add_entry_date_label), value = formatEntryDate(state.entryDate, monthLabels), onClick = onDateClick, leadingIcon = Res.drawable.ic_calendar, ) + if (state.canEditRepeat) { + FieldRow( + label = stringResource(Res.string.add_entry_repeat_label), + value = repeatSummary(state), + onClick = onRepeatOpen, + leadingIcon = Res.drawable.ic_calendar, + ) + } + // Editing a schedule has to anchor on an occurrence it actually produces; anything else + // would silently re-time the series, so the date is picked from a list, not a calendar. + if (state.isEditingSeries) { + FieldRow( + label = stringResource(Res.string.add_entry_effective_from_label), + value = + state.effectiveFrom + ?.let { formatEntryDate(it, monthLabels) } + .orEmpty(), + onClick = onEffectiveFromClick, + leadingIcon = Res.drawable.ic_calendar, + ) + Text( + text = stringResource(Res.string.add_entry_effective_from_note), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + } + if (!state.isOnline && !state.isEditing) { + Text( + text = stringResource(Res.string.add_entry_repeat_offline_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + } } VerticalSpacer(24.dp) } @@ -318,6 +428,29 @@ internal fun AddEntryScreen( ) } + if (state.isReceivedByPickerVisible) { + PaidByPickerSheet( + // The payer is filtered out: the server refuses a settlement whose two sides are the + // same person, so it is not offered in the first place. + members = state.members.filterNot { it.userId == state.paidByUserId }, + currentUserId = state.currentUserId, + selectedUserId = state.receivedByUserId, + isIncome = true, + onSelect = onReceivedBySelected, + onDismiss = onReceivedByPickerDismiss, + ) + } + + if (state.isEffectiveFromPickerVisible) { + EffectiveFromSheet( + options = state.effectiveFromOptions, + selected = state.effectiveFrom, + monthLabels = monthLabels, + onSelect = onEffectiveFromSelected, + onDismiss = onEffectiveFromPickerDismiss, + ) + } + if (state.isDatePickerVisible) { DatePickerSheet( initialEpochMillis = state.entryDate.atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds(), @@ -335,6 +468,38 @@ internal fun AddEntryScreen( ) } + if (state.isRepeatEditorVisible) { + RepeatEditorScreen( + state = state, + monthLabels = monthLabels, + onFrequencyChange = onRepeatFrequencyChange, + onIntervalChange = onRepeatIntervalChange, + onStartDateClick = onRepeatStartPickerOpen, + onEndChange = onRepeatEndChange, + onEndDateClick = onRepeatEndPickerOpen, + ) + } + + if (state.isRepeatStartPickerVisible) { + DatePickerSheet( + initialEpochMillis = + state.repeatStartDate.atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds(), + onDismiss = onRepeatStartPickerDismiss, + onConfirm = { millis -> onRepeatStartDateChange(millis.toUtcDate()) }, + ) + } + + if (state.isRepeatEndPickerVisible) { + DatePickerSheet( + initialEpochMillis = + ((state.repeatEnd as? RecurringEnd.Until)?.date ?: state.repeatStartDate) + .atStartOfDayIn(TimeZone.UTC) + .toEpochMilliseconds(), + onDismiss = onRepeatEndPickerDismiss, + onConfirm = { millis -> onRepeatEndChange(RecurringEnd.Until(millis.toUtcDate())) }, + ) + } + if (state.isSplitEditorVisible) { SplitEditorScreen( state = state, @@ -403,6 +568,7 @@ private fun EntryKindToggle( when (kind) { EntryKind.EXPENSE -> stringResource(Res.string.add_entry_kind_expense) EntryKind.INCOME -> stringResource(Res.string.add_entry_kind_income) + EntryKind.SETTLEMENT -> stringResource(Res.string.add_entry_kind_settlement) }, ) }, @@ -638,6 +804,24 @@ internal fun FieldRow( } } +/** + * The display name for any participant the form references. Resolved through the wider map rather + * than the member list, so someone removed from the group since the entry was written is still + * nameable instead of rendering blank. + */ +@Composable +private fun participantDisplay( + state: AddEntryState, + userId: String, +): String { + val participant = state.participantsById[userId] + return when { + participant == null -> stringResource(Res.string.expense_detail_removed_member) + participant.userId == state.currentUserId -> stringResource(Res.string.add_entry_paid_by_you) + else -> participant.username + } +} + @Composable private fun paidByDisplay(state: AddEntryState): String { // Resolved through the wider map, not the member list: the payer of an edited entry may have @@ -790,3 +974,58 @@ internal fun DatePickerSheet( /** Gap between the hero amount and its currency symbol, on whichever side the locale puts it. */ private val SYMBOL_GAP = 4.dp + +/** + * Picks which upcoming occurrence a schedule edit takes effect from. + * + * A list rather than a date picker because the server only accepts a date the current schedule + * actually produces — offering a calendar would mostly offer dates it will refuse. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun EffectiveFromSheet( + options: List, + selected: LocalDate?, + monthLabels: List, + onSelect: (LocalDate) -> Unit, + onDismiss: () -> Unit, +) { + ModalBottomSheet(onDismissRequest = onDismiss) { + Column(modifier = Modifier.padding(horizontal = 16.dp).padding(bottom = 24.dp)) { + Text( + text = stringResource(Res.string.add_entry_effective_from_label), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(bottom = 12.dp), + ) + options.forEach { option -> + EffectiveFromRow( + label = formatEntryDate(option, monthLabels), + selected = option == selected, + onClick = { onSelect(option) }, + ) + } + } + } +} + +/** Date pickers hand back epoch millis; the form works in calendar dates. */ +private fun Long.toUtcDate(): LocalDate = + Instant + .fromEpochMilliseconds(this) + .toLocalDateTime(TimeZone.UTC) + .date + +@Composable +private fun EffectiveFromRow( + label: String, + selected: Boolean, + onClick: () -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(vertical = 4.dp), + ) { + RadioButton(selected = selected, onClick = onClick) + Text(text = label, style = MaterialTheme.typography.bodyLarge) + } +} diff --git a/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/addentry/AddEntryState.kt b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/addentry/AddEntryState.kt index 98333ee0..ddf6fb9e 100644 --- a/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/addentry/AddEntryState.kt +++ b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/addentry/AddEntryState.kt @@ -4,6 +4,8 @@ import androidx.compose.foundation.text.input.TextFieldState import de.tabmates.features.tabgroup.domain.models.Currency import de.tabmates.features.tabgroup.domain.models.GroupParticipant import de.tabmates.features.tabgroup.domain.models.SplitType +import de.tabmates.features.tabgroup.domain.recurring.RecurrenceFrequency +import de.tabmates.features.tabgroup.domain.recurring.RecurringEnd import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime @@ -50,6 +52,8 @@ data class AddEntryState( val titleTextState: TextFieldState = TextFieldState(), val descriptionTextState: TextFieldState = TextFieldState(), val paidByUserId: String = "", + /** Settlements only: who received the money. Must differ from [paidByUserId]. */ + val receivedByUserId: String = "", val splitType: SplitType = SplitType.EQUAL, val splitInputs: List = emptyList(), val entryDate: LocalDate = @@ -58,12 +62,76 @@ data class AddEntryState( .toLocalDateTime(TimeZone.currentSystemDefault()) .date, val isPaidByPickerVisible: Boolean = false, + val isReceivedByPickerVisible: Boolean = false, val isSplitEditorVisible: Boolean = false, val isDatePickerVisible: Boolean = false, + // --- repeat editor --- + // Held as separate fields rather than a nullable RepeatConfig so the editor keeps the interval, + // start date and end rule while the user flips through "Never" and back. [repeat] assembles + // them, and is null exactly when the entry does not repeat. + val repeatFrequency: RecurrenceFrequency? = null, + val repeatInterval: Int = 1, + val repeatStartDate: LocalDate = + Clock.System + .now() + .toLocalDateTime(TimeZone.currentSystemDefault()) + .date, + val repeatEnd: RecurringEnd = RecurringEnd.Never, + val isRepeatEditorVisible: Boolean = false, + val isRepeatStartPickerVisible: Boolean = false, + val isRepeatEndPickerVisible: Boolean = false, + /** + * Schedule management needs a connection. Unlike an entry, a schedule is a standing instruction + * to write into other people's ledgers, so it is not queued offline — the repeat controls are + * disabled instead of silently deferring. + */ + val isOnline: Boolean = true, + /** Set when the screen is editing a schedule rather than an entry. */ + val editingSeriesId: String? = null, + /** + * The occurrence a schedule edit takes effect from. The server only accepts a future date the + * current schedule actually produces, so these are offered as a list rather than a free picker. + */ + val effectiveFromOptions: List = emptyList(), + val effectiveFrom: LocalDate? = null, + val isEffectiveFromPickerVisible: Boolean = false, ) { + /** + * How the entry repeats, or null for a one-off. Non-null makes the form save a recurring + * schedule *instead of* an entry — the server writes the first occurrence itself, so saving both + * would book the same thing twice. + */ + val repeat: RepeatConfig? + get() = + repeatFrequency?.let { frequency -> + RepeatConfig( + frequency = frequency, + interval = repeatInterval, + startDate = repeatStartDate, + end = repeatEnd, + ) + } + /** True when the chosen expense currency differs from the group's base currency. */ val isForeignCurrency: Boolean get() = baseCurrencyCode.isNotEmpty() && entryCurrencyCode != baseCurrencyCode + + val isSettlement: Boolean + get() = entryKind == EntryKind.SETTLEMENT + + val isEditingSeries: Boolean + get() = editingSeriesId != null + + /** Splits only exist for expenses and incomes; a settlement moves a fixed amount one way. */ + val hasSplits: Boolean + get() = !isSettlement + + /** + * Whether the repeat controls can be touched. Editing an existing one-off entry cannot turn it + * into a schedule — the entry is already written, and the server has no path that converts one. + */ + val canEditRepeat: Boolean + get() = isOnline && (!isEditing || isEditingSeries) } data class ParticipantSplitInput( diff --git a/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/addentry/AddEntryViewModel.kt b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/addentry/AddEntryViewModel.kt index e5daf4f9..4042ff98 100644 --- a/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/addentry/AddEntryViewModel.kt +++ b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/addentry/AddEntryViewModel.kt @@ -21,8 +21,18 @@ import de.tabmates.features.tabgroup.domain.currency.ExchangeRateRepository import de.tabmates.features.tabgroup.domain.group.GroupRepository import de.tabmates.features.tabgroup.domain.models.SplitType import de.tabmates.features.tabgroup.domain.models.TabEntry +import de.tabmates.features.tabgroup.domain.models.TabEntrySplit import de.tabmates.features.tabgroup.domain.models.referencedParticipantIds +import de.tabmates.features.tabgroup.domain.recurring.NewRecurringTemplateSplit +import de.tabmates.features.tabgroup.domain.recurring.RecurrenceFrequency +import de.tabmates.features.tabgroup.domain.recurring.RecurringEnd +import de.tabmates.features.tabgroup.domain.recurring.RecurringEntryType +import de.tabmates.features.tabgroup.domain.recurring.RecurringOccurrenceCalculator +import de.tabmates.features.tabgroup.domain.recurring.RecurringSeriesRepository +import de.tabmates.features.tabgroup.domain.recurring.RecurringTemplate +import de.tabmates.features.tabgroup.domain.sync.ConnectionStatusRepository import de.tabmates.features.tabgroup.domain.tabentry.NewTabEntrySplit +import de.tabmates.features.tabgroup.domain.tabentry.SplitResolver import de.tabmates.features.tabgroup.domain.tabentry.TabEntryRepository import de.tabmates.features.tabgroup.presentation.navigation.creategroup.CurrencyPickerUiState import de.tabmates.features.tabgroup.presentation.navigation.creategroup.buildCurrencyPickerState @@ -33,11 +43,14 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.WhileSubscribed import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime import org.koin.core.annotation.InjectedParam @@ -45,32 +58,56 @@ import org.koin.core.annotation.KoinViewModel import tabmatesapp.features.tabgroup.presentation.generated.resources.Res import tabmatesapp.features.tabgroup.presentation.generated.resources.add_entry_error_amount_required import tabmatesapp.features.tabgroup.presentation.generated.resources.add_entry_error_description_too_long +import tabmatesapp.features.tabgroup.presentation.generated.resources.add_entry_error_effective_from_required import tabmatesapp.features.tabgroup.presentation.generated.resources.add_entry_error_no_splits import tabmatesapp.features.tabgroup.presentation.generated.resources.add_entry_error_paid_by_required +import tabmatesapp.features.tabgroup.presentation.generated.resources.add_entry_error_received_by_required +import tabmatesapp.features.tabgroup.presentation.generated.resources.add_entry_error_same_payer_and_receiver import tabmatesapp.features.tabgroup.presentation.generated.resources.add_entry_error_split_total_mismatch import tabmatesapp.features.tabgroup.presentation.generated.resources.add_entry_error_title_required import tabmatesapp.features.tabgroup.presentation.generated.resources.add_entry_error_title_too_long import kotlin.math.abs +import kotlin.time.Clock import kotlin.time.Duration.Companion.seconds import kotlin.time.Instant +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid @KoinViewModel class AddEntryViewModel( @InjectedParam private val groupId: String, @InjectedParam private val entryId: String, + @InjectedParam private val seriesId: String, private val tabEntryRepository: TabEntryRepository, + private val recurringSeriesRepository: RecurringSeriesRepository, + connectionStatusRepository: ConnectionStatusRepository, private val groupRepository: GroupRepository, private val currencyRepository: CurrencyRepository, private val exchangeRateRepository: ExchangeRateRepository, currentAccount: CurrentAccount, private val numberSymbols: NumberSymbols, ) : ViewModel() { - private val isEditing = entryId.isNotBlank() + private val isEditingSeries = seriesId.isNotBlank() + + /** True only for an existing *entry* — the one case that has a row to load and update. */ + private val isEditingEntry = entryId.isNotBlank() + + /** + * True while the form is bound to something that already exists, entry or schedule. Locks + * the kind toggle in both cases: the server has separate update paths per entry type, and a + * series' type is fixed for its whole life. + */ + private val isEditing = isEditingEntry || isEditingSeries private val currentUserId = currentAccount.userId().orEmpty() private val _state = MutableStateFlow( - AddEntryState(groupId = groupId, currentUserId = currentUserId, isEditing = isEditing), + AddEntryState( + groupId = groupId, + currentUserId = currentUserId, + isEditing = isEditing, + editingSeriesId = seriesId.takeIf { isEditingSeries }, + ), ) private var hasLoadedInitialData = false @@ -87,6 +124,15 @@ class AddEntryViewModel( initialValue = _state.value, ) + init { + // Schedules are written straight to the server with no outbox behind them, so the repeat + // controls follow the live connection instead of queueing a standing instruction on a + // device that may not be online again for days. + connectionStatusRepository.isConnected + .onEach { connected -> _state.update { it.copy(isOnline = connected) } } + .launchIn(viewModelScope) + } + private val eventChannel = Channel() val events = eventChannel.receiveAsFlow() @@ -149,27 +195,67 @@ class AddEntryViewModel( val activeMembers = group?.participants.orEmpty().toList() // Edit mode loads an existing split-carrying entry (expense OR income); its kind is // then fixed for the rest of the edit. Create mode starts from the toggle default. + // Gated on the entry id alone — a schedule edit carries no entry to look up. val existing = - if (isEditing) { + if (isEditingEntry) { tabEntryRepository.getTabEntryById(entryId).first() } else { null } + val series = + if (isEditingSeries) { + recurringSeriesRepository.getSeriesById(seriesId).first() + } else { + null + } val existingKind = - when (existing) { - is TabEntry.Income -> EntryKind.INCOME - else -> EntryKind.EXPENSE + when { + series != null -> { + when (series.entryType) { + RecurringEntryType.EXPENSE -> EntryKind.EXPENSE + RecurringEntryType.INCOME -> EntryKind.INCOME + RecurringEntryType.SETTLEMENT -> EntryKind.SETTLEMENT + } + } + + existing is TabEntry.Income -> { + EntryKind.INCOME + } + + existing is TabEntry.Settlement -> { + EntryKind.SETTLEMENT + } + + else -> { + EntryKind.EXPENSE + } } + // The server only accepts an edit anchored on a future date the schedule actually + // produces, so the picker is built from the schedule itself rather than a calendar. + val effectiveFromOptions = + series + ?.let { + RecurringOccurrenceCalculator.upcomingOccurrences( + rule = it.rule, + after = todayUtc(), + limit = EFFECTIVE_FROM_OPTION_COUNT, + skippedDates = it.skippedOccurrenceDates, + ) + }.orEmpty() + // A schedule's splits live on its template, not on any entry. Reading only [existing] + // here left a series edit with no splits at all, which unchecks every row below and + // saves the schedule with nobody on it. val existingSplits = - when (existing) { - is TabEntry.Expense -> existing.splits - is TabEntry.Income -> existing.splits - else -> emptyList() - } + series?.rule?.splits?.map { SplitSeed(it.participantId, it.splitType, it.value) } + ?: when (existing) { + is TabEntry.Expense -> existing.splits.map { it.toSeed() } + is TabEntry.Income -> existing.splits.map { it.toSeed() } + else -> emptyList() + } val baseCurrencyCode = group?.defaultCurrencyCode.orEmpty() val baseCurrency = currencies.firstOrNull { it.code == baseCurrencyCode } // Expense currency defaults to the group's base; an edited expense keeps its own. - val entryCurrencyCode = existing?.currencyCode ?: baseCurrencyCode + val entryCurrencyCode = series?.rule?.currencyCode ?: existing?.currencyCode ?: baseCurrencyCode val entryCurrency = currencies.firstOrNull { it.code == entryCurrencyCode } val decimals = entryCurrency?.decimalDigits ?: 2 val defaultPaidBy = @@ -181,9 +267,15 @@ class AddEntryViewModel( // They are not in [activeMembers], so building the split rows from membership alone // would drop their splits on save — the entry would silently lose money. Resolve them // from the global participant table instead and keep their rows editable. + // A parked schedule is one naming somebody who left, and this form is where it gets + // repaired — so the template's own people have to be resolvable too, not just an + // entry's. val activeMemberIds = activeMembers.map { it.userId }.toSet() - val formerParticipantIds = - listOfNotNull(existing).referencedParticipantIds() - activeMemberIds + val referencedIds = + listOfNotNull(existing).referencedParticipantIds() + + existingSplits.map { it.participantId } + + listOfNotNull(series?.rule?.paidByUserId, series?.rule?.receivedByUserId) + val formerParticipantIds = referencedIds - activeMemberIds val formerParticipants = if (formerParticipantIds.isEmpty()) { emptyList() @@ -205,7 +297,17 @@ class AddEntryViewModel( participantsById = (activeMembers + formerParticipants).associateBy { participant -> participant.userId }, formerParticipantIds = formerParticipantIds, - paidByUserId = defaultPaidBy, + paidByUserId = series?.rule?.paidByUserId ?: defaultPaidBy, + receivedByUserId = + series?.rule?.receivedByUserId + ?: (existing as? TabEntry.Settlement)?.receivedByUserId + ?: activeMembers.firstOrNull { m -> m.userId != defaultPaidBy }?.userId.orEmpty(), + repeatFrequency = series?.rule?.frequency, + repeatInterval = series?.rule?.interval ?: 1, + repeatStartDate = series?.rule?.startDate ?: it.repeatStartDate, + repeatEnd = series?.rule?.end ?: it.repeatEnd, + effectiveFromOptions = effectiveFromOptions, + effectiveFrom = effectiveFromOptions.firstOrNull(), entryCurrencyCode = entryCurrencyCode, entryCurrencySymbol = entryCurrency?.nativeSymbol ?: entryCurrencyCode, entryCurrencyDecimalDigits = decimals, @@ -215,16 +317,17 @@ class AddEntryViewModel( supportedCurrencies = currencies, ratesByCurrency = rates.associate { it.currencyCode to it.rateToBase }, ratesLastUpdatedAt = rates.maxOfOrNull { rate -> rate.lastUpdatedAt }, - originalCurrencyCode = existing?.currencyCode.orEmpty(), - originalExchangeRate = existing?.exchangeRate, - entryDate = existing?.entryDate ?: it.entryDate, + originalCurrencyCode = series?.rule?.currencyCode ?: existing?.currencyCode.orEmpty(), + originalExchangeRate = series?.rule?.exchangeRate ?: existing?.exchangeRate, + entryDate = effectiveFromOptions.firstOrNull() ?: existing?.entryDate ?: it.entryDate, splitType = existingSplits.firstOrNull()?.splitType ?: it.splitType, - titleTextState = TextFieldState(existing?.title.orEmpty()), - descriptionTextState = TextFieldState(existing?.description.orEmpty()), + titleTextState = TextFieldState(series?.rule?.title ?: existing?.title.orEmpty()), + descriptionTextState = + TextFieldState(series?.rule?.description ?: existing?.description.orEmpty()), amountTextState = TextFieldState( - existing - ?.let { e -> formatAmountForInput(e.amount, decimals, numberSymbols) } + (series?.rule?.amount ?: existing?.amount) + ?.let { amount -> formatAmountForInput(amount, decimals, numberSymbols) } .orEmpty(), ), splitInputs = @@ -276,6 +379,128 @@ class AddEntryViewModel( _state.update { it.copy(paidByUserId = userId, isPaidByPickerVisible = false) } } + fun onReceivedByClick() { + _state.update { it.copy(isReceivedByPickerVisible = true) } + } + + fun onReceivedByPickerDismiss() { + _state.update { it.copy(isReceivedByPickerVisible = false) } + } + + fun onReceivedBySelected(userId: String) { + _state.update { it.copy(receivedByUserId = userId, isReceivedByPickerVisible = false) } + } + + /** + * Opens the repeat editor, seeding its start date from the entry's own date. + * + * Only when that date is still in the future: a schedule may not start in the past, so + * inheriting a back-dated entry's date would open the editor already invalid. + */ + fun onRepeatOpen() { + _state.update { current -> + val today = todayUtc() + current.copy( + isRepeatEditorVisible = true, + repeatStartDate = + if (current.repeatFrequency == null) { + maxOf(current.entryDate, today) + } else { + current.repeatStartDate + }, + ) + } + } + + /** + * Closes the repeat editor and lines the entry date up with the schedule. + * + * For a schedule the two mean the same thing — the first occurrence — so leaving them apart + * would show a date the series is never going to produce. + */ + fun onRepeatDismiss() { + _state.update { current -> + current.copy( + isRepeatEditorVisible = false, + entryDate = if (current.repeatFrequency != null) current.repeatStartDate else current.entryDate, + ) + } + } + + /** Null clears the repeat, which is what makes the form save a one-off entry again. */ + fun onRepeatFrequencyChange(frequency: RecurrenceFrequency?) { + _state.update { it.copy(repeatFrequency = frequency) } + } + + fun onRepeatIntervalChange(interval: Int) { + _state.update { it.copy(repeatInterval = interval.coerceAtLeast(1)) } + } + + /** Clamped to today: the server refuses a schedule that reaches back into the past. */ + fun onRepeatStartDateChange(date: LocalDate) { + _state.update { + it.copy(repeatStartDate = maxOf(date, todayUtc()), isRepeatStartPickerVisible = false) + } + } + + /** + * The date picker offers the whole calendar, including days before the schedule starts. An end + * that early describes a series that produces nothing at all, so it is pulled forward to the + * start date — one occurrence — rather than saved as written. + */ + fun onRepeatEndChange(end: RecurringEnd) { + _state.update { current -> + val clamped = + when (end) { + is RecurringEnd.Until -> RecurringEnd.Until(maxOf(end.date, current.repeatStartDate)) + else -> end + } + current.copy(repeatEnd = clamped, isRepeatEndPickerVisible = false) + } + } + + fun onRepeatStartPickerOpen() { + _state.update { it.copy(isRepeatStartPickerVisible = true) } + } + + fun onRepeatStartPickerDismiss() { + _state.update { it.copy(isRepeatStartPickerVisible = false) } + } + + fun onRepeatEndPickerOpen() { + _state.update { it.copy(isRepeatEndPickerVisible = true) } + } + + fun onRepeatEndPickerDismiss() { + _state.update { it.copy(isRepeatEndPickerVisible = false) } + } + + fun onEffectiveFromClick() { + _state.update { it.copy(isEffectiveFromPickerVisible = true) } + } + + fun onEffectiveFromPickerDismiss() { + _state.update { it.copy(isEffectiveFromPickerVisible = false) } + } + + /** + * Picks the occurrence a schedule edit takes effect from. + * + * The new template's start date has to equal it — the server rejects any other pairing, because + * an edit anchored anywhere else silently re-times the whole series to whichever day the edit + * was made. + */ + fun onEffectiveFromSelected(date: LocalDate) { + _state.update { current -> + current.copy( + effectiveFrom = date, + entryDate = date, + repeatStartDate = date, + isEffectiveFromPickerVisible = false, + ) + } + } + fun onSplitOpen() { _state.update { it.copy(isSplitEditorVisible = true) } } @@ -357,14 +582,61 @@ class AddEntryViewModel( emitError(UiText.Resource(Res.string.add_entry_error_paid_by_required)) return } - val splits = buildSplits(current, amount) ?: return + if (current.isSettlement) { + if (current.receivedByUserId.isBlank()) { + emitError(UiText.Resource(Res.string.add_entry_error_received_by_required)) + return + } + // The server refuses this too, but a settlement from someone to themselves is a typo + // worth catching on the form rather than as a round trip. + if (current.receivedByUserId == current.paidByUserId) { + emitError(UiText.Resource(Res.string.add_entry_error_same_payer_and_receiver)) + return + } + } + // Settlements carry no splits; the other two must reconcile to the total. + val splits = if (current.isSettlement) emptyList() else buildSplits(current, amount) ?: return val exchangeRate = resolveExchangeRate(current) viewModelScope.launch { _state.update { it.copy(isSubmitting = true) } + if (current.repeat != null) { + saveSeries(current, title, description, amount, splits, exchangeRate) + return@launch + } val isIncome = current.entryKind == EntryKind.INCOME + val isSettlement = current.isSettlement val result = when { + isEditing && isSettlement -> { + tabEntryRepository.updateSettlement( + tabEntryId = entryId, + groupId = current.groupId, + title = title, + description = description, + amount = amount, + currencyCode = current.entryCurrencyCode, + exchangeRate = exchangeRate, + paidByUserId = current.paidByUserId, + receivedByUserId = current.receivedByUserId, + entryDate = current.entryDate, + ) + } + + isSettlement -> { + tabEntryRepository.createSettlement( + groupId = current.groupId, + title = title, + description = description, + amount = amount, + currencyCode = current.entryCurrencyCode, + exchangeRate = exchangeRate, + paidByUserId = current.paidByUserId, + receivedByUserId = current.receivedByUserId, + entryDate = current.entryDate, + ) + } + isEditing && isIncome -> { tabEntryRepository.updateIncome( tabEntryId = entryId, @@ -434,6 +706,122 @@ class AddEntryViewModel( } } + /** + * Writes a recurring schedule instead of an entry. + * + * Creating a schedule does **not** also write today's entry: the server owns occurrence + * generation, and it will write the first one itself on its next sweep. Doing both here would + * book the same rent twice, once by hand and once by the sweep, in everybody's ledger. + * + * [seriesId] is client-generated so a create retried after a dropped response resolves to the + * same schedule rather than a second one quietly writing the same amount every month. + */ + private suspend fun saveSeries( + current: AddEntryState, + title: String, + description: String, + amount: Double, + splits: List, + exchangeRate: Double?, + ) { + val repeat = current.repeat ?: return + val template = + RecurringTemplate( + entryType = + when (current.entryKind) { + EntryKind.EXPENSE -> RecurringEntryType.EXPENSE + EntryKind.INCOME -> RecurringEntryType.INCOME + EntryKind.SETTLEMENT -> RecurringEntryType.SETTLEMENT + }, + title = title, + description = description, + amount = amount, + currencyCode = current.entryCurrencyCode, + exchangeRate = exchangeRate, + paidByUserId = current.paidByUserId, + receivedByUserId = current.receivedByUserId.takeIf { current.isSettlement }, + // The server stores the resolved amount alongside the rule so every occurrence + // copies identical shares, rather than re-resolving a percentage against a total + // that could drift. Same resolver the one-off path uses, so the two cannot diverge. + splits = + splits.zip(SplitResolver.resolveAmounts(splits, amount)) { split, resolved -> + NewRecurringTemplateSplit( + participantId = split.participantId, + splitType = split.splitType, + value = split.value, + resolvedAmount = resolved, + ) + }, + frequency = repeat.frequency, + interval = repeat.interval, + startDate = repeat.startDate, + end = repeat.end, + ) + + val seriesId = current.editingSeriesId + val result = + if (seriesId != null) { + // An edit has to anchor on an occurrence the current schedule actually produces; + // the picker only offers such dates, and the template starts on the same one. + val effectiveFrom = current.effectiveFrom + if (effectiveFrom == null) { + _state.update { it.copy(isSubmitting = false) } + emitError(UiText.Resource(Res.string.add_entry_error_effective_from_required)) + return + } + recurringSeriesRepository.updateSeries( + seriesId = seriesId, + effectiveFrom = effectiveFrom, + template = template.copy(startDate = effectiveFrom), + ) + } else { + recurringSeriesRepository.createSeries( + seriesId = generateSeriesId(), + groupId = current.groupId, + template = template, + ) + } + + result + .onSuccess { + _state.update { it.copy(isSubmitting = false) } + eventChannel.send(AddEntryEvent.EntrySaved) + }.onFailure { error -> + _state.update { it.copy(isSubmitting = false) } + eventChannel.send(AddEntryEvent.Error(error.toUiText())) + } + } + + /** + * The id a new schedule is created under, minted here so a retried create cannot produce a + * second schedule — the server treats it as the idempotency key. + */ + @OptIn(ExperimentalUuidApi::class) + private fun generateSeriesId(): String = Uuid.random().toString() + + /** + * Today as the scheduler counts it. + * + * UTC, not the device's zone: this clamps a schedule's start date and anchors the occurrences an + * edit may take effect from, and the server measures both against its own UTC day. West of UTC + * the local date runs a day behind, which let the form offer a start date the server then + * rejected as being in the past. + */ + private fun todayUtc(): LocalDate = + Clock.System + .now() + .toLocalDateTime(TimeZone.UTC) + .date + + /** The split fields the form seeds its rows from, whichever of entry or template they came from. */ + private data class SplitSeed( + val participantId: String, + val splitType: SplitType, + val value: Double, + ) + + private fun TabEntrySplit.toSeed() = SplitSeed(participantId, splitType, value) + /** * The rate locked onto the expense at save time (group base currency per 1 unit of the * expense currency) — the same value the rate hint on screen shows, so what the user sees is @@ -549,6 +937,9 @@ class AddEntryViewModel( } private companion object { + /** Upcoming occurrences offered as the anchor for a "this and future" edit. */ + const val EFFECTIVE_FROM_OPTION_COUNT = 6 + private const val MAX_TITLE_LENGTH = 255 private const val MAX_DESCRIPTION_LENGTH = 255 } diff --git a/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/addentry/EntryKind.kt b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/addentry/EntryKind.kt index 8fb6bf0e..37523da0 100644 --- a/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/addentry/EntryKind.kt +++ b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/addentry/EntryKind.kt @@ -1,11 +1,19 @@ package de.tabmates.features.tabgroup.presentation.navigation.addentry /** - * The kind of split-carrying tab entry the add/edit + detail screens operate on. Picked via the - * on-screen toggle in create mode and fixed to the loaded entry's kind in edit mode. Settlements - * are handled by their own dedicated screens and are not represented here. + * The kind of tab entry the add/edit + detail screens operate on. + * + * Picked via the on-screen toggle while creating and fixed to the loaded entry's kind while editing, + * because an entry's type is not something the server lets change — and a recurring series' type is + * fixed for its whole life. */ enum class EntryKind { EXPENSE, INCOME, + + /** + * A payment from one member to another. Carries a receiver instead of splits, which is why the + * form hides the split editor and shows a second person picker for this kind. + */ + SETTLEMENT, } diff --git a/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/addentry/RepeatConfig.kt b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/addentry/RepeatConfig.kt new file mode 100644 index 00000000..ebaf0d8a --- /dev/null +++ b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/addentry/RepeatConfig.kt @@ -0,0 +1,41 @@ +package de.tabmates.features.tabgroup.presentation.navigation.addentry + +import de.tabmates.features.tabgroup.domain.recurring.RecurrenceFrequency +import de.tabmates.features.tabgroup.domain.recurring.RecurringEnd +import kotlinx.datetime.LocalDate + +/** + * The repeat half of the entry form, assembled from the editor's live fields. + * + * Null on the state means "does not repeat", which is what makes the form save an ordinary one-off + * entry instead of a schedule. + */ +data class RepeatConfig( + val frequency: RecurrenceFrequency, + /** Repeat every N periods of [frequency]; 1 means every period. */ + val interval: Int = 1, + /** + * First occurrence, and the anchor every later date is computed from. + * + * The server refuses a start date in the past — a schedule may not reach back and invent entries + * nobody agreed to — so the form clamps this to today or later rather than relying on the one + * day of slack the server allows for clock skew. + */ + val startDate: LocalDate, + val end: RecurringEnd = RecurringEnd.Never, +) + +/** What the "Ends" section of the repeat editor is currently set to. */ +enum class RepeatEndKind { + NEVER, + ON_DATE, + AFTER_COUNT, +} + +val RecurringEnd.kind: RepeatEndKind + get() = + when (this) { + RecurringEnd.Never -> RepeatEndKind.NEVER + is RecurringEnd.Until -> RepeatEndKind.ON_DATE + is RecurringEnd.Count -> RepeatEndKind.AFTER_COUNT + } diff --git a/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/addentry/RepeatEditorScreen.kt b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/addentry/RepeatEditorScreen.kt new file mode 100644 index 00000000..337872c9 --- /dev/null +++ b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/addentry/RepeatEditorScreen.kt @@ -0,0 +1,461 @@ +package de.tabmates.features.tabgroup.presentation.navigation.addentry + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import de.tabmates.core.designsystem.spacer.HorizontalSpacer +import de.tabmates.core.designsystem.spacer.VerticalSpacer +import de.tabmates.core.designsystem.text.SectionLabel +import de.tabmates.features.tabgroup.domain.recurring.RecurrenceFrequency +import de.tabmates.features.tabgroup.domain.recurring.RecurringEnd +import de.tabmates.features.tabgroup.domain.recurring.RecurringOccurrenceCalculator +import de.tabmates.features.tabgroup.domain.recurring.RecurringRule +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.minus +import org.jetbrains.compose.resources.stringResource +import tabmatesapp.features.tabgroup.presentation.generated.resources.Res +import tabmatesapp.features.tabgroup.presentation.generated.resources.add_entry_repeat_daily +import tabmatesapp.features.tabgroup.presentation.generated.resources.add_entry_repeat_decrease_cd +import tabmatesapp.features.tabgroup.presentation.generated.resources.add_entry_repeat_ends_after_count +import tabmatesapp.features.tabgroup.presentation.generated.resources.add_entry_repeat_ends_count_suffix +import tabmatesapp.features.tabgroup.presentation.generated.resources.add_entry_repeat_ends_label +import tabmatesapp.features.tabgroup.presentation.generated.resources.add_entry_repeat_ends_never +import tabmatesapp.features.tabgroup.presentation.generated.resources.add_entry_repeat_ends_on_date +import tabmatesapp.features.tabgroup.presentation.generated.resources.add_entry_repeat_every_label +import tabmatesapp.features.tabgroup.presentation.generated.resources.add_entry_repeat_frequency_label +import tabmatesapp.features.tabgroup.presentation.generated.resources.add_entry_repeat_increase_cd +import tabmatesapp.features.tabgroup.presentation.generated.resources.add_entry_repeat_interval_label +import tabmatesapp.features.tabgroup.presentation.generated.resources.add_entry_repeat_monthly +import tabmatesapp.features.tabgroup.presentation.generated.resources.add_entry_repeat_never +import tabmatesapp.features.tabgroup.presentation.generated.resources.add_entry_repeat_preview_label +import tabmatesapp.features.tabgroup.presentation.generated.resources.add_entry_repeat_replaces_entry_note +import tabmatesapp.features.tabgroup.presentation.generated.resources.add_entry_repeat_starts_label +import tabmatesapp.features.tabgroup.presentation.generated.resources.add_entry_repeat_weekday_note +import tabmatesapp.features.tabgroup.presentation.generated.resources.add_entry_repeat_weekly +import tabmatesapp.features.tabgroup.presentation.generated.resources.add_entry_repeat_yearly +import tabmatesapp.features.tabgroup.presentation.generated.resources.repeat_unit_day +import tabmatesapp.features.tabgroup.presentation.generated.resources.repeat_unit_days +import tabmatesapp.features.tabgroup.presentation.generated.resources.repeat_unit_month +import tabmatesapp.features.tabgroup.presentation.generated.resources.repeat_unit_months +import tabmatesapp.features.tabgroup.presentation.generated.resources.repeat_unit_week +import tabmatesapp.features.tabgroup.presentation.generated.resources.repeat_unit_weeks +import tabmatesapp.features.tabgroup.presentation.generated.resources.repeat_unit_year +import tabmatesapp.features.tabgroup.presentation.generated.resources.repeat_unit_years + +/** + * Full-screen editor for how an entry repeats, mirroring the split editor: an in-screen sub-view + * rather than a nav destination, editing the form state live with no separate confirm step. + * + * A bottom sheet was the obvious shape and the wrong one — the frequency list, an interval stepper, + * a start date and three end options do not fit one without scrolling a sheet inside a sheet, and + * the preview below is what makes a schedule legible before it is saved. + */ +@Composable +internal fun RepeatEditorScreen( + state: AddEntryState, + monthLabels: List, + onFrequencyChange: (RecurrenceFrequency?) -> Unit, + onIntervalChange: (Int) -> Unit, + onStartDateClick: () -> Unit, + onEndChange: (RecurringEnd) -> Unit, + onEndDateClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = + modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + // One rail for the whole screen, owned here rather than by each child — the same wrapper + // the form that opens this editor uses, so the two read as one flow instead of two screens. + // FieldRow below carries no padding of its own by design; without this it drew its outline + // hard against both screen edges. + Column( + modifier = + Modifier + .widthIn(max = 600.dp) + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + VerticalSpacer(8.dp) + SectionLabel( + text = stringResource(Res.string.add_entry_repeat_frequency_label), + fontWeight = FontWeight.SemiBold, + ) + RepeatOptionRow( + label = stringResource(Res.string.add_entry_repeat_never), + selected = state.repeatFrequency == null, + onClick = { onFrequencyChange(null) }, + ) + RecurrenceFrequency.entries.forEach { candidate -> + RepeatOptionRow( + label = candidate.label(), + selected = state.repeatFrequency == candidate, + onClick = { onFrequencyChange(candidate) }, + ) + } + + if (state.repeatFrequency != null) { + VerticalSpacer(8.dp) + HorizontalDivider() + VerticalSpacer(8.dp) + + SectionLabel( + text = stringResource(Res.string.add_entry_repeat_interval_label), + fontWeight = FontWeight.SemiBold, + ) + IntervalStepper( + frequency = state.repeatFrequency, + interval = state.repeatInterval, + onIntervalChange = onIntervalChange, + ) + + FieldRow( + label = stringResource(Res.string.add_entry_repeat_starts_label), + value = formatEntryDate(state.repeatStartDate, monthLabels), + onClick = onStartDateClick, + leadingIcon = null, + ) + + VerticalSpacer(8.dp) + SectionLabel( + text = stringResource(Res.string.add_entry_repeat_ends_label), + fontWeight = FontWeight.SemiBold, + ) + RepeatOptionRow( + label = stringResource(Res.string.add_entry_repeat_ends_never), + selected = state.repeatEnd.kind == RepeatEndKind.NEVER, + onClick = { onEndChange(RecurringEnd.Never) }, + ) + RepeatOptionRow( + label = stringResource(Res.string.add_entry_repeat_ends_on_date), + selected = state.repeatEnd.kind == RepeatEndKind.ON_DATE, + // Opening the picker is how this option gets a date at all, so selecting it and + // picking one are the same gesture rather than two. + onClick = onEndDateClick, + trailing = + (state.repeatEnd as? RecurringEnd.Until) + ?.let { formatEntryDate(it.date, monthLabels) }, + ) + RepeatOptionRow( + label = stringResource(Res.string.add_entry_repeat_ends_after_count), + selected = state.repeatEnd.kind == RepeatEndKind.AFTER_COUNT, + onClick = { onEndChange(RecurringEnd.Count(defaultEndCount(state.repeatEnd))) }, + ) + if (state.repeatEnd is RecurringEnd.Count) { + CountStepper( + count = state.repeatEnd.count, + onCountChange = { onEndChange(RecurringEnd.Count(it)) }, + ) + } + + VerticalSpacer(16.dp) + HorizontalDivider() + VerticalSpacer(8.dp) + RepeatPreview(state = state, monthLabels = monthLabels) + } + VerticalSpacer(32.dp) + } + } +} + +/** One-line summary of the current repeat setting, for the form row that opens this editor. */ +@Composable +internal fun repeatSummary(state: AddEntryState): String { + val frequency = state.repeatFrequency ?: return stringResource(Res.string.add_entry_repeat_never) + return if (state.repeatInterval == 1) { + frequency.label() + } else { + stringResource( + Res.string.add_entry_repeat_every_label, + state.repeatInterval, + frequency.unitLabel(state.repeatInterval), + ) + } +} + +/** + * The next few dates this schedule will actually produce. + * + * Worth the space: a monthly schedule anchored on the 31st lands on the 28th in February and back + * on the 31st in March, and no amount of label copy explains that as well as showing it. + */ +@Composable +private fun RepeatPreview( + state: AddEntryState, + monthLabels: List, +) { + val repeat = state.repeat ?: return + // Anchored one day before the start so the first occurrence is included: the calculator + // returns dates strictly after what it is given. Remembered because it walks the schedule slot + // by slot, and nothing about it changes between recompositions of the same config. + val dates = + remember(repeat) { + RecurringOccurrenceCalculator.upcomingOccurrences( + rule = repeat.toPreviewRule(), + after = repeat.startDate.minus(1, DateTimeUnit.DAY), + limit = PREVIEW_COUNT, + ) + } + + Column { + SectionLabel( + text = stringResource(Res.string.add_entry_repeat_preview_label), + fontWeight = FontWeight.SemiBold, + ) + VerticalSpacer(8.dp) + // Same size and spacing as the occurrence rows on the schedule's detail screen — this is + // the same list of dates, one screen earlier, so it should not read as a denser thing. + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + dates.forEach { date -> + Text( + text = formatEntryDate(date, monthLabels), + style = MaterialTheme.typography.bodyLarge, + ) + } + } + if (state.repeatFrequency == RecurrenceFrequency.WEEKLY) { + VerticalSpacer(16.dp) + RepeatNote(stringResource(Res.string.add_entry_repeat_weekday_note)) + } + VerticalSpacer(16.dp) + RepeatNote(stringResource(Res.string.add_entry_repeat_replaces_entry_note)) + } +} + +@Composable +private fun RepeatOptionRow( + label: String, + selected: Boolean, + onClick: () -> Unit, + trailing: String? = null, +) { + // The row is the whole target, and the button inside it is decoration — two separate click + // handlers would have a screen reader announce the same option twice. + // + // That is also why the height has to be stated here: Material only applies its 48dp minimum + // touch target on RadioButton's *clickable* branch, so passing `onClick = null` drops the floor + // and nothing else in this row puts one back. 56dp is Material's one-line list-item height, + // which is where the rest of the app's rows sit. + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = 56.dp) + .selectable(selected = selected, role = Role.RadioButton, onClick = onClick), + ) { + RadioButton(selected = selected, onClick = null) + HorizontalSpacer(8.dp) + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f), + ) + if (trailing != null) { + Text( + text = trailing, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun IntervalStepper( + frequency: RecurrenceFrequency, + interval: Int, + onIntervalChange: (Int) -> Unit, +) { + StepperRow( + label = stringResource(Res.string.add_entry_repeat_every_label, interval, frequency.unitLabel(interval)), + value = interval, + minValue = 1, + maxValue = MAX_INTERVAL, + onValueChange = onIntervalChange, + ) +} + +@Composable +private fun CountStepper( + count: Int, + onCountChange: (Int) -> Unit, +) { + StepperRow( + label = "$count ${stringResource(Res.string.add_entry_repeat_ends_count_suffix)}", + value = count, + minValue = 1, + maxValue = MAX_END_COUNT, + onValueChange = onCountChange, + ) +} + +/** + * The buttons are bare glyphs, so each carries its own description — a screen reader has nothing + * else to announce them by. + */ +@Composable +private fun StepperRow( + label: String, + value: Int, + minValue: Int, + maxValue: Int, + onValueChange: (Int) -> Unit, +) { + val decreaseLabel = stringResource(Res.string.add_entry_repeat_decrease_cd) + val increaseLabel = stringResource(Res.string.add_entry_repeat_increase_cd) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f), + ) + IconButton( + onClick = { onValueChange((value - 1).coerceAtLeast(minValue)) }, + enabled = value > minValue, + modifier = Modifier.semantics { contentDescription = decreaseLabel }, + ) { + Text(text = "−", style = MaterialTheme.typography.titleLarge) + } + Text( + text = value.toString(), + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 4.dp), + ) + IconButton( + onClick = { onValueChange((value + 1).coerceAtMost(maxValue)) }, + enabled = value < maxValue, + modifier = Modifier.semantics { contentDescription = increaseLabel }, + ) { + Text(text = "+", style = MaterialTheme.typography.titleLarge) + } + } +} + +@Composable +private fun RepeatNote(text: String) { + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} + +@Composable +private fun RecurrenceFrequency.label(): String = + when (this) { + RecurrenceFrequency.DAILY -> stringResource(Res.string.add_entry_repeat_daily) + RecurrenceFrequency.WEEKLY -> stringResource(Res.string.add_entry_repeat_weekly) + RecurrenceFrequency.MONTHLY -> stringResource(Res.string.add_entry_repeat_monthly) + RecurrenceFrequency.YEARLY -> stringResource(Res.string.add_entry_repeat_yearly) + } + +/** "day"/"days" etc., for the "Every N …" stepper label. */ +@Composable +private fun RecurrenceFrequency.unitLabel(count: Int): String = + stringResource( + when (this) { + RecurrenceFrequency.DAILY -> { + if (count == + 1 + ) { + Res.string.repeat_unit_day + } else { + Res.string.repeat_unit_days + } + } + + RecurrenceFrequency.WEEKLY -> { + if (count == + 1 + ) { + Res.string.repeat_unit_week + } else { + Res.string.repeat_unit_weeks + } + } + + RecurrenceFrequency.MONTHLY -> { + if (count == 1) Res.string.repeat_unit_month else Res.string.repeat_unit_months + } + + RecurrenceFrequency.YEARLY -> { + if (count == + 1 + ) { + Res.string.repeat_unit_year + } else { + Res.string.repeat_unit_years + } + } + }, + ) + +/** Keeps a previously chosen count when the option is re-selected, rather than resetting it. */ +private fun defaultEndCount(current: RecurringEnd): Int = + (current as? RecurringEnd.Count)?.count ?: DEFAULT_END_COUNT + +/** + * The config as a rule the occurrence calculator can walk. Only the schedule fields matter for a + * preview, so the template half is filled with placeholders that are never read. + */ +private fun RepeatConfig.toPreviewRule(): RecurringRule = + RecurringRule( + ruleId = "", + title = "", + description = "", + amount = 0.0, + currencyCode = "", + exchangeRate = null, + paidByUserId = "", + receivedByUserId = null, + splits = emptyList(), + frequency = frequency, + interval = interval, + startDate = startDate, + end = end, + ) + +private const val PREVIEW_COUNT = 4 +private const val DEFAULT_END_COUNT = 12 + +// Stepper ceilings, not a server contract — it states no bound. They exist so a held-down button +// cannot walk the value somewhere nobody meant it to go. +private const val MAX_INTERVAL = 99 +private const val MAX_END_COUNT = 999 diff --git a/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/editsettlement/EditSettlementEvent.kt b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/editsettlement/EditSettlementEvent.kt deleted file mode 100644 index c3210085..00000000 --- a/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/editsettlement/EditSettlementEvent.kt +++ /dev/null @@ -1,9 +0,0 @@ -package de.tabmates.features.tabgroup.presentation.navigation.editsettlement - -import de.tabmates.core.presentation.util.UiText - -sealed interface EditSettlementEvent { - data object SettlementSaved : EditSettlementEvent - - data class Error(val message: UiText) : EditSettlementEvent -} diff --git a/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/editsettlement/EditSettlementRoot.kt b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/editsettlement/EditSettlementRoot.kt deleted file mode 100644 index 6374f10a..00000000 --- a/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/editsettlement/EditSettlementRoot.kt +++ /dev/null @@ -1,158 +0,0 @@ -package de.tabmates.features.tabgroup.presentation.navigation.editsettlement - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation3.runtime.NavKey -import de.tabmates.core.designsystem.spacer.VerticalSpacer -import de.tabmates.core.designsystem.textfields.TabMatesTextField -import de.tabmates.core.presentation.navigation.TopBarActions -import de.tabmates.core.presentation.util.ObserveAsEvents -import de.tabmates.features.tabgroup.domain.models.GroupParticipant -import de.tabmates.features.tabgroup.presentation.components.rememberAmountInputTransformation -import de.tabmates.features.tabgroup.presentation.navigation.addentry.DatePickerSheet -import de.tabmates.features.tabgroup.presentation.navigation.addentry.FieldRow -import de.tabmates.features.tabgroup.presentation.navigation.addentry.formatEntryDate -import de.tabmates.features.tabgroup.presentation.navigation.addentry.rememberMonthAbbreviations -import kotlinx.datetime.TimeZone -import kotlinx.datetime.atStartOfDayIn -import org.jetbrains.compose.resources.stringResource -import org.koin.compose.viewmodel.koinViewModel -import org.koin.core.parameter.parametersOf -import tabmatesapp.features.tabgroup.presentation.generated.resources.Res -import tabmatesapp.features.tabgroup.presentation.generated.resources.add_entry_date_label -import tabmatesapp.features.tabgroup.presentation.generated.resources.add_entry_save -import tabmatesapp.features.tabgroup.presentation.generated.resources.expense_detail_removed_member -import tabmatesapp.features.tabgroup.presentation.generated.resources.ic_calendar -import tabmatesapp.features.tabgroup.presentation.generated.resources.settle_up_amount_dialog_subtitle -import tabmatesapp.features.tabgroup.presentation.generated.resources.settle_up_amount_label - -@Composable -fun EditSettlementRoot( - groupId: String, - settlementId: String, - navKey: NavKey, - snackbarHostState: SnackbarHostState, - onSaved: () -> Unit, - modifier: Modifier = Modifier, - viewModel: EditSettlementViewModel = - koinViewModel( - key = settlementId, - parameters = { parametersOf(groupId, settlementId) }, - ), -) { - val state by viewModel.state.collectAsStateWithLifecycle() - - ObserveAsEvents(viewModel.events) { event -> - when (event) { - EditSettlementEvent.SettlementSaved -> onSaved() - is EditSettlementEvent.Error -> snackbarHostState.showSnackbar(event.message.asStringAsync()) - } - } - - TopBarActions(navKey) { - TextButton( - onClick = viewModel::onSaveClick, - enabled = !state.isSubmitting, - ) { - Text( - text = stringResource(Res.string.add_entry_save), - fontWeight = FontWeight.SemiBold, - ) - } - } - - EditSettlementScreen( - state = state, - onDateClick = viewModel::onDateClick, - onDatePickerDismiss = viewModel::onDatePickerDismiss, - onDateSelected = viewModel::onDateSelected, - onSaveClick = viewModel::onSaveClick, - modifier = modifier, - ) -} - -@Composable -private fun EditSettlementScreen( - state: EditSettlementState, - onDateClick: () -> Unit, - onDatePickerDismiss: () -> Unit, - onDateSelected: (Long) -> Unit, - onSaveClick: () -> Unit, - modifier: Modifier = Modifier, -) { - val focusManager = LocalFocusManager.current - val monthLabels = rememberMonthAbbreviations() - - Column( - modifier = - modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 16.dp), - ) { - VerticalSpacer(16.dp) - Text( - text = - stringResource( - Res.string.settle_up_amount_dialog_subtitle, - participantLabel(state.membersById[state.paidByUserId]), - participantLabel(state.membersById[state.receivedByUserId]), - ), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - ) - VerticalSpacer(16.dp) - TabMatesTextField( - state = state.amountTextState, - title = stringResource(Res.string.settle_up_amount_label), - singleLine = true, - keyboardType = KeyboardType.Decimal, - imeAction = ImeAction.Done, - inputTransformation = rememberAmountInputTransformation(state.currencyDecimalDigits), - onKeyboardAction = { - if (!state.isSubmitting) { - focusManager.clearFocus() - onSaveClick() - } - }, - modifier = Modifier.fillMaxWidth(), - ) - VerticalSpacer(12.dp) - FieldRow( - label = stringResource(Res.string.add_entry_date_label), - value = formatEntryDate(state.entryDate, monthLabels), - onClick = onDateClick, - leadingIcon = Res.drawable.ic_calendar, - ) - VerticalSpacer(24.dp) - } - - if (state.isDatePickerVisible) { - DatePickerSheet( - initialEpochMillis = state.entryDate.atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds(), - onDismiss = onDatePickerDismiss, - onConfirm = onDateSelected, - ) - } -} - -@Composable -private fun participantLabel(participant: GroupParticipant?): String = - participant?.username ?: stringResource(Res.string.expense_detail_removed_member) diff --git a/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/editsettlement/EditSettlementState.kt b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/editsettlement/EditSettlementState.kt deleted file mode 100644 index bc15fc9c..00000000 --- a/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/editsettlement/EditSettlementState.kt +++ /dev/null @@ -1,32 +0,0 @@ -package de.tabmates.features.tabgroup.presentation.navigation.editsettlement - -import androidx.compose.foundation.text.input.TextFieldState -import de.tabmates.features.tabgroup.domain.models.GroupParticipant -import kotlinx.datetime.LocalDate -import kotlinx.datetime.TimeZone -import kotlinx.datetime.toLocalDateTime -import kotlin.time.Clock - -data class EditSettlementState( - val settlementId: String = "", - val isLoading: Boolean = true, - val isSubmitting: Boolean = false, - val amountTextState: TextFieldState = TextFieldState(), - val entryDate: LocalDate = - Clock.System - .now() - .toLocalDateTime(TimeZone.currentSystemDefault()) - .date, - val isDatePickerVisible: Boolean = false, - // Fixed fields: never rendered as editable, round-tripped into updateSettlement on save. - val title: String = "", - val description: String = "", - val currencyCode: String = "", - val currencySymbol: String = "", - val currencyDecimalDigits: Int = 2, - val exchangeRate: Double? = null, - val paidByUserId: String = "", - val receivedByUserId: String = "", - val currentUserId: String = "", - val membersById: Map = emptyMap(), -) diff --git a/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/editsettlement/EditSettlementViewModel.kt b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/editsettlement/EditSettlementViewModel.kt deleted file mode 100644 index 504b2f6c..00000000 --- a/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/editsettlement/EditSettlementViewModel.kt +++ /dev/null @@ -1,159 +0,0 @@ -package de.tabmates.features.tabgroup.presentation.navigation.editsettlement - -import androidx.compose.foundation.text.input.TextFieldState -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import de.tabmates.core.domain.auth.CurrentAccount -import de.tabmates.core.domain.util.onFailure -import de.tabmates.core.domain.util.onSuccess -import de.tabmates.core.presentation.format.NumberSymbols -import de.tabmates.core.presentation.format.formatAmountForInput -import de.tabmates.core.presentation.format.parseAmount -import de.tabmates.core.presentation.util.UiText -import de.tabmates.core.presentation.util.toUiText -import de.tabmates.features.tabgroup.domain.currency.CurrencyRepository -import de.tabmates.features.tabgroup.domain.group.GroupRepository -import de.tabmates.features.tabgroup.domain.models.TabEntry -import de.tabmates.features.tabgroup.domain.tabentry.TabEntryRepository -import de.tabmates.features.tabgroup.presentation.util.observeGroupWithParticipants -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.WhileSubscribed -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlinx.datetime.TimeZone -import kotlinx.datetime.toLocalDateTime -import org.koin.core.annotation.InjectedParam -import org.koin.core.annotation.KoinViewModel -import tabmatesapp.features.tabgroup.presentation.generated.resources.Res -import tabmatesapp.features.tabgroup.presentation.generated.resources.add_entry_error_amount_required -import kotlin.time.Duration.Companion.seconds -import kotlin.time.Instant - -@KoinViewModel -class EditSettlementViewModel( - @InjectedParam private val groupId: String, - @InjectedParam private val settlementId: String, - private val tabEntryRepository: TabEntryRepository, - private val groupRepository: GroupRepository, - private val currencyRepository: CurrencyRepository, - currentAccount: CurrentAccount, - private val numberSymbols: NumberSymbols, -) : ViewModel() { - private val currentUserId = - currentAccount.userId().orEmpty() - private val _state = - MutableStateFlow(EditSettlementState(settlementId = settlementId, currentUserId = currentUserId)) - private var hasLoadedInitialData = false - - val state: StateFlow = - _state - .onStart { - if (!hasLoadedInitialData) { - hasLoadedInitialData = true - loadInitialData() - } - }.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5.seconds), - initialValue = _state.value, - ) - - private val eventChannel = Channel() - val events = eventChannel.receiveAsFlow() - - private fun loadInitialData() { - viewModelScope.launch { - val groupData = groupRepository.observeGroupWithParticipants(groupId).first() - val group = groupData.group - val currencies = currencyRepository.getCurrencies().first() - val settlement = - tabEntryRepository.getTabEntryById(settlementId).first() as? TabEntry.Settlement - val currencyCode = settlement?.currencyCode ?: group?.defaultCurrencyCode.orEmpty() - val currency = currencies.firstOrNull { it.code == currencyCode } - val decimals = currency?.decimalDigits ?: 2 - _state.update { - it.copy( - isLoading = false, - amountTextState = - TextFieldState( - settlement - ?.let { s -> formatAmountForInput(s.amount, decimals, numberSymbols) } - .orEmpty(), - ), - entryDate = settlement?.entryDate ?: it.entryDate, - title = settlement?.title.orEmpty(), - description = settlement?.description.orEmpty(), - currencyCode = currencyCode, - currencySymbol = currency?.nativeSymbol ?: currencyCode, - currencyDecimalDigits = decimals, - exchangeRate = settlement?.exchangeRate, - paidByUserId = settlement?.paidByUserId.orEmpty(), - receivedByUserId = settlement?.receivedByUserId.orEmpty(), - membersById = groupData.participantsById, - ) - } - } - } - - fun onDateClick() { - _state.update { it.copy(isDatePickerVisible = true) } - } - - fun onDatePickerDismiss() { - _state.update { it.copy(isDatePickerVisible = false) } - } - - fun onDateSelected(epochMillis: Long) { - _state.update { - it.copy( - entryDate = Instant.fromEpochMilliseconds(epochMillis).toLocalDateTime(TimeZone.UTC).date, - isDatePickerVisible = false, - ) - } - } - - fun onSaveClick() { - val current = _state.value - if (current.isSubmitting || current.isLoading) return - val amount = - parseAmount(current.amountTextState.text.toString(), numberSymbols)?.takeIf { it > 0.0 } - if (amount == null) { - viewModelScope.launch { - eventChannel.send( - EditSettlementEvent.Error(UiText.Resource(Res.string.add_entry_error_amount_required)), - ) - } - return - } - viewModelScope.launch { - _state.update { it.copy(isSubmitting = true) } - tabEntryRepository - .updateSettlement( - tabEntryId = settlementId, - groupId = groupId, - title = current.title, - description = current.description, - amount = amount, - currencyCode = current.currencyCode, - // Currency is not editable here, so the originally locked rate always stays. - exchangeRate = current.exchangeRate, - paidByUserId = current.paidByUserId, - receivedByUserId = current.receivedByUserId, - entryDate = current.entryDate, - ).onSuccess { - _state.update { it.copy(isSubmitting = false) } - eventChannel.send(EditSettlementEvent.SettlementSaved) - }.onFailure { error -> - _state.update { it.copy(isSubmitting = false) } - eventChannel.send(EditSettlementEvent.Error(error.toUiText())) - } - } - } -} diff --git a/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/entrydetail/EntryDetailRoot.kt b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/entrydetail/EntryDetailRoot.kt index a672247a..a1998f69 100644 --- a/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/entrydetail/EntryDetailRoot.kt +++ b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/entrydetail/EntryDetailRoot.kt @@ -1,6 +1,5 @@ package de.tabmates.features.tabgroup.presentation.navigation.entrydetail -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -8,9 +7,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator @@ -29,7 +26,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation3.runtime.NavKey @@ -40,7 +36,7 @@ import de.tabmates.core.presentation.navigation.TopBarActions import de.tabmates.core.presentation.util.ObserveAsEvents import de.tabmates.features.tabgroup.domain.currency.CurrencyConverter import de.tabmates.features.tabgroup.domain.models.TabEntrySplit -import de.tabmates.features.tabgroup.presentation.components.SyncStatusChip +import de.tabmates.features.tabgroup.presentation.components.DetailHero import de.tabmates.features.tabgroup.presentation.components.formatMoney import de.tabmates.features.tabgroup.presentation.components.formatRate import de.tabmates.features.tabgroup.presentation.components.rateUpdatedLabel @@ -48,7 +44,6 @@ import de.tabmates.features.tabgroup.presentation.navigation.addentry.EntryKind import de.tabmates.features.tabgroup.presentation.navigation.addentry.formatEntryDate import de.tabmates.features.tabgroup.presentation.navigation.addentry.rememberMonthAbbreviations import de.tabmates.features.tabgroup.presentation.navigation.groupoverview.UserAvatar -import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.vectorResource import org.koin.compose.viewmodel.koinViewModel @@ -165,7 +160,7 @@ private fun EntryDetailScreen( return@Column } VerticalSpacer(8.dp) - HeroSection( + DetailHero( icon = if (isIncome) Res.drawable.ic_redeem else Res.drawable.ic_restaurant, title = entry.title, amountFormatted = @@ -174,7 +169,7 @@ private fun EntryDetailScreen( entry.amount, state.entryCurrencyDecimalDigits, ), - dateText = formatEntryDate(entry.entryDate, monthLabels), + subtitle = formatEntryDate(entry.entryDate, monthLabels), description = entry.description, isPendingSync = entry.isPendingSync, ) @@ -228,66 +223,6 @@ private fun EntryDetailScreen( } } -@Composable -private fun HeroSection( - icon: DrawableResource, - title: String, - amountFormatted: String, - dateText: String, - description: String, - isPendingSync: Boolean, -) { - Column( - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Box( - modifier = - Modifier - .size(96.dp) - .background( - color = MaterialTheme.colorScheme.tertiaryContainer, - shape = CircleShape, - ), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = vectorResource(icon), - contentDescription = null, - tint = MaterialTheme.colorScheme.onTertiaryContainer, - modifier = Modifier.size(40.dp), - ) - } - Text( - text = title, - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.SemiBold, - ) - if (isPendingSync) { - SyncStatusChip() - } - Text( - text = amountFormatted, - style = MaterialTheme.typography.displaySmall, - fontWeight = FontWeight.Bold, - ) - Text( - text = dateText, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - if (description.isNotBlank()) { - Text( - text = description, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center, - ) - } - } -} - @Composable private fun ForeignCurrencyDetails(state: EntryDetailState) { val entry = state.entry ?: return diff --git a/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupdetail/GroupDetailRoot.kt b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupdetail/GroupDetailRoot.kt index 20f0a9e5..6d600ef5 100644 --- a/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupdetail/GroupDetailRoot.kt +++ b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupdetail/GroupDetailRoot.kt @@ -46,6 +46,8 @@ fun GroupDetailRoot( onSettleUpClick: () -> Unit, onEntryClick: (String) -> Unit, onSettlementClick: (String) -> Unit, + onRecurringSeriesClick: (String) -> Unit, + onManageSchedulesClick: () -> Unit, modifier: Modifier = Modifier, viewModel: GroupDetailViewModel = koinViewModel( @@ -119,6 +121,9 @@ fun GroupDetailRoot( onSettleUpClick = onSettleUpClick, onEntryClick = onEntryClick, onSettlementClick = onSettlementClick, + recurringSeries = state.recurringSeries, + onRecurringSeriesClick = onRecurringSeriesClick, + onManageSchedulesClick = onManageSchedulesClick, snackbarHostState = snackbarHostState, modifier = modifier.fillMaxSize(), ) diff --git a/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupdetail/GroupDetailViewModel.kt b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupdetail/GroupDetailViewModel.kt index dbb7fb02..1b6372b1 100644 --- a/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupdetail/GroupDetailViewModel.kt +++ b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupdetail/GroupDetailViewModel.kt @@ -17,7 +17,9 @@ import de.tabmates.features.tabgroup.domain.models.GroupBalance import de.tabmates.features.tabgroup.domain.models.GroupParticipant import de.tabmates.features.tabgroup.domain.models.TabEntry import de.tabmates.features.tabgroup.domain.models.referencedParticipantIds -import de.tabmates.features.tabgroup.domain.tabentry.TabEntryRepository +import de.tabmates.features.tabgroup.domain.recurring.RecurringSeries +import de.tabmates.features.tabgroup.domain.recurring.RecurringSeriesRepository +import de.tabmates.features.tabgroup.domain.recurring.ScheduledLedger import de.tabmates.features.tabgroup.presentation.navigation.activity.ActivityFeedBuilder import de.tabmates.features.tabgroup.presentation.navigation.activity.ActivitySection import de.tabmates.features.tabgroup.presentation.navigation.groupoverview.GroupOverviewItem @@ -66,6 +68,8 @@ data class GroupDetailState( val hasOutstandingDebts: Boolean = false, val currencyByCode: Map = emptyMap(), val ratesByCurrency: Map = emptyMap(), + /** The group's schedules, active and ended alike; the tab filters them itself. */ + val recurringSeries: List = emptyList(), /** This group's activity log, newest first, for the History tab. */ val historySections: List = emptyList(), val canLoadMoreHistory: Boolean = false, @@ -76,7 +80,8 @@ data class GroupDetailState( class GroupDetailViewModel( @InjectedParam private val groupId: String, private val groupRepository: GroupRepository, - private val tabEntryRepository: TabEntryRepository, + private val scheduledLedger: ScheduledLedger, + private val recurringSeriesRepository: RecurringSeriesRepository, currencyRepository: CurrencyRepository, exchangeRateRepository: ExchangeRateRepository, activityRepository: ActivityRepository, @@ -99,6 +104,21 @@ class GroupDetailViewModel( ) { feed, participants, limit -> HistoryInput(feed, participants, limit) } .onStart { emit(HistoryInput()) } + /** + * The schedules themselves, for the recurring tab. Separate from [scheduledLedger], which folds + * them into projected entries and does not surface the schedules it used. + */ + private val recurringSeries: Flow> = + recurringSeriesRepository.getSeriesForGroup(groupId).onStart { emit(emptyList()) } + + private val sideInputs: Flow = + combine(history, recurringSeries) { history, series -> SideInput(history, series) } + + private data class SideInput( + val history: HistoryInput, + val recurringSeries: List, + ) + val state: StateFlow = combine( groupRepository @@ -106,13 +126,16 @@ class GroupDetailViewModel( .map { groups -> GroupLookup(hasLoaded = true, group = groups.firstOrNull { it.id == groupId }) } .onStart { emit(GroupLookup()) }, currencyRepository.getCurrencies().onStart { emit(emptyList()) }, - tabEntryRepository - .getTabEntriesForGroup(groupId) + // Entries plus the occurrences the group's schedules already owe. One reader, so + // every screen showing this group's balance shows the same number. + scheduledLedger + .observeEntriesForGroup(groupId) .onStart { emit(emptyList()) }, exchangeRateRepository.getExchangeRates().onStart { emit(emptyList()) }, - history, - ) { lookup, currencies, entries, rates, history -> + sideInputs, + ) { lookup, currencies, entries, rates, sideInputs -> val group = lookup.group + val history = sideInputs.history val visibleEntries = entries.filterNot { it.isDeleted } val conversion = group?.let { CurrencyConversion.from(it.defaultCurrencyCode, rates) } val activeMembers = group?.participants?.toList().orEmpty() @@ -160,6 +183,7 @@ class GroupDetailViewModel( memberNetBalances.values.any { GroupBalance.fromNet(it) != GroupBalance.Settled }, currencyByCode = currencies.associateBy { it.code }, ratesByCurrency = rates.associate { it.currencyCode to it.rateToBase }, + recurringSeries = sideInputs.recurringSeries, historySections = ActivityFeedBuilder.build( items = history.feed, diff --git a/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/EntryIcon.kt b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/EntryIcon.kt new file mode 100644 index 00000000..3e5a654c --- /dev/null +++ b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/EntryIcon.kt @@ -0,0 +1,43 @@ +package de.tabmates.features.tabgroup.presentation.navigation.groupoverview + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.DrawableResource +import org.jetbrains.compose.resources.vectorResource + +/** + * The leading badge on any row that stands for one entry or one schedule. + * + * Shared rather than private to the group screen: the schedules list shows the same objects one + * level down, and a row that changed shape on the way there would read as a different kind of thing. + */ +@Composable +internal fun EntryIcon( + icon: DrawableResource, + containerColor: Color = MaterialTheme.colorScheme.surfaceVariant, + contentColor: Color = MaterialTheme.colorScheme.onSurfaceVariant, +) { + Box( + modifier = + Modifier + .size(40.dp) + .background(containerColor, RoundedCornerShape(10.dp)), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = vectorResource(icon), + contentDescription = null, + tint = contentColor, + modifier = Modifier.size(20.dp), + ) + } +} diff --git a/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/GroupDetailPane.kt b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/GroupDetailPane.kt index 710eac99..e5ccf258 100644 --- a/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/GroupDetailPane.kt +++ b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/GroupDetailPane.kt @@ -1,6 +1,5 @@ package de.tabmates.features.tabgroup.presentation.navigation.groupoverview -import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -12,6 +11,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape @@ -21,6 +22,7 @@ import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -30,6 +32,7 @@ import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface import androidx.compose.material3.Tab import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2 @@ -41,6 +44,7 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -51,14 +55,18 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.layout import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_MEDIUM_LOWER_BOUND import de.tabmates.core.designsystem.spacer.HorizontalSpacer import de.tabmates.core.designsystem.spacer.VerticalSpacer +import de.tabmates.core.designsystem.text.SectionLabel import de.tabmates.core.designsystem.theme.extended import de.tabmates.core.presentation.format.AmountSign +import de.tabmates.core.presentation.format.DEFAULT_CURRENCY_DECIMALS import de.tabmates.core.presentation.share.LinkShareResult import de.tabmates.core.presentation.share.rememberLinkSharer import de.tabmates.features.tabgroup.domain.balance.UserBalanceCalculator @@ -67,15 +75,20 @@ import de.tabmates.features.tabgroup.domain.models.Currency import de.tabmates.features.tabgroup.domain.models.GroupBalance import de.tabmates.features.tabgroup.domain.models.GroupParticipant import de.tabmates.features.tabgroup.domain.models.TabEntry +import de.tabmates.features.tabgroup.domain.recurring.RecurringSeries import de.tabmates.features.tabgroup.presentation.components.GroupAvatar import de.tabmates.features.tabgroup.presentation.components.SyncStatusChip import de.tabmates.features.tabgroup.presentation.navigation.activity.ActivitySection import de.tabmates.features.tabgroup.presentation.navigation.activity.LoadMoreOnApproachingEnd import de.tabmates.features.tabgroup.presentation.navigation.activity.activityFeed +import de.tabmates.features.tabgroup.presentation.navigation.addentry.formatEntryDate import de.tabmates.features.tabgroup.presentation.navigation.addentry.rememberMonthAbbreviations import de.tabmates.features.tabgroup.presentation.navigation.groupdetail.buildInviteUrl +import de.tabmates.features.tabgroup.presentation.navigation.recurringdetail.frequencyLabel import kotlinx.coroutines.launch -import org.jetbrains.compose.resources.DrawableResource +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.vectorResource @@ -115,6 +128,7 @@ import tabmatesapp.features.tabgroup.presentation.generated.resources.groups_mem import tabmatesapp.features.tabgroup.presentation.generated.resources.groups_members_count import tabmatesapp.features.tabgroup.presentation.generated.resources.groups_status_settled import tabmatesapp.features.tabgroup.presentation.generated.resources.ic_arrow_back +import tabmatesapp.features.tabgroup.presentation.generated.resources.ic_calendar import tabmatesapp.features.tabgroup.presentation.generated.resources.ic_chevron_right import tabmatesapp.features.tabgroup.presentation.generated.resources.ic_person_add import tabmatesapp.features.tabgroup.presentation.generated.resources.ic_redeem @@ -122,11 +136,38 @@ import tabmatesapp.features.tabgroup.presentation.generated.resources.ic_restaur import tabmatesapp.features.tabgroup.presentation.generated.resources.ic_settings import tabmatesapp.features.tabgroup.presentation.generated.resources.ic_swap_horiz import tabmatesapp.features.tabgroup.presentation.generated.resources.member_label_former +import tabmatesapp.features.tabgroup.presentation.generated.resources.recurring_chip_scheduled +import tabmatesapp.features.tabgroup.presentation.generated.resources.recurring_next_on +import tabmatesapp.features.tabgroup.presentation.generated.resources.recurring_upcoming_fix_hint +import tabmatesapp.features.tabgroup.presentation.generated.resources.recurring_upcoming_manage +import tabmatesapp.features.tabgroup.presentation.generated.resources.recurring_upcoming_row_cd +import tabmatesapp.features.tabgroup.presentation.generated.resources.recurring_upcoming_section +import tabmatesapp.features.tabgroup.presentation.generated.resources.recurring_upcoming_show_all +import tabmatesapp.features.tabgroup.presentation.generated.resources.recurring_upcoming_show_less import tabmatesapp.features.tabgroup.presentation.generated.resources.settle_up_action import kotlin.math.abs import kotlin.math.roundToInt +import kotlin.time.Clock -private enum class DetailTab { TRANSACTIONS, HISTORY, BALANCES } +private enum class DetailTab { TRANSACTIONS, BALANCES, HISTORY } + +/** + * Stores the selected tab by name rather than by identity. + * + * A stack persisted by a build with a different set of tabs — the retired Repeating tab, say — + * would otherwise restore a constant this build no longer has. + */ +private val DetailTabSaver: Saver = + Saver( + save = { it.name }, + restore = { name -> DetailTab.entries.firstOrNull { it.name == name } ?: DetailTab.TRANSACTIONS }, + ) + +/** How far a not-yet-written occurrence is faded relative to a real entry. */ +private const val SCHEDULED_ROW_ALPHA = 0.6f + +/** How many schedules the upcoming section shows before it needs to be expanded. */ +private const val UPCOMING_PEEK_LIMIT = 3 /** Bottom space reserved so the last row can scroll clear of the host "Add Entry" FAB. */ private val FabBottomClearance = 96.dp @@ -153,6 +194,9 @@ internal fun GroupDetailPane( onSettleUpClick: () -> Unit = {}, onEntryClick: (String) -> Unit = {}, onSettlementClick: (String) -> Unit = {}, + recurringSeries: List = emptyList(), + onRecurringSeriesClick: (String) -> Unit = {}, + onManageSchedulesClick: () -> Unit = {}, snackbarHostState: SnackbarHostState, modifier: Modifier = Modifier, ) { @@ -160,7 +204,8 @@ internal fun GroupDetailPane( currentWindowAdaptiveInfoV2().windowSizeClass.isWidthAtLeastBreakpoint( WIDTH_DP_MEDIUM_LOWER_BOUND, ) - var selectedTab by rememberSaveable(item.id) { mutableStateOf(DetailTab.TRANSACTIONS) } + var selectedTab by + rememberSaveable(item.id, stateSaver = DetailTabSaver) { mutableStateOf(DetailTab.TRANSACTIONS) } val linkSharer = rememberLinkSharer() val scope = rememberCoroutineScope() val inviteUrl = remember(item.inviteToken) { buildInviteUrl(item.inviteToken) } @@ -252,10 +297,13 @@ internal fun GroupDetailPane( currentUserId = currentUserId, participantsById = participantsById, entries = entries, + recurringSeries = recurringSeries, currencyByCode = currencyByCode, ratesByCurrency = ratesByCurrency, onEntryClick = onEntryClick, onSettlementClick = onSettlementClick, + onSeriesClick = onRecurringSeriesClick, + onManageSchedulesClick = onManageSchedulesClick, ) } @@ -416,79 +464,182 @@ private fun HistoryTab( } } +/** + * The group's whole time axis: what is coming, then what has happened. + * + * Upcoming sits above the ledger rather than beside it in its own tab, because a schedule is only + * ever read as "the next rent" — but it stays a section with its own heading and a muted amount + * column, because a future occurrence has not moved anybody's balance yet. The ones that *have* + * (occurrences already due but not yet written by the server) are ordinary rows down in the ledger, + * at their own date, faded and chipped. + */ @Composable private fun TransactionsTab( item: GroupOverviewItem, currentUserId: String, participantsById: Map, entries: List, + recurringSeries: List, currencyByCode: Map, ratesByCurrency: Map, onEntryClick: (String) -> Unit, onSettlementClick: (String) -> Unit, + onSeriesClick: (String) -> Unit, + onManageSchedulesClick: () -> Unit, ) { val removedMemberName = stringResource(Res.string.expense_detail_removed_member) - Column( - modifier = - Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .padding(bottom = FabBottomClearance), + val monthLabels = rememberMonthAbbreviations() + // The day the server's sweep measures against, so both agree on what is still upcoming. + val today = + remember { + Clock.System + .now() + .toLocalDateTime(TimeZone.UTC) + .date + } + val upcoming = rememberUpcomingSchedules(recurringSeries, today) + val hasParkedSchedule = upcoming.any { it.series.needsAttention } + // A parked schedule creates nothing until someone repairs it, and nothing else on this screen + // would say so. Opening the section on its own is the only carrier of that now the tab dot is + // gone, so the peek limit does not get to hide it. + var isUpcomingExpanded by + rememberSaveable(item.id, hasParkedSchedule) { mutableStateOf(hasParkedSchedule) } + + LazyColumn( + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(top = 16.dp, bottom = FabBottomClearance), ) { - VerticalSpacer(16.dp) - StatCardsRow(item = item, modifier = Modifier.padding(horizontal = 24.dp)) - VerticalSpacer(12.dp) + item(key = "stats") { + StatCardsRow(item = item, modifier = Modifier.padding(horizontal = 24.dp)) + VerticalSpacer(12.dp) + } + upcomingSection( + upcoming = upcoming, + currencyByCode = currencyByCode, + monthLabels = monthLabels, + isExpanded = isUpcomingExpanded, + onToggleExpanded = { isUpcomingExpanded = !isUpcomingExpanded }, + onSeriesClick = onSeriesClick, + onManageClick = onManageSchedulesClick, + ) if (entries.isEmpty()) { - EmptyTabHint( - text = stringResource(Res.string.groups_detail_empty_expenses), - modifier = Modifier.padding(horizontal = 24.dp), - ) + item(key = "empty") { + EmptyTabHint( + text = stringResource(Res.string.groups_detail_empty_expenses), + modifier = Modifier.padding(horizontal = 24.dp), + ) + } } else { - entries.forEach { entry -> - when (entry) { - is TabEntry.Expense -> { - ExpenseRow( - expense = entry, - currentUserId = currentUserId, - payerName = participantsById[entry.paidByUserId]?.username ?: removedMemberName, - item = item, - currency = currencyByCode[entry.currencyCode], - ratesByCurrency = ratesByCurrency, - onClick = { onEntryClick(entry.tabEntryId) }, - ) - } + items(entries, key = { it.tabEntryId }) { entry -> + EntryRow( + entry = entry, + item = item, + currentUserId = currentUserId, + participantsById = participantsById, + removedMemberName = removedMemberName, + currencyByCode = currencyByCode, + ratesByCurrency = ratesByCurrency, + onEntryClick = onEntryClick, + onSettlementClick = onSettlementClick, + ) + } + } + } +} - is TabEntry.Settlement -> { - SettlementRow( - settlement = entry, - currentUserId = currentUserId, - payerName = participantsById[entry.paidByUserId]?.username ?: removedMemberName, - recipientName = - participantsById[entry.receivedByUserId]?.username ?: removedMemberName, - item = item, - currency = currencyByCode[entry.currencyCode], - ratesByCurrency = ratesByCurrency, - onClick = { onSettlementClick(entry.tabEntryId) }, - ) - } +@Composable +private fun EntryRow( + entry: TabEntry, + item: GroupOverviewItem, + currentUserId: String, + participantsById: Map, + removedMemberName: String, + currencyByCode: Map, + ratesByCurrency: Map, + onEntryClick: (String) -> Unit, + onSettlementClick: (String) -> Unit, +) { + // A scheduled placeholder is an occurrence the server owes but has not written yet. It counts in + // the balances above — that is the point, the numbers must not jump when the sweep lands — but + // there is nothing to open: it has no id on the server, and every action lives on its schedule. + val isScheduled = entry.isScheduledPlaceholder + Box( + modifier = + Modifier.graphicsLayer { + alpha = if (isScheduled) SCHEDULED_ROW_ALPHA else 1f + }, + ) { + when (entry) { + is TabEntry.Expense -> { + ExpenseRow( + expense = entry, + currentUserId = currentUserId, + payerName = participantsById[entry.paidByUserId]?.username ?: removedMemberName, + item = item, + currency = currencyByCode[entry.currencyCode], + ratesByCurrency = ratesByCurrency, + onClick = { onEntryClick(entry.tabEntryId) }.takeIf { !isScheduled }, + ) + } - is TabEntry.Income -> { - IncomeRow( - income = entry, - currentUserId = currentUserId, - payerName = participantsById[entry.paidByUserId]?.username ?: removedMemberName, - item = item, - currency = currencyByCode[entry.currencyCode], - ratesByCurrency = ratesByCurrency, - onClick = { onEntryClick(entry.tabEntryId) }, - ) - } - } + is TabEntry.Settlement -> { + SettlementRow( + settlement = entry, + currentUserId = currentUserId, + payerName = participantsById[entry.paidByUserId]?.username ?: removedMemberName, + recipientName = + participantsById[entry.receivedByUserId]?.username ?: removedMemberName, + item = item, + currency = currencyByCode[entry.currencyCode], + ratesByCurrency = ratesByCurrency, + onClick = { onSettlementClick(entry.tabEntryId) }.takeIf { !isScheduled }, + ) } + + is TabEntry.Income -> { + IncomeRow( + income = entry, + currentUserId = currentUserId, + payerName = participantsById[entry.paidByUserId]?.username ?: removedMemberName, + item = item, + currency = currencyByCode[entry.currencyCode], + ratesByCurrency = ratesByCurrency, + onClick = { onEntryClick(entry.tabEntryId) }.takeIf { !isScheduled }, + ) + } + } + // Drawn last so it sits above the row rather than under its amount column. The end inset + // matches the row's own 24.dp so the chip lines up with the amount beneath it instead of + // overhanging it. + if (isScheduled) { + ScheduledRowChip( + modifier = Modifier.align(Alignment.TopEnd).padding(top = 8.dp, end = 24.dp), + ) } } } +/** + * Makes an entry row tappable, or leaves it inert when there is nothing to open. + * + * A null [onClick] has to mean *no* [clickable] rather than an empty one: a scheduled placeholder + * has no server id and no detail screen, and a no-op handler would still ripple under the finger and + * still announce itself to a screen reader as something that can be activated. + */ +private fun Modifier.rowClick(onClick: (() -> Unit)?): Modifier = + then(onClick?.let { Modifier.clickable(onClick = it) } ?: Modifier) + +/** Marks a row as an occurrence that is due but not yet written by the server. */ +@Composable +private fun ScheduledRowChip(modifier: Modifier = Modifier) { + Text( + text = stringResource(Res.string.recurring_chip_scheduled), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = modifier, + ) +} + @Composable private fun ExpenseRow( expense: TabEntry.Expense, @@ -497,13 +648,13 @@ private fun ExpenseRow( item: GroupOverviewItem, currency: Currency?, ratesByCurrency: Map, - onClick: () -> Unit, + onClick: (() -> Unit)?, ) { Row( modifier = Modifier .fillMaxWidth() - .clickable(onClick = onClick) + .rowClick(onClick) .padding(horizontal = 24.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, ) { @@ -600,13 +751,13 @@ private fun IncomeRow( item: GroupOverviewItem, currency: Currency?, ratesByCurrency: Map, - onClick: () -> Unit, + onClick: (() -> Unit)?, ) { Row( modifier = Modifier .fillMaxWidth() - .clickable(onClick = onClick) + .rowClick(onClick) .padding(horizontal = 24.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, ) { @@ -708,7 +859,7 @@ private fun SettlementRow( item: GroupOverviewItem, currency: Currency?, ratesByCurrency: Map, - onClick: () -> Unit, + onClick: (() -> Unit)?, ) { val extended = MaterialTheme.colorScheme.extended // Direction colors mirror the balance stat card: money in = positive, money out = negative, @@ -760,7 +911,7 @@ private fun SettlementRow( modifier = Modifier .fillMaxWidth() - .clickable(onClick = onClick) + .rowClick(onClick) .padding(horizontal = 24.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { @@ -864,28 +1015,6 @@ private fun convertEntryAmount( rates = ratesByCurrency, ) -@Composable -private fun EntryIcon( - icon: DrawableResource, - containerColor: Color = MaterialTheme.colorScheme.surfaceVariant, - contentColor: Color = MaterialTheme.colorScheme.onSurfaceVariant, -) { - Box( - modifier = - Modifier - .size(40.dp) - .background(containerColor, RoundedCornerShape(10.dp)), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = vectorResource(icon), - contentDescription = null, - tint = contentColor, - modifier = Modifier.size(20.dp), - ) - } -} - @Composable private fun BalancesTab( item: GroupOverviewItem, @@ -1198,8 +1327,8 @@ private fun EmptyTabHint( private fun DetailTab.label(): String = when (this) { DetailTab.TRANSACTIONS -> stringResource(Res.string.groups_detail_tab_transactions) - DetailTab.HISTORY -> stringResource(Res.string.groups_detail_tab_history) DetailTab.BALANCES -> stringResource(Res.string.groups_detail_tab_balances) + DetailTab.HISTORY -> stringResource(Res.string.groups_detail_tab_history) } @Composable @@ -1217,3 +1346,166 @@ private fun expenseCaption(count: Int): String = } else { stringResource(Res.string.groups_expense_count, count) } + +/** + * The active schedules with something still to come, parked ones first. + * + * Computed as one list rather than per row: finding a next date walks the schedule slot by slot from + * its start, and sorting the rows would otherwise walk every schedule a second time. + */ +@Composable +private fun rememberUpcomingSchedules( + series: List, + today: LocalDate, +): List = remember(series, today) { upcomingSchedules(series, today) } + +/** + * What the group's schedules are about to produce. + * + * Read-only, like the schedules screen it links to: everything you can do to a schedule lives on + * its detail screen, so a row is a link and nothing else. + */ +private fun LazyListScope.upcomingSection( + upcoming: List, + currencyByCode: Map, + monthLabels: List, + isExpanded: Boolean, + onToggleExpanded: () -> Unit, + onSeriesClick: (String) -> Unit, + onManageClick: () -> Unit, +) { + if (upcoming.isEmpty()) return + + item(key = "upcoming-header") { + Row( + modifier = + Modifier + .fillMaxWidth() + // A TextButton carries 12.dp of its own horizontal content padding, so the end + // inset is 24 - 12: it is the label that has to land on the tab's 24.dp rail, + // not the button's touch target. + .padding(start = 24.dp, end = 12.dp) + .padding(top = 12.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + SectionLabel( + text = stringResource(Res.string.recurring_upcoming_section), + modifier = Modifier.weight(1f), + ) + TextButton(onClick = onManageClick) { + Text(stringResource(Res.string.recurring_upcoming_manage)) + } + } + } + val visible = if (isExpanded) upcoming else upcoming.take(UPCOMING_PEEK_LIMIT) + items(visible, key = { "upcoming-${it.series.seriesId}" }) { schedule -> + UpcomingRow( + schedule = schedule, + currency = currencyByCode[schedule.series.rule.currencyCode], + monthLabels = monthLabels, + onClick = { onSeriesClick(schedule.series.seriesId) }, + ) + } + if (upcoming.size > UPCOMING_PEEK_LIMIT) { + item(key = "upcoming-toggle") { + TextButton( + onClick = onToggleExpanded, + // 24 - 12 again, so the label starts under the titles rather than the icons. + modifier = Modifier.padding(start = 12.dp), + ) { + Text( + text = + if (isExpanded) { + stringResource(Res.string.recurring_upcoming_show_less) + } else { + stringResource(Res.string.recurring_upcoming_show_all, upcoming.size) + }, + ) + } + } + } + item(key = "upcoming-divider") { + HorizontalDivider(modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp)) + } +} + +/** + * Built to the same geometry as [ExpenseRow] rather than on a stock `ListItem`. + * + * The section sits inside the ledger, so its rows have to hang off the same 24.dp rail, the same + * 40.dp icon container and the same type weights — a `ListItem` brings its own 16.dp rail and reads + * as a component bolted on from somewhere else. Only the colour says these are different: the + * amount is muted because nothing here has been booked or counted into the cards above. + */ +@Composable +private fun UpcomingRow( + schedule: UpcomingSchedule, + currency: Currency?, + monthLabels: List, + onClick: () -> Unit, +) { + val isParked = schedule.series.needsAttention + val rule = schedule.series.rule + val rowLabel = stringResource(Res.string.recurring_upcoming_row_cd, rule.title) + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + // The calendar icon is decorative and the section heading scrolls away, so the row + // has to say for itself that it is a schedule rather than an entry. + .semantics { contentDescription = rowLabel } + .padding(horizontal = 24.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (isParked) { + EntryIcon( + icon = Res.drawable.ic_calendar, + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + ) + } else { + EntryIcon(Res.drawable.ic_calendar) + } + HorizontalSpacer(12.dp) + Column(modifier = Modifier.weight(1f)) { + Text( + text = rule.title, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = + if (isParked) { + stringResource(Res.string.recurring_upcoming_fix_hint) + } else { + listOfNotNull( + frequencyLabel(rule.frequency, rule.interval), + schedule.nextDate?.let { + stringResource(Res.string.recurring_next_on, formatEntryDate(it, monthLabels)) + }, + ).joinToString(" · ") + }, + style = MaterialTheme.typography.bodySmall, + color = + if (isParked) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + HorizontalSpacer(8.dp) + Column(horizontalAlignment = Alignment.End) { + Text( + text = + formatAmount( + rule.amount, + currency?.nativeSymbol ?: rule.currencyCode, + currency?.decimalDigits ?: DEFAULT_CURRENCY_DECIMALS, + ), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/GroupOverviewViewModel.kt b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/GroupOverviewViewModel.kt index bd8933f3..6bc33848 100644 --- a/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/GroupOverviewViewModel.kt +++ b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/GroupOverviewViewModel.kt @@ -12,7 +12,7 @@ import de.tabmates.features.tabgroup.domain.group.GroupRepository import de.tabmates.features.tabgroup.domain.models.ExchangeRate import de.tabmates.features.tabgroup.domain.models.GroupBalance import de.tabmates.features.tabgroup.domain.models.TabEntry -import de.tabmates.features.tabgroup.domain.tabentry.TabEntryRepository +import de.tabmates.features.tabgroup.domain.recurring.ScheduledLedger import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -32,7 +32,7 @@ import kotlin.time.Duration.Companion.seconds @KoinViewModel class GroupOverviewViewModel( groupRepository: GroupRepository, - tabEntryRepository: TabEntryRepository, + scheduledLedger: ScheduledLedger, currencyRepository: CurrencyRepository, exchangeRateRepository: ExchangeRateRepository, currentAccount: CurrentAccount, @@ -57,20 +57,20 @@ class GroupOverviewViewModel( if (items.isEmpty()) { flowOf(items) } else { - enrichWithStats(items, tabEntryRepository, exchangeRateRepository.getExchangeRates()) + enrichWithStats(items, scheduledLedger, exchangeRateRepository.getExchangeRates()) } } private fun enrichWithStats( baseItems: List, - tabEntryRepository: TabEntryRepository, + scheduledLedger: ScheduledLedger, ratesFlow: Flow>, ): Flow> { val entriesPerItem: Flow>>> = combine( baseItems.map { item -> - tabEntryRepository - .getTabEntriesForGroup(item.id) + scheduledLedger + .observeEntriesForGroup(item.id) .onStart { emit(emptyList()) } .map { entries -> item to entries } }, diff --git a/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/UpcomingSchedule.kt b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/UpcomingSchedule.kt new file mode 100644 index 00000000..0ea90a6a --- /dev/null +++ b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/UpcomingSchedule.kt @@ -0,0 +1,55 @@ +package de.tabmates.features.tabgroup.presentation.navigation.groupoverview + +import de.tabmates.features.tabgroup.domain.recurring.RecurringOccurrenceCalculator +import de.tabmates.features.tabgroup.domain.recurring.RecurringSeries +import kotlinx.datetime.LocalDate + +/** A schedule as the transactions tab's upcoming section shows it: the rule plus its next date. */ +internal data class UpcomingSchedule( + val series: RecurringSeries, + val nextDate: LocalDate?, +) + +/** + * What the group's schedules are about to produce, in the order the section lists them. + * + * Only *upcoming* work belongs here — occurrences already due but unwritten are placeholders down in + * the ledger, where they are counted in the balances. Nothing this function returns has moved a + * balance yet. + * + * @param today the UTC day the server's sweep measures against, so both agree on what is still ahead + */ +internal fun upcomingSchedules( + series: List, + today: LocalDate, +): List = + series + .filter { it.isActive } + .map { candidate -> + UpcomingSchedule( + series = candidate, + // A parked schedule promises no date: the server writes nothing for it until a + // member repairs the template. + nextDate = + if (candidate.needsAttention) { + null + } else { + RecurringOccurrenceCalculator + .upcomingOccurrences( + rule = candidate.rule, + after = today, + limit = 1, + skippedDates = candidate.skippedOccurrenceDates, + ).firstOrNull() + }, + ) + } + // An active schedule whose dates have run out has nothing upcoming to promise, so it belongs + // on the schedules screen and not in a section named for what is coming. + .filter { it.series.needsAttention || it.nextDate != null } + // Parked first: they are the only rows asking for something, and the peek limit could + // otherwise bury the one schedule that is silently producing nothing. + .sortedWith( + compareByDescending { it.series.needsAttention } + .thenBy(nullsLast()) { it.nextDate }, + ) diff --git a/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/grouppeople/GroupPeopleViewModel.kt b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/grouppeople/GroupPeopleViewModel.kt index 58eed349..54833619 100644 --- a/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/grouppeople/GroupPeopleViewModel.kt +++ b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/grouppeople/GroupPeopleViewModel.kt @@ -22,7 +22,7 @@ import de.tabmates.features.tabgroup.domain.models.Group import de.tabmates.features.tabgroup.domain.models.GroupBalance import de.tabmates.features.tabgroup.domain.models.GroupParticipant import de.tabmates.features.tabgroup.domain.models.ParticipantType -import de.tabmates.features.tabgroup.domain.tabentry.TabEntryRepository +import de.tabmates.features.tabgroup.domain.recurring.ScheduledLedger import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -48,7 +48,7 @@ import kotlin.time.Duration.Companion.seconds class GroupPeopleViewModel( @InjectedParam private val groupId: String, private val groupRepository: GroupRepository, - private val tabEntryRepository: TabEntryRepository, + private val scheduledLedger: ScheduledLedger, private val currencyRepository: CurrencyRepository, private val exchangeRateRepository: ExchangeRateRepository, currentAccount: CurrentAccount, @@ -145,7 +145,7 @@ class GroupPeopleViewModel( viewModelScope.launch { val group = groupRepository.getGroups().first().firstOrNull { it.id == groupId } ?: return@launch val participant = group.participants.firstOrNull { it.userId == personId } ?: return@launch - val entries = tabEntryRepository.getTabEntriesForGroup(groupId).first().filterNot { it.isDeleted } + val entries = scheduledLedger.observeEntriesForGroup(groupId).first().filterNot { it.isDeleted } val rates = exchangeRateRepository.getExchangeRates().first() val net = UserBalanceCalculator.computeNet( diff --git a/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupschedules/GroupSchedulesScreen.kt b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupschedules/GroupSchedulesScreen.kt new file mode 100644 index 00000000..9232953b --- /dev/null +++ b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupschedules/GroupSchedulesScreen.kt @@ -0,0 +1,285 @@ +package de.tabmates.features.tabgroup.presentation.navigation.groupschedules + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import de.tabmates.core.designsystem.spacer.HorizontalSpacer +import de.tabmates.core.designsystem.text.SectionLabel +import de.tabmates.features.tabgroup.domain.models.Currency +import de.tabmates.features.tabgroup.domain.recurring.RecurringOccurrenceCalculator +import de.tabmates.features.tabgroup.domain.recurring.RecurringSeries +import de.tabmates.features.tabgroup.presentation.navigation.addentry.formatEntryDate +import de.tabmates.features.tabgroup.presentation.navigation.addentry.rememberMonthAbbreviations +import de.tabmates.features.tabgroup.presentation.navigation.groupoverview.EntryIcon +import de.tabmates.features.tabgroup.presentation.navigation.groupoverview.formatAmount +import de.tabmates.features.tabgroup.presentation.navigation.recurringdetail.frequencyLabel +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf +import tabmatesapp.features.tabgroup.presentation.generated.resources.Res +import tabmatesapp.features.tabgroup.presentation.generated.resources.ic_calendar +import tabmatesapp.features.tabgroup.presentation.generated.resources.recurring_chip_ended +import tabmatesapp.features.tabgroup.presentation.generated.resources.recurring_chip_needs_attention +import tabmatesapp.features.tabgroup.presentation.generated.resources.recurring_empty_hint +import tabmatesapp.features.tabgroup.presentation.generated.resources.recurring_next_on +import tabmatesapp.features.tabgroup.presentation.generated.resources.recurring_section_active +import tabmatesapp.features.tabgroup.presentation.generated.resources.recurring_section_ended +import kotlin.time.Clock + +@Composable +fun GroupSchedulesRoot( + groupId: String, + onSeriesClick: (String) -> Unit, + modifier: Modifier = Modifier, + viewModel: GroupSchedulesViewModel = + koinViewModel( + key = groupId, + parameters = { parametersOf(groupId) }, + ), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + GroupSchedulesScreen( + state = state, + onSeriesClick = onSeriesClick, + modifier = modifier, + ) +} + +/** + * Every schedule in the group, read-only. + * + * Everything you can do to a schedule lives on its detail screen, so a row is a link and nothing + * else. Ended schedules stay in the list rather than vanishing: they explain entries that already + * exist, and hiding them would make those entries look like they came from nowhere. + */ +@Composable +private fun GroupSchedulesScreen( + state: GroupSchedulesState, + onSeriesClick: (String) -> Unit, + modifier: Modifier = Modifier, +) { + if (state.isLoading) { + Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + return + } + if (state.isEmpty) { + Box(modifier = modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) { + Text( + text = stringResource(Res.string.recurring_empty_hint), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + return + } + + val monthLabels = rememberMonthAbbreviations() + val today = rememberTodayUtc() + LazyColumn( + modifier = modifier.fillMaxWidth(), + contentPadding = PaddingValues(vertical = 8.dp), + ) { + scheduleSection( + sectionKey = "active", + title = Res.string.recurring_section_active, + series = state.active, + currencyByCode = state.currencyByCode, + monthLabels = monthLabels, + today = today, + onSeriesClick = onSeriesClick, + ) + scheduleSection( + sectionKey = "ended", + title = Res.string.recurring_section_ended, + series = state.ended, + currencyByCode = state.currencyByCode, + monthLabels = monthLabels, + today = today, + onSeriesClick = onSeriesClick, + ) + } +} + +private fun LazyListScope.scheduleSection( + sectionKey: String, + title: StringResource, + series: List, + currencyByCode: Map, + monthLabels: List, + today: LocalDate, + onSeriesClick: (String) -> Unit, +) { + if (series.isEmpty()) return + item(key = "header-$sectionKey") { + SectionLabel( + text = stringResource(title), + // Same 24.dp rail the rows below use, which is the rail the transactions tab this + // screen is reached from uses too. + modifier = Modifier.padding(horizontal = 24.dp).padding(top = 12.dp, bottom = 4.dp), + ) + } + items(series, key = { it.seriesId }) { candidate -> + ScheduleRow( + series = candidate, + currency = currencyByCode[candidate.rule.currencyCode], + monthLabels = monthLabels, + today = today, + onClick = { onSeriesClick(candidate.seriesId) }, + ) + } +} + +/** + * The same row the transactions tab's upcoming section draws, so a schedule does not change shape + * on the way to the screen that lists all of them. + */ +@Composable +private fun ScheduleRow( + series: RecurringSeries, + currency: Currency?, + monthLabels: List, + today: LocalDate, + onClick: () -> Unit, +) { + // A parked schedule has no next occurrence to promise — the server writes nothing for it until + // the template is repaired — so the subtitle falls back to the cadence alone. Remembered because + // finding the date walks the schedule slot by slot from its start. + val nextOccurrence = + remember(series, today) { + if (series.isActive && !series.needsAttention) { + RecurringOccurrenceCalculator + .upcomingOccurrences( + rule = series.rule, + after = today, + limit = 1, + skippedDates = series.skippedOccurrenceDates, + ).firstOrNull() + } else { + null + } + } + + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 24.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (series.needsAttention) { + EntryIcon( + icon = Res.drawable.ic_calendar, + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + ) + } else { + EntryIcon(Res.drawable.ic_calendar) + } + HorizontalSpacer(12.dp) + Column(modifier = Modifier.weight(1f)) { + Text( + text = series.rule.title, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = + listOfNotNull( + frequencyLabel(series.rule.frequency, series.rule.interval), + nextOccurrence?.let { + stringResource(Res.string.recurring_next_on, formatEntryDate(it, monthLabels)) + }, + ).joinToString(" · "), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + HorizontalSpacer(8.dp) + Column(horizontalAlignment = Alignment.End) { + Text( + text = + formatAmount( + series.rule.amount, + currency?.nativeSymbol ?: series.rule.currencyCode, + currency?.decimalDigits ?: DEFAULT_DECIMALS, + ), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + // A template is not a booked amount, so it reads muted here exactly as it does in + // the upcoming section. + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + when { + series.needsAttention -> { + ScheduleStateChip( + text = stringResource(Res.string.recurring_chip_needs_attention), + color = MaterialTheme.colorScheme.error, + ) + } + + !series.isActive -> { + ScheduleStateChip( + text = stringResource(Res.string.recurring_chip_ended), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} + +@Composable +private fun ScheduleStateChip( + text: String, + color: Color, +) { + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + color = color, + ) +} + +/** The day the server's sweep measures against, so both agree on what is still upcoming. */ +@Composable +private fun rememberTodayUtc(): LocalDate = + remember { + Clock.System + .now() + .toLocalDateTime(TimeZone.UTC) + .date + } + +private const val DEFAULT_DECIMALS = 2 diff --git a/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupschedules/GroupSchedulesState.kt b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupschedules/GroupSchedulesState.kt new file mode 100644 index 00000000..cadcf66b --- /dev/null +++ b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupschedules/GroupSchedulesState.kt @@ -0,0 +1,19 @@ +package de.tabmates.features.tabgroup.presentation.navigation.groupschedules + +import de.tabmates.features.tabgroup.domain.models.Currency +import de.tabmates.features.tabgroup.domain.recurring.RecurringSeries + +/** + * Every schedule in one group, split the way the screen shows them. + * + * Ended schedules are kept rather than dropped: they explain entries that already exist, and hiding + * them would make those entries look like they came from nowhere. + */ +data class GroupSchedulesState( + val isLoading: Boolean = true, + val active: List = emptyList(), + val ended: List = emptyList(), + val currencyByCode: Map = emptyMap(), +) { + val isEmpty: Boolean get() = active.isEmpty() && ended.isEmpty() +} diff --git a/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupschedules/GroupSchedulesViewModel.kt b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupschedules/GroupSchedulesViewModel.kt new file mode 100644 index 00000000..1b5e3319 --- /dev/null +++ b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupschedules/GroupSchedulesViewModel.kt @@ -0,0 +1,48 @@ +package de.tabmates.features.tabgroup.presentation.navigation.groupschedules + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import de.tabmates.features.tabgroup.domain.currency.CurrencyRepository +import de.tabmates.features.tabgroup.domain.recurring.RecurringSeriesRepository +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.WhileSubscribed +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import org.koin.core.annotation.InjectedParam +import org.koin.core.annotation.KoinViewModel +import kotlin.time.Duration.Companion.seconds + +/** + * The group's schedules, listed. Deliberately thin: it reads the local mirror and nothing else. + * + * Separate from `GroupDetailViewModel` rather than reusing it — that one carries the whole group + * screen (entries, balances, history, pagination) and none of it is needed to show a list of rules. + */ +@KoinViewModel +class GroupSchedulesViewModel( + @InjectedParam private val groupId: String, + recurringSeriesRepository: RecurringSeriesRepository, + currencyRepository: CurrencyRepository, +) : ViewModel() { + val state: StateFlow = + combine( + recurringSeriesRepository.getSeriesForGroup(groupId), + currencyRepository.getCurrencies().onStart { emit(emptyList()) }, + ) { series, currencies -> + val (active, ended) = series.partition { it.isActive } + GroupSchedulesState( + isLoading = false, + // Newest first within each group, matching how the rest of the app orders things a + // member created. The list is short enough that no other ordering earns its keep. + active = active.sortedByDescending { it.createdAt }, + ended = ended.sortedByDescending { it.createdAt }, + currencyByCode = currencies.associateBy { it.code }, + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5.seconds), + initialValue = GroupSchedulesState(), + ) +} diff --git a/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupsettings/GroupSettingsScreen.kt b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupsettings/GroupSettingsScreen.kt index 516629a8..ccea5997 100644 --- a/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupsettings/GroupSettingsScreen.kt +++ b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupsettings/GroupSettingsScreen.kt @@ -38,6 +38,7 @@ import de.tabmates.core.designsystem.spacer.VerticalSpacer import de.tabmates.core.designsystem.textfields.TabMatesTextField import de.tabmates.core.presentation.util.ObserveAsEvents import de.tabmates.features.tabgroup.presentation.components.GroupAvatar +import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.vectorResource import org.koin.compose.viewmodel.koinViewModel @@ -56,6 +57,8 @@ import tabmatesapp.features.tabgroup.presentation.generated.resources.group_sett import tabmatesapp.features.tabgroup.presentation.generated.resources.group_settings_people import tabmatesapp.features.tabgroup.presentation.generated.resources.group_settings_save import tabmatesapp.features.tabgroup.presentation.generated.resources.group_settings_saved +import tabmatesapp.features.tabgroup.presentation.generated.resources.group_settings_schedules +import tabmatesapp.features.tabgroup.presentation.generated.resources.ic_calendar import tabmatesapp.features.tabgroup.presentation.generated.resources.ic_chevron_right import tabmatesapp.features.tabgroup.presentation.generated.resources.ic_logout import tabmatesapp.features.tabgroup.presentation.generated.resources.ic_person_add @@ -64,6 +67,7 @@ import tabmatesapp.features.tabgroup.presentation.generated.resources.ic_person_ fun GroupSettingsRoot( groupId: String, onPeopleClick: () -> Unit, + onSchedulesClick: () -> Unit, onLeft: () -> Unit, snackbarHostState: SnackbarHostState, modifier: Modifier = Modifier, @@ -94,6 +98,7 @@ fun GroupSettingsRoot( state = state, onAction = viewModel::onAction, onPeopleClick = onPeopleClick, + onSchedulesClick = onSchedulesClick, modifier = modifier, ) } @@ -103,6 +108,7 @@ private fun GroupSettingsScreen( state: GroupSettingsState, onAction: (GroupSettingsAction) -> Unit, onPeopleClick: () -> Unit, + onSchedulesClick: () -> Unit, modifier: Modifier = Modifier, ) { if (state.isLoading) { @@ -153,11 +159,22 @@ private fun GroupSettingsScreen( modifier = Modifier.widthIn(max = 600.dp).fillMaxWidth(), ) VerticalSpacer(8.dp) - PeopleCard( - peopleCount = state.peopleCount, + // Members and placeholders are managed together one level down; this row is the way in. + NavCard( + icon = Res.drawable.ic_person_add, + label = stringResource(Res.string.group_settings_people), + value = state.peopleCount.toString(), onClick = onPeopleClick, modifier = Modifier.widthIn(max = 600.dp).fillMaxWidth(), ) + // The transactions tab surfaces the schedules that are about to produce something. This is + // the way to the rest of them — ended ones included, which have nothing upcoming to show. + NavCard( + icon = Res.drawable.ic_calendar, + label = stringResource(Res.string.group_settings_schedules), + onClick = onSchedulesClick, + modifier = Modifier.widthIn(max = 600.dp).fillMaxWidth(), + ) VerticalSpacer(8.dp) Text( text = stringResource(Res.string.group_settings_danger_zone), @@ -204,12 +221,14 @@ private fun GroupSettingsScreen( } } -/** Members and placeholders are managed together one level down; this row is the way in. */ +/** One row that opens a screen managing part of the group. */ @Composable -private fun PeopleCard( - peopleCount: Int, +private fun NavCard( + icon: DrawableResource, + label: String, onClick: () -> Unit, modifier: Modifier = Modifier, + value: String? = null, ) { Card( shape = RoundedCornerShape(14.dp), @@ -226,22 +245,24 @@ private fun PeopleCard( verticalAlignment = Alignment.CenterVertically, ) { Icon( - imageVector = vectorResource(Res.drawable.ic_person_add), + imageVector = vectorResource(icon), contentDescription = null, modifier = Modifier.size(20.dp), ) HorizontalSpacer(12.dp) Text( - text = stringResource(Res.string.group_settings_people), + text = label, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f), ) - Text( - text = peopleCount.toString(), - style = MaterialTheme.typography.bodyMedium, - ) - HorizontalSpacer(8.dp) + if (value != null) { + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + ) + HorizontalSpacer(8.dp) + } Icon( imageVector = vectorResource(Res.drawable.ic_chevron_right), contentDescription = null, diff --git a/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/home/HomeViewModel.kt b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/home/HomeViewModel.kt index 95d99617..33becd92 100644 --- a/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/home/HomeViewModel.kt +++ b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/home/HomeViewModel.kt @@ -14,7 +14,7 @@ import de.tabmates.features.tabgroup.domain.models.ExchangeRate import de.tabmates.features.tabgroup.domain.models.Group import de.tabmates.features.tabgroup.domain.models.GroupBalance import de.tabmates.features.tabgroup.domain.models.TabEntry -import de.tabmates.features.tabgroup.domain.tabentry.TabEntryRepository +import de.tabmates.features.tabgroup.domain.recurring.ScheduledLedger import de.tabmates.features.tabgroup.presentation.navigation.groupoverview.byMostRecentActivity import de.tabmates.features.tabgroup.presentation.navigation.groupoverview.toUiItem import de.tabmates.features.tabgroup.presentation.navigation.groupoverview.withStats @@ -34,7 +34,7 @@ import kotlin.time.Duration.Companion.seconds @KoinViewModel class HomeViewModel( groupRepository: GroupRepository, - tabEntryRepository: TabEntryRepository, + scheduledLedger: ScheduledLedger, currencyRepository: CurrencyRepository, exchangeRateRepository: ExchangeRateRepository, currentAccount: CurrentAccount, @@ -54,8 +54,8 @@ class HomeViewModel( } else { combine( groups.map { group -> - tabEntryRepository - .getTabEntriesForGroup(group.id) + scheduledLedger + .observeEntriesForGroup(group.id) .map { entries -> group to entries } }, ) { groupEntries -> buildState(groupEntries.toList(), currencies, rates) } diff --git a/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/recurringdetail/RecurringSeriesDetailEvent.kt b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/recurringdetail/RecurringSeriesDetailEvent.kt new file mode 100644 index 00000000..6a4f91a4 --- /dev/null +++ b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/recurringdetail/RecurringSeriesDetailEvent.kt @@ -0,0 +1,15 @@ +package de.tabmates.features.tabgroup.presentation.navigation.recurringdetail + +import de.tabmates.core.presentation.util.UiText + +sealed interface RecurringSeriesDetailEvent { + /** The schedule was ended; the screen has nothing left to show. */ + data object SeriesEnded : RecurringSeriesDetailEvent + + /** The schedule is gone — deleted with its group, or never synced to this device. */ + data object SeriesUnavailable : RecurringSeriesDetailEvent + + data class Error( + val message: UiText, + ) : RecurringSeriesDetailEvent +} diff --git a/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/recurringdetail/RecurringSeriesDetailRoot.kt b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/recurringdetail/RecurringSeriesDetailRoot.kt new file mode 100644 index 00000000..381a4a7e --- /dev/null +++ b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/recurringdetail/RecurringSeriesDetailRoot.kt @@ -0,0 +1,339 @@ +package de.tabmates.features.tabgroup.presentation.navigation.recurringdetail + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation3.runtime.NavKey +import de.tabmates.core.designsystem.banner.StatusBanner +import de.tabmates.core.designsystem.banner.StatusBannerTone +import de.tabmates.core.designsystem.spacer.VerticalSpacer +import de.tabmates.core.designsystem.text.SectionLabel +import de.tabmates.core.presentation.navigation.TopBarActions +import de.tabmates.core.presentation.util.ObserveAsEvents +import de.tabmates.features.tabgroup.presentation.components.DetailHero +import de.tabmates.features.tabgroup.presentation.components.formatMoney +import de.tabmates.features.tabgroup.presentation.navigation.addentry.formatEntryDate +import de.tabmates.features.tabgroup.presentation.navigation.addentry.rememberMonthAbbreviations +import kotlinx.datetime.LocalDate +import org.jetbrains.compose.resources.DrawableResource +import org.jetbrains.compose.resources.stringResource +import org.jetbrains.compose.resources.vectorResource +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf +import tabmatesapp.features.tabgroup.presentation.generated.resources.Res +import tabmatesapp.features.tabgroup.presentation.generated.resources.ic_calendar +import tabmatesapp.features.tabgroup.presentation.generated.resources.ic_close +import tabmatesapp.features.tabgroup.presentation.generated.resources.ic_delete +import tabmatesapp.features.tabgroup.presentation.generated.resources.ic_edit +import tabmatesapp.features.tabgroup.presentation.generated.resources.ic_refresh +import tabmatesapp.features.tabgroup.presentation.generated.resources.recurring_detail_created_by +import tabmatesapp.features.tabgroup.presentation.generated.resources.recurring_detail_edit_cd +import tabmatesapp.features.tabgroup.presentation.generated.resources.recurring_detail_end_cd +import tabmatesapp.features.tabgroup.presentation.generated.resources.recurring_detail_end_dialog_cancel +import tabmatesapp.features.tabgroup.presentation.generated.resources.recurring_detail_end_dialog_confirm +import tabmatesapp.features.tabgroup.presentation.generated.resources.recurring_detail_end_dialog_message +import tabmatesapp.features.tabgroup.presentation.generated.resources.recurring_detail_end_dialog_title +import tabmatesapp.features.tabgroup.presentation.generated.resources.recurring_detail_ended_note +import tabmatesapp.features.tabgroup.presentation.generated.resources.recurring_detail_needs_attention_message +import tabmatesapp.features.tabgroup.presentation.generated.resources.recurring_detail_needs_attention_title +import tabmatesapp.features.tabgroup.presentation.generated.resources.recurring_detail_offline_note +import tabmatesapp.features.tabgroup.presentation.generated.resources.recurring_detail_skip_cd +import tabmatesapp.features.tabgroup.presentation.generated.resources.recurring_detail_skip_note +import tabmatesapp.features.tabgroup.presentation.generated.resources.recurring_detail_skipped_section +import tabmatesapp.features.tabgroup.presentation.generated.resources.recurring_detail_unavailable +import tabmatesapp.features.tabgroup.presentation.generated.resources.recurring_detail_unskip_cd +import tabmatesapp.features.tabgroup.presentation.generated.resources.recurring_detail_upcoming_section + +/** + * Read-only detail for one schedule, mirroring the entry detail screen: the template on top, the + * actions in the top bar, and nothing editable inline. + */ +@Composable +fun RecurringSeriesDetailRoot( + groupId: String, + seriesId: String, + navKey: NavKey, + snackbarHostState: SnackbarHostState, + onBack: () -> Unit, + onEdit: (String) -> Unit, + modifier: Modifier = Modifier, + viewModel: RecurringSeriesDetailViewModel = + koinViewModel(key = seriesId, parameters = { parametersOf(groupId, seriesId) }), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + + ObserveAsEvents(viewModel.events) { event -> + when (event) { + RecurringSeriesDetailEvent.SeriesEnded -> { + onBack() + } + + RecurringSeriesDetailEvent.SeriesUnavailable -> { + onBack() + } + + is RecurringSeriesDetailEvent.Error -> { + snackbarHostState.showSnackbar(event.message.asStringAsync()) + } + } + } + + // A series that never arrives is a series that is gone — deleted with its group, or never + // synced here. Leaving the screen on an empty page would look like a hang. + LaunchedEffect(state.isLoading, state.series) { + if (!state.isLoading && state.series == null) viewModel.onMissingSeries() + } + + // Same two actions, in the same place, as an entry's detail screen. Before this they lived + // nowhere: editing was reachable only by tapping the banner a *broken* schedule shows, so a + // healthy one could not be edited at all. + TopBarActions(navKey) { + if (state.canEdit) { + IconButton(onClick = { onEdit(seriesId) }) { + Icon( + imageVector = vectorResource(Res.drawable.ic_edit), + contentDescription = stringResource(Res.string.recurring_detail_edit_cd), + ) + } + } + if (state.canEnd) { + IconButton(onClick = viewModel::onEndClick) { + Icon( + imageVector = vectorResource(Res.drawable.ic_delete), + contentDescription = stringResource(Res.string.recurring_detail_end_cd), + ) + } + } + } + + RecurringSeriesDetailScreen( + state = state, + onSkip = viewModel::onSkipOccurrence, + onUnskip = viewModel::onUnskipOccurrence, + onEndDismiss = viewModel::onEndDismiss, + onEndConfirm = viewModel::onEndConfirm, + onEdit = { onEdit(seriesId) }, + modifier = modifier, + ) +} + +@Composable +private fun RecurringSeriesDetailScreen( + state: RecurringSeriesDetailState, + onSkip: (LocalDate) -> Unit, + onUnskip: (LocalDate) -> Unit, + onEndDismiss: () -> Unit, + onEndConfirm: () -> Unit, + onEdit: () -> Unit, + modifier: Modifier = Modifier, +) { + val series = state.series + if (state.isLoading || series == null) { + Column( + modifier = modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (state.isLoading) { + VerticalSpacer(48.dp) + CircularProgressIndicator() + } else { + VerticalSpacer(48.dp) + Text( + text = stringResource(Res.string.recurring_detail_unavailable), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + return + } + + val monthLabels = rememberMonthAbbreviations() + + Column( + modifier = + modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + ) { + // Both notices sit above the masthead: they change what the rest of the screen means, so + // reading them after the amount would be reading them too late. + if (state.needsAttention) { + StatusBanner( + text = + stringResource(Res.string.recurring_detail_needs_attention_title) + + " · " + + stringResource( + Res.string.recurring_detail_needs_attention_message, + state.departedParticipants.joinToString { it.username }, + ), + tone = StatusBannerTone.Attention, + // A second, louder way to the edit form than the pencil in the top bar. + onClick = onEdit.takeIf { state.canEdit }, + modifier = Modifier.padding(horizontal = 16.dp).padding(top = 12.dp), + ) + } + if (!state.isActive) { + Text( + text = stringResource(Res.string.recurring_detail_ended_note), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp).padding(top = 12.dp), + ) + } + + VerticalSpacer(8.dp) + // The cadence takes the slot an entry's date takes, because it is the same answer to the + // same question: when does this hit the ledger? + DetailHero( + icon = Res.drawable.ic_calendar, + title = series.rule.title, + amountFormatted = + formatMoney(state.currencySymbol, series.rule.amount, state.currencyDecimalDigits), + subtitle = scheduleSummary(series, monthLabels), + description = series.rule.description, + ) + VerticalSpacer(8.dp) + Text( + text = stringResource(Res.string.recurring_detail_created_by, series.createdBy.username), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + textAlign = TextAlign.Center, + ) + + VerticalSpacer(24.dp) + Column(modifier = Modifier.padding(horizontal = 16.dp)) { + if (state.upcomingOccurrences.isNotEmpty()) { + SectionLabel( + text = stringResource(Res.string.recurring_detail_upcoming_section), + fontWeight = FontWeight.SemiBold, + ) + VerticalSpacer(8.dp) + state.upcomingOccurrences.forEach { date -> + val label = formatEntryDate(date, monthLabels) + OccurrenceRow( + label = label, + actionIcon = Res.drawable.ic_close, + actionDescription = stringResource(Res.string.recurring_detail_skip_cd, label), + // Only a future, not-yet-created occurrence can be skipped; a created one is + // an ordinary entry and is deleted from the entry itself. + actionEnabled = state.canEdit, + onAction = { onSkip(date) }, + ) + } + Text( + text = stringResource(Res.string.recurring_detail_skip_note), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp), + ) + } + + if (state.skippedUpcoming.isNotEmpty()) { + VerticalSpacer(20.dp) + SectionLabel( + text = stringResource(Res.string.recurring_detail_skipped_section), + fontWeight = FontWeight.SemiBold, + ) + VerticalSpacer(8.dp) + state.skippedUpcoming.forEach { date -> + val label = formatEntryDate(date, monthLabels) + OccurrenceRow( + label = label, + actionIcon = Res.drawable.ic_refresh, + actionDescription = stringResource(Res.string.recurring_detail_unskip_cd, label), + actionEnabled = state.canEdit, + onAction = { onUnskip(date) }, + ) + } + } + + // Offline the top bar shows no actions at all, and nothing else on screen would say why. + if (!state.isOnline) { + VerticalSpacer(16.dp) + Text( + text = stringResource(Res.string.recurring_detail_offline_note), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + VerticalSpacer(24.dp) + } + + if (state.isEndDialogVisible) { + AlertDialog( + onDismissRequest = onEndDismiss, + title = { Text(stringResource(Res.string.recurring_detail_end_dialog_title)) }, + text = { Text(stringResource(Res.string.recurring_detail_end_dialog_message)) }, + confirmButton = { + TextButton(onClick = onEndConfirm) { + Text( + text = stringResource(Res.string.recurring_detail_end_dialog_confirm), + color = MaterialTheme.colorScheme.error, + ) + } + }, + dismissButton = { + TextButton(onClick = onEndDismiss) { + Text(stringResource(Res.string.recurring_detail_end_dialog_cancel)) + } + }, + ) + } +} + +/** + * One future date and the one thing you can do to it. + * + * The action is an icon rather than a labelled button because these rows repeat: six identical + * "Skip" buttons were the loudest thing on the screen and six identical screen-reader + * announcements. The description names the date so each one still says which date it acts on. + */ +@Composable +private fun OccurrenceRow( + label: String, + actionIcon: DrawableResource, + actionDescription: String, + actionEnabled: Boolean, + onAction: () -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f), + ) + IconButton(onClick = onAction, enabled = actionEnabled) { + Icon( + imageVector = vectorResource(actionIcon), + contentDescription = actionDescription, + ) + } + } +} diff --git a/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/recurringdetail/RecurringSeriesDetailState.kt b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/recurringdetail/RecurringSeriesDetailState.kt new file mode 100644 index 00000000..515dd815 --- /dev/null +++ b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/recurringdetail/RecurringSeriesDetailState.kt @@ -0,0 +1,42 @@ +package de.tabmates.features.tabgroup.presentation.navigation.recurringdetail + +import de.tabmates.features.tabgroup.domain.models.GroupParticipant +import de.tabmates.features.tabgroup.domain.recurring.RecurringSeries +import kotlinx.datetime.LocalDate + +data class RecurringSeriesDetailState( + val isLoading: Boolean = true, + val series: RecurringSeries? = null, + val participantsById: Map = emptyMap(), + val currencySymbol: String = "", + val currencyDecimalDigits: Int = 2, + /** The next occurrences the schedule will produce, skipped dates already left out. */ + val upcomingOccurrences: List = emptyList(), + /** Dates a member skipped that have not passed yet, so they can be un-skipped. */ + val skippedUpcoming: List = emptyList(), + /** + * Members named by the template who are no longer in the group. This is what + * [RecurringSeries.needsAttention] actually means, and naming them is the difference between a + * warning somebody can act on and one they cannot. + */ + val departedParticipants: List = emptyList(), + val isOnline: Boolean = true, + val isMutating: Boolean = false, + val isEndDialogVisible: Boolean = false, +) { + val needsAttention: Boolean + get() = series?.needsAttention == true + + val isActive: Boolean + get() = series?.isActive == true + + /** + * Ending is one-way in this UI. The server's only route back is an edit, which revives the + * series as a side effect — not something to expose behind a button labelled anything else. + */ + val canEdit: Boolean + get() = isActive && isOnline && !isMutating + + val canEnd: Boolean + get() = isActive && isOnline && !isMutating +} diff --git a/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/recurringdetail/RecurringSeriesDetailViewModel.kt b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/recurringdetail/RecurringSeriesDetailViewModel.kt new file mode 100644 index 00000000..82804621 --- /dev/null +++ b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/recurringdetail/RecurringSeriesDetailViewModel.kt @@ -0,0 +1,203 @@ +package de.tabmates.features.tabgroup.presentation.navigation.recurringdetail + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import de.tabmates.core.domain.util.DataError +import de.tabmates.core.domain.util.EmptyResult +import de.tabmates.core.domain.util.onFailure +import de.tabmates.core.domain.util.onSuccess +import de.tabmates.core.presentation.util.toUiText +import de.tabmates.features.tabgroup.domain.currency.CurrencyRepository +import de.tabmates.features.tabgroup.domain.group.GroupRepository +import de.tabmates.features.tabgroup.domain.recurring.RecurringOccurrenceCalculator +import de.tabmates.features.tabgroup.domain.recurring.RecurringSeries +import de.tabmates.features.tabgroup.domain.recurring.RecurringSeriesRepository +import de.tabmates.features.tabgroup.domain.sync.ConnectionStatusRepository +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.WhileSubscribed +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import org.koin.core.annotation.InjectedParam +import org.koin.core.annotation.KoinViewModel +import kotlin.time.Clock +import kotlin.time.Duration.Companion.seconds + +/** + * A read-only view of one schedule, plus the three things a member can do to it: skip a future + * occurrence, edit the template from a future occurrence onwards, and end it. + * + * All three go straight to the server. There is no outbox behind a schedule — it is a standing + * instruction to write into other people's ledgers — so every action is gated on being online and + * reports its own failure rather than deferring. + */ +@KoinViewModel +class RecurringSeriesDetailViewModel( + @InjectedParam private val groupId: String, + @InjectedParam private val seriesId: String, + private val recurringSeriesRepository: RecurringSeriesRepository, + groupRepository: GroupRepository, + currencyRepository: CurrencyRepository, + connectionStatusRepository: ConnectionStatusRepository, +) : ViewModel() { + private val mutation = MutableStateFlow(MutationState()) + + private val eventChannel = Channel() + val events = eventChannel.receiveAsFlow() + + val state: StateFlow = + combine( + recurringSeriesRepository.getSeriesById(seriesId).onStart { emit(null) }, + groupRepository.getGroups().map { groups -> groups.firstOrNull { it.id == groupId } }, + groupRepository.getAllParticipants().onStart { emit(emptyList()) }, + currencyRepository.getCurrencies().onStart { emit(emptyList()) }, + mutation, + ) { series, group, allParticipants, currencies, mutation -> + if (series == null) { + return@combine RecurringSeriesDetailState(isLoading = false, isOnline = mutation.isOnline) + } + + val today = todayUtc() + val currency = currencies.firstOrNull { it.code == series.rule.currencyCode } + // Names come from every known participant, not the group's current members: a template + // can outlive the membership of the people in it, and naming them is the whole point of + // the warning shown when it does. + val participantsById = + (allParticipants + group?.participants.orEmpty()).associateBy { it.userId } + val activeMemberIds = group?.participants.orEmpty().mapTo(mutableSetOf()) { it.userId } + + RecurringSeriesDetailState( + isLoading = false, + series = series, + participantsById = participantsById, + currencySymbol = currency?.nativeSymbol ?: series.rule.currencyCode, + currencyDecimalDigits = currency?.decimalDigits ?: DEFAULT_DECIMALS, + upcomingOccurrences = + if (series.isActive) { + RecurringOccurrenceCalculator.upcomingOccurrences( + rule = series.rule, + after = today, + limit = UPCOMING_COUNT, + skippedDates = series.skippedOccurrenceDates, + ) + } else { + emptyList() + }, + skippedUpcoming = series.skippedOccurrenceDates.filter { it > today }.sorted(), + departedParticipants = + series + .templateParticipantIds() + .filterNot { it in activeMemberIds } + .mapNotNull { participantsById[it] }, + isOnline = mutation.isOnline, + isMutating = mutation.isMutating, + isEndDialogVisible = mutation.isEndDialogVisible, + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5.seconds), + initialValue = RecurringSeriesDetailState(), + ) + + init { + connectionStatusRepository.isConnected + .onEach { connected -> mutation.update { it.copy(isOnline = connected) } } + .launchIn(viewModelScope) + } + + /** + * Skips one future occurrence. The slot is still consumed, so the schedule does not run a + * period longer to make up for it — the copy on screen says so. + */ + fun onSkipOccurrence(date: LocalDate) { + runMutation { recurringSeriesRepository.skipOccurrence(seriesId, date) } + } + + fun onUnskipOccurrence(date: LocalDate) { + runMutation { recurringSeriesRepository.unskipOccurrence(seriesId, date) } + } + + fun onEndClick() { + mutation.update { it.copy(isEndDialogVisible = true) } + } + + fun onEndDismiss() { + mutation.update { it.copy(isEndDialogVisible = false) } + } + + /** Stops the schedule. Entries it already produced are ordinary entries and are left alone. */ + fun onEndConfirm() { + mutation.update { it.copy(isEndDialogVisible = false) } + runMutation(onSuccess = RecurringSeriesDetailEvent.SeriesEnded) { + recurringSeriesRepository.endSeries(seriesId) + } + } + + /** + * Tells the screen the schedule is gone rather than leaving it on a blank page. Separate from + * the loading state so a slow first read is not mistaken for a missing series. + */ + fun onMissingSeries() { + viewModelScope.launch { + if (recurringSeriesRepository.getSeriesById(seriesId).first() == null) { + eventChannel.send(RecurringSeriesDetailEvent.SeriesUnavailable) + } + } + } + + private fun runMutation( + onSuccess: RecurringSeriesDetailEvent? = null, + block: suspend () -> EmptyResult, + ) { + if (mutation.value.isMutating) return + viewModelScope.launch { + mutation.update { it.copy(isMutating = true) } + block() + .onSuccess { + mutation.update { current -> current.copy(isMutating = false) } + onSuccess?.let { eventChannel.send(it) } + }.onFailure { error -> + mutation.update { current -> current.copy(isMutating = false) } + eventChannel.send(RecurringSeriesDetailEvent.Error(error.toUiText())) + } + } + } + + private fun RecurringSeries.templateParticipantIds(): List = + buildList { + add(rule.paidByUserId) + rule.receivedByUserId?.let(::add) + rule.splits.mapTo(this) { it.participantId } + }.distinct() + + /** Matches the day the server's sweep measures against, so both agree on what is still future. */ + private fun todayUtc(): LocalDate = + Clock.System + .now() + .toLocalDateTime(TimeZone.UTC) + .date + + private data class MutationState( + val isOnline: Boolean = true, + val isMutating: Boolean = false, + val isEndDialogVisible: Boolean = false, + ) + + private companion object { + const val UPCOMING_COUNT = 6 + const val DEFAULT_DECIMALS = 2 + } +} diff --git a/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/recurringdetail/ScheduleSummary.kt b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/recurringdetail/ScheduleSummary.kt new file mode 100644 index 00000000..c80437cb --- /dev/null +++ b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/recurringdetail/ScheduleSummary.kt @@ -0,0 +1,72 @@ +package de.tabmates.features.tabgroup.presentation.navigation.recurringdetail + +import androidx.compose.runtime.Composable +import de.tabmates.features.tabgroup.domain.recurring.RecurrenceFrequency +import de.tabmates.features.tabgroup.domain.recurring.RecurringEnd +import de.tabmates.features.tabgroup.domain.recurring.RecurringSeries +import de.tabmates.features.tabgroup.presentation.navigation.addentry.formatEntryDate +import org.jetbrains.compose.resources.stringResource +import tabmatesapp.features.tabgroup.presentation.generated.resources.Res +import tabmatesapp.features.tabgroup.presentation.generated.resources.add_entry_repeat_daily +import tabmatesapp.features.tabgroup.presentation.generated.resources.add_entry_repeat_every_n_days +import tabmatesapp.features.tabgroup.presentation.generated.resources.add_entry_repeat_every_n_months +import tabmatesapp.features.tabgroup.presentation.generated.resources.add_entry_repeat_every_n_weeks +import tabmatesapp.features.tabgroup.presentation.generated.resources.add_entry_repeat_every_n_years +import tabmatesapp.features.tabgroup.presentation.generated.resources.add_entry_repeat_monthly +import tabmatesapp.features.tabgroup.presentation.generated.resources.add_entry_repeat_weekly +import tabmatesapp.features.tabgroup.presentation.generated.resources.add_entry_repeat_yearly +import tabmatesapp.features.tabgroup.presentation.generated.resources.recurring_ends_after +import tabmatesapp.features.tabgroup.presentation.generated.resources.recurring_ends_on +import tabmatesapp.features.tabgroup.presentation.generated.resources.recurring_starts_on + +/** + * How a schedule reads in one line: cadence, when it started, and how it stops. + * + * Resolved in a composable and passed down rather than built in a ViewModel — `getString()` off the + * composition crashes the headless desktop tests. + */ +@Composable +fun scheduleSummary( + series: RecurringSeries, + monthLabels: List, +): String { + val rule = series.rule + val cadence = frequencyLabel(rule.frequency, rule.interval) + val start = stringResource(Res.string.recurring_starts_on, formatEntryDate(rule.startDate, monthLabels)) + val end = + when (val ruleEnd = rule.end) { + RecurringEnd.Never -> { + null + } + + is RecurringEnd.Until -> { + stringResource(Res.string.recurring_ends_on, formatEntryDate(ruleEnd.date, monthLabels)) + } + + is RecurringEnd.Count -> { + stringResource(Res.string.recurring_ends_after, ruleEnd.count) + } + } + return listOfNotNull(cadence, start, end).joinToString(" · ") +} + +@Composable +fun frequencyLabel( + frequency: RecurrenceFrequency, + interval: Int, +): String = + if (interval == 1) { + when (frequency) { + RecurrenceFrequency.DAILY -> stringResource(Res.string.add_entry_repeat_daily) + RecurrenceFrequency.WEEKLY -> stringResource(Res.string.add_entry_repeat_weekly) + RecurrenceFrequency.MONTHLY -> stringResource(Res.string.add_entry_repeat_monthly) + RecurrenceFrequency.YEARLY -> stringResource(Res.string.add_entry_repeat_yearly) + } + } else { + when (frequency) { + RecurrenceFrequency.DAILY -> stringResource(Res.string.add_entry_repeat_every_n_days, interval) + RecurrenceFrequency.WEEKLY -> stringResource(Res.string.add_entry_repeat_every_n_weeks, interval) + RecurrenceFrequency.MONTHLY -> stringResource(Res.string.add_entry_repeat_every_n_months, interval) + RecurrenceFrequency.YEARLY -> stringResource(Res.string.add_entry_repeat_every_n_years, interval) + } + } diff --git a/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/settlementdetail/SettlementDetailRoot.kt b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/settlementdetail/SettlementDetailRoot.kt index 6f4c5278..9b66d7e4 100644 --- a/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/settlementdetail/SettlementDetailRoot.kt +++ b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/settlementdetail/SettlementDetailRoot.kt @@ -1,16 +1,12 @@ package de.tabmates.features.tabgroup.presentation.navigation.settlementdetail -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator @@ -38,7 +34,7 @@ import de.tabmates.core.designsystem.text.SectionLabel import de.tabmates.core.presentation.navigation.TopBarActions import de.tabmates.core.presentation.util.ObserveAsEvents import de.tabmates.features.tabgroup.domain.models.GroupParticipant -import de.tabmates.features.tabgroup.presentation.components.SyncStatusChip +import de.tabmates.features.tabgroup.presentation.components.DetailHero import de.tabmates.features.tabgroup.presentation.components.formatMoney import de.tabmates.features.tabgroup.presentation.navigation.addentry.formatEntryDate import de.tabmates.features.tabgroup.presentation.navigation.addentry.rememberMonthAbbreviations @@ -150,7 +146,8 @@ private fun SettlementDetailScreen( return@Column } VerticalSpacer(8.dp) - HeroSection( + DetailHero( + icon = Res.drawable.ic_swap_horiz, title = settlement.title, amountFormatted = formatMoney( @@ -158,7 +155,7 @@ private fun SettlementDetailScreen( settlement.amount, state.groupCurrencyDecimalDigits, ), - dateText = formatEntryDate(settlement.entryDate, monthLabels), + subtitle = formatEntryDate(settlement.entryDate, monthLabels), isPendingSync = settlement.isPendingSync, ) VerticalSpacer(24.dp) @@ -191,56 +188,6 @@ private fun SettlementDetailScreen( } } -@Composable -private fun HeroSection( - title: String, - amountFormatted: String, - dateText: String, - isPendingSync: Boolean, -) { - Column( - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Box( - modifier = - Modifier - .size(96.dp) - .background( - color = MaterialTheme.colorScheme.tertiaryContainer, - shape = CircleShape, - ), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = vectorResource(Res.drawable.ic_swap_horiz), - contentDescription = null, - tint = MaterialTheme.colorScheme.onTertiaryContainer, - modifier = Modifier.size(40.dp), - ) - } - Text( - text = title, - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.SemiBold, - ) - if (isPendingSync) { - SyncStatusChip() - } - Text( - text = amountFormatted, - style = MaterialTheme.typography.displaySmall, - fontWeight = FontWeight.Bold, - ) - Text( - text = dateText, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } -} - @Composable private fun ParticipantRow( participant: GroupParticipant?, diff --git a/features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/navigation/addentry/AddEntryRecurringTest.kt b/features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/navigation/addentry/AddEntryRecurringTest.kt new file mode 100644 index 00000000..548b8052 --- /dev/null +++ b/features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/navigation/addentry/AddEntryRecurringTest.kt @@ -0,0 +1,273 @@ +package de.tabmates.features.tabgroup.presentation.navigation.addentry + +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import de.tabmates.core.presentation.format.NumberSymbols +import de.tabmates.features.tabgroup.domain.models.TabEntry +import de.tabmates.features.tabgroup.domain.recurring.RecurrenceFrequency +import de.tabmates.features.tabgroup.domain.recurring.RecurringEnd +import de.tabmates.features.tabgroup.presentation.navigation.creategroup.FakeCurrencyRepository +import de.tabmates.features.tabgroup.presentation.navigation.creategroup.FakeGroupRepository +import de.tabmates.features.tabgroup.presentation.testing.FakeConnectionStatusRepository +import de.tabmates.features.tabgroup.presentation.testing.FakeCurrentAccount +import de.tabmates.features.tabgroup.presentation.testing.FakeExchangeRateRepository +import de.tabmates.features.tabgroup.presentation.testing.FakeRecurringSeriesRepository +import de.tabmates.features.tabgroup.presentation.testing.FakeTabEntryRepository +import de.tabmates.features.tabgroup.presentation.testing.Fixtures +import de.tabmates.features.tabgroup.presentation.testing.RecurringFixtures +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.plus +import kotlinx.datetime.toLocalDateTime +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Clock + +/** + * The form's two new jobs: creating settlements, and creating a schedule instead of an entry. + * + * The second one is the load-bearing case. The server writes a schedule's first occurrence itself, + * so a form that saved both an entry and a schedule would book the same thing twice in everybody's + * ledger — with no error anywhere. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class AddEntryRecurringTest { + private val dispatcher = UnconfinedTestDispatcher() + private val alice = Fixtures.participant(id = "user-1", name = "Alice") + private val bob = Fixtures.participant(id = "user-2", name = "Bob") + + @BeforeTest + fun setUp() = Dispatchers.setMain(dispatcher) + + @AfterTest + fun tearDown() = Dispatchers.resetMain() + + @Test + fun `setting a repeat creates a series and no one-off entry`() = + runTest(dispatcher) { + val entries = FakeTabEntryRepository() + val series = FakeRecurringSeriesRepository() + val viewModel = viewModel(entries, series) + activate(viewModel) + + viewModel.state.value.titleTextState + .setTextAndPlaceCursorAtEnd("Rent") + viewModel.state.value.amountTextState + .setTextAndPlaceCursorAtEnd("900") + viewModel.onRepeatFrequencyChange(RecurrenceFrequency.MONTHLY) + viewModel.onRepeatIntervalChange(1) + viewModel.onRepeatStartDateChange(today()) + viewModel.onRepeatEndChange(RecurringEnd.Never) + viewModel.onSaveClick() + advanceUntilIdle() + + assertEquals(1, series.recordedWrites.count { it.startsWith("create:") }) + // The sweep writes the first occurrence; writing one here too would double-book it. + assertTrue(entries.getTabEntriesForGroup("g1").first().isEmpty()) + } + + @Test + fun `leaving repeat unset still creates an ordinary entry`() = + runTest(dispatcher) { + val entries = FakeTabEntryRepository() + val series = FakeRecurringSeriesRepository() + val viewModel = viewModel(entries, series) + activate(viewModel) + + viewModel.state.value.titleTextState + .setTextAndPlaceCursorAtEnd("Lunch") + viewModel.state.value.amountTextState + .setTextAndPlaceCursorAtEnd("20") + viewModel.onSaveClick() + advanceUntilIdle() + + assertEquals(1, entries.getTabEntriesForGroup("g1").first().size) + assertTrue(series.recordedWrites.isEmpty()) + } + + @Test + fun `a repeat starting in the past is pulled forward to today`() = + runTest(dispatcher) { + // The server refuses a schedule that reaches back and invents entries nobody agreed to, + // so the form corrects the date rather than posting a request that comes back a 400. + val viewModel = viewModel() + activate(viewModel) + + viewModel.onRepeatFrequencyChange(RecurrenceFrequency.MONTHLY) + viewModel.onRepeatStartDateChange(LocalDate(2020, 1, 1)) + // Closing the editor is what lines the entry date up with the schedule. + viewModel.onRepeatDismiss() + advanceUntilIdle() + + assertEquals( + today(), + viewModel.state.value.repeat + ?.startDate, + ) + assertEquals(today(), viewModel.state.value.entryDate) + } + + @Test + fun `switching to Never and back keeps the interval and end rule`() = + runTest(dispatcher) { + // The editor holds these as separate fields for exactly this reason: flipping through + // "Never" while deciding must not silently reset a schedule the user already tuned. + val viewModel = viewModel() + activate(viewModel) + + viewModel.onRepeatFrequencyChange(RecurrenceFrequency.WEEKLY) + viewModel.onRepeatIntervalChange(3) + viewModel.onRepeatEndChange(RecurringEnd.Count(8)) + viewModel.onRepeatFrequencyChange(null) + advanceUntilIdle() + + assertNull(viewModel.state.value.repeat) + + viewModel.onRepeatFrequencyChange(RecurrenceFrequency.WEEKLY) + advanceUntilIdle() + + val repeat = assertNotNull(viewModel.state.value.repeat) + assertEquals(3, repeat.interval) + assertEquals(RecurringEnd.Count(8), repeat.end) + } + + @Test + fun `opening the editor seeds its start date from a future entry date`() = + runTest(dispatcher) { + val viewModel = viewModel() + activate(viewModel) + + val future = LocalDate(today().year + 1, 6, 15) + viewModel.onDateSelected(future.atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds()) + viewModel.onRepeatOpen() + advanceUntilIdle() + + assertEquals(future, viewModel.state.value.repeatStartDate) + } + + @Test + fun `creating a settlement writes a settlement entry`() = + runTest(dispatcher) { + val entries = FakeTabEntryRepository() + val viewModel = viewModel(entries) + activate(viewModel) + + viewModel.onKindChange(EntryKind.SETTLEMENT) + viewModel.onPaidBySelected("user-1") + viewModel.onReceivedBySelected("user-2") + viewModel.state.value.titleTextState + .setTextAndPlaceCursorAtEnd("Payback") + viewModel.state.value.amountTextState + .setTextAndPlaceCursorAtEnd("30") + viewModel.onSaveClick() + advanceUntilIdle() + + val settlement = assertIs(entries.getTabEntriesForGroup("g1").first().single()) + assertEquals("user-1", settlement.paidByUserId) + assertEquals("user-2", settlement.receivedByUserId) + assertEquals(30.0, settlement.amount) + } + + @Test + fun `a settlement to yourself is refused before it reaches the server`() = + runTest(dispatcher) { + val entries = FakeTabEntryRepository() + val viewModel = viewModel(entries) + activate(viewModel) + + viewModel.onKindChange(EntryKind.SETTLEMENT) + viewModel.onPaidBySelected("user-1") + viewModel.onReceivedBySelected("user-1") + viewModel.state.value.titleTextState + .setTextAndPlaceCursorAtEnd("Oops") + viewModel.state.value.amountTextState + .setTextAndPlaceCursorAtEnd("30") + viewModel.onSaveClick() + advanceUntilIdle() + + assertTrue(entries.getTabEntriesForGroup("g1").first().isEmpty()) + } + + @Test + fun `editing a series seeds the split rows from its template`() = + runTest(dispatcher) { + // The template's splits are the only ones a schedule has — it owns no entry to read + // them from. Loading none left every row unchecked, which saves the edit with nobody + // on it or fails validation outright. + val series = FakeRecurringSeriesRepository() + series.setSeries( + RecurringFixtures.series( + startDate = today().plus(1, DateTimeUnit.MONTH), + splits = + listOf( + RecurringFixtures.templateSplit("user-1", resolvedAmount = 60.0), + RecurringFixtures.templateSplit("user-2", resolvedAmount = 40.0), + ), + ), + ) + val viewModel = viewModel(series = series, seriesId = "series-1") + activate(viewModel) + + val state = viewModel.state.value + assertEquals(setOf("user-1", "user-2"), state.splitInputs.map { it.participantId }.toSet()) + assertTrue( + state.splitInputs.all { it.included }, + "everyone the template splits across starts out included", + ) + assertEquals("Rent", state.titleTextState.text.toString()) + } + + private fun today() = + Clock.System + .now() + .toLocalDateTime(TimeZone.UTC) + .date + + /** + * Keeps a collector alive for the whole test. A one-shot `first()` lets `stateIn`'s + * `WhileSubscribed` window lapse the moment virtual time is advanced, after which `state.value` + * silently stops tracking the ViewModel. + */ + private fun TestScope.activate(viewModel: AddEntryViewModel) { + backgroundScope.launch { viewModel.state.collect {} } + advanceUntilIdle() + } + + private fun viewModel( + entries: FakeTabEntryRepository = FakeTabEntryRepository(), + series: FakeRecurringSeriesRepository = FakeRecurringSeriesRepository(), + seriesId: String = "", + ) = AddEntryViewModel( + groupId = "g1", + entryId = "", + seriesId = seriesId, + tabEntryRepository = entries, + recurringSeriesRepository = series, + connectionStatusRepository = FakeConnectionStatusRepository(), + groupRepository = + FakeGroupRepository( + initialGroups = listOf(Fixtures.group(id = "g1", participants = setOf(alice, bob))), + ), + currencyRepository = FakeCurrencyRepository(), + exchangeRateRepository = FakeExchangeRateRepository(), + currentAccount = FakeCurrentAccount(), + numberSymbols = NumberSymbols.Fallback, + ) +} diff --git a/features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/navigation/addentry/AddEntryViewModelTest.kt b/features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/navigation/addentry/AddEntryViewModelTest.kt index cc025c7c..1939ccde 100644 --- a/features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/navigation/addentry/AddEntryViewModelTest.kt +++ b/features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/navigation/addentry/AddEntryViewModelTest.kt @@ -7,8 +7,10 @@ import de.tabmates.features.tabgroup.domain.models.SplitType import de.tabmates.features.tabgroup.domain.models.TabEntry import de.tabmates.features.tabgroup.presentation.navigation.creategroup.FakeCurrencyRepository import de.tabmates.features.tabgroup.presentation.navigation.creategroup.FakeGroupRepository +import de.tabmates.features.tabgroup.presentation.testing.FakeConnectionStatusRepository import de.tabmates.features.tabgroup.presentation.testing.FakeCurrentAccount import de.tabmates.features.tabgroup.presentation.testing.FakeExchangeRateRepository +import de.tabmates.features.tabgroup.presentation.testing.FakeRecurringSeriesRepository import de.tabmates.features.tabgroup.presentation.testing.FakeTabEntryRepository import de.tabmates.features.tabgroup.presentation.testing.Fixtures import kotlinx.coroutines.Dispatchers @@ -523,6 +525,7 @@ class AddEntryViewModelTest { groupId: String, entryId: String = "", tabEntryRepository: FakeTabEntryRepository = FakeTabEntryRepository(), + recurringSeriesRepository: FakeRecurringSeriesRepository = FakeRecurringSeriesRepository(), groupRepository: FakeGroupRepository = FakeGroupRepository(initialGroups = listOf(Fixtures.group(id = "g1", currency = "EUR"))), currencyRepository: FakeCurrencyRepository = FakeCurrencyRepository(), @@ -532,7 +535,10 @@ class AddEntryViewModelTest { AddEntryViewModel( groupId = groupId, entryId = entryId, + seriesId = "", tabEntryRepository = tabEntryRepository, + recurringSeriesRepository = recurringSeriesRepository, + connectionStatusRepository = FakeConnectionStatusRepository(), groupRepository = groupRepository, currencyRepository = currencyRepository, exchangeRateRepository = exchangeRateRepository, diff --git a/features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/navigation/editsettlement/EditSettlementViewModelTest.kt b/features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/navigation/editsettlement/EditSettlementViewModelTest.kt deleted file mode 100644 index 6cff2606..00000000 --- a/features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/navigation/editsettlement/EditSettlementViewModelTest.kt +++ /dev/null @@ -1,188 +0,0 @@ -package de.tabmates.features.tabgroup.presentation.navigation.editsettlement - -import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd -import de.tabmates.core.presentation.format.NumberSymbols -import de.tabmates.features.tabgroup.domain.models.TabEntry -import de.tabmates.features.tabgroup.presentation.navigation.creategroup.FakeCurrencyRepository -import de.tabmates.features.tabgroup.presentation.navigation.creategroup.FakeGroupRepository -import de.tabmates.features.tabgroup.presentation.testing.FakeCurrentAccount -import de.tabmates.features.tabgroup.presentation.testing.FakeTabEntryRepository -import de.tabmates.features.tabgroup.presentation.testing.Fixtures -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain -import kotlinx.datetime.LocalDate -import kotlinx.datetime.TimeZone -import kotlinx.datetime.atStartOfDayIn -import kotlin.test.AfterTest -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertIs - -@OptIn(ExperimentalCoroutinesApi::class) -class EditSettlementViewModelTest { - private val testDispatcher = UnconfinedTestDispatcher() - - @BeforeTest - fun setUp() { - Dispatchers.setMain(testDispatcher) - } - - @AfterTest - fun tearDown() { - Dispatchers.resetMain() - } - - @Test - fun prefillsAmountDateAndFixedFields() = - runTest(testDispatcher) { - val tabEntryRepo = FakeTabEntryRepository() - tabEntryRepo.emit( - groupId = "g1", - entries = - listOf( - Fixtures.settlement( - id = "s1", - groupId = "g1", - amount = 12.5, - paidByUserId = "user-1", - receivedByUserId = "user-2", - entryDate = LocalDate.parse("2024-03-05"), - ), - ), - ) - val viewModel = createViewModel(tabEntryRepository = tabEntryRepo) - activateState(viewModel) - advanceUntilIdle() - - val state = viewModel.state.value - assertFalse(state.isLoading) - assertEquals("12.50", state.amountTextState.text.toString()) - assertEquals(LocalDate.parse("2024-03-05"), state.entryDate) - assertEquals("Settlement", state.title) - assertEquals("EUR", state.currencyCode) - assertEquals("user-1", state.paidByUserId) - assertEquals("user-2", state.receivedByUserId) - } - - @Test - fun saveUpdatesAmountAndDateKeepingFixedFields() = - runTest(testDispatcher) { - val tabEntryRepo = FakeTabEntryRepository() - tabEntryRepo.emit( - groupId = "g1", - entries = - listOf( - Fixtures.settlement( - id = "s1", - groupId = "g1", - amount = 12.5, - paidByUserId = "user-1", - receivedByUserId = "user-2", - entryDate = LocalDate.parse("2024-03-05"), - ), - ), - ) - val viewModel = createViewModel(tabEntryRepository = tabEntryRepo) - val events = collectEvents(viewModel) - activateState(viewModel) - advanceUntilIdle() - - viewModel.state.value.amountTextState - .setTextAndPlaceCursorAtEnd("20") - viewModel.onDateSelected( - LocalDate.parse("2024-04-01").atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds(), - ) - viewModel.onSaveClick() - advanceUntilIdle() - - val updated = - assertIs( - tabEntryRepo.getTabEntriesForGroup("g1").first().single(), - ) - assertEquals(20.0, updated.amount) - assertEquals(LocalDate.parse("2024-04-01"), updated.entryDate) - assertEquals("Settlement", updated.title) - assertEquals("EUR", updated.currencyCode) - assertEquals("user-1", updated.paidByUserId) - assertEquals("user-2", updated.receivedByUserId) - assertIs(events.last()) - } - - @Test - fun invalidAmountEmitsErrorWithoutSaving() = - runTest(testDispatcher) { - val tabEntryRepo = FakeTabEntryRepository() - tabEntryRepo.emit( - groupId = "g1", - entries = listOf(Fixtures.settlement(id = "s1", groupId = "g1", amount = 12.5)), - ) - val viewModel = createViewModel(tabEntryRepository = tabEntryRepo) - val events = collectEvents(viewModel) - activateState(viewModel) - advanceUntilIdle() - - viewModel.state.value.amountTextState - .setTextAndPlaceCursorAtEnd("") - viewModel.onSaveClick() - advanceUntilIdle() - - assertIs(events.last()) - val unchanged = - assertIs( - tabEntryRepo.getTabEntriesForGroup("g1").first().single(), - ) - assertEquals(12.5, unchanged.amount) - } - - private fun TestScope.collectEvents(viewModel: EditSettlementViewModel): List { - val events = mutableListOf() - backgroundScope.launch { viewModel.events.collect { events.add(it) } } - return events - } - - private fun TestScope.activateState(viewModel: EditSettlementViewModel) { - backgroundScope.launch { viewModel.state.collect {} } - advanceUntilIdle() - } - - private fun createViewModel( - groupId: String = "g1", - settlementId: String = "s1", - tabEntryRepository: FakeTabEntryRepository = FakeTabEntryRepository(), - groupRepository: FakeGroupRepository = - FakeGroupRepository( - initialGroups = - listOf( - Fixtures.group( - id = "g1", - participants = - setOf( - Fixtures.participant("user-1", "Alice"), - Fixtures.participant("user-2", "Bob"), - ), - ), - ), - ), - currencyRepository: FakeCurrencyRepository = FakeCurrencyRepository(), - currentAccount: FakeCurrentAccount = FakeCurrentAccount(), - ): EditSettlementViewModel = - EditSettlementViewModel( - groupId = groupId, - settlementId = settlementId, - tabEntryRepository = tabEntryRepository, - groupRepository = groupRepository, - currencyRepository = currencyRepository, - currentAccount = currentAccount, - numberSymbols = NumberSymbols.Fallback, - ) -} diff --git a/features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupdetail/GroupDetailScheduledEntriesTest.kt b/features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupdetail/GroupDetailScheduledEntriesTest.kt new file mode 100644 index 00000000..91dc575e --- /dev/null +++ b/features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupdetail/GroupDetailScheduledEntriesTest.kt @@ -0,0 +1,184 @@ +package de.tabmates.features.tabgroup.presentation.navigation.groupdetail + +import app.cash.turbine.test +import de.tabmates.core.presentation.format.NumberSymbols +import de.tabmates.features.tabgroup.domain.recurring.RecurringSlot +import de.tabmates.features.tabgroup.presentation.navigation.creategroup.FakeCurrencyRepository +import de.tabmates.features.tabgroup.presentation.navigation.creategroup.FakeGroupRepository +import de.tabmates.features.tabgroup.presentation.testing.FakeActivityRepository +import de.tabmates.features.tabgroup.presentation.testing.FakeCurrentAccount +import de.tabmates.features.tabgroup.presentation.testing.FakeExchangeRateRepository +import de.tabmates.features.tabgroup.presentation.testing.FakeRecurringSeriesRepository +import de.tabmates.features.tabgroup.presentation.testing.FakeScheduledLedger +import de.tabmates.features.tabgroup.presentation.testing.FakeTabEntryRepository +import de.tabmates.features.tabgroup.presentation.testing.Fixtures +import de.tabmates.features.tabgroup.presentation.testing.RecurringFixtures +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.minus +import kotlinx.datetime.plus +import kotlinx.datetime.toLocalDateTime +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Clock + +/** + * The group screen's half of the placeholder contract: occurrences a schedule owes but the server + * has not written yet have to appear in the list *and* move the balances, so the numbers do not + * jump when the server's sweep eventually writes the entry. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class GroupDetailScheduledEntriesTest { + private val dispatcher = UnconfinedTestDispatcher() + + // The projector measures against the same UTC day the server's sweep does. + private val today = + Clock.System + .now() + .toLocalDateTime(TimeZone.UTC) + .date + private val alice = Fixtures.participant(id = "user-1", name = "Alice") + private val bob = Fixtures.participant(id = "user-2", name = "Bob") + + @BeforeTest + fun setUp() = Dispatchers.setMain(dispatcher) + + @AfterTest + fun tearDown() = Dispatchers.resetMain() + + @Test + fun `a due occurrence appears as a scheduled placeholder`() = + runTest(dispatcher) { + val recurring = FakeRecurringSeriesRepository() + recurring.setSeries(dueSeries()) + val viewModel = viewModel(recurring) + + viewModel.state.test { + advanceUntilIdle() + val placeholders = expectMostRecentItem().entries.filter { it.isScheduledPlaceholder } + assertEquals(1, placeholders.size) + assertEquals(today, placeholders.single().entryDate) + assertEquals("Rent", placeholders.single().title) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `a placeholder moves the balances like a real entry`() = + runTest(dispatcher) { + val recurring = FakeRecurringSeriesRepository() + recurring.setSeries(dueSeries()) + val viewModel = viewModel(recurring) + + viewModel.state.test { + advanceUntilIdle() + val state = expectMostRecentItem() + // Alice paid 100 and owes 50 of it, so she is up 50 and Bob is down 50 — exactly + // what the numbers will read once the server writes the entry. + assertEquals(50.0, state.memberNetBalances["user-1"]) + assertEquals(-50.0, state.memberNetBalances["user-2"]) + assertTrue(state.hasOutstandingDebts) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `a claimed slot produces no placeholder`() = + runTest(dispatcher) { + // The slot the server already wrote an entry for — including one since deleted, which + // is why the claim is tracked separately from the entries themselves. + val recurring = FakeRecurringSeriesRepository() + recurring.setSeries(dueSeries()) + recurring.setClaimedSlots(RecurringSlot("series-1", today)) + val viewModel = viewModel(recurring) + + viewModel.state.test { + advanceUntilIdle() + val state = expectMostRecentItem() + assertTrue(state.entries.none { it.isScheduledPlaceholder }) + assertFalse(state.hasOutstandingDebts) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `a parked series produces no placeholder`() = + runTest(dispatcher) { + val recurring = FakeRecurringSeriesRepository() + recurring.setSeries(dueSeries().copy(needsAttention = true)) + val viewModel = viewModel(recurring) + + viewModel.state.test { + advanceUntilIdle() + assertTrue(expectMostRecentItem().entries.none { it.isScheduledPlaceholder }) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `a future occurrence is not projected into the list`() = + runTest(dispatcher) { + val recurring = FakeRecurringSeriesRepository() + recurring.setSeries(dueSeries(startDate = today.plusOneYear())) + val viewModel = viewModel(recurring) + + viewModel.state.test { + advanceUntilIdle() + assertTrue(expectMostRecentItem().entries.none { it.isScheduledPlaceholder }) + cancelAndIgnoreRemainingEvents() + } + } + + private fun dueSeries(startDate: LocalDate = today) = + RecurringFixtures.series( + startDate = startDate, + amount = 100.0, + paidByUserId = "user-1", + splits = + listOf( + RecurringFixtures.templateSplit("user-1", resolvedAmount = 50.0), + RecurringFixtures.templateSplit("user-2", resolvedAmount = 50.0), + ), + ) + + /** Calendar-aware: Feb 29 has no counterpart next year, so it clamps to Feb 28 rather than throwing. */ + private fun LocalDate.plusOneYear(): LocalDate { + val firstOfTargetMonth = LocalDate(year + 1, month, 1) + val lengthOfTargetMonth = + firstOfTargetMonth + .plus(1, DateTimeUnit.MONTH) + .minus(1, DateTimeUnit.DAY) + .day + return LocalDate(year + 1, month, minOf(day, lengthOfTargetMonth)) + } + + private fun viewModel(recurring: FakeRecurringSeriesRepository): GroupDetailViewModel { + val entries = FakeTabEntryRepository() + return GroupDetailViewModel( + groupId = "g1", + groupRepository = + FakeGroupRepository( + initialGroups = listOf(Fixtures.group(id = "g1", participants = setOf(alice, bob))), + ), + scheduledLedger = FakeScheduledLedger(entries, recurring, today), + recurringSeriesRepository = recurring, + currencyRepository = FakeCurrencyRepository(), + exchangeRateRepository = FakeExchangeRateRepository(), + activityRepository = FakeActivityRepository(), + currentAccount = FakeCurrentAccount(), + numberSymbols = NumberSymbols.Fallback, + ) + } +} diff --git a/features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupdetail/GroupDetailViewModelTest.kt b/features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupdetail/GroupDetailViewModelTest.kt index 44bff7ba..4f7c210e 100644 --- a/features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupdetail/GroupDetailViewModelTest.kt +++ b/features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupdetail/GroupDetailViewModelTest.kt @@ -7,6 +7,8 @@ import de.tabmates.features.tabgroup.presentation.navigation.creategroup.FakeGro import de.tabmates.features.tabgroup.presentation.testing.FakeActivityRepository import de.tabmates.features.tabgroup.presentation.testing.FakeCurrentAccount import de.tabmates.features.tabgroup.presentation.testing.FakeExchangeRateRepository +import de.tabmates.features.tabgroup.presentation.testing.FakeRecurringSeriesRepository +import de.tabmates.features.tabgroup.presentation.testing.FakeScheduledLedger import de.tabmates.features.tabgroup.presentation.testing.FakeTabEntryRepository import de.tabmates.features.tabgroup.presentation.testing.Fixtures import kotlinx.coroutines.Dispatchers @@ -355,11 +357,13 @@ class GroupDetailViewModelTest { exchangeRateRepository: FakeExchangeRateRepository = FakeExchangeRateRepository(), activityRepository: FakeActivityRepository = FakeActivityRepository(), currentAccount: FakeCurrentAccount = FakeCurrentAccount(), + recurringSeriesRepository: FakeRecurringSeriesRepository = FakeRecurringSeriesRepository(), ): GroupDetailViewModel = GroupDetailViewModel( groupId = groupId, groupRepository = groupRepository, - tabEntryRepository = tabEntryRepository, + scheduledLedger = FakeScheduledLedger(tabEntryRepository, recurringSeriesRepository), + recurringSeriesRepository = recurringSeriesRepository, currencyRepository = currencyRepository, exchangeRateRepository = exchangeRateRepository, activityRepository = activityRepository, diff --git a/features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/GroupOverviewViewModelTest.kt b/features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/GroupOverviewViewModelTest.kt index 4ee14756..dd690319 100644 --- a/features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/GroupOverviewViewModelTest.kt +++ b/features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/GroupOverviewViewModelTest.kt @@ -7,6 +7,7 @@ import de.tabmates.features.tabgroup.presentation.navigation.creategroup.FakeCur import de.tabmates.features.tabgroup.presentation.navigation.creategroup.FakeGroupRepository import de.tabmates.features.tabgroup.presentation.testing.FakeCurrentAccount import de.tabmates.features.tabgroup.presentation.testing.FakeExchangeRateRepository +import de.tabmates.features.tabgroup.presentation.testing.FakeScheduledLedger import de.tabmates.features.tabgroup.presentation.testing.FakeTabEntryRepository import de.tabmates.features.tabgroup.presentation.testing.Fixtures import kotlinx.coroutines.Dispatchers @@ -307,7 +308,7 @@ class GroupOverviewViewModelTest { ): GroupOverviewViewModel = GroupOverviewViewModel( groupRepository = groupRepository, - tabEntryRepository = tabEntryRepository, + scheduledLedger = FakeScheduledLedger(tabEntryRepository), currencyRepository = currencyRepository, exchangeRateRepository = exchangeRateRepository, currentAccount = currentAccount, diff --git a/features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/UpcomingSchedulesTest.kt b/features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/UpcomingSchedulesTest.kt new file mode 100644 index 00000000..f8befe63 --- /dev/null +++ b/features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/UpcomingSchedulesTest.kt @@ -0,0 +1,102 @@ +package de.tabmates.features.tabgroup.presentation.navigation.groupoverview + +import de.tabmates.features.tabgroup.domain.recurring.RecurrenceFrequency +import de.tabmates.features.tabgroup.domain.recurring.RecurringEnd +import de.tabmates.features.tabgroup.presentation.testing.RecurringFixtures +import kotlinx.datetime.LocalDate +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The upcoming section shows what the group's schedules are *about to* produce. Nothing it lists has + * moved a balance yet — the occurrences that have are placeholders down in the ledger instead. + */ +class UpcomingSchedulesTest { + private val today = LocalDate.parse("2026-03-10") + + @Test + fun `an active schedule is listed with its next date`() { + val series = + RecurringFixtures.series( + startDate = LocalDate.parse("2026-01-05"), + frequency = RecurrenceFrequency.MONTHLY, + ) + + val upcoming = upcomingSchedules(listOf(series), today) + + assertEquals(1, upcoming.size) + assertEquals(LocalDate.parse("2026-04-05"), upcoming.single().nextDate) + } + + @Test + fun `an ended schedule is left out`() { + // Ended schedules still explain entries that exist, but they belong on the schedules screen: + // a section named for what is coming must not list something that produces nothing. + val series = RecurringFixtures.series(isActive = false) + + assertTrue(upcomingSchedules(listOf(series), today).isEmpty()) + } + + @Test + fun `an active schedule with no dates left is left out`() { + val series = + RecurringFixtures.series( + startDate = LocalDate.parse("2026-01-05"), + frequency = RecurrenceFrequency.MONTHLY, + end = RecurringEnd.Count(2), + ) + + assertTrue(upcomingSchedules(listOf(series), today).isEmpty()) + } + + @Test + fun `a parked schedule is listed without a date`() { + // The server writes nothing for a parked schedule until someone repairs the template, so + // promising a date would promise entries that are not coming. + val series = RecurringFixtures.series(needsAttention = true) + + val upcoming = upcomingSchedules(listOf(series), today) + + assertEquals(1, upcoming.size) + assertNull(upcoming.single().nextDate) + } + + @Test + fun `parked schedules sort ahead of dated ones which sort by date`() { + // The section shows only the first few rows until it is expanded, and the parked one is the + // only row asking for something — it must not be the one that gets hidden. + val soon = + RecurringFixtures.series( + seriesId = "soon", + startDate = LocalDate.parse("2026-03-12"), + frequency = RecurrenceFrequency.MONTHLY, + ) + val later = + RecurringFixtures.series( + seriesId = "later", + startDate = LocalDate.parse("2026-03-28"), + frequency = RecurrenceFrequency.MONTHLY, + ) + val parked = RecurringFixtures.series(seriesId = "parked", needsAttention = true) + + val upcoming = upcomingSchedules(listOf(later, soon, parked), today) + + assertEquals(listOf("parked", "soon", "later"), upcoming.map { it.series.seriesId }) + } + + @Test + fun `a skipped next occurrence advances to the one after it`() { + val series = + RecurringFixtures.series( + startDate = LocalDate.parse("2026-01-05"), + frequency = RecurrenceFrequency.MONTHLY, + skipped = setOf(LocalDate.parse("2026-04-05")), + ) + + val upcoming = upcomingSchedules(listOf(series), today) + + assertEquals(LocalDate.parse("2026-05-05"), upcoming.single().nextDate) + } +} diff --git a/features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/navigation/grouppeople/GroupPeopleViewModelTest.kt b/features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/navigation/grouppeople/GroupPeopleViewModelTest.kt index 06348d2c..0c3aeca3 100644 --- a/features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/navigation/grouppeople/GroupPeopleViewModelTest.kt +++ b/features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/navigation/grouppeople/GroupPeopleViewModelTest.kt @@ -12,6 +12,7 @@ import de.tabmates.features.tabgroup.presentation.navigation.creategroup.FakeCur import de.tabmates.features.tabgroup.presentation.navigation.creategroup.FakeGroupRepository import de.tabmates.features.tabgroup.presentation.testing.FakeCurrentAccount import de.tabmates.features.tabgroup.presentation.testing.FakeExchangeRateRepository +import de.tabmates.features.tabgroup.presentation.testing.FakeScheduledLedger import de.tabmates.features.tabgroup.presentation.testing.FakeTabEntryRepository import de.tabmates.features.tabgroup.presentation.testing.Fixtures import kotlinx.coroutines.Dispatchers @@ -70,7 +71,7 @@ class GroupPeopleViewModelTest { GroupPeopleViewModel( groupId = "g1", groupRepository = repo, - tabEntryRepository = tabEntryRepository, + scheduledLedger = FakeScheduledLedger(tabEntryRepository), currencyRepository = FakeCurrencyRepository(), exchangeRateRepository = FakeExchangeRateRepository(), currentAccount = FakeCurrentAccount(id = currentUserId), diff --git a/features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupschedules/GroupSchedulesViewModelTest.kt b/features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupschedules/GroupSchedulesViewModelTest.kt new file mode 100644 index 00000000..e4097819 --- /dev/null +++ b/features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupschedules/GroupSchedulesViewModelTest.kt @@ -0,0 +1,84 @@ +package de.tabmates.features.tabgroup.presentation.navigation.groupschedules + +import app.cash.turbine.test +import de.tabmates.features.tabgroup.presentation.navigation.creategroup.FakeCurrencyRepository +import de.tabmates.features.tabgroup.presentation.testing.FakeRecurringSeriesRepository +import de.tabmates.features.tabgroup.presentation.testing.RecurringFixtures +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class GroupSchedulesViewModelTest { + private val dispatcher = UnconfinedTestDispatcher() + + @BeforeTest + fun setUp() = Dispatchers.setMain(dispatcher) + + @AfterTest + fun tearDown() = Dispatchers.resetMain() + + @Test + fun `splits the group's schedules into active and ended`() = + runTest(dispatcher) { + val recurring = FakeRecurringSeriesRepository() + recurring.setSeries( + RecurringFixtures.series(seriesId = "live", isActive = true), + // Ended schedules stay listed: they explain entries that already exist, and hiding + // them would make those entries look like they came from nowhere. + RecurringFixtures.series(seriesId = "done", isActive = false), + ) + + viewModel(recurring).state.test { + advanceUntilIdle() + val state = expectMostRecentItem() + assertEquals(listOf("live"), state.active.map { it.seriesId }) + assertEquals(listOf("done"), state.ended.map { it.seriesId }) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `leaves out schedules belonging to another group`() = + runTest(dispatcher) { + val recurring = FakeRecurringSeriesRepository() + recurring.setSeries( + RecurringFixtures.series(seriesId = "ours", groupId = "g1"), + RecurringFixtures.series(seriesId = "theirs", groupId = "g2"), + ) + + viewModel(recurring).state.test { + advanceUntilIdle() + assertEquals(listOf("ours"), expectMostRecentItem().active.map { it.seriesId }) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `a group with no schedules reports empty rather than loading`() = + runTest(dispatcher) { + viewModel(FakeRecurringSeriesRepository()).state.test { + advanceUntilIdle() + val state = expectMostRecentItem() + assertTrue(state.isEmpty) + assertTrue(!state.isLoading) + cancelAndIgnoreRemainingEvents() + } + } + + private fun viewModel(recurring: FakeRecurringSeriesRepository) = + GroupSchedulesViewModel( + groupId = "g1", + recurringSeriesRepository = recurring, + currencyRepository = FakeCurrencyRepository(), + ) +} diff --git a/features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/navigation/home/HomeViewModelTest.kt b/features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/navigation/home/HomeViewModelTest.kt index 0bf8535f..17978343 100644 --- a/features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/navigation/home/HomeViewModelTest.kt +++ b/features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/navigation/home/HomeViewModelTest.kt @@ -4,6 +4,7 @@ import de.tabmates.features.tabgroup.presentation.navigation.creategroup.FakeCur import de.tabmates.features.tabgroup.presentation.navigation.creategroup.FakeGroupRepository import de.tabmates.features.tabgroup.presentation.testing.FakeCurrentAccount import de.tabmates.features.tabgroup.presentation.testing.FakeExchangeRateRepository +import de.tabmates.features.tabgroup.presentation.testing.FakeScheduledLedger import de.tabmates.features.tabgroup.presentation.testing.FakeTabEntryRepository import de.tabmates.features.tabgroup.presentation.testing.Fixtures import kotlinx.coroutines.Dispatchers @@ -124,7 +125,7 @@ class HomeViewModelTest { ): HomeViewModel = HomeViewModel( groupRepository = groupRepository, - tabEntryRepository = tabEntryRepository, + scheduledLedger = FakeScheduledLedger(tabEntryRepository), currencyRepository = currencyRepository, exchangeRateRepository = exchangeRateRepository, currentAccount = currentAccount, diff --git a/features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/testing/FakeConnectionStatusRepository.kt b/features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/testing/FakeConnectionStatusRepository.kt new file mode 100644 index 00000000..471b1ef8 --- /dev/null +++ b/features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/testing/FakeConnectionStatusRepository.kt @@ -0,0 +1,21 @@ +package de.tabmates.features.tabgroup.presentation.testing + +import de.tabmates.features.tabgroup.domain.sync.ConnectionStatusRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlin.time.Instant + +/** Connected by default — the offline path is the exception a test opts into. */ +class FakeConnectionStatusRepository( + connected: Boolean = true, + lastContactAt: Instant? = null, +) : ConnectionStatusRepository { + private val connectedFlow = MutableStateFlow(connected) + + override val isConnected: StateFlow = connectedFlow + override val lastServerContactAt: StateFlow = MutableStateFlow(lastContactAt) + + fun setConnected(value: Boolean) { + connectedFlow.value = value + } +} diff --git a/features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/testing/FakeRecurringSeriesRepository.kt b/features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/testing/FakeRecurringSeriesRepository.kt new file mode 100644 index 00000000..327c6753 --- /dev/null +++ b/features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/testing/FakeRecurringSeriesRepository.kt @@ -0,0 +1,91 @@ +package de.tabmates.features.tabgroup.presentation.testing + +import de.tabmates.core.domain.util.DataError +import de.tabmates.core.domain.util.EmptyResult +import de.tabmates.core.domain.util.Result +import de.tabmates.features.tabgroup.domain.recurring.RecurringSeries +import de.tabmates.features.tabgroup.domain.recurring.RecurringSeriesRepository +import de.tabmates.features.tabgroup.domain.recurring.RecurringSlot +import de.tabmates.features.tabgroup.domain.recurring.RecurringTemplate +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.datetime.LocalDate + +class FakeRecurringSeriesRepository : RecurringSeriesRepository { + private val series = MutableStateFlow>(emptyList()) + private val claimedSlots = MutableStateFlow>(emptySet()) + + /** Schedule writes the screen asked for, in order, for tests that assert on intent. */ + val recordedWrites = mutableListOf() + + var writeResult: Result? = null + var writeError: DataError.Remote? = null + + fun setSeries(vararg values: RecurringSeries) { + series.value = values.toList() + } + + fun setClaimedSlots(vararg values: RecurringSlot) { + claimedSlots.value = values.toSet() + } + + override fun getSeriesForGroup(groupId: String): Flow> = + series.map { all -> all.filter { it.groupId == groupId } } + + override fun getSeriesById(seriesId: String): Flow = + series.map { all -> all.firstOrNull { it.seriesId == seriesId } } + + // Scoped to the group like the real one is: an unscoped fake would leak one group's claims into + // another's projection, and a test with two groups would be quietly wrong rather than red. + override fun getClaimedSlotsForGroup(groupId: String): Flow> = + combine(series, claimedSlots) { all, slots -> + val idsInGroup = all.filter { it.groupId == groupId }.mapTo(mutableSetOf()) { it.seriesId } + slots.filterTo(mutableSetOf()) { it.seriesId in idsInGroup } + } + + override suspend fun createSeries( + seriesId: String, + groupId: String, + template: RecurringTemplate, + ): Result { + recordedWrites += "create:$seriesId" + writeError?.let { return Result.Failure(it) } + return writeResult ?: Result.Failure(DataError.Remote.UNKNOWN) + } + + override suspend fun updateSeries( + seriesId: String, + effectiveFrom: LocalDate, + template: RecurringTemplate, + ): Result { + recordedWrites += "update:$seriesId@$effectiveFrom" + writeError?.let { return Result.Failure(it) } + return writeResult ?: Result.Failure(DataError.Remote.UNKNOWN) + } + + override suspend fun skipOccurrence( + seriesId: String, + occurrenceDate: LocalDate, + ): EmptyResult { + recordedWrites += "skip:$seriesId@$occurrenceDate" + return writeError?.let { Result.Failure(it) } ?: Result.Success(Unit) + } + + override suspend fun unskipOccurrence( + seriesId: String, + occurrenceDate: LocalDate, + ): EmptyResult { + recordedWrites += "unskip:$seriesId@$occurrenceDate" + return writeError?.let { Result.Failure(it) } ?: Result.Success(Unit) + } + + override suspend fun endSeries(seriesId: String): EmptyResult { + recordedWrites += "end:$seriesId" + return writeError?.let { Result.Failure(it) } ?: Result.Success(Unit) + } + + override suspend fun refreshSeriesForGroup(groupId: String): EmptyResult = + Result.Success(Unit) +} diff --git a/features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/testing/FakeScheduledLedger.kt b/features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/testing/FakeScheduledLedger.kt new file mode 100644 index 00000000..47e86c18 --- /dev/null +++ b/features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/testing/FakeScheduledLedger.kt @@ -0,0 +1,45 @@ +package de.tabmates.features.tabgroup.presentation.testing + +import de.tabmates.features.tabgroup.domain.models.TabEntry +import de.tabmates.features.tabgroup.domain.recurring.ScheduledEntryProjector +import de.tabmates.features.tabgroup.domain.recurring.ScheduledLedger +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Clock + +/** + * Runs the real projection over fake sources. + * + * Deliberately not a stub: the thing worth testing at the screen level is that placeholders reach + * the balances, and a fake that just returned a canned list would prove nothing about that. Tests + * with no schedules get exactly the entries they emitted. + * + * [today] is settable so a test can pin the day rather than depend on when it runs. + */ +class FakeScheduledLedger( + private val tabEntryRepository: FakeTabEntryRepository = FakeTabEntryRepository(), + private val recurringSeriesRepository: FakeRecurringSeriesRepository = FakeRecurringSeriesRepository(), + private val today: LocalDate = + Clock.System + .now() + .toLocalDateTime(TimeZone.UTC) + .date, +) : ScheduledLedger { + override fun observeEntriesForGroup(groupId: String): Flow> = + combine( + tabEntryRepository.getTabEntriesForGroup(groupId), + recurringSeriesRepository.getSeriesForGroup(groupId), + recurringSeriesRepository.getClaimedSlotsForGroup(groupId), + ) { entries, series, claimedSlots -> + entries + + ScheduledEntryProjector.project( + series = series, + existingEntries = entries, + claimedSlots = claimedSlots, + today = today, + ) + } +} diff --git a/features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/testing/RecurringFixtures.kt b/features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/testing/RecurringFixtures.kt new file mode 100644 index 00000000..9c8f7a20 --- /dev/null +++ b/features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/testing/RecurringFixtures.kt @@ -0,0 +1,75 @@ +package de.tabmates.features.tabgroup.presentation.testing + +import de.tabmates.features.tabgroup.domain.models.GroupParticipant +import de.tabmates.features.tabgroup.domain.models.SplitType +import de.tabmates.features.tabgroup.domain.recurring.RecurrenceFrequency +import de.tabmates.features.tabgroup.domain.recurring.RecurringEnd +import de.tabmates.features.tabgroup.domain.recurring.RecurringEntryType +import de.tabmates.features.tabgroup.domain.recurring.RecurringRule +import de.tabmates.features.tabgroup.domain.recurring.RecurringSeries +import de.tabmates.features.tabgroup.domain.recurring.RecurringTemplateSplit +import kotlinx.datetime.LocalDate +import kotlin.time.Instant + +/** Builders for recurring schedules, kept next to [Fixtures] and used the same way. */ +object RecurringFixtures { + fun series( + seriesId: String = "series-1", + groupId: String = "g1", + entryType: RecurringEntryType = RecurringEntryType.EXPENSE, + isActive: Boolean = true, + needsAttention: Boolean = false, + amount: Double = 100.0, + title: String = "Rent", + paidByUserId: String = "user-1", + receivedByUserId: String? = null, + splits: List = emptyList(), + frequency: RecurrenceFrequency = RecurrenceFrequency.MONTHLY, + interval: Int = 1, + startDate: LocalDate = LocalDate.parse("2026-01-15"), + end: RecurringEnd = RecurringEnd.Never, + skipped: Set = emptySet(), + createdBy: GroupParticipant = Fixtures.participant(), + ): RecurringSeries = + RecurringSeries( + seriesId = seriesId, + groupId = groupId, + entryType = entryType, + isActive = isActive, + needsAttention = needsAttention, + createdAt = Instant.fromEpochMilliseconds(0), + createdBy = createdBy, + updatedAt = Instant.fromEpochMilliseconds(0), + rule = + RecurringRule( + ruleId = "$seriesId-rule", + title = title, + description = "", + amount = amount, + currencyCode = "EUR", + exchangeRate = null, + paidByUserId = paidByUserId, + receivedByUserId = receivedByUserId, + splits = splits, + frequency = frequency, + interval = interval, + startDate = startDate, + end = end, + ), + skippedOccurrenceDates = skipped, + ) + + fun templateSplit( + participantId: String, + resolvedAmount: Double, + splitType: SplitType = SplitType.EQUAL, + value: Double = 1.0, + ): RecurringTemplateSplit = + RecurringTemplateSplit( + splitId = null, + participantId = participantId, + splitType = splitType, + value = value, + resolvedAmount = resolvedAmount, + ) +}