diff --git a/api/src/main/kotlin/de/chaosdorf/mete/AuditEntryId.kt b/api/src/main/kotlin/de/chaosdorf/mete/AuditEntryId.kt new file mode 100644 index 0000000000000000000000000000000000000000..d1725c4307a22b314cc66aeccdac97980edc0c69 --- /dev/null +++ b/api/src/main/kotlin/de/chaosdorf/mete/AuditEntryId.kt @@ -0,0 +1,31 @@ +/* + * 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.mete + +import kotlinx.serialization.Serializable + +@Serializable +@JvmInline +value class AuditEntryId(val value: Long) diff --git a/api/src/main/kotlin/de/chaosdorf/mete/v1/AuditEntryModelV1.kt b/api/src/main/kotlin/de/chaosdorf/mete/v1/AuditEntryModelV1.kt index 8ba703392c092f96a4e70d126a34507e513b8905..b3cf90126b925d431e3a6d6b5c38090482e6c1c3 100644 --- a/api/src/main/kotlin/de/chaosdorf/mete/v1/AuditEntryModelV1.kt +++ b/api/src/main/kotlin/de/chaosdorf/mete/v1/AuditEntryModelV1.kt @@ -24,13 +24,17 @@ package de.chaosdorf.mete.v1 +import de.chaosdorf.mete.AuditEntryId import de.chaosdorf.mete.DrinkId -import de.chaosdorf.mete.UserId import kotlinx.datetime.Instant +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +@Serializable data class AuditEntryModelV1( + val id: AuditEntryId, + @SerialName("created_at") + val createdAt: Instant, val difference: Double, - val drink: DrinkId, - val user: UserId, - val createdAt: Instant + val drink: DrinkId? ) diff --git a/api/src/main/kotlin/de/chaosdorf/mete/v1/AuditResponseV1.kt b/api/src/main/kotlin/de/chaosdorf/mete/v1/AuditResponseV1.kt new file mode 100644 index 0000000000000000000000000000000000000000..c0d79e1746447b91a38fe269eafb78472f6990bc --- /dev/null +++ b/api/src/main/kotlin/de/chaosdorf/mete/v1/AuditResponseV1.kt @@ -0,0 +1,40 @@ +/* + * 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.mete.v1 + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class AuditResponseV1( + @SerialName("payments_sum") + val payments: Double, + @SerialName("deposits_sum") + val deposits: Double, + @SerialName("sum") + val total: Double, + @SerialName("audits") + val entries: List<AuditEntryModelV1> +) 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 0a1b1ee34a1c7568fcf9c1c2546aac1cce7eeae8..d08da96ac03856aa36bc128445742e2f17e238cf 100644 --- a/api/src/main/kotlin/de/chaosdorf/mete/v1/MeteApiV1.kt +++ b/api/src/main/kotlin/de/chaosdorf/mete/v1/MeteApiV1.kt @@ -29,13 +29,16 @@ import de.chaosdorf.mete.PwaManifest import de.chaosdorf.mete.UserId import retrofit2.http.GET import retrofit2.http.Path +import retrofit2.http.Query interface MeteApiV1 { @GET("manifest.json") suspend fun getManifest(): PwaManifest? @GET("api/v1/audits.json") - suspend fun getAudits(): List<AuditEntryModelV1> + suspend fun getAudits( + @Query("user") user: Long? = null + ): AuditResponseV1 @GET("api/v1/barcodes.json") suspend fun listBarcodes(): List<BarcodeModelV1> diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1efc1531328c9883640ef4b1e792d70d2eb1f811..e68570b35f3d3f16626d1d7b054277297ab2eba8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -56,6 +56,9 @@ android { dependencies { implementation(libs.kotlin.stdlib) + implementation(libs.kotlinx.datetime) + implementation(libs.kotlinx.serialization.json) + coreLibraryDesugaring(libs.desugar.jdk) implementation(libs.kotlinx.coroutines.android) testImplementation(libs.kotlinx.coroutines.test) @@ -86,14 +89,13 @@ dependencies { implementation(libs.androidx.navigation.compose) implementation(libs.okhttp) - implementation(libs.kotlinx.serialization.json) implementation(libs.coil.compose) implementation(libs.hilt.navigation) implementation(libs.hilt.android) ksp(libs.hilt.compiler) - implementation("androidx.datastore:datastore-preferences:1.0.0") + implementation(libs.androidx.datastore.preferences) implementation(project(":api")) implementation(project(":persistence")) diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/di/DatabaseModule.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/di/DatabaseModule.kt index a8606f1c8d59c667229468ca6ac15eecd8f955f0..8d35e31128fafdc943f6f8be158020b3f0522f2c 100644 --- a/app/src/main/kotlin/de/chaosdorf/meteroid/di/DatabaseModule.kt +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/di/DatabaseModule.kt @@ -33,6 +33,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import de.chaosdorf.meteroid.MeteroidDatabase import de.chaosdorf.meteroid.model.DrinkRepository +import de.chaosdorf.meteroid.model.PurchaseRepository import de.chaosdorf.meteroid.model.ServerRepository import de.chaosdorf.meteroid.model.UserRepository import javax.inject.Singleton @@ -59,6 +60,11 @@ object DatabaseModule { database: MeteroidDatabase ): UserRepository = database.users() + @Provides + fun providePurchaseRepository( + database: MeteroidDatabase + ): PurchaseRepository = database.purchases() + @Provides fun provideServerRepository( database: MeteroidDatabase diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/sample/SampleDrinkProvider.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/sample/SampleDrinkProvider.kt new file mode 100644 index 0000000000000000000000000000000000000000..c29e736c162cbb4082ca818151c0415667082468 --- /dev/null +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/sample/SampleDrinkProvider.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.sample + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import de.chaosdorf.mete.DrinkId +import de.chaosdorf.meteroid.model.Drink +import de.chaosdorf.meteroid.model.ServerId +import kotlinx.datetime.Instant + +class SampleDrinkProvider : PreviewParameterProvider<Drink> { + override val values = sequenceOf( + Drink( + serverId = ServerId(-1), + drinkId = DrinkId(27), + active = true, + name = "Club Mate", + volume = 0.5, + caffeine = null, + price = 1.5, + createdAt = Instant.fromEpochMilliseconds(1684598011800), + updatedAt = Instant.fromEpochMilliseconds(1684607122132), + logoUrl = "http://192.168.188.36:8080/system/drinks/logos/000/000/027/thumb/logo.png", + logoFileName = "logo.png", + logoContentType = "image/png", + logoFileSize = 183063, + logoUpdatedAt = Instant.fromEpochMilliseconds(1684607121995) + ), + Drink( + serverId = ServerId(-1), + drinkId = DrinkId(15), + active = false, + name = "Paulaner Spezi", + volume = 0.5, + caffeine = null, + price = 1.5, + createdAt = Instant.fromEpochMilliseconds(1684597806099), + updatedAt = Instant.fromEpochMilliseconds(1684607346944), + logoUrl = "http://192.168.188.36:8080/system/drinks/logos/000/000/015/thumb/logo.png", + logoFileName = "logo.png", + logoContentType = "image/png", + logoFileSize = 173265, + logoUpdatedAt = Instant.fromEpochMilliseconds(1684607346835) + ) + ) +} diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/sync/AccountProvider.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/sync/AccountProvider.kt new file mode 100644 index 0000000000000000000000000000000000000000..4420a5eda68e7e94a37a2ce7b38f7a9e4ca4294b --- /dev/null +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/sync/AccountProvider.kt @@ -0,0 +1,60 @@ +/* + * 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 + +import de.chaosdorf.meteroid.model.Server +import de.chaosdorf.meteroid.model.ServerRepository +import de.chaosdorf.meteroid.model.User +import de.chaosdorf.meteroid.model.UserRepository +import de.chaosdorf.meteroid.storage.AccountPreferences +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.mapLatest +import javax.inject.Inject + +class AccountProvider @Inject constructor( + accountPreferences: AccountPreferences, + serverRepository: ServerRepository, + userRepository: UserRepository, +) { + val account: Flow<Pair<Server, User?>?> = + accountPreferences.state.flatMapLatest { preferences -> + if (preferences.server == null) { + flowOf(null) + } else { + serverRepository.getFlow(preferences.server).flatMapLatest { server -> + if (server == null) { + flowOf(null) + } else if (preferences.user == null) { + flowOf(Pair(server, null)) + } else { + userRepository.getFlow(server.serverId, preferences.user) + .mapLatest { user -> Pair(server, user) } + } + } + } + } +} diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/storage/DrinkSyncHandler.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/sync/DrinkSyncHandler.kt similarity index 95% rename from app/src/main/kotlin/de/chaosdorf/meteroid/storage/DrinkSyncHandler.kt rename to app/src/main/kotlin/de/chaosdorf/meteroid/sync/DrinkSyncHandler.kt index 732f3ed1c05d366481e12a247e172f096bd3fee1..dc8e5ed99634ea494b8f9269b6255cb4bd049732 100644 --- a/app/src/main/kotlin/de/chaosdorf/meteroid/storage/DrinkSyncHandler.kt +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/sync/DrinkSyncHandler.kt @@ -22,7 +22,7 @@ * THE SOFTWARE. */ -package de.chaosdorf.meteroid.storage +package de.chaosdorf.meteroid.sync import androidx.room.withTransaction import de.chaosdorf.mete.DrinkId @@ -59,6 +59,6 @@ class DrinkSyncHandler @Inject constructor( override suspend fun loadCurrent(context: Server): List<Drink> { val api = MeteApiV1Factory.newInstance(context.url) val loadedEntries = api.listDrinks() - return loadedEntries.map { Drink.fromModelV1(context.serverId, it) } + return loadedEntries.map { Drink.fromModelV1(context, it) } } } diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/sync/PurchaseSyncHandler.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/sync/PurchaseSyncHandler.kt new file mode 100644 index 0000000000000000000000000000000000000000..8ba6652d07b5d7958abab18f18b3813d4ce9fe2b --- /dev/null +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/sync/PurchaseSyncHandler.kt @@ -0,0 +1,66 @@ +/* + * 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 + +import androidx.room.withTransaction +import de.chaosdorf.mete.AuditEntryId +import de.chaosdorf.mete.UserId +import de.chaosdorf.mete.v1.MeteApiV1Factory +import de.chaosdorf.meteroid.MeteroidDatabase +import de.chaosdorf.meteroid.model.Purchase +import de.chaosdorf.meteroid.model.PurchaseRepository +import de.chaosdorf.meteroid.model.Server +import de.chaosdorf.meteroid.model.ServerId +import javax.inject.Inject + +class PurchaseSyncHandler @Inject constructor( + private val db: MeteroidDatabase, + private val repository: PurchaseRepository +) : SyncHandler<Pair<Server, UserId>, Purchase, PurchaseSyncHandler.Key>() { + data class Key( + val server: ServerId, val purchase: AuditEntryId + ) + + override suspend fun withTransaction(block: suspend () -> Unit) = + db.withTransaction(block) + + override suspend fun store(entry: Purchase) = + repository.save(entry) + + override suspend fun delete(key: Key) = + repository.delete(key.server, key.purchase) + + override fun entryToKey(entry: Purchase) = Key(entry.serverId, entry.purchaseId) + + override suspend fun loadStored(context: Pair<Server, UserId>): List<Purchase> = + repository.getAll(context.first.serverId, context.second) + + override suspend fun loadCurrent(context: Pair<Server, UserId>): List<Purchase> { + val (server, userId) = context + val api = MeteApiV1Factory.newInstance(server.url) + val loadedEntries = api.getAudits(user = userId.value).entries + return loadedEntries.map { Purchase.fromModelV1(server, userId, it) } + } +} diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/storage/SyncHandler.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/sync/SyncHandler.kt similarity index 98% rename from app/src/main/kotlin/de/chaosdorf/meteroid/storage/SyncHandler.kt rename to app/src/main/kotlin/de/chaosdorf/meteroid/sync/SyncHandler.kt index 40be77a41d187054aa92a1b86423ee6d6010a1fd..6bce3403a0f01c15166d0b1f67a99ecd70bccf4b 100644 --- a/app/src/main/kotlin/de/chaosdorf/meteroid/storage/SyncHandler.kt +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/sync/SyncHandler.kt @@ -22,7 +22,7 @@ * THE SOFTWARE. */ -package de.chaosdorf.meteroid.storage +package de.chaosdorf.meteroid.sync import android.util.Log import kotlinx.coroutines.flow.MutableStateFlow diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/sync/SyncViewModel.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/sync/SyncViewModel.kt new file mode 100644 index 0000000000000000000000000000000000000000..5ae0c58b4528f76c57d8fad51672d29a0ac33e49 --- /dev/null +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/sync/SyncViewModel.kt @@ -0,0 +1,35 @@ +/* + * 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 + +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class SyncViewModel @Inject constructor( +) : ViewModel() { + +} diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/storage/UserSyncHandler.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/sync/UserSyncHandler.kt similarity index 95% rename from app/src/main/kotlin/de/chaosdorf/meteroid/storage/UserSyncHandler.kt rename to app/src/main/kotlin/de/chaosdorf/meteroid/sync/UserSyncHandler.kt index 906771a524a782b8a3d58383beb3b849d737890c..a6f9291d3db387c6737d8f3f1ba64ca03d320124 100644 --- a/app/src/main/kotlin/de/chaosdorf/meteroid/storage/UserSyncHandler.kt +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/sync/UserSyncHandler.kt @@ -22,7 +22,7 @@ * THE SOFTWARE. */ -package de.chaosdorf.meteroid.storage +package de.chaosdorf.meteroid.sync import androidx.room.withTransaction import de.chaosdorf.mete.UserId @@ -59,6 +59,6 @@ class UserSyncHandler @Inject constructor( override suspend fun loadCurrent(context: Server): List<User> { val api = MeteApiV1Factory.newInstance(context.url) val loadedEntries = api.listUsers() - return loadedEntries.map { User.fromModelV1(context.serverId, it) } + return loadedEntries.map { User.fromModelV1(context, it) } } } diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/AppRouter.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/AppRouter.kt index b22f4ce8f05065532a28afbe460fdedd28ad1633..81b5e89523a17d84e13df25d394e7c4cc2650732 100644 --- a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/AppRouter.kt +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/AppRouter.kt @@ -25,23 +25,28 @@ package de.chaosdorf.meteroid.ui import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Modifier +import androidx.compose.ui.Alignment import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.navigation import androidx.navigation.compose.rememberNavController -import de.chaosdorf.meteroid.ui.home.DrinkListScreen -import de.chaosdorf.meteroid.ui.home.DrinkListViewModel -import de.chaosdorf.meteroid.ui.home.HomeSections +import de.chaosdorf.meteroid.ui.drinks.DrinkListScreen +import de.chaosdorf.meteroid.ui.drinks.DrinkListViewModel +import de.chaosdorf.meteroid.ui.money.MoneyListScreen +import de.chaosdorf.meteroid.ui.money.MoneyListViewModel +import de.chaosdorf.meteroid.ui.navigation.Routes +import de.chaosdorf.meteroid.ui.purchases.PurchaseListScreen +import de.chaosdorf.meteroid.ui.purchases.PurchaseViewModel import de.chaosdorf.meteroid.ui.servers.AddServerScreen import de.chaosdorf.meteroid.ui.servers.ServerListScreen import de.chaosdorf.meteroid.ui.users.UserListScreen @@ -65,8 +70,11 @@ fun AppRouter(viewModel: AppViewModel = viewModel()) { NavHost(navController, startDestination = Routes.Init) { composable(route = Routes.Init) { _ -> - Box { - Text("Loading") + Box(contentAlignment = Alignment.Center) { + Column { + CircularProgressIndicator() + Text("Loading") + } } } @@ -116,26 +124,15 @@ fun AppRouter(viewModel: AppViewModel = viewModel()) { navigation(route = Routes.Home.Root, startDestination = Routes.Home.Purchase) { composable(Routes.Home.Purchase) { _ -> val drinkListViewModel = hiltViewModel<DrinkListViewModel>() - MeteroidScaffold( - routes = HomeSections.entries, - currentRoute = HomeSections.PURCHASE, - navigateTo = navController::navigate, - onBack = { navController.navigate(Routes.Users.Root) }, - ) { paddingValues -> - DrinkListScreen(drinkListViewModel, Modifier.padding(paddingValues)) - } + DrinkListScreen(drinkListViewModel, navController::navigate) } composable(Routes.Home.Deposit) { _ -> - MeteroidScaffold( - routes = HomeSections.entries, - currentRoute = HomeSections.DEPOSIT, - navigateTo = navController::navigate, - onBack = { navController.navigate(Routes.Users.Root) }, - ) { paddingValues -> - Box(Modifier.padding(paddingValues)) { - Text("TODO: Deposit") - } - } + val moneyListViewModel = hiltViewModel<MoneyListViewModel>() + MoneyListScreen(moneyListViewModel, navController::navigate) + } + composable(Routes.Home.History) { _ -> + val purchaseViewModel = hiltViewModel<PurchaseViewModel>() + PurchaseListScreen(purchaseViewModel, navController::navigate) } } } 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 3781fd46a11af9cb723face86a61c2fe8a952fa3..bd066bb443087ce79a3443a72e69524010a02fc5 100644 --- a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/AppViewModel.kt +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/AppViewModel.kt @@ -32,12 +32,14 @@ import de.chaosdorf.meteroid.model.Server import de.chaosdorf.meteroid.model.ServerId import de.chaosdorf.meteroid.model.ServerRepository import de.chaosdorf.meteroid.storage.AccountPreferences -import de.chaosdorf.meteroid.storage.DrinkSyncHandler -import de.chaosdorf.meteroid.storage.UserSyncHandler +import de.chaosdorf.meteroid.sync.AccountProvider +import de.chaosdorf.meteroid.sync.DrinkSyncHandler +import de.chaosdorf.meteroid.sync.PurchaseSyncHandler +import de.chaosdorf.meteroid.sync.UserSyncHandler import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.onEach @@ -46,10 +48,12 @@ import javax.inject.Inject @HiltViewModel class AppViewModel @Inject constructor( + accountProvider: AccountProvider, private val accountPreferences: AccountPreferences, private val serverRepository: ServerRepository, private val userSyncHandler: UserSyncHandler, private val drinkSyncHandler: DrinkSyncHandler, + private val purchaseSyncHandler: PurchaseSyncHandler ) : ViewModel() { val initState: StateFlow<InitState> = accountPreferences.state .flatMapLatest { preferences -> @@ -63,17 +67,14 @@ class AppViewModel @Inject constructor( } }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), InitState.LOADING) - private val server: StateFlow<Server?> = - accountPreferences.state.flatMapLatest { preferences -> - if (preferences.server == null) flowOf(null) - else serverRepository.getFlow(preferences.server) - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) - init { - server.onEach { server -> - if (server != null) { + accountProvider.account.distinctUntilChanged().onEach { account -> + account?.let { (server, user) -> userSyncHandler.sync(server) drinkSyncHandler.sync(server) + user?.let { user -> + purchaseSyncHandler.sync(Pair(server, user.userId)) + } } }.launchIn(viewModelScope) } diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/drinks/DrinkListScreen.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/drinks/DrinkListScreen.kt new file mode 100644 index 0000000000000000000000000000000000000000..46b2833374568b31f86fee4ff18f620c34a2577a --- /dev/null +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/drinks/DrinkListScreen.kt @@ -0,0 +1,80 @@ +/* + * 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.ui.drinks + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import de.chaosdorf.meteroid.sync.SyncHandler +import de.chaosdorf.meteroid.ui.navigation.HomeSections +import de.chaosdorf.meteroid.ui.navigation.MeteroidBottomBar +import de.chaosdorf.meteroid.ui.navigation.MeteroidTopBar + +@Composable +fun DrinkListScreen( + viewModel: DrinkListViewModel, + onNavigate: (String) -> Unit = {} +) { + val account by viewModel.account.collectAsState() + val drinks by viewModel.drinks.collectAsState() + val syncState by viewModel.syncState.collectAsState() + Scaffold( + topBar = { MeteroidTopBar(account, onNavigate) }, + bottomBar = { + MeteroidBottomBar( + currentRoute = HomeSections.PURCHASE, + historyEnabled = account?.second?.audit == true, + navigateTo = onNavigate + ) + } + ) { paddingValues: PaddingValues -> + Column { + if (syncState == SyncHandler.State.Loading) { + LinearProgressIndicator() + } + LazyVerticalGrid( + GridCells.Adaptive(120.dp), + modifier = Modifier.padding(paddingValues), + contentPadding = PaddingValues(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + items(drinks) { drink -> + DrinkTile(drink) + } + } + } + } +} diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/home/DrinkListViewModel.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/drinks/DrinkListViewModel.kt similarity index 67% rename from app/src/main/kotlin/de/chaosdorf/meteroid/ui/home/DrinkListViewModel.kt rename to app/src/main/kotlin/de/chaosdorf/meteroid/ui/drinks/DrinkListViewModel.kt index 331771d492ce0a43d5c8577778e509e9b1c12efa..1a10f7dc1d592371aa20107ca4c40d40207a601f 100644 --- a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/home/DrinkListViewModel.kt +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/drinks/DrinkListViewModel.kt @@ -22,37 +22,40 @@ * THE SOFTWARE. */ -package de.chaosdorf.meteroid.ui.home +package de.chaosdorf.meteroid.ui.drinks import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import de.chaosdorf.meteroid.model.Drink import de.chaosdorf.meteroid.model.DrinkRepository -import de.chaosdorf.meteroid.model.ServerId -import de.chaosdorf.meteroid.storage.AccountPreferences -import de.chaosdorf.meteroid.storage.DrinkSyncHandler -import kotlinx.coroutines.flow.Flow +import de.chaosdorf.meteroid.model.Server +import de.chaosdorf.meteroid.model.User +import de.chaosdorf.meteroid.sync.AccountProvider +import de.chaosdorf.meteroid.sync.DrinkSyncHandler +import de.chaosdorf.meteroid.sync.SyncHandler import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.stateIn import javax.inject.Inject @HiltViewModel class DrinkListViewModel @Inject constructor( - drinkRepository: DrinkRepository, - accountPreferences: AccountPreferences, + accountProvider: AccountProvider, + repository: DrinkRepository, syncHandler: DrinkSyncHandler ) : ViewModel() { - private val serverId: Flow<ServerId?> = accountPreferences.state.mapLatest { it.server } - val drinks: StateFlow<List<Drink>> = serverId.flatMapLatest { - it?.let { serverId -> - drinkRepository.getAllFlow(serverId) - } ?: flowOf(emptyList()) - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList()) + val account: StateFlow<Pair<Server, User?>?> = accountProvider.account + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) - val syncState = syncHandler.state + val drinks: StateFlow<List<Drink>> = accountProvider.account + .flatMapLatest { account -> + account?.let { (server, _) -> + repository.getAllFlow(server.serverId) + } ?: flowOf(emptyList()) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList()) + + val syncState: StateFlow<SyncHandler.State> = syncHandler.state } 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 new file mode 100644 index 0000000000000000000000000000000000000000..2099f684419e787c81d533635ac1f1d1f5c170af --- /dev/null +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/drinks/DrinkTile.kt @@ -0,0 +1,112 @@ +/* + * 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.ui.drinks + +import androidx.compose.foundation.Image +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.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +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.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.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import coil.compose.rememberAsyncImagePainter +import de.chaosdorf.meteroid.model.Drink +import de.chaosdorf.meteroid.sample.SampleDrinkProvider + +@Preview(widthDp = 120, showBackground = true) +@Composable +fun DrinkTile( + @PreviewParameter(SampleDrinkProvider::class) item: Drink +) { + val thumbPainter = rememberAsyncImagePainter( + item.logoUrl + ) + val drinkPainter = rememberAsyncImagePainter( + item.logoUrl.replace("/thumb/", "/original/"), + error = thumbPainter + ) + + Column( + modifier = Modifier.padding(4.dp) + ) { + Box { + Image( + drinkPainter, + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primaryContainer) + .aspectRatio(1.0f) + .padding(8.dp) + ) + Text( + String.format("%.02f €", item.price), + color = MaterialTheme.colorScheme.onPrimary, + fontWeight = FontWeight.SemiBold, + modifier = Modifier + .padding(vertical = 12.dp) + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.primary) + .align(Alignment.BottomEnd) + .padding(horizontal = 8.dp) + ) + } + Text( + item.name, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 4.dp) + ) + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .padding(horizontal = 4.dp) + .fillMaxWidth() + ) { + Text( + String.format("%.02f l", item.volume), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f) + ) + } + } +} diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/MeteroidScaffold.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/money/MoneyListScreen.kt similarity index 52% rename from app/src/main/kotlin/de/chaosdorf/meteroid/ui/MeteroidScaffold.kt rename to app/src/main/kotlin/de/chaosdorf/meteroid/ui/money/MoneyListScreen.kt index 61340f69ba23abacd4d66ceb74da0fe94ff9d5df..9092436d6a46f478fcfcb032b1681c8dda884de6 100644 --- a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/MeteroidScaffold.kt +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/money/MoneyListScreen.kt @@ -22,53 +22,53 @@ * THE SOFTWARE. */ -package de.chaosdorf.meteroid.ui +package de.chaosdorf.meteroid.ui.money +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Menu -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.NavigationBar +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.shadow import androidx.compose.ui.unit.dp -import androidx.compose.ui.zIndex +import de.chaosdorf.meteroid.ui.navigation.HomeSections +import de.chaosdorf.meteroid.ui.navigation.MeteroidBottomBar +import de.chaosdorf.meteroid.ui.navigation.MeteroidTopBar @Composable -fun <T : MeteroidNavSection> MeteroidScaffold( - routes: Iterable<T>, - currentRoute: T, - navigateTo: (String) -> Unit, - onBack: () -> Unit, - content: @Composable (PaddingValues) -> Unit +fun MoneyListScreen( + viewModel: MoneyListViewModel, + onNavigate: (String) -> Unit = {} ) { + val account by viewModel.account.collectAsState() + Scaffold( - topBar = { - TopAppBar( - title = { Text("Meteroid") }, - navigationIcon = { - IconButton(onClick = onBack) { - Icon(Icons.Default.Menu, contentDescription = "Menu") - } - }, - modifier = Modifier - .padding(8.dp) - .shadow(4.dp, shape = RoundedCornerShape(8.dp)) - .zIndex(1.0f) - ) - }, + topBar = { MeteroidTopBar(account, onNavigate) }, bottomBar = { - NavigationBar { - MeteroidNavSections(routes, currentRoute, navigateTo) + MeteroidBottomBar( + currentRoute = HomeSections.DEPOSIT, + historyEnabled = account?.second?.audit == true, + navigateTo = onNavigate + ) + } + ) { paddingValues: PaddingValues -> + Column { + LazyVerticalGrid( + GridCells.Adaptive(120.dp), + modifier = Modifier.padding(paddingValues), + contentPadding = PaddingValues(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + items(viewModel.money) { monetaryAmount -> + MoneyTile(monetaryAmount) + } } - }, - content = content - ) + } + } } diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/money/MoneyListViewModel.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/money/MoneyListViewModel.kt new file mode 100644 index 0000000000000000000000000000000000000000..138661767ee0c3a97d721eec86371e82041258f7 --- /dev/null +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/money/MoneyListViewModel.kt @@ -0,0 +1,58 @@ +/* + * 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.ui.money + +import androidx.annotation.DrawableRes +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import de.chaosdorf.meteroid.R +import de.chaosdorf.meteroid.model.Server +import de.chaosdorf.meteroid.model.User +import de.chaosdorf.meteroid.sync.AccountProvider +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject + +enum class MonetaryAmount(val amount: Double, @DrawableRes val image: Int) { + MONEY_50(0.50, R.drawable.euro_50), + MONEY_100(1.00, R.drawable.euro_100), + MONEY_200(2.00, R.drawable.euro_200), + MONEY_500(5.00, R.drawable.euro_500), + MONEY_1000(10.00, R.drawable.euro_1000), + MONEY_2000(20.00, R.drawable.euro_2000), + MONEY_5000(50.00, R.drawable.euro_5000), +} + +@HiltViewModel +class MoneyListViewModel @Inject constructor( + accountProvider: AccountProvider +) : ViewModel() { + val account: StateFlow<Pair<Server, User?>?> = accountProvider.account + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) + + val money: List<MonetaryAmount> = MonetaryAmount.entries +} diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/money/MoneyTile.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/money/MoneyTile.kt new file mode 100644 index 0000000000000000000000000000000000000000..f584cb0ebd1db92969539ae4c885d9e59ab154d3 --- /dev/null +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/money/MoneyTile.kt @@ -0,0 +1,77 @@ +/* + * 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.ui.money + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +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.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp + +@Composable +fun MoneyTile( + item: MonetaryAmount +) { + Column( + modifier = Modifier.padding(4.dp) + ) { + Box { + Image( + painterResource(item.image), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primaryContainer) + .aspectRatio(1.0f) + .padding(8.dp) + ) + Text( + String.format("%.02f €", item.amount), + color = MaterialTheme.colorScheme.onPrimary, + fontWeight = FontWeight.SemiBold, + modifier = Modifier + .padding(vertical = 12.dp) + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.primary) + .align(Alignment.BottomEnd) + .padding(horizontal = 8.dp) + ) + } + } +} diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/home/HomeSections.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/navigation/HomeSections.kt similarity index 84% rename from app/src/main/kotlin/de/chaosdorf/meteroid/ui/home/HomeSections.kt rename to app/src/main/kotlin/de/chaosdorf/meteroid/ui/navigation/HomeSections.kt index 80d9d5eab7542df27750aeac6e8d18d6f775e3eb..c0879743211d72fca287405f5dbd5243784856c1 100644 --- a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/home/HomeSections.kt +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/navigation/HomeSections.kt @@ -22,15 +22,14 @@ * THE SOFTWARE. */ -package de.chaosdorf.meteroid.ui.home +package de.chaosdorf.meteroid.ui.navigation import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.twotone.Money +import androidx.compose.material.icons.twotone.History +import androidx.compose.material.icons.twotone.LocalAtm import androidx.compose.ui.graphics.vector.ImageVector import de.chaosdorf.meteroid.icons.MeteroidIcons import de.chaosdorf.meteroid.icons.twotone.WaterFull -import de.chaosdorf.meteroid.ui.MeteroidNavSection -import de.chaosdorf.meteroid.ui.Routes enum class HomeSections( override val title: String, @@ -38,5 +37,6 @@ enum class HomeSections( override val route: String ) : MeteroidNavSection { PURCHASE("Drinks", MeteroidIcons.TwoTone.WaterFull, Routes.Home.Purchase), - DEPOSIT("Money", Icons.TwoTone.Money, Routes.Home.Deposit); + DEPOSIT("Money", Icons.TwoTone.LocalAtm, Routes.Home.Deposit), + HISTORY("History", Icons.TwoTone.History, Routes.Home.History); } diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/MeteroidNavSection.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/navigation/MeteroidBottomBar.kt similarity index 71% rename from app/src/main/kotlin/de/chaosdorf/meteroid/ui/MeteroidNavSection.kt rename to app/src/main/kotlin/de/chaosdorf/meteroid/ui/navigation/MeteroidBottomBar.kt index 4a3a6092479780c5c0a0264dc877102e1841bf06..ebe94f2df394db701947871f6d81811a7ef25373 100644 --- a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/MeteroidNavSection.kt +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/navigation/MeteroidBottomBar.kt @@ -22,36 +22,32 @@ * THE SOFTWARE. */ -package de.chaosdorf.meteroid.ui +package de.chaosdorf.meteroid.ui.navigation -import androidx.compose.foundation.layout.RowScope import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector - -interface MeteroidNavSection { - val title: String - val icon: ImageVector - val route: String -} @Composable -fun <T : MeteroidNavSection> RowScope.MeteroidNavSections( - routes: Iterable<T>, +fun <T : MeteroidNavSection> MeteroidBottomBar( currentRoute: T, navigateTo: (String) -> Unit, + historyEnabled: Boolean, modifier: Modifier = Modifier ) { - for (route in routes) { - NavigationBarItem( - icon = { Icon(route.icon, contentDescription = route.title) }, - label = { Text(route.title) }, - selected = route == currentRoute, - onClick = { navigateTo(route.route) }, - modifier = modifier - ) + NavigationBar { + for (route in HomeSections.entries) { + NavigationBarItem( + icon = { Icon(route.icon, contentDescription = route.title) }, + label = { Text(route.title) }, + selected = route == currentRoute, + onClick = { navigateTo(route.route) }, + modifier = modifier, + enabled = route != HomeSections.HISTORY || historyEnabled + ) + } } } diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/navigation/MeteroidNavSection.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/navigation/MeteroidNavSection.kt new file mode 100644 index 0000000000000000000000000000000000000000..f89144fcbf37f8c13f75dc011de5c305e8bf85f4 --- /dev/null +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/navigation/MeteroidNavSection.kt @@ -0,0 +1,33 @@ +/* + * 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.ui.navigation + +import androidx.compose.ui.graphics.vector.ImageVector + +interface MeteroidNavSection { + val title: String + val icon: ImageVector + val route: String +} 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 new file mode 100644 index 0000000000000000000000000000000000000000..798b8a854e1f3f726788cdf94414f39e830f2c81 --- /dev/null +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/navigation/MeteroidTopBar.kt @@ -0,0 +1,89 @@ +/* + * 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.ui.navigation + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import de.chaosdorf.meteroid.model.Server +import de.chaosdorf.meteroid.model.User + +@Composable +fun MeteroidTopBar( + account: Pair<Server, User?>?, + onNavigate: (String) -> Unit = {} +) { + TopAppBar( + title = { + Text( + account?.second?.name + ?: account?.first?.name + ?: "Meteroid" + ) + }, + navigationIcon = { + IconButton(onClick = { onNavigate(Routes.Users.Root) }) { + Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back") + } + }, + actions = { + account?.second?.let { user -> + val (foreground, background) = + if (user.balance < 0) + Pair(MaterialTheme.colorScheme.onError, MaterialTheme.colorScheme.error) + else + Pair(MaterialTheme.colorScheme.onPrimary, MaterialTheme.colorScheme.primary) + + Text( + String.format("%.02f €", user.balance), + color = foreground, + fontSize = 14.sp, + lineHeight = 20.sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier + .padding(end = 20.dp) + .clip(RoundedCornerShape(16.dp)) + .background(background) + .padding(horizontal = 8.dp) + ) + } + }, + modifier = Modifier.shadow(4.dp) + ) +} diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/Routes.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/navigation/Routes.kt similarity index 85% rename from app/src/main/kotlin/de/chaosdorf/meteroid/ui/Routes.kt rename to app/src/main/kotlin/de/chaosdorf/meteroid/ui/navigation/Routes.kt index e06f5c2fee3833b6118151c546cec5ca931cda54..f9938c462ca77b3fdf2ee291a48d3394cbeb68fe 100644 --- a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/Routes.kt +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/navigation/Routes.kt @@ -22,7 +22,7 @@ * THE SOFTWARE. */ -package de.chaosdorf.meteroid.ui +package de.chaosdorf.meteroid.ui.navigation object Routes { const val Init = "init" @@ -35,13 +35,14 @@ object Routes { object Users { const val Root = "users" - const val List = "${Root}/list" - //const val Add = "${Root}/new" + const val List = "$Root/list" + //const val Add = "$Root/new" } object Home { const val Root = "home" - const val Deposit = "home/deposit" - const val Purchase = "home/purchase" + const val Deposit = "$Root/deposit" + const val Purchase = "$Root/purchase" + const val History = "$Root/history" } } diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/purchases/PurchaseListItem.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/purchases/PurchaseListItem.kt new file mode 100644 index 0000000000000000000000000000000000000000..ed56a75c8623e0e0dca5d6d33c6ef73bfe48d6b4 --- /dev/null +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/purchases/PurchaseListItem.kt @@ -0,0 +1,139 @@ +/* + * 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.ui.purchases + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AttachMoney +import androidx.compose.material.icons.filled.QuestionMark +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +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.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil.compose.rememberAsyncImagePainter +import de.chaosdorf.meteroid.model.Drink +import de.chaosdorf.meteroid.model.Purchase +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toJavaLocalDateTime +import kotlinx.datetime.toLocalDateTime +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle + +@Composable +fun PurchaseListItem( + purchase: Purchase, + drink: Drink? +) { + val timestamp = purchase.createdAt.toLocalDateTime(TimeZone.currentSystemDefault()) + val formatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT) + + ListItem( + headlineContent = { + val label = + if (drink != null) drink.name + else if (purchase.difference > 0.0) "Deposit" + else "Unknown" + Text(label) + }, + supportingContent = { + Text(formatter.format(timestamp.toJavaLocalDateTime())) + }, + leadingContent = { + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primaryContainer) + .aspectRatio(1.0f) + ) { + if (drink != null) { + val thumbPainter = rememberAsyncImagePainter( + drink.logoUrl + ) + val originalPainter = rememberAsyncImagePainter( + drink.logoUrl.replace("/thumb/", "/original/"), + error = thumbPainter + ) + + Image( + painter = originalPainter, + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier + .align(Alignment.Center) + .fillMaxSize() + ) + } else if (purchase.difference > 0) { + Icon( + Icons.Default.AttachMoney, + contentDescription = null, + modifier = Modifier.align(Alignment.Center) + ) + } else { + Icon( + Icons.Default.QuestionMark, + contentDescription = null, + modifier = Modifier.align(Alignment.Center) + ) + } + } + }, + trailingContent = { + val (foreground, background) = + if (purchase.difference < 0) + Pair(MaterialTheme.colorScheme.onError, MaterialTheme.colorScheme.error) + else + Pair(MaterialTheme.colorScheme.onPrimary, MaterialTheme.colorScheme.primary) + + Text( + String.format("%.02f €", purchase.difference), + color = foreground, + fontSize = 14.sp, + lineHeight = 20.sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier + .clip(RoundedCornerShape(16.dp)) + .background(background) + .padding(horizontal = 8.dp) + ) + } + ) +} diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/home/DrinkListScreen.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/purchases/PurchaseListScreen.kt similarity index 57% rename from app/src/main/kotlin/de/chaosdorf/meteroid/ui/home/DrinkListScreen.kt rename to app/src/main/kotlin/de/chaosdorf/meteroid/ui/purchases/PurchaseListScreen.kt index f80918e98f9189613e6201189aff3fff46ceef3f..84d1f8b4e8953644635124db85d0bf6ae97598ba 100644 --- a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/home/DrinkListScreen.kt +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/purchases/PurchaseListScreen.kt @@ -22,40 +22,51 @@ * THE SOFTWARE. */ -package de.chaosdorf.meteroid.ui.home +package de.chaosdorf.meteroid.ui.purchases -import android.annotation.SuppressLint import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.ListItem -import androidx.compose.material3.Text +import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview -import androidx.lifecycle.viewmodel.compose.viewModel -import de.chaosdorf.meteroid.storage.SyncHandler +import de.chaosdorf.meteroid.sync.SyncHandler +import de.chaosdorf.meteroid.ui.navigation.HomeSections +import de.chaosdorf.meteroid.ui.navigation.MeteroidBottomBar +import de.chaosdorf.meteroid.ui.navigation.MeteroidTopBar -@Preview @Composable -fun DrinkListScreen( - viewModel: DrinkListViewModel = viewModel(), - @SuppressLint("ModifierParameter") modifier: Modifier = Modifier +fun PurchaseListScreen( + viewModel: PurchaseViewModel, + onNavigate: (route: String) -> Unit = {} ) { - val drinks by viewModel.drinks.collectAsState() + val account by viewModel.account.collectAsState() + val purchases by viewModel.purchases.collectAsState() val syncState by viewModel.syncState.collectAsState() - Column(modifier = modifier) { - if (syncState == SyncHandler.State.Loading) { - LinearProgressIndicator() + Scaffold( + topBar = { MeteroidTopBar(account, onNavigate) }, + bottomBar = { + MeteroidBottomBar( + currentRoute = HomeSections.HISTORY, + historyEnabled = account?.second?.audit == true, + navigateTo = onNavigate + ) } - LazyColumn { - items(drinks) { drink -> - ListItem(headlineContent = { Text(drink.name) }, - supportingContent = { Text("${drink.volume}l · ${drink.price}€") }) + ) { paddingValues: PaddingValues -> + Column { + if (syncState == SyncHandler.State.Loading) { + LinearProgressIndicator() + } + LazyColumn(modifier = Modifier.padding(paddingValues)) { + items(purchases) { (purchase, drink) -> + PurchaseListItem(purchase, drink) + } } } } diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/purchases/PurchaseViewModel.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/purchases/PurchaseViewModel.kt new file mode 100644 index 0000000000000000000000000000000000000000..86a6af815d79682e971f83644f8f183cd1f250a1 --- /dev/null +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/purchases/PurchaseViewModel.kt @@ -0,0 +1,104 @@ +/* + * 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.ui.purchases + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import de.chaosdorf.meteroid.model.Drink +import de.chaosdorf.meteroid.model.DrinkRepository +import de.chaosdorf.meteroid.model.Purchase +import de.chaosdorf.meteroid.model.PurchaseRepository +import de.chaosdorf.meteroid.model.Server +import de.chaosdorf.meteroid.model.User +import de.chaosdorf.meteroid.sync.AccountProvider +import de.chaosdorf.meteroid.sync.PurchaseSyncHandler +import de.chaosdorf.meteroid.sync.SyncHandler +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject +import kotlin.time.Duration.Companion.minutes + +@HiltViewModel +class PurchaseViewModel @Inject constructor( + accountProvider: AccountProvider, + repository: PurchaseRepository, + drinkRepository: DrinkRepository, + syncHandler: PurchaseSyncHandler +) : ViewModel() { + val account: StateFlow<Pair<Server, User?>?> = accountProvider.account + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) + + val purchases: StateFlow<List<Pair<Purchase, Drink?>>> = accountProvider.account + .flatMapLatest { account -> + account?.let { (server, user) -> + user?.let { user -> + combine( + repository.getAllFlow(server.serverId, user.userId), + drinkRepository.getAllFlow(server.serverId) + ) { purchases, drinks -> + purchases.map { purchase -> + Pair(purchase, drinks.firstOrNull { drink -> drink.drinkId == purchase.drinkId }) + } + } + } + } ?: flowOf(emptyList()) + }.mapLatest { list -> + list.mergeAdjecentDeposits() + .filter { it.second != null || it.first.difference != 0.0 } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList()) + + val syncState: StateFlow<SyncHandler.State> = syncHandler.state +} + +fun List<Pair<Purchase, Drink?>>.mergeAdjecentDeposits(): List<Pair<Purchase, Drink?>> { + val result = mutableListOf<Pair<Purchase, Drink?>>() + for (entry in this) { + val previous = result.lastOrNull() + if (previous != null + && previous.first.difference > 0 + && entry.first.difference > 0 + && previous.second == null + && entry.second == null + && entry.first.createdAt.minus(previous.first.createdAt) < 5.minutes + ) { + result.removeLast() + result.add( + Pair( + entry.first.copy(difference = entry.first.difference + previous.first.difference), + null + ) + ) + } else { + result.add(entry) + } + } + return result +} diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/servers/ServerListScreen.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/servers/ServerListScreen.kt index b30db84d789c8883babd31304c1ce541a5a1999e..ff0c99bb0f4780eef72be228c453163238036b59 100644 --- a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/servers/ServerListScreen.kt +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/servers/ServerListScreen.kt @@ -25,15 +25,21 @@ package de.chaosdorf.meteroid.ui.servers import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.ListItem +import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import de.chaosdorf.meteroid.model.ServerId @@ -45,18 +51,31 @@ fun ServerListScreen( onSelect: (ServerId) -> Unit = {} ) { val servers by viewModel.servers.collectAsState() - LazyColumn { - items(servers) { server -> - ListItem( - headlineContent = { Text(server.name ?: server.url) }, - modifier = Modifier.clickable { onSelect(server.serverId) } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Meteroid") }, + modifier = Modifier.shadow(4.dp) ) } - item { - ListItem( - headlineContent = { Text("Add Server") }, - modifier = Modifier.clickable { onAdd() } - ) + ) { paddingValues -> + Column { + LazyColumn(modifier = Modifier.padding(paddingValues)) { + items(servers) { server -> + ListItem( + headlineContent = { Text(server.name ?: server.url) }, + supportingContent = { if (server.name != null) Text(server.url) }, + modifier = Modifier.clickable { onSelect(server.serverId) } + ) + } + item { + ListItem( + headlineContent = { Text("Add Server") }, + modifier = Modifier.clickable { onAdd() } + ) + } + } } } } diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/theme/Theme.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/theme/Theme.kt index dbc712cf33722d47815b41e6d6941a80fedb26c8..db521b5df691e3ce5e5e4865cf185be016154730 100644 --- a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/theme/Theme.kt +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/theme/Theme.kt @@ -40,55 +40,55 @@ import androidx.compose.ui.platform.LocalView import androidx.core.view.WindowCompat private val DarkColorScheme = darkColorScheme( - primary = Purple80, - secondary = PurpleGrey80, - tertiary = Pink80 + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80 ) private val LightColorScheme = lightColorScheme( - primary = Purple40, - secondary = PurpleGrey40, - tertiary = Pink40 + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40 - /* Other default colors to override - background = Color(0xFFFFFBFE), - surface = Color(0xFFFFFBFE), - onPrimary = Color.White, - onSecondary = Color.White, - onTertiary = Color.White, - onBackground = Color(0xFF1C1B1F), - onSurface = Color(0xFF1C1B1F), - */ + /* Other default colors to override + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), + */ ) @Composable fun MeteroidTheme( - darkTheme: Boolean = isSystemInDarkTheme(), - // Dynamic color is available on Android 12+ - dynamicColor: Boolean = true, - content: @Composable () -> Unit + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: @Composable () -> Unit ) { - val colorScheme = when { - dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { - val context = LocalContext.current - if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) - } - - darkTheme -> DarkColorScheme - else -> LightColorScheme + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) } - val view = LocalView.current - if (!view.isInEditMode) { - SideEffect { - val window = (view.context as Activity).window - window.statusBarColor = colorScheme.primary.toArgb() - WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme - } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + window.statusBarColor = colorScheme.primary.toArgb() + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme } + } - MaterialTheme( - colorScheme = colorScheme, - typography = Typography, - content = content - ) + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) } diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/theme/Type.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/theme/Type.kt index 31e4b39550b391efd9b29299c351e1bec7f83cf8..e6759ded7701183e45a771ab7713993b1a15df77 100644 --- a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/theme/Type.kt +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/theme/Type.kt @@ -8,27 +8,27 @@ import androidx.compose.ui.unit.sp // Set of Material typography styles to start with val Typography = Typography( - bodyLarge = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 16.sp, - lineHeight = 24.sp, - letterSpacing = 0.5.sp - ) - /* Other default text styles to override - titleLarge = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 22.sp, - lineHeight = 28.sp, - letterSpacing = 0.sp - ), - labelSmall = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Medium, - fontSize = 11.sp, - lineHeight = 16.sp, - letterSpacing = 0.5.sp - ) - */ -) \ No newline at end of file + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/users/UserListScreen.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/users/UserListScreen.kt index 678516dec2500c912ede880d8ac1dfcba61b0282..92cbdad4aea35a18fd0b5292e6b3430b5031767f 100644 --- a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/users/UserListScreen.kt +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/users/UserListScreen.kt @@ -29,7 +29,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.Icon @@ -44,48 +43,48 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.zIndex -import androidx.lifecycle.viewmodel.compose.viewModel import de.chaosdorf.mete.UserId -import de.chaosdorf.meteroid.storage.SyncHandler +import de.chaosdorf.meteroid.sync.SyncHandler -@Preview @Composable fun UserListScreen( - viewModel: UserListViewModel = viewModel(), + viewModel: UserListViewModel, onAdd: () -> Unit = {}, onSelect: (UserId) -> Unit = {}, onBack: () -> Unit = {}, ) { + val server by viewModel.account.collectAsState() val users by viewModel.users.collectAsState() val syncState by viewModel.syncState.collectAsState() Scaffold( topBar = { TopAppBar( - title = { Text("Meteroid") }, + title = { + Text( + server?.first?.name + ?: "Meteroid" + ) + }, navigationIcon = { - IconButton(onClick = onBack) { + IconButton(onClick = { onBack() }) { Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back") } }, - modifier = Modifier - .padding(8.dp) - .shadow(4.dp, shape = RoundedCornerShape(8.dp)) - .zIndex(1.0f) + modifier = Modifier.shadow(4.dp) ) } ) { paddingValues -> - Column(modifier = Modifier.padding(paddingValues)) { + Column { if (syncState == SyncHandler.State.Loading) { LinearProgressIndicator() } - LazyColumn { + LazyColumn(modifier = Modifier.padding(paddingValues)) { items(users) { user -> ListItem( headlineContent = { Text(user.name) }, + supportingContent = { Text(user.email) }, modifier = Modifier.clickable { onSelect(user.userId) } ) } diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/users/UserListViewModel.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/users/UserListViewModel.kt index ef0f243759c6d82ad6d343e62c41a33bc4d0b4e8..7e74e6bb4c2625f722938f8bb974696b5749017f 100644 --- a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/users/UserListViewModel.kt +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/users/UserListViewModel.kt @@ -27,30 +27,34 @@ package de.chaosdorf.meteroid.ui.users import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import de.chaosdorf.meteroid.model.Server import de.chaosdorf.meteroid.model.User import de.chaosdorf.meteroid.model.UserRepository -import de.chaosdorf.meteroid.storage.AccountPreferences -import de.chaosdorf.meteroid.storage.UserSyncHandler +import de.chaosdorf.meteroid.sync.AccountProvider +import de.chaosdorf.meteroid.sync.SyncHandler +import de.chaosdorf.meteroid.sync.UserSyncHandler import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.stateIn import javax.inject.Inject @HiltViewModel class UserListViewModel @Inject constructor( - accountPreferences: AccountPreferences, - userRepository: UserRepository, + accountProvider: AccountProvider, + repository: UserRepository, syncHandler: UserSyncHandler ) : ViewModel() { - private val serverId = accountPreferences.state.mapLatest { it.server } + val account: StateFlow<Pair<Server, User?>?> = accountProvider.account + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) - val users: StateFlow<List<User>> = serverId.flatMapLatest { serverId -> - if (serverId == null) flowOf(emptyList()) - else userRepository.getAllFlow(serverId) - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList()) + val users: StateFlow<List<User>> = accountProvider.account + .flatMapLatest { account -> + account?.let { (server, _) -> + repository.getAllFlow(server.serverId) + } ?: flowOf(emptyList()) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList()) - val syncState = syncHandler.state + val syncState: StateFlow<SyncHandler.State> = syncHandler.state } diff --git a/app/src/main/res/drawable-nodpi/euro_100.png b/app/src/main/res/drawable-nodpi/euro_100.png new file mode 100644 index 0000000000000000000000000000000000000000..c25bfa9937bf5967d34ddeba149a8dd403fb38f5 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/euro_100.png differ diff --git a/app/src/main/res/drawable-nodpi/euro_1000.png b/app/src/main/res/drawable-nodpi/euro_1000.png new file mode 100644 index 0000000000000000000000000000000000000000..a9100393f67d7f42e2ce25df7e276ee8c756da36 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/euro_1000.png differ diff --git a/app/src/main/res/drawable-nodpi/euro_200.png b/app/src/main/res/drawable-nodpi/euro_200.png new file mode 100644 index 0000000000000000000000000000000000000000..0e9f69d9220105c731134504675ee021d1e29e6f Binary files /dev/null and b/app/src/main/res/drawable-nodpi/euro_200.png differ diff --git a/app/src/main/res/drawable-nodpi/euro_2000.png b/app/src/main/res/drawable-nodpi/euro_2000.png new file mode 100644 index 0000000000000000000000000000000000000000..8a73934afb6e7ea5533dad7aacb0b4f4e26fc32c Binary files /dev/null and b/app/src/main/res/drawable-nodpi/euro_2000.png differ diff --git a/app/src/main/res/drawable-nodpi/euro_50.png b/app/src/main/res/drawable-nodpi/euro_50.png new file mode 100644 index 0000000000000000000000000000000000000000..0874c2d70abc2d57314a351ad9f6092e0b00805a Binary files /dev/null and b/app/src/main/res/drawable-nodpi/euro_50.png differ diff --git a/app/src/main/res/drawable-nodpi/euro_500.png b/app/src/main/res/drawable-nodpi/euro_500.png new file mode 100644 index 0000000000000000000000000000000000000000..541261000c834577f31a610e4a9d1059b74205b9 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/euro_500.png differ diff --git a/app/src/main/res/drawable-nodpi/euro_5000.png b/app/src/main/res/drawable-nodpi/euro_5000.png new file mode 100644 index 0000000000000000000000000000000000000000..7e8387e6ecc49f6da878c071f2ed79b9726f5f08 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/euro_5000.png differ diff --git a/gradle/convention/src/main/kotlin/AndroidApplicationConvention.kt b/gradle/convention/src/main/kotlin/AndroidApplicationConvention.kt index 629dd50abcdc62ac44805f8b32c58f58cf89e311..a6a58c475dcc2b736963bf649aad2bbd940a4f26 100644 --- a/gradle/convention/src/main/kotlin/AndroidApplicationConvention.kt +++ b/gradle/convention/src/main/kotlin/AndroidApplicationConvention.kt @@ -5,7 +5,7 @@ import org.gradle.api.Project import org.gradle.kotlin.dsl.configure import util.cmd import util.properties -import java.util.* +import java.util.Locale class AndroidApplicationConvention : Plugin<Project> { override fun apply(target: Project) { @@ -48,6 +48,8 @@ class AndroidApplicationConvention : Plugin<Project> { compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 + + isCoreLibraryDesugaringEnabled = true } testOptions { diff --git a/gradle/convention/src/main/kotlin/AndroidLibraryConvention.kt b/gradle/convention/src/main/kotlin/AndroidLibraryConvention.kt index c048de7841d78191de848ec5808a009d99f0fa52..ead4e60c0cec6ebd386f3b559a5210b89e8a51d2 100644 --- a/gradle/convention/src/main/kotlin/AndroidLibraryConvention.kt +++ b/gradle/convention/src/main/kotlin/AndroidLibraryConvention.kt @@ -2,7 +2,6 @@ import com.android.build.api.dsl.LibraryExtension import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.kotlin.dsl.configure -import java.util.* class AndroidLibraryConvention : Plugin<Project> { override fun apply(target: Project) { @@ -24,6 +23,10 @@ class AndroidLibraryConvention : Plugin<Project> { testInstrumentationRunnerArguments["disableAnalytics"] = "true" } + compileOptions { + isCoreLibraryDesugaringEnabled = true + } + lint { warningsAsErrors = true lintConfig = file("../lint.xml") diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6d134b53a8a807526f367db7ac77de8022a63134..0e5d0c5d8b42f548935dd864d8295394a0983cb0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,11 +8,13 @@ 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-datastore = "1.0.0" androidx-hilt = "1.0.0" -androidx-navigation = "2.7.4" +androidx-navigation = "2.7.5" androidx-room = "2.6.0" coil = "2.4.0" dagger-hilt = "2.48.1" +desugar-jdk = "2.0.4" kotlin = "1.9.10" kotlin-ksp = "1.9.10-1.0.13" kotlinxCoroutines = "1.7.1" @@ -44,6 +46,8 @@ androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-toolin androidx-compose-ui-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview", version.ref = "androidx-compose-tooling" } androidx-compose-ui-util = { group = "androidx.compose.ui", name = "ui-util" } +androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "androidx-datastore" } + androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "androidx-room" } androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "androidx-room" } androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "androidx-room" } @@ -72,6 +76,8 @@ kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-cor kotlinx-datetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime", version.ref = "kotlinxDatetime" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } +desugar-jdk = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar-jdk" } + # Dependencies of the included build-logic android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "androidGradlePlugin" } kotlin-gradlePlugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" } diff --git a/persistence/build.gradle.kts b/persistence/build.gradle.kts index fe766b996906bd3699652432eaea9ca24a80c6ea..0f261366d71c6daad151e60fff55b86edb47657c 100644 --- a/persistence/build.gradle.kts +++ b/persistence/build.gradle.kts @@ -37,6 +37,9 @@ ksp { dependencies { implementation(libs.kotlin.stdlib) + implementation(libs.kotlinx.datetime) + implementation(libs.kotlinx.serialization.json) + coreLibraryDesugaring(libs.desugar.jdk) implementation(libs.kotlinx.coroutines.core) testImplementation(libs.kotlinx.coroutines.test) @@ -54,7 +57,5 @@ dependencies { implementation(libs.hilt.android) ksp(libs.hilt.compiler) - implementation(libs.kotlinx.datetime) - implementation(libs.kotlinx.serialization.json) implementation(project(":api")) } diff --git a/persistence/room/schemas/de.chaosdorf.meteroid.MeteroidDatabase/1.json b/persistence/room/schemas/de.chaosdorf.meteroid.MeteroidDatabase/1.json index f78e5632816d67577e2735d078c9603ac4814075..b8a0cbb9f2075143fb44088bef045f59af22274f 100644 --- a/persistence/room/schemas/de.chaosdorf.meteroid.MeteroidDatabase/1.json +++ b/persistence/room/schemas/de.chaosdorf.meteroid.MeteroidDatabase/1.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "bee2a6e9f2b7a72c80a0e97b71f55e51", + "identityHash": "957bb839c1c422a256c594595437ee45", "entities": [ { "tableName": "Drink", @@ -239,12 +239,122 @@ ] } ] + }, + { + "tableName": "Purchase", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` INTEGER NOT NULL, `purchaseId` INTEGER NOT NULL, `userId` INTEGER NOT NULL, `drinkId` INTEGER, `difference` REAL NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `purchaseId`), FOREIGN KEY(`serverId`) REFERENCES `Server`(`serverId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`serverId`, `userId`) REFERENCES `User`(`serverId`, `userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`serverId`, `drinkId`) REFERENCES `Drink`(`serverId`, `drinkId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "purchaseId", + "columnName": "purchaseId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "drinkId", + "columnName": "drinkId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "difference", + "columnName": "difference", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "purchaseId" + ] + }, + "indices": [ + { + "name": "index_Purchase_serverId_userId", + "unique": false, + "columnNames": [ + "serverId", + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Purchase_serverId_userId` ON `${TABLE_NAME}` (`serverId`, `userId`)" + }, + { + "name": "index_Purchase_serverId_drinkId", + "unique": false, + "columnNames": [ + "serverId", + "drinkId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Purchase_serverId_drinkId` ON `${TABLE_NAME}` (`serverId`, `drinkId`)" + } + ], + "foreignKeys": [ + { + "table": "Server", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId" + ], + "referencedColumns": [ + "serverId" + ] + }, + { + "table": "User", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId", + "userId" + ], + "referencedColumns": [ + "serverId", + "userId" + ] + }, + { + "table": "Drink", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "serverId", + "drinkId" + ], + "referencedColumns": [ + "serverId", + "drinkId" + ] + } + ] } ], "views": [], "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, 'bee2a6e9f2b7a72c80a0e97b71f55e51')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '957bb839c1c422a256c594595437ee45')" ] } } \ No newline at end of file diff --git a/persistence/src/main/kotlin/de/chaosdorf/meteroid/MeteroidDatabase.kt b/persistence/src/main/kotlin/de/chaosdorf/meteroid/MeteroidDatabase.kt index d360cc3e95c961de17e1a88bab8333f7e3ab03e8..a8b32864c10b8d73627ebc960cbe1809b224a17e 100644 --- a/persistence/src/main/kotlin/de/chaosdorf/meteroid/MeteroidDatabase.kt +++ b/persistence/src/main/kotlin/de/chaosdorf/meteroid/MeteroidDatabase.kt @@ -24,12 +24,13 @@ package de.chaosdorf.meteroid -import androidx.room.AutoMigration import androidx.room.Database import androidx.room.RoomDatabase import androidx.room.TypeConverters import de.chaosdorf.meteroid.model.Drink import de.chaosdorf.meteroid.model.DrinkRepository +import de.chaosdorf.meteroid.model.Purchase +import de.chaosdorf.meteroid.model.PurchaseRepository import de.chaosdorf.meteroid.model.Server import de.chaosdorf.meteroid.model.ServerRepository import de.chaosdorf.meteroid.model.User @@ -41,7 +42,8 @@ import de.chaosdorf.meteroid.util.KotlinDatetimeTypeConverter entities = [ Drink::class, Server::class, - User::class + User::class, + Purchase::class ], autoMigrations = [], ) @@ -50,4 +52,5 @@ abstract class MeteroidDatabase : RoomDatabase() { abstract fun drinks(): DrinkRepository abstract fun server(): ServerRepository abstract fun users(): UserRepository + abstract fun purchases(): PurchaseRepository } diff --git a/persistence/src/main/kotlin/de/chaosdorf/meteroid/model/Drink.kt b/persistence/src/main/kotlin/de/chaosdorf/meteroid/model/Drink.kt index c9435469aa0b97bc42f38bd48dd602f4c4301bd5..ade8587945246de5bcbb0d1adf2a93e3933e6cc2 100644 --- a/persistence/src/main/kotlin/de/chaosdorf/meteroid/model/Drink.kt +++ b/persistence/src/main/kotlin/de/chaosdorf/meteroid/model/Drink.kt @@ -34,6 +34,7 @@ import de.chaosdorf.mete.DrinkId import de.chaosdorf.mete.v1.DrinkModelV1 import kotlinx.coroutines.flow.Flow import kotlinx.datetime.Instant +import java.net.URI @Entity( primaryKeys = ["serverId", "drinkId"], @@ -58,8 +59,8 @@ data class Drink( val logoUpdatedAt: Instant ) { companion object { - fun fromModelV1(serverId: ServerId, value: DrinkModelV1) = Drink( - serverId, + fun fromModelV1(server: Server, value: DrinkModelV1) = Drink( + server.serverId, value.id, value.active, value.name, @@ -68,7 +69,7 @@ data class Drink( value.price, value.createdAt, value.updatedAt, - value.logoUrl, + URI.create(server.url).resolve(value.logoUrl).toString(), value.logoFileName, value.logoContentType, value.logoFileSize, @@ -85,10 +86,10 @@ interface DrinkRepository { @Query("SELECT * FROM Drink WHERE serverId = :serverId AND drinkId = :drinkId LIMIT 1") fun getFlow(serverId: ServerId, drinkId: DrinkId): Flow<Drink?> - @Query("SELECT * FROM Drink WHERE serverId = :serverId") + @Query("SELECT * FROM Drink WHERE serverId = :serverId ORDER BY NAME ASC") suspend fun getAll(serverId: ServerId): List<Drink> - @Query("SELECT * FROM Drink WHERE serverId = :serverId") + @Query("SELECT * FROM Drink WHERE serverId = :serverId ORDER BY NAME ASC") fun getAllFlow(serverId: ServerId): Flow<List<Drink>> @Insert(onConflict = OnConflictStrategy.REPLACE) diff --git a/persistence/src/main/kotlin/de/chaosdorf/meteroid/model/Purchase.kt b/persistence/src/main/kotlin/de/chaosdorf/meteroid/model/Purchase.kt new file mode 100644 index 0000000000000000000000000000000000000000..fad3d86e350b4c7afd02239cdb84c17a82577a9f --- /dev/null +++ b/persistence/src/main/kotlin/de/chaosdorf/meteroid/model/Purchase.kt @@ -0,0 +1,92 @@ +/* + * 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.model + +import androidx.room.Dao +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import de.chaosdorf.mete.AuditEntryId +import de.chaosdorf.mete.DrinkId +import de.chaosdorf.mete.UserId +import de.chaosdorf.mete.v1.AuditEntryModelV1 +import kotlinx.coroutines.flow.Flow +import kotlinx.datetime.Instant + +@Entity( + primaryKeys = ["serverId", "purchaseId"], + foreignKeys = [ + ForeignKey(Server::class, ["serverId"], ["serverId"], onDelete = ForeignKey.CASCADE), + ForeignKey(User::class, ["serverId", "userId"], ["serverId", "userId"], onDelete = ForeignKey.CASCADE), + ForeignKey(Drink::class, ["serverId", "drinkId"], ["serverId", "drinkId"], onDelete = ForeignKey.NO_ACTION) + ], + indices = [ + Index("serverId", "userId"), + Index("serverId", "drinkId") + ] +) +data class Purchase( + val serverId: ServerId, + val purchaseId: AuditEntryId, + val userId: UserId, + val drinkId: DrinkId?, + val difference: Double, + val createdAt: Instant +) { + companion object { + fun fromModelV1(server: Server, userId: UserId, value: AuditEntryModelV1) = Purchase( + server.serverId, + value.id, + userId, + value.drink, + value.difference, + value.createdAt + ) + } +} + +@Dao +interface PurchaseRepository { + @Query("SELECT * FROM Purchase WHERE serverId = :serverId AND userId = :userId ORDER BY createdAt DESC") + suspend fun getAll(serverId: ServerId, userId: UserId): List<Purchase> + + @Query("SELECT * FROM Purchase WHERE serverId = :serverId AND userId = :userId ORDER BY createdAt DESC") + fun getAllFlow(serverId: ServerId, userId: UserId): Flow<List<Purchase>> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun save(purchase: Purchase) + + @Query("DELETE FROM Purchase WHERE serverId = :serverId AND purchaseId = :purchaseId") + suspend fun delete(serverId: ServerId, purchaseId: AuditEntryId) + + @Query("DELETE FROM Purchase WHERE serverId = :serverId AND userId = :userId") + suspend fun deleteAll(serverId: ServerId, userId: UserId) + + @Query("DELETE FROM Purchase WHERE serverId = :serverId") + suspend fun deleteAll(serverId: ServerId) +} diff --git a/persistence/src/main/kotlin/de/chaosdorf/meteroid/model/User.kt b/persistence/src/main/kotlin/de/chaosdorf/meteroid/model/User.kt index 1c669a62ee67b9ad8fb508d8a8b94b3775d2e648..5e1b16671c9786bb004e5e23e308c457ceea8f92 100644 --- a/persistence/src/main/kotlin/de/chaosdorf/meteroid/model/User.kt +++ b/persistence/src/main/kotlin/de/chaosdorf/meteroid/model/User.kt @@ -54,8 +54,8 @@ data class User( val updatedAt: Instant, ) { companion object { - fun fromModelV1(serverId: ServerId, value: UserModelV1) = User( - serverId, + fun fromModelV1(server: Server, value: UserModelV1) = User( + server.serverId, value.id, value.active, value.name, @@ -77,10 +77,10 @@ interface UserRepository { @Query("SELECT * FROM User WHERE serverId = :serverId AND userId = :userId LIMIT 1") fun getFlow(serverId: ServerId, userId: UserId): Flow<User?> - @Query("SELECT * FROM User WHERE serverId = :serverId") + @Query("SELECT * FROM User WHERE serverId = :serverId ORDER BY NAME ASC") suspend fun getAll(serverId: ServerId): List<User> - @Query("SELECT * FROM User WHERE serverId = :serverId") + @Query("SELECT * FROM User WHERE serverId = :serverId ORDER BY NAME ASC") fun getAllFlow(serverId: ServerId): Flow<List<User>> @Insert(onConflict = OnConflictStrategy.REPLACE)