From 0f27a74cd63e43473a55b53f70f471373729dd37 Mon Sep 17 00:00:00 2001 From: Janne Mareike Koschinski <janne@kuschku.de> Date: Sat, 25 Nov 2023 19:27:45 +0100 Subject: [PATCH] feat: support incremental syncs --- .../kotlin/de/chaosdorf/mete/model/MeteApi.kt | 10 ++- .../kotlin/de/chaosdorf/mete/v1/MeteApiV1.kt | 12 +++- .../meteroid/sync/DrinkSyncHandler.kt | 5 +- .../de/chaosdorf/meteroid/sync/SyncManager.kt | 14 ++-- ...ncHandler.kt => TransactionSyncHandler.kt} | 42 +++++++++++- .../meteroid/sync/UserSyncHandler.kt | 5 +- .../sync/base/BaseIncrementalSyncHandler.kt | 68 +++++++++++++++++++ .../BaseSyncHandler.kt} | 23 +++---- .../sync/base/IncrementalSyncHandler.kt | 29 ++++++++ .../meteroid/sync/base/SyncHandler.kt | 29 ++++++++ .../de/chaosdorf/meteroid/ui/AppViewModel.kt | 2 +- .../de/chaosdorf/meteroid/ui/PriceBadge.kt | 4 +- .../meteroid/ui/drinks/DrinkListViewModel.kt | 8 +++ .../chaosdorf/meteroid/ui/drinks/DrinkTile.kt | 8 ++- .../meteroid/ui/navigation/MeteroidTopBar.kt | 32 +++++---- .../ui/transactions/PurchaseViewModel.kt | 17 +++-- .../meteroid/ui/wrapped/WrappedSlide.kt | 2 +- .../meteroid/ui/wrapped/WrappedViewModel.kt | 36 ++++------ gradle/libs.versions.toml | 10 +-- .../chaosdorf/meteroid/model/Transaction.kt | 2 + 20 files changed, 283 insertions(+), 75 deletions(-) rename app/src/main/kotlin/de/chaosdorf/meteroid/sync/{PurchaseSyncHandler.kt => TransactionSyncHandler.kt} (61%) create mode 100644 app/src/main/kotlin/de/chaosdorf/meteroid/sync/base/BaseIncrementalSyncHandler.kt rename app/src/main/kotlin/de/chaosdorf/meteroid/sync/{SyncHandler.kt => base/BaseSyncHandler.kt} (83%) create mode 100644 app/src/main/kotlin/de/chaosdorf/meteroid/sync/base/IncrementalSyncHandler.kt create mode 100644 app/src/main/kotlin/de/chaosdorf/meteroid/sync/base/SyncHandler.kt diff --git a/api/src/main/kotlin/de/chaosdorf/mete/model/MeteApi.kt b/api/src/main/kotlin/de/chaosdorf/mete/model/MeteApi.kt index 8313d89..ae0cee0 100644 --- a/api/src/main/kotlin/de/chaosdorf/mete/model/MeteApi.kt +++ b/api/src/main/kotlin/de/chaosdorf/mete/model/MeteApi.kt @@ -24,11 +24,19 @@ package de.chaosdorf.mete.model +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDate +import retrofit2.http.Query import java.math.BigDecimal interface MeteApi { suspend fun getManifest(): PwaManifest? - suspend fun listTransactions(user: UserId? = null): TransactionSummaryModel + suspend fun listTransactions( + user: UserId, + startYear: Int, startMonth: Int, startDay: Int, + endYear: Int, endMonth: Int, endDay: Int, + ): TransactionSummaryModel + suspend fun listBarcodes(): List<BarcodeModel> suspend fun getBarcode(id: BarcodeId): BarcodeModel? suspend fun listDrinks(): List<DrinkModel> diff --git a/api/src/main/kotlin/de/chaosdorf/mete/v1/MeteApiV1.kt b/api/src/main/kotlin/de/chaosdorf/mete/v1/MeteApiV1.kt index 7c25ea6..cc9f921 100644 --- a/api/src/main/kotlin/de/chaosdorf/mete/v1/MeteApiV1.kt +++ b/api/src/main/kotlin/de/chaosdorf/mete/v1/MeteApiV1.kt @@ -28,12 +28,16 @@ import de.chaosdorf.mete.model.BarcodeId import de.chaosdorf.mete.model.DrinkId import de.chaosdorf.mete.model.MeteApi import de.chaosdorf.mete.model.PwaManifest +import de.chaosdorf.mete.model.TransactionSummaryModel import de.chaosdorf.mete.model.UserId +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDate import retrofit2.Response import retrofit2.http.GET import retrofit2.http.Path import retrofit2.http.Query import java.math.BigDecimal +import java.time.Year internal interface MeteApiV1 : MeteApi { @GET("manifest.json") @@ -41,7 +45,13 @@ internal interface MeteApiV1 : MeteApi { @GET("api/v1/audits.json") override suspend fun listTransactions( - @Query("user") user: UserId? + @Query("user") user: UserId, + @Query("start_date[year]") startYear: Int, + @Query("start_date[month]") startMonth: Int, + @Query("start_date[day]") startDay: Int, + @Query("end_date[year]") endYear: Int, + @Query("end_date[month]") endMonth: Int, + @Query("end_date[day]") endDay: Int, ): TransactionSummaryModelV1 @GET("api/v1/barcodes.json") diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/sync/DrinkSyncHandler.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/sync/DrinkSyncHandler.kt index d9d2a16..b60189c 100644 --- a/app/src/main/kotlin/de/chaosdorf/meteroid/sync/DrinkSyncHandler.kt +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/sync/DrinkSyncHandler.kt @@ -32,18 +32,19 @@ import de.chaosdorf.meteroid.model.Drink import de.chaosdorf.meteroid.model.DrinkRepository import de.chaosdorf.meteroid.model.Server import de.chaosdorf.meteroid.model.ServerId +import de.chaosdorf.meteroid.sync.base.BaseSyncHandler import javax.inject.Inject class DrinkSyncHandler @Inject constructor( private val factory: MeteApiFactory, private val db: MeteroidDatabase, private val repository: DrinkRepository -) : SyncHandler<Server, Drink, DrinkSyncHandler.Key>() { +) : BaseSyncHandler<Server, Drink, DrinkSyncHandler.Key>() { data class Key( val server: ServerId, val drink: DrinkId ) - override suspend fun withTransaction(block: suspend () -> Unit) = + override suspend fun <T>withTransaction(block: suspend () -> T): T = db.withTransaction(block) override suspend fun store(entry: Drink) = diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/sync/SyncManager.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/sync/SyncManager.kt index c217089..5e9a4e3 100644 --- a/app/src/main/kotlin/de/chaosdorf/meteroid/sync/SyncManager.kt +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/sync/SyncManager.kt @@ -54,12 +54,16 @@ class SyncManager @Inject constructor( } } - suspend fun sync(server: Server, user: User?) { + suspend fun sync(server: Server, user: User?, incremental: Boolean) { try { userSyncHandler.sync(server) drinkSyncHandler.sync(server) if (user != null) { - transactionSyncHandler.sync(Pair(server, user.userId)) + if (incremental) { + transactionSyncHandler.syncIncremental(Pair(server, user.userId)) + } else { + transactionSyncHandler.sync(Pair(server, user.userId)) + } } } catch (e: Exception) { Log.e( @@ -75,7 +79,7 @@ class SyncManager @Inject constructor( val api = factory.newInstance(account.server.url) try { api.purchase(user.userId, drink.drinkId) - sync(account.server, user) + sync(account.server, user, incremental = true) } catch (e: Exception) { Log.e( "Sync", @@ -91,7 +95,7 @@ class SyncManager @Inject constructor( try { val api = factory.newInstance(account.server.url) api.deposit(user.userId, amount) - sync(account.server, user) + sync(account.server, user, incremental = true) } catch (e: Exception) { Log.e( "Sync", @@ -107,7 +111,7 @@ class SyncManager @Inject constructor( val api = factory.newInstance(account.server.url) try { api.withdraw(user.userId, amount) - sync(account.server, user) + sync(account.server, user, incremental = true) } catch (e: Exception) { Log.e( "Sync", diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/sync/PurchaseSyncHandler.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/sync/TransactionSyncHandler.kt similarity index 61% rename from app/src/main/kotlin/de/chaosdorf/meteroid/sync/PurchaseSyncHandler.kt rename to app/src/main/kotlin/de/chaosdorf/meteroid/sync/TransactionSyncHandler.kt index 071c20c..e9797e9 100644 --- a/app/src/main/kotlin/de/chaosdorf/meteroid/sync/PurchaseSyncHandler.kt +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/sync/TransactionSyncHandler.kt @@ -33,18 +33,25 @@ import de.chaosdorf.meteroid.model.Server import de.chaosdorf.meteroid.model.ServerId import de.chaosdorf.meteroid.model.Transaction import de.chaosdorf.meteroid.model.TransactionRepository +import de.chaosdorf.meteroid.sync.base.BaseIncrementalSyncHandler +import kotlinx.datetime.Clock +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.plus +import kotlinx.datetime.toLocalDateTime import javax.inject.Inject class TransactionSyncHandler @Inject constructor( private val factory: MeteApiFactory, private val db: MeteroidDatabase, private val repository: TransactionRepository -) : SyncHandler<Pair<Server, UserId>, Transaction, TransactionSyncHandler.Key>() { +) : BaseIncrementalSyncHandler<Pair<Server, UserId>, Transaction, TransactionSyncHandler.Key>() { data class Key( val server: ServerId, val transaction: TransactionId ) - override suspend fun withTransaction(block: suspend () -> Unit) = + override suspend fun <T> withTransaction(block: suspend () -> T): T = db.withTransaction(block) override suspend fun store(entry: Transaction) = @@ -61,7 +68,36 @@ class TransactionSyncHandler @Inject constructor( override suspend fun loadCurrent(context: Pair<Server, UserId>): List<Transaction> { val (server, userId) = context val api = factory.newInstance(server.url) - val loadedEntries = api.listTransactions(user = userId).entries + val startDate = LocalDate(1970, 1, 1) + val endDate = Clock.System.now().toLocalDateTime(TimeZone.UTC).date + .plus(1, DateTimeUnit.DAY) + + val loadedEntries = api.listTransactions( + userId, + startDate.year, startDate.month.value, startDate.dayOfMonth, + endDate.year, endDate.month.value, endDate.dayOfMonth, + ).entries + return loadedEntries.map { Transaction.fromModel(server, userId, it) } + } + + override suspend fun loadLatestEntry(context: Pair<Server, UserId>): Transaction? = + repository.getLatest(context.first.serverId, context.second) + + override suspend fun loadAdded( + context: Pair<Server, UserId>, + latest: Transaction + ): List<Transaction> { + val (server, userId) = context + val api = factory.newInstance(server.url) + val startDate = latest.timestamp.toLocalDateTime(TimeZone.UTC).date + val endDate = Clock.System.now().toLocalDateTime(TimeZone.UTC).date + .plus(1, DateTimeUnit.DAY) + + val loadedEntries = api.listTransactions( + userId, + startDate.year, startDate.month.value, startDate.dayOfMonth, + endDate.year, endDate.month.value, endDate.dayOfMonth, + ).entries return loadedEntries.map { Transaction.fromModel(server, userId, it) } } } diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/sync/UserSyncHandler.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/sync/UserSyncHandler.kt index 179a5db..0f50531 100644 --- a/app/src/main/kotlin/de/chaosdorf/meteroid/sync/UserSyncHandler.kt +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/sync/UserSyncHandler.kt @@ -32,18 +32,19 @@ import de.chaosdorf.meteroid.model.Server import de.chaosdorf.meteroid.model.ServerId import de.chaosdorf.meteroid.model.User import de.chaosdorf.meteroid.model.UserRepository +import de.chaosdorf.meteroid.sync.base.BaseSyncHandler import javax.inject.Inject class UserSyncHandler @Inject constructor( private val factory: MeteApiFactory, private val db: MeteroidDatabase, private val repository: UserRepository -) : SyncHandler<Server, User, UserSyncHandler.Key>() { +) : BaseSyncHandler<Server, User, UserSyncHandler.Key>() { data class Key( val server: ServerId, val user: UserId ) - override suspend fun withTransaction(block: suspend () -> Unit) = + override suspend fun <T>withTransaction(block: suspend () -> T): T = db.withTransaction(block) override suspend fun store(entry: User) = diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/sync/base/BaseIncrementalSyncHandler.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/sync/base/BaseIncrementalSyncHandler.kt new file mode 100644 index 0000000..16ae412 --- /dev/null +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/sync/base/BaseIncrementalSyncHandler.kt @@ -0,0 +1,68 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2013-2023 Chaosdorf e.V. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package de.chaosdorf.meteroid.sync.base + +import android.util.Log + +abstract class BaseIncrementalSyncHandler<Context, Entry, Key> : + BaseSyncHandler<Context, Entry, Key>(), + IncrementalSyncHandler<Context> { + abstract suspend fun loadLatestEntry(context: Context): Entry? + abstract suspend fun loadAdded(context: Context, latest: Entry): List<Entry> + + override suspend fun syncIncremental(context: Context) { + if (syncState.compareAndSet(State.Idle, State.Loading) || + syncState.compareAndSet(State.Error(), State.Loading)) { + Log.w(this::class.simpleName, "Started incremental sync") + val success = try { + val result = withTransaction { + val latestEntry = loadLatestEntry(context) + if (latestEntry != null) { + val addedEntries = loadAdded(context, latestEntry) + for (loadedEntry in addedEntries) { + store(loadedEntry) + } + true + } else { + false + } + } + syncState.value = State.Idle + Log.w(this::class.simpleName, "Finished incremental sync") + result + } catch (e: Exception) { + Log.e(this::class.simpleName, "Error while syncing data", e) + syncState.value = State.Error("Error while syncing data: $e") + false + } + // If we can't do an incremental sync, do a full sync + if (!success) { + sync(context) + } + } else { + Log.w(this::class.simpleName, "Already syncing, disregarding sync request") + } + } +} diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/sync/SyncHandler.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/sync/base/BaseSyncHandler.kt similarity index 83% rename from app/src/main/kotlin/de/chaosdorf/meteroid/sync/SyncHandler.kt rename to app/src/main/kotlin/de/chaosdorf/meteroid/sync/base/BaseSyncHandler.kt index b7a6199..00d33c1 100644 --- a/app/src/main/kotlin/de/chaosdorf/meteroid/sync/SyncHandler.kt +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/sync/base/BaseSyncHandler.kt @@ -22,13 +22,13 @@ * THE SOFTWARE. */ -package de.chaosdorf.meteroid.sync +package de.chaosdorf.meteroid.sync.base import android.util.Log import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -abstract class SyncHandler<Context, Entry, Key> { +abstract class BaseSyncHandler<Context, Entry, Key>: SyncHandler<Context> { sealed class State { data object Idle : State() data object Loading : State() @@ -45,7 +45,7 @@ abstract class SyncHandler<Context, Entry, Key> { } } - abstract suspend fun withTransaction(block: suspend () -> Unit) + abstract suspend fun <T>withTransaction(block: suspend () -> T): T abstract suspend fun loadCurrent(context: Context): List<Entry> abstract suspend fun loadStored(context: Context): List<Entry> @@ -55,15 +55,12 @@ abstract class SyncHandler<Context, Entry, Key> { abstract suspend fun delete(key: Key) abstract suspend fun store(entry: Entry) - private val _state = MutableStateFlow<State>(State.Idle) - val state: StateFlow<State> = _state + protected val syncState = MutableStateFlow<State>(State.Idle) + val state: StateFlow<State> = syncState - suspend fun sync(context: Context) { - if (_state.compareAndSet(State.Idle, State.Loading) || _state.compareAndSet( - State.Error(), - State.Loading - ) - ) { + override suspend fun sync(context: Context) { + if (syncState.compareAndSet(State.Idle, State.Loading) || + syncState.compareAndSet(State.Error(), State.Loading)) { Log.w(this::class.simpleName, "Started sync") try { val loadedEntries = loadCurrent(context) @@ -80,11 +77,11 @@ abstract class SyncHandler<Context, Entry, Key> { store(loadedEntry) } } - _state.value = State.Idle + syncState.value = State.Idle Log.w(this::class.simpleName, "Finished sync") } catch (e: Exception) { Log.e(this::class.simpleName, "Error while syncing data", e) - _state.value = State.Error("Error while syncing data: $e") + syncState.value = State.Error("Error while syncing data: $e") } } else { Log.w(this::class.simpleName, "Already syncing, disregarding sync request") diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/sync/base/IncrementalSyncHandler.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/sync/base/IncrementalSyncHandler.kt new file mode 100644 index 0000000..c07f0af --- /dev/null +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/sync/base/IncrementalSyncHandler.kt @@ -0,0 +1,29 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2013-2023 Chaosdorf e.V. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package de.chaosdorf.meteroid.sync.base + +interface IncrementalSyncHandler<Context> { + suspend fun syncIncremental(context: Context) +} diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/sync/base/SyncHandler.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/sync/base/SyncHandler.kt new file mode 100644 index 0000000..7046454 --- /dev/null +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/sync/base/SyncHandler.kt @@ -0,0 +1,29 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2013-2023 Chaosdorf e.V. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package de.chaosdorf.meteroid.sync.base + +interface SyncHandler<Context> { + suspend fun sync(context: Context) +} diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/AppViewModel.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/AppViewModel.kt index 853744c..8e0cf09 100644 --- a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/AppViewModel.kt +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/AppViewModel.kt @@ -66,7 +66,7 @@ class AppViewModel @Inject constructor( init { accountProvider.account.distinctUntilChanged().onEach { account -> account?.let { (server, maybeUser) -> - syncManager.sync(server, maybeUser) + syncManager.sync(server, maybeUser, incremental = true) } }.launchIn(viewModelScope) } diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/PriceBadge.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/PriceBadge.kt index 7ded488..2231c37 100644 --- a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/PriceBadge.kt +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/PriceBadge.kt @@ -41,10 +41,10 @@ fun PriceBadge( price: BigDecimal, modifier: Modifier = Modifier, containerColor: Color = - if (price > BigDecimal.ZERO) MaterialTheme.colorScheme.primary + if (price >= BigDecimal.ZERO) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error, textColor: Color = - if (price > BigDecimal.ZERO) MaterialTheme.colorScheme.onPrimary + if (price >= BigDecimal.ZERO) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onError, textStyle: TextStyle = MaterialTheme.typography.labelLarge ) { diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/drinks/DrinkListViewModel.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/drinks/DrinkListViewModel.kt index e56a782..52c751e 100644 --- a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/drinks/DrinkListViewModel.kt +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/drinks/DrinkListViewModel.kt @@ -98,6 +98,14 @@ class DrinkListViewModel @Inject constructor( } } + fun sync() { + account.value?.let { (server, user) -> + viewModelScope.launch { + syncManager.sync(server, user, incremental = true) + } + } + } + suspend fun checkOffline(server: Server?): Boolean = if (server == null) true else syncManager.checkOffline(server) diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/drinks/DrinkTile.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/drinks/DrinkTile.kt index ab9b07c..f079ffd 100644 --- a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/drinks/DrinkTile.kt +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/drinks/DrinkTile.kt @@ -57,6 +57,7 @@ import coil.compose.rememberAsyncImagePainter import de.chaosdorf.meteroid.model.Drink import de.chaosdorf.meteroid.sample.SampleDrinkProvider import de.chaosdorf.meteroid.ui.PriceBadge +import java.math.BigDecimal @Preview(widthDp = 120, showBackground = true) @Composable @@ -109,8 +110,13 @@ fun DrinkTile( ) Spacer(Modifier.height(4.dp)) Row(modifier = Modifier.align(Alignment.CenterHorizontally)) { + val unitPrice = + if (item.volume <= BigDecimal.ZERO) null + else item.price / item.volume + Text( - String.format("%.02fl · %.02f€/l", item.volume, item.price / item.volume), + if (unitPrice == null) String.format("%.02fl", item.volume) + else String.format("%.02fl · %.02f€/l", item.volume, item.price / item.volume), modifier = Modifier .fillMaxWidth() .padding(horizontal = 8.dp), diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/navigation/MeteroidTopBar.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/navigation/MeteroidTopBar.kt index 5cc575b..f0b6bc2 100644 --- a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/navigation/MeteroidTopBar.kt +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/navigation/MeteroidTopBar.kt @@ -36,7 +36,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.PushPin import androidx.compose.material.icons.outlined.PushPin -import androidx.compose.material.icons.twotone.PushPin import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -48,19 +47,19 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.navigation.NavOptions import coil.compose.AsyncImage import de.chaosdorf.meteroid.model.AccountInfo import de.chaosdorf.meteroid.ui.PriceBadge import okhttp3.HttpUrl.Companion.toHttpUrl -import okhttp3.internal.toCanonicalHost @Composable fun MeteroidTopBar( account: AccountInfo?, onNavigate: (String, NavOptions) -> Unit, - onTogglePin: () -> Unit, + onTogglePin: () -> Unit ) { Surface( modifier = Modifier.padding(8.dp), @@ -83,35 +82,44 @@ fun MeteroidTopBar( .background(MaterialTheme.colorScheme.tertiary) ) Spacer(Modifier.width(16.dp)) - Column(modifier = Modifier.align(Alignment.CenterVertically)) { + Column( + modifier = Modifier + .align(Alignment.CenterVertically) + .weight(1.0f, fill = true) + ) { if (account != null) { if (account.user != null) { Text( account.user!!.name, - fontWeight = FontWeight.SemiBold + fontWeight = FontWeight.SemiBold, + overflow = TextOverflow.Ellipsis, + softWrap = false ) Text( account.server.url.toHttpUrl().host, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.67f), - fontWeight = FontWeight.Medium + fontWeight = FontWeight.Medium, + overflow = TextOverflow.Ellipsis, + softWrap = false ) } else { Text( account.server.url.toHttpUrl().host, - fontWeight = FontWeight.SemiBold + fontWeight = FontWeight.SemiBold, + overflow = TextOverflow.Ellipsis, + softWrap = false ) } } else { Text( "Meteroid", - fontWeight = FontWeight.SemiBold + fontWeight = FontWeight.SemiBold, + overflow = TextOverflow.Ellipsis, + softWrap = false ) } } - Spacer( - Modifier - .weight(1.0f) - .width(16.dp)) + Spacer(Modifier.width(16.dp)) IconButton(onClick = onTogglePin) { Icon( if (account?.pinned == true) Icons.Filled.PushPin diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/transactions/PurchaseViewModel.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/transactions/PurchaseViewModel.kt index 15e41aa..7242558 100644 --- a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/transactions/PurchaseViewModel.kt +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/transactions/PurchaseViewModel.kt @@ -24,6 +24,7 @@ package de.chaosdorf.meteroid.ui.transactions +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel @@ -32,9 +33,7 @@ import de.chaosdorf.meteroid.model.DrinkRepository import de.chaosdorf.meteroid.model.Server import de.chaosdorf.meteroid.model.TransactionRepository import de.chaosdorf.meteroid.sync.AccountProvider -import de.chaosdorf.meteroid.sync.SyncHandler import de.chaosdorf.meteroid.sync.SyncManager -import de.chaosdorf.meteroid.sync.TransactionSyncHandler import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine @@ -75,8 +74,10 @@ class TransactionViewModel @Inject constructor( } } ?: flowOf(emptyList()) }.mapLatest { list -> - list.mergeAdjecentDeposits() - .filter { it.drink != null || it.transaction.difference != BigDecimal.ZERO } + list.mergeAdjecentDeposits().filter { + it.drink != null || + it.transaction.difference.abs() >= 0.01.toBigDecimal() + } }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList()) fun togglePin() { @@ -89,6 +90,14 @@ class TransactionViewModel @Inject constructor( } } + fun sync() { + account.value?.let { (server, user) -> + viewModelScope.launch { + syncManager.sync(server, user, incremental = true) + } + } + } + suspend fun checkOffline(server: Server?): Boolean = if (server == null) true else syncManager.checkOffline(server) diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/wrapped/WrappedSlide.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/wrapped/WrappedSlide.kt index 4899c2e..ec262ad 100644 --- a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/wrapped/WrappedSlide.kt +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/wrapped/WrappedSlide.kt @@ -95,7 +95,7 @@ sealed class WrappedSlide { dosage, Animal.values() .sortedBy(Animal::lethalDosage) - .firstOrNull { it.lethalDosage < dosage } + .lastOrNull { it.lethalDosage < dosage } ) } } diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/wrapped/WrappedViewModel.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/wrapped/WrappedViewModel.kt index 722226a..d50c28a 100644 --- a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/wrapped/WrappedViewModel.kt +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/wrapped/WrappedViewModel.kt @@ -32,6 +32,7 @@ import de.chaosdorf.meteroid.model.AccountInfo import de.chaosdorf.meteroid.model.Drink import de.chaosdorf.meteroid.model.DrinkRepository import de.chaosdorf.meteroid.model.Server +import de.chaosdorf.meteroid.model.Transaction import de.chaosdorf.meteroid.model.TransactionRepository import de.chaosdorf.meteroid.sync.AccountProvider import de.chaosdorf.meteroid.sync.SyncManager @@ -60,6 +61,16 @@ class WrappedViewModel @Inject constructor( val account: StateFlow<AccountInfo?> = accountProvider.account .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) + private fun List<Transaction>.filterAudits(year: Int): List<Transaction> { + val yearBegin = LocalDateTime(year, Month.JANUARY, 1, 0, 0, 0) + .toInstant(TimeZone.UTC) + val yearEnd = LocalDateTime(year, Month.DECEMBER, 31, 23, 59, 59) + .toInstant(TimeZone.UTC) + return this.filter { + it.timestamp in yearBegin..yearEnd + } + } + val slides: StateFlow<List<WrappedSlide>> = accountProvider.account .flatMapLatest { account -> account?.let { (server, maybeUser) -> @@ -74,28 +85,9 @@ class WrappedViewModel @Inject constructor( WrappedSlide.Caffeine, WrappedSlide.MostActive ) - val timeZone = TimeZone.currentSystemDefault() - val now = Clock.System.now().toLocalDateTime(timeZone) - val yearBegin = LocalDateTime( - year = now.year, - month = Month.JANUARY, - dayOfMonth = 1, - hour = 0, - minute = 0, - second = 0 - ).toInstant(timeZone) - val yearEnd = LocalDateTime( - year = now.year + 1, - month = Month.JANUARY, - dayOfMonth = 1, - hour = 0, - minute = 0, - second = 0 - ).toInstant(timeZone) - val thisYear = transactions.filter { - it.timestamp in yearBegin..yearEnd - } - factories.mapNotNull { it.create(thisYear, drinkMap) } + val now = Clock.System.now().toLocalDateTime(TimeZone.UTC) + val content = transactions.filterAudits(now.year) + factories.mapNotNull { it.create(content, drinkMap) } } } } ?: flowOf(emptyList()) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7d17d98..25ea8c9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,13 +1,13 @@ [versions] -androidGradlePlugin = "8.1.3" -androidx-activity = "1.8.0" +androidGradlePlugin = "8.1.4" +androidx-activity = "1.8.1" androidx-appcompat = "1.6.1" androidx-compose-bom = "2023.10.01" androidx-compose-compiler = "1.5.4" androidx-compose-material = "1.5.0-alpha04" -androidx-compose-material3 = "1.2.0-alpha10" -androidx-compose-runtimetracing = "1.0.0-alpha04" -androidx-compose-tooling = "1.6.0-alpha08" +androidx-compose-material3 = "1.2.0-alpha11" +androidx-compose-runtimetracing = "1.0.0-alpha05" +androidx-compose-tooling = "1.6.0-beta01" androidx-datastore = "1.0.0" androidx-hilt = "1.1.0" androidx-navigation = "2.7.5" diff --git a/persistence/src/main/kotlin/de/chaosdorf/meteroid/model/Transaction.kt b/persistence/src/main/kotlin/de/chaosdorf/meteroid/model/Transaction.kt index ac66251..f320689 100644 --- a/persistence/src/main/kotlin/de/chaosdorf/meteroid/model/Transaction.kt +++ b/persistence/src/main/kotlin/de/chaosdorf/meteroid/model/Transaction.kt @@ -86,6 +86,8 @@ interface TransactionRepository { @Query("SELECT * FROM `Transaction` WHERE serverId = :serverId AND userId = :userId ORDER BY timestamp DESC") fun getAllFlow(serverId: ServerId, userId: UserId): Flow<List<Transaction>> + @Query("SELECT * FROM `Transaction` WHERE serverId = :serverId AND userId = :userId ORDER BY timestamp DESC LIMIT 1") + suspend fun getLatest(serverId: ServerId, userId: UserId): Transaction? @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun save(transaction: Transaction) -- GitLab