Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found
Select Git revision

Target

Select target project
  • justJanne/meteroid
1 result
Select Git revision
Show changes
Showing
with 590 additions and 132 deletions
/*
* 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 de.chaosdorf.mete.model.TransactionSummaryModel
import de.chaosdorf.mete.util.BigDecimalSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.math.BigDecimal
@Serializable
internal data class TransactionSummaryModelV1(
@SerialName("payments_sum")
@Serializable(with = BigDecimalSerializer::class)
override val payments: BigDecimal,
@SerialName("deposits_sum")
@Serializable(with = BigDecimalSerializer::class)
override val deposits: BigDecimal,
@SerialName("sum")
@Serializable(with = BigDecimalSerializer::class)
override val total: BigDecimal,
@SerialName("audits")
override val entries: List<TransactionModelV1>
) : TransactionSummaryModel
......@@ -24,22 +24,33 @@
package de.chaosdorf.mete.v1
import de.chaosdorf.mete.UserId
import de.chaosdorf.mete.model.UserId
import de.chaosdorf.mete.model.UserModel
import de.chaosdorf.mete.util.BigDecimalSerializer
import kotlinx.datetime.Instant
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.math.BigDecimal
@Serializable
data class UserModelV1(
val id: UserId,
val name: String,
val email: String,
internal data class UserModelV1(
@SerialName("id")
override val userId: UserId,
@SerialName("name")
override val name: String,
@SerialName("email")
override val email: String?,
@SerialName("balance")
@Serializable(with = BigDecimalSerializer::class)
override val balance: BigDecimal,
@SerialName("active")
override val active: Boolean,
@SerialName("audit")
override val audit: Boolean,
@SerialName("redirect")
override val redirect: Boolean,
@SerialName("created_at")
val createdAt: Instant,
@SerialName("updated_at")
val updatedAt: Instant,
val balance: Double,
val active: Boolean,
val audit: Boolean,
val redirect: Boolean
)
) : UserModel
......@@ -31,13 +31,12 @@ android {
buildTypes {
getByName("release") {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android.txt"),
"proguard-rules.pro"
)
signingConfig = signingConfigs.getByName("debug")
}
getByName("debug") {
......
......@@ -25,7 +25,26 @@
package de.chaosdorf.meteroid
import android.app.Application
import coil.ImageLoader
import coil.ImageLoaderFactory
import coil.disk.DiskCache
import coil.memory.MemoryCache
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class MeteroidApplication : Application()
class MeteroidApplication : Application(), ImageLoaderFactory {
override fun newImageLoader() = ImageLoader.Builder(applicationContext)
.memoryCache {
MemoryCache.Builder(applicationContext)
.maxSizePercent(0.25)
.build()
}
.diskCache {
DiskCache.Builder()
.directory(applicationContext.cacheDir.resolve("image_cache"))
.maxSizePercent(0.02)
.build()
}
.respectCacheHeaders(false)
.build()
}
......@@ -31,10 +31,13 @@ import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import de.chaosdorf.mete.model.MeteApiFactory
import de.chaosdorf.mete.v1.MeteApiV1Factory
import de.chaosdorf.meteroid.MeteroidDatabase
import de.chaosdorf.meteroid.model.DrinkRepository
import de.chaosdorf.meteroid.model.PurchaseRepository
import de.chaosdorf.meteroid.model.PinnedUserRepository
import de.chaosdorf.meteroid.model.ServerRepository
import de.chaosdorf.meteroid.model.TransactionRepository
import de.chaosdorf.meteroid.model.UserRepository
import javax.inject.Singleton
......@@ -61,12 +64,20 @@ object DatabaseModule {
): UserRepository = database.users()
@Provides
fun providePurchaseRepository(
fun providePinnedUserRepository(
database: MeteroidDatabase
): PurchaseRepository = database.purchases()
): PinnedUserRepository = database.pinnedUsers()
@Provides
fun provideTransactionRepository(
database: MeteroidDatabase
): TransactionRepository = database.transactions()
@Provides
fun provideServerRepository(
database: MeteroidDatabase
): ServerRepository = database.server()
@Provides
fun providesMeteFactory(): MeteApiFactory = MeteApiV1Factory
}
......@@ -25,10 +25,9 @@
package de.chaosdorf.meteroid.sample
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import de.chaosdorf.mete.DrinkId
import de.chaosdorf.mete.model.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(
......@@ -37,32 +36,20 @@ class SampleDrinkProvider : PreviewParameterProvider<Drink> {
drinkId = DrinkId(27),
active = true,
name = "Club Mate",
volume = 0.5,
volume = 0.5.toBigDecimal(),
caffeine = null,
price = 1.5,
createdAt = Instant.fromEpochMilliseconds(1684598011800),
updatedAt = Instant.fromEpochMilliseconds(1684607122132),
price = 1.5.toBigDecimal(),
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,
volume = 0.5.toBigDecimal(),
caffeine = null,
price = 1.5,
createdAt = Instant.fromEpochMilliseconds(1684597806099),
updatedAt = Instant.fromEpochMilliseconds(1684607346944),
price = 1.5.toBigDecimal(),
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)
)
)
}
......@@ -24,7 +24,7 @@
package de.chaosdorf.meteroid.storage
import de.chaosdorf.mete.UserId
import de.chaosdorf.mete.model.UserId
import de.chaosdorf.meteroid.model.ServerId
import kotlinx.coroutines.flow.Flow
......
......@@ -28,7 +28,7 @@ import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.longPreferencesKey
import de.chaosdorf.mete.UserId
import de.chaosdorf.mete.model.UserId
import de.chaosdorf.meteroid.model.ServerId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.mapLatest
......
......@@ -24,23 +24,27 @@
package de.chaosdorf.meteroid.sync
import de.chaosdorf.meteroid.model.Server
import de.chaosdorf.mete.model.UserId
import de.chaosdorf.meteroid.model.AccountInfo
import de.chaosdorf.meteroid.model.PinnedUser
import de.chaosdorf.meteroid.model.PinnedUserRepository
import de.chaosdorf.meteroid.model.ServerId
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.combine
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,
private val pinnedUserRepository: PinnedUserRepository,
) {
val account: Flow<Pair<Server, User?>?> =
val account: Flow<AccountInfo?> =
accountPreferences.state.flatMapLatest { preferences ->
if (preferences.server == null) {
flowOf(null)
......@@ -49,12 +53,24 @@ class AccountProvider @Inject constructor(
if (server == null) {
flowOf(null)
} else if (preferences.user == null) {
flowOf(Pair(server, null))
flowOf(AccountInfo(server, null, false))
} else {
userRepository.getFlow(server.serverId, preferences.user)
.mapLatest { user -> Pair(server, user) }
combine(
userRepository.getFlow(server.serverId, preferences.user),
pinnedUserRepository.isPinnedFlow(server.serverId, preferences.user)
) { user, pinned ->
AccountInfo(server, user, pinned)
}
}
}
}
}
suspend fun togglePin(serverId: ServerId, userId: UserId) {
if (pinnedUserRepository.isPinned(serverId, userId)) {
pinnedUserRepository.delete(serverId, userId)
} else {
pinnedUserRepository.save(PinnedUser(serverId, userId))
}
}
}
......@@ -25,8 +25,8 @@
package de.chaosdorf.meteroid.sync
import androidx.room.withTransaction
import de.chaosdorf.mete.DrinkId
import de.chaosdorf.mete.v1.MeteApiV1Factory
import de.chaosdorf.mete.model.DrinkId
import de.chaosdorf.mete.model.MeteApiFactory
import de.chaosdorf.meteroid.MeteroidDatabase
import de.chaosdorf.meteroid.model.Drink
import de.chaosdorf.meteroid.model.DrinkRepository
......@@ -35,6 +35,7 @@ import de.chaosdorf.meteroid.model.ServerId
import javax.inject.Inject
class DrinkSyncHandler @Inject constructor(
private val factory: MeteApiFactory,
private val db: MeteroidDatabase,
private val repository: DrinkRepository
) : SyncHandler<Server, Drink, DrinkSyncHandler.Key>() {
......@@ -57,8 +58,8 @@ class DrinkSyncHandler @Inject constructor(
repository.getAll(context.serverId)
override suspend fun loadCurrent(context: Server): List<Drink> {
val api = MeteApiV1Factory.newInstance(context.url)
val api = factory.newInstance(context.url)
val loadedEntries = api.listDrinks()
return loadedEntries.map { Drink.fromModelV1(context, it) }
return loadedEntries.map { Drink.fromModel(context, it) }
}
}
......@@ -25,42 +25,43 @@
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.mete.model.MeteApiFactory
import de.chaosdorf.mete.model.TransactionId
import de.chaosdorf.mete.model.UserId
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 de.chaosdorf.meteroid.model.Transaction
import de.chaosdorf.meteroid.model.TransactionRepository
import javax.inject.Inject
class PurchaseSyncHandler @Inject constructor(
class TransactionSyncHandler @Inject constructor(
private val factory: MeteApiFactory,
private val db: MeteroidDatabase,
private val repository: PurchaseRepository
) : SyncHandler<Pair<Server, UserId>, Purchase, PurchaseSyncHandler.Key>() {
private val repository: TransactionRepository
) : SyncHandler<Pair<Server, UserId>, Transaction, TransactionSyncHandler.Key>() {
data class Key(
val server: ServerId, val purchase: AuditEntryId
val server: ServerId, val transaction: TransactionId
)
override suspend fun withTransaction(block: suspend () -> Unit) =
db.withTransaction(block)
override suspend fun store(entry: Purchase) =
override suspend fun store(entry: Transaction) =
repository.save(entry)
override suspend fun delete(key: Key) =
repository.delete(key.server, key.purchase)
repository.delete(key.server, key.transaction)
override fun entryToKey(entry: Purchase) = Key(entry.serverId, entry.purchaseId)
override fun entryToKey(entry: Transaction) = Key(entry.serverId, entry.transactionId)
override suspend fun loadStored(context: Pair<Server, UserId>): List<Purchase> =
override suspend fun loadStored(context: Pair<Server, UserId>): List<Transaction> =
repository.getAll(context.first.serverId, context.second)
override suspend fun loadCurrent(context: Pair<Server, UserId>): List<Purchase> {
override suspend fun loadCurrent(context: Pair<Server, UserId>): List<Transaction> {
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) }
val api = factory.newInstance(server.url)
val loadedEntries = api.listTransactions(user = userId).entries
return loadedEntries.map { Transaction.fromModel(server, userId, it) }
}
}
......@@ -73,6 +73,7 @@ abstract class SyncHandler<Context, Entry, Key> {
val loadedKeys = loadedEntries.map(::entryToKey).toSet()
val removedKeys = storedKeys - loadedKeys
for (removedKey in removedKeys) {
Log.e("SyncHandler", "deleting: $removedKey")
delete(removedKey)
}
for (loadedEntry in loadedEntries) {
......
/*
* 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 android.util.Log
import de.chaosdorf.mete.model.MeteApiFactory
import de.chaosdorf.meteroid.model.AccountInfo
import de.chaosdorf.meteroid.model.Drink
import de.chaosdorf.meteroid.model.Server
import de.chaosdorf.meteroid.model.ServerRepository
import de.chaosdorf.meteroid.model.User
import de.chaosdorf.meteroid.util.newServer
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import java.math.BigDecimal
import javax.inject.Inject
class SyncManager @Inject constructor(
private val factory: MeteApiFactory,
private val serverRepository: ServerRepository,
private val userSyncHandler: UserSyncHandler,
private val drinkSyncHandler: DrinkSyncHandler,
private val transactionSyncHandler: TransactionSyncHandler
) {
suspend fun checkOffline(server: Server): Boolean {
val updated = factory.newServer(server.serverId, server.url)
return if (updated == null) {
true
} else {
serverRepository.save(updated)
false
}
}
suspend fun sync(server: Server, user: User?) {
try {
userSyncHandler.sync(server)
drinkSyncHandler.sync(server)
if (user != null) {
transactionSyncHandler.sync(Pair(server, user.userId))
}
} catch (e: Exception) {
Log.e(
"Sync",
"Could not finish transaction for ${user?.name} (${user?.userId}) on ${server.url}",
e
)
}
}
suspend fun purchase(account: AccountInfo, drink: Drink) {
account.user?.let { user ->
val api = factory.newInstance(account.server.url)
try {
api.purchase(user.userId, drink.drinkId)
sync(account.server, user)
} catch (e: Exception) {
Log.e(
"Sync",
"Could not finish transaction for ${user.name} (${user.userId}) on ${account.server.url}",
e
)
}
}
}
suspend fun deposit(account: AccountInfo, amount: BigDecimal) {
account.user?.let { user ->
try {
val api = factory.newInstance(account.server.url)
api.deposit(user.userId, amount)
sync(account.server, user)
} catch (e: Exception) {
Log.e(
"Sync",
"Could not finish transaction for ${user.name} (${user.userId}) on ${account.server.url}",
e
)
}
}
}
suspend fun withdraw(account: AccountInfo, amount: BigDecimal) {
account.user?.let { user ->
val api = factory.newInstance(account.server.url)
try {
api.withdraw(user.userId, amount)
sync(account.server, user)
} catch (e: Exception) {
Log.e(
"Sync",
"Could not finish transaction for ${user.name} (${user.userId}) on ${account.server.url}",
e
)
}
}
}
}
......@@ -25,8 +25,8 @@
package de.chaosdorf.meteroid.sync
import androidx.room.withTransaction
import de.chaosdorf.mete.UserId
import de.chaosdorf.mete.v1.MeteApiV1Factory
import de.chaosdorf.mete.model.MeteApiFactory
import de.chaosdorf.mete.model.UserId
import de.chaosdorf.meteroid.MeteroidDatabase
import de.chaosdorf.meteroid.model.Server
import de.chaosdorf.meteroid.model.ServerId
......@@ -35,6 +35,7 @@ import de.chaosdorf.meteroid.model.UserRepository
import javax.inject.Inject
class UserSyncHandler @Inject constructor(
private val factory: MeteApiFactory,
private val db: MeteroidDatabase,
private val repository: UserRepository
) : SyncHandler<Server, User, UserSyncHandler.Key>() {
......@@ -57,8 +58,8 @@ class UserSyncHandler @Inject constructor(
repository.getAll(context.serverId)
override suspend fun loadCurrent(context: Server): List<User> {
val api = MeteApiV1Factory.newInstance(context.url)
val api = factory.newInstance(context.url)
val loadedEntries = api.listUsers()
return loadedEntries.map { User.fromModelV1(context, it) }
return loadedEntries.map { User.fromModel(context, it) }
}
}
......@@ -26,6 +26,7 @@ package de.chaosdorf.meteroid.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
......@@ -34,8 +35,10 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavOptions
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.navigation
......@@ -45,11 +48,13 @@ 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.transactions.TransactionListScreen
import de.chaosdorf.meteroid.ui.transactions.TransactionViewModel
import de.chaosdorf.meteroid.ui.users.UserListScreen
import de.chaosdorf.meteroid.ui.wrapped.WrappedScreen
import de.chaosdorf.meteroid.ui.wrapped.WrappedViewModel
import kotlinx.coroutines.launch
@Composable
......@@ -60,18 +65,37 @@ fun AppRouter(viewModel: AppViewModel = viewModel()) {
LaunchedEffect(initState) {
when (initState) {
AppViewModel.InitState.LOADING -> navController.navigate(Routes.Init)
AppViewModel.InitState.CREATE_SERVER -> navController.navigate(Routes.Servers.Add)
AppViewModel.InitState.SELECT_SERVER -> navController.navigate(Routes.Servers.List)
AppViewModel.InitState.SELECT_USER -> navController.navigate(Routes.Users.List)
AppViewModel.InitState.HOME -> navController.navigate(Routes.Home.Root)
AppViewModel.InitState.LOADING -> navController.navigate(
Routes.Init,
NavOptions.Builder().setPopUpTo(Routes.Init, true).build()
)
AppViewModel.InitState.CREATE_SERVER -> navController.navigate(
Routes.Servers.Add,
NavOptions.Builder().setPopUpTo(Routes.Servers.Add, true).build()
)
AppViewModel.InitState.SELECT_SERVER -> navController.navigate(
Routes.Servers.List,
NavOptions.Builder().setPopUpTo(Routes.Servers.List, true).build()
)
AppViewModel.InitState.SELECT_USER -> navController.navigate(
Routes.Users.List,
NavOptions.Builder().setPopUpTo(Routes.Users.List, true).build()
)
AppViewModel.InitState.HOME -> navController.navigate(
Routes.Home.Root,
NavOptions.Builder().setPopUpTo(Routes.Home.Root, true).build()
)
}
}
NavHost(navController, startDestination = Routes.Init) {
composable(route = Routes.Init) { _ ->
Box(contentAlignment = Alignment.Center) {
Column {
Box(Modifier.fillMaxSize()) {
Column(Modifier.align(Alignment.Center)) {
CircularProgressIndicator()
Text("Loading")
}
......@@ -94,6 +118,8 @@ fun AppRouter(viewModel: AppViewModel = viewModel()) {
composable(Routes.Servers.Add) { _ ->
AddServerScreen(
hiltViewModel(),
isFirst = initState == AppViewModel.InitState.CREATE_SERVER,
onBack = { navController.navigate(Routes.Servers.List) },
onAdd = { navController.navigate(Routes.Servers.List) }
)
}
......@@ -131,8 +157,12 @@ fun AppRouter(viewModel: AppViewModel = viewModel()) {
MoneyListScreen(moneyListViewModel, navController::navigate)
}
composable(Routes.Home.History) { _ ->
val purchaseViewModel = hiltViewModel<PurchaseViewModel>()
PurchaseListScreen(purchaseViewModel, navController::navigate)
val transactionViewModel = hiltViewModel<TransactionViewModel>()
TransactionListScreen(transactionViewModel, navController::navigate)
}
composable(Routes.Home.Wrapped) { _ ->
val wrappedViewModel = hiltViewModel<WrappedViewModel>()
WrappedScreen(wrappedViewModel, navController::navigate)
}
}
}
......
......@@ -27,15 +27,13 @@ package de.chaosdorf.meteroid.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.chaosdorf.mete.UserId
import de.chaosdorf.mete.model.UserId
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.sync.AccountProvider
import de.chaosdorf.meteroid.sync.DrinkSyncHandler
import de.chaosdorf.meteroid.sync.PurchaseSyncHandler
import de.chaosdorf.meteroid.sync.UserSyncHandler
import de.chaosdorf.meteroid.sync.SyncManager
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
......@@ -51,9 +49,7 @@ 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
private val syncManager: SyncManager
) : ViewModel() {
val initState: StateFlow<InitState> = accountPreferences.state
.flatMapLatest { preferences ->
......@@ -69,12 +65,8 @@ class AppViewModel @Inject constructor(
init {
accountProvider.account.distinctUntilChanged().onEach { account ->
account?.let { (server, user) ->
userSyncHandler.sync(server)
drinkSyncHandler.sync(server)
user?.let { user ->
purchaseSyncHandler.sync(Pair(server, user.userId))
}
account?.let { (server, maybeUser) ->
syncManager.sync(server, maybeUser)
}
}.launchIn(viewModelScope)
}
......
/*
* 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
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Badge
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import java.math.BigDecimal
@Composable
fun PriceBadge(
price: BigDecimal,
modifier: Modifier = Modifier,
containerColor: Color =
if (price > BigDecimal.ZERO) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.error,
textColor: Color =
if (price > BigDecimal.ZERO) MaterialTheme.colorScheme.onPrimary
else MaterialTheme.colorScheme.onError,
textStyle: TextStyle = MaterialTheme.typography.labelLarge
) {
Badge(
containerColor = containerColor,
modifier = modifier
) {
Text(
"%.02f €".format(price),
style = textStyle,
color = textColor,
modifier = Modifier.padding(8.dp, 4.dp)
)
}
}
/*
* 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.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun DrinkListFilterChip(
label: String,
selected: Boolean,
onClick: () -> Unit,
) {
FilterChip(
label = {
Text(label, style = MaterialTheme.typography.labelLarge)
},
selected = selected,
leadingIcon = {
if (selected) {
Icon(
Icons.Default.Check,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
}
},
onClick = onClick
)
}
......@@ -26,53 +26,101 @@ package de.chaosdorf.meteroid.ui.drinks
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
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.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import de.chaosdorf.meteroid.sync.SyncHandler
import androidx.navigation.NavOptions
import de.chaosdorf.meteroid.ui.navigation.HomeSections
import de.chaosdorf.meteroid.ui.navigation.MeteroidBottomBar
import de.chaosdorf.meteroid.ui.navigation.MeteroidTopBar
import de.chaosdorf.meteroid.ui.navigation.Routes
import kotlinx.datetime.Clock
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import java.time.Month
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun DrinkListScreen(
viewModel: DrinkListViewModel,
onNavigate: (String) -> Unit = {}
onNavigate: (String, NavOptions) -> Unit
) {
val onBack = remember {
{
onNavigate(
Routes.Users.List,
NavOptions.Builder().setPopUpTo(Routes.Users.List, false).build()
)
}
}
val account by viewModel.account.collectAsState()
val drinks by viewModel.drinks.collectAsState()
val syncState by viewModel.syncState.collectAsState()
val filters by viewModel.filters.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(account) {
val offline = viewModel.checkOffline(account?.server)
snackbarHostState.currentSnackbarData?.dismiss()
if (offline) {
snackbarHostState.showSnackbar(
message = "Unable to connect to server",
duration = SnackbarDuration.Indefinite
)
}
}
Scaffold(
topBar = { MeteroidTopBar(account, onNavigate) },
topBar = { MeteroidTopBar(account, onNavigate, viewModel::togglePin) },
bottomBar = {
MeteroidBottomBar(
currentRoute = HomeSections.PURCHASE,
historyEnabled = account?.second?.audit == true,
historyEnabled = account?.user?.audit == true,
wrappedEnabled = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault())
.month.let { it == Month.NOVEMBER || it == Month.DECEMBER },
navigateTo = onNavigate
)
},
snackbarHost = {
SnackbarHost(hostState = snackbarHostState)
}
) { paddingValues: PaddingValues ->
Column {
if (syncState == SyncHandler.State.Loading) {
LinearProgressIndicator()
Column(Modifier.padding(paddingValues)) {
FlowRow(
modifier = Modifier.padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
DrinkListFilterChip(
label = "Active",
selected = filters.contains(DrinkListViewModel.Filter.Active),
onClick = { viewModel.toggleFilter(DrinkListViewModel.Filter.Active) }
)
DrinkListFilterChip(
label = "Coffeine Free",
selected = filters.contains(DrinkListViewModel.Filter.CaffeineFree),
onClick = { viewModel.toggleFilter(DrinkListViewModel.Filter.CaffeineFree) }
)
}
LazyVerticalGrid(
GridCells.Adaptive(120.dp),
modifier = Modifier.padding(paddingValues),
contentPadding = PaddingValues(12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
GridCells.Adaptive(104.dp),
modifier = Modifier.padding(horizontal = 8.dp)
) {
items(drinks) { drink ->
DrinkTile(drink)
DrinkTile(drink) { viewModel.purchase(it, onBack) }
}
}
}
......
......@@ -24,38 +24,91 @@
package de.chaosdorf.meteroid.ui.drinks
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.chaosdorf.meteroid.model.AccountInfo
import de.chaosdorf.meteroid.model.Drink
import de.chaosdorf.meteroid.model.DrinkRepository
import de.chaosdorf.meteroid.model.Server
import de.chaosdorf.meteroid.model.User
import de.chaosdorf.meteroid.sync.AccountProvider
import de.chaosdorf.meteroid.sync.DrinkSyncHandler
import de.chaosdorf.meteroid.sync.SyncHandler
import de.chaosdorf.meteroid.sync.SyncManager
import de.chaosdorf.meteroid.util.update
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.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class DrinkListViewModel @Inject constructor(
accountProvider: AccountProvider,
private val accountProvider: AccountProvider,
repository: DrinkRepository,
syncHandler: DrinkSyncHandler
private val syncManager: SyncManager,
private val savedStateHandle: SavedStateHandle
) : ViewModel() {
val account: StateFlow<Pair<Server, User?>?> = accountProvider.account
val account: StateFlow<AccountInfo?> = accountProvider.account
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null)
val drinks: StateFlow<List<Drink>> = accountProvider.account
.flatMapLatest { account ->
val filters: StateFlow<Set<Filter>> =
savedStateHandle.getStateFlow("filters", setOf(Filter.Active))
val drinks: StateFlow<List<Drink>> = combine(
accountProvider.account.flatMapLatest { account ->
account?.let { (server, _) ->
repository.getAllFlow(server.serverId)
} ?: flowOf(emptyList())
},
filters
) { drinks, filters ->
drinks.filter { item ->
filters.all { filter -> filter.matches(item) }
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList())
val syncState: StateFlow<SyncHandler.State> = syncHandler.state
fun toggleFilter(filter: Filter) {
savedStateHandle.update<Set<Filter>>("filters", emptySet()) { filters ->
if (filters.contains(filter)) filters - filter
else filters + filter
}
}
fun purchase(item: Drink, onBack: () -> Unit) {
account.value?.let { account ->
viewModelScope.launch {
syncManager.purchase(account, item)
if (!account.pinned) {
onBack()
}
}
}
}
fun togglePin() {
account.value?.let { account ->
account.user?.let { user ->
viewModelScope.launch {
accountProvider.togglePin(account.server.serverId, user.userId)
}
}
}
}
suspend fun checkOffline(server: Server?): Boolean =
if (server == null) true
else syncManager.checkOffline(server)
enum class Filter {
CaffeineFree,
Active;
fun matches(item: Drink) = when (this) {
CaffeineFree -> item.caffeine == 0
Active -> item.active
}
}
}