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 8313d89c82dceb7a519b58deeb4bd193be14c109..ae0cee0199d3b3edd57a6a77c7ec6d1f6f6c0dd6 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 7c25ea6828c4a9ef0e2fd1a52192c315cca613ea..cc9f921706cbdf72c4c540809d589ff011430977 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 d9d2a162ff3c962aa0eac128b059985af0e1460d..b60189cf1f7d50e8938b861dbd754666e2d84a71 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 c217089b28f93c7bce8eea31d3a64bd1963f5f2f..5e9a4e3bc5e9580836764ed5713408abdf735a4c 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 071c20c4881f368c6397e03c370e69cc1a6f6168..e9797e920269ed6075625fa00167ffbc24fb3878 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 179a5dbd20dd46c856f3ad6e99447bc1ceffad4b..0f50531c4e5c95f2c30af6dca0e560e53d7632dd 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 0000000000000000000000000000000000000000..16ae412fcf7d5e88b5b8c4c2bc0f7e9b2df518d0 --- /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 b7a61994adc21e850d8dc80cc570fe74de637ba6..00d33c17f74ae242e6eaa66755c147f1bbdee3c6 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 0000000000000000000000000000000000000000..c07f0af190dd16b862cb5e799232dee6fff0017a --- /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 0000000000000000000000000000000000000000..7046454852242e021d13a64684d63b46d18e4686 --- /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 853744c36968967346db957c350110449dc7b3c9..8e0cf09af985c05ee25db0f6a43f05870e838ca7 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 7ded488064f050a2dae6b47c24ceb08ba5ee660d..2231c375270f284fcd144aa8f4cf47e4e33b494c 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 e56a782ba0ca524025f9aadd6a574900592ee836..52c751eb9fed226b7f244be5532bb4552f5aaa5d 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 ab9b07c052f48a4d853fb4d05e6d68e4def8b967..f079ffd6976cb23c8b53f70b7c4f2894d36da180 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 5cc575b87cb1c33b52c0d42a6e544d277cdf2cfb..f0b6bc200d1ea6e201c7b74fe0c9ff3a88ff1798 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 15e41aa85b282727d8332841ee72e565b6f65096..7242558d27e2738ca796588b9ab8eb34540a1ea9 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 4899c2ecda023e56684a4cdaa1d58fe2f90f8953..ec262adfa75678e180ef581d1bdd2cade205ed9f 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 722226a4dfc8c7c0b7f2bfde6c43e20b096cee1c..d50c28a621a80cc311df6a607dc88cc5af72aefe 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 7d17d98c36153e3781bde380b0437745f8e56180..25ea8c9b8d705f693154d2c4971d74e8f084a3f1 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 ac662512f36498378124d296e93df268c076d18c..f3206899c63ef84c68c0f5a77dcafb8eb80aa5f2 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)