diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/App.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/App.kt index c71dddb9382b5ee3245d1766cde4efde348800f1..231dc84aa621a74fe2378b57bc9ee077468775c2 100644 --- a/app/src/main/java/net/novagamestudios/kaffeekasse/App.kt +++ b/app/src/main/java/net/novagamestudios/kaffeekasse/App.kt @@ -41,6 +41,7 @@ import net.novagamestudios.kaffeekasse.repositories.i11.KaffeekasseRepository import net.novagamestudios.kaffeekasse.repositories.i11.PortalRepository import net.novagamestudios.kaffeekasse.repositories.newSettingsStore import net.novagamestudios.kaffeekasse.repositories.releases.GitLabReleases +import net.novagamestudios.kaffeekasse.ui.util.derived class App : Application(), RepositoryProvider, CoroutineScope by MainScope() + CoroutineName(App::class.simpleName!!), Logger { @@ -101,7 +102,7 @@ class App : Application(), RepositoryProvider, CoroutineScope by MainScope() + C @Composable fun settings(): SettingsRepository = app().settingsRepository - val developerMode: Boolean @Composable get() = settings().value.developerMode + val developerMode: Boolean @Composable get() = settings().derived { developerMode }.value } diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/Settings.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/Settings.kt index ff57c0a6a01d8be268e7865114da220a25463295..6ca7957e5ed99e6a6da986750c5999332e650bd8 100644 --- a/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/Settings.kt +++ b/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/Settings.kt @@ -3,6 +3,7 @@ package net.novagamestudios.kaffeekasse.repositories import android.content.Context import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.State import androidx.datastore.core.MultiProcessDataStoreFactory import kotlinx.coroutines.CoroutineScope import kotlinx.serialization.ExperimentalSerializationApi @@ -15,12 +16,14 @@ import net.novagamestudios.common_utils.compose.state.DataStoreState import net.novagamestudios.common_utils.compose.state.MutableDataStoreState import net.novagamestudios.common_utils.compose.state.stateIn import net.novagamestudios.kaffeekasse.model.credentials.DeviceCredentials +import net.novagamestudios.kaffeekasse.ui.util.derived import java.io.File @Serializable data class Settings( val themeMode: ThemeMode = ThemeMode.Dark, val fullscreen: Boolean = false, + val animationsEnabled: Boolean = true, val autoLogin: Boolean = false, val deviceCredentials: DeviceCredentials? = null, val developerMode: Boolean = false, @@ -32,7 +35,7 @@ data class Settings( } companion object { - val Settings.isDarkMode @Composable get() = when(themeMode) { + val State<Settings>.isDarkMode: Boolean @Composable get() = when(derived { themeMode }.value) { ThemeMode.Unspecified -> isSystemInDarkTheme() ThemeMode.Dark -> true ThemeMode.Light -> false diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/Updates.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/Updates.kt index 084c3774e1f92a6dba846e866c1711eab61eea39..6ffe9836aea2c89254e3f5e5d92074a661f65149 100644 --- a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/Updates.kt +++ b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/Updates.kt @@ -322,6 +322,13 @@ private fun AppInfoDialog( Text("Made with love\nby\nJonas Broeckmann", textAlign = TextAlign.Center) Spacer(Modifier.height(16.dp)) val settings = settings() + TransparentListItem( + headlineContent = { Text("Fancy animations") }, + trailingContent = { Switch( + settings.value.animationsEnabled, + onCheckedChange = { new -> settings.tryUpdate { it.copy(animationsEnabled = new) } } + ) } + ) TransparentListItem( headlineContent = { Text("Developer mode") }, trailingContent = { Switch( diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/KaffeekasseModule.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/KaffeekasseModule.kt index 9d500a064c914ad2291e56dcb149bcf97f5916cb..c4024d68462a82d68d71b4cd10aa8130ed84b404 100644 --- a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/KaffeekasseModule.kt +++ b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/KaffeekasseModule.kt @@ -20,6 +20,7 @@ import net.novagamestudios.kaffeekasse.ui.AppModuleSelection import net.novagamestudios.kaffeekasse.ui.kaffeekasse.ManualBillViewModel.Companion.backNavigationHandler import net.novagamestudios.kaffeekasse.ui.navigation.AppSubpageTitle import net.novagamestudios.kaffeekasse.ui.navigation.KaffeekasseNavigation +import net.novagamestudios.kaffeekasse.ui.theme.ifAnimationsEnabled import net.novagamestudios.kaffeekasse.ui.util.BackNavigationHandler @@ -69,8 +70,8 @@ fun KaffeekasseTopBarNavigation( val backNavigationHandler = vm.backNavigationHandler(navigator) AnimatedVisibility( visible = !backNavigationHandler.canNavigateBack() && vm.cart.isNotEmpty(), - enter = expandHorizontally(), - exit = shrinkHorizontally() + enter = expandHorizontally().ifAnimationsEnabled(), + exit = shrinkHorizontally().ifAnimationsEnabled() ) { IconButton(onClick = { vm.cart.clear() }) { Icon(Icons.Default.Delete, "Empty cart") diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/components/CategorizedItems.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/components/CategorizedItems.kt index 4dedb12b77e4372f88d0addd44886a04152036c2..62ebfdedac2549dcb6742cfe0854536ef6658571 100644 --- a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/components/CategorizedItems.kt +++ b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/components/CategorizedItems.kt @@ -1,5 +1,6 @@ package net.novagamestudios.kaffeekasse.ui.kaffeekasse.components +import android.content.Context import androidx.compose.animation.AnimatedContent import androidx.compose.animation.ContentTransform import androidx.compose.animation.fadeIn @@ -18,15 +19,18 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import kotlinx.coroutines.launch +import net.novagamestudios.common_utils.compose.state.MutableDataStoreState import net.novagamestudios.common_utils.toastShort import net.novagamestudios.kaffeekasse.App.Companion.settings import net.novagamestudios.kaffeekasse.model.kaffeekasse.Cart import net.novagamestudios.kaffeekasse.model.kaffeekasse.Item import net.novagamestudios.kaffeekasse.model.kaffeekasse.ItemCategory import net.novagamestudios.kaffeekasse.model.kaffeekasse.MutableCart +import net.novagamestudios.kaffeekasse.repositories.UserSettings import net.novagamestudios.kaffeekasse.ui.kaffeekasse.components.cards.BasicCardGrid import net.novagamestudios.kaffeekasse.ui.kaffeekasse.components.cards.CategoryCard import net.novagamestudios.kaffeekasse.ui.kaffeekasse.components.cards.LazyBasicCardGrid +import net.novagamestudios.kaffeekasse.ui.theme.ifAnimationsEnabled class CategorizedItemsViewModel( items: Iterable<Item> @@ -67,7 +71,7 @@ fun CategorizedItems( AnimatedContent( targetState = vm.selectedCategory, Modifier.fillMaxHeight(), - transitionSpec = { + transitionSpec = ifAnimationsEnabled { val categoryGridScale = 1.6f val itemGridScale = 0.5f if (targetState == null) ContentTransform( @@ -166,20 +170,25 @@ private fun ItemCard( onRemove = { cart -= item }, onLongClick = { coroutineScope.launch { - userSettings?.update { - val list = it.favoriteItemIds.toMutableList() - val wasFavorite = item.id in list - if (wasFavorite) list -= item.id - else list += item.id - it.copy(favoriteItemIds = list).also { - context.toastShort( - if (wasFavorite) "Aus Favoriten entfernt" - else "Zu Favoriten hinzugefügt" - ) - } - } + userSettings?.addToFavourites(item, context) } } ) } +private suspend fun MutableDataStoreState<UserSettings>.addToFavourites( + item: Item, + context: Context +) = update { old -> + val list = old.favoriteItemIds.toMutableList() + val wasFavorite = item.id in list + if (wasFavorite) list -= item.id + else list += item.id + old.copy(favoriteItemIds = list).also { + context.toastShort( + if (wasFavorite) "Aus Favoriten entfernt" + else "Zu Favoriten hinzugefügt" + ) + } +} + diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/components/Checkout.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/components/Checkout.kt index 091ac1258045e3fe377d761ad87dd810460ff0bf..ab519a0e4aa0bf45d351c5c19c276c9280138782 100644 --- a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/components/Checkout.kt +++ b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/components/Checkout.kt @@ -24,7 +24,6 @@ import androidx.compose.material3.FloatingActionButtonDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.ListItem import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet @@ -50,6 +49,7 @@ import kotlinx.coroutines.launch import net.novagamestudios.common_utils.Logger import net.novagamestudios.common_utils.compose.components.BoxCenter import net.novagamestudios.common_utils.compose.components.CircularLoadingBox +import net.novagamestudios.common_utils.compose.components.TransparentListItem import net.novagamestudios.common_utils.compose.maskedCircleIcon import net.novagamestudios.common_utils.compose.state.ReentrantActionState import net.novagamestudios.common_utils.toastLong @@ -378,7 +378,7 @@ private fun CheckoutCartEntry( isLoading: () -> Boolean, onRemove: () -> Unit, modifier: Modifier = Modifier -) = ListItem( +) = TransparentListItem( headlineContent = { Row(verticalAlignment = Alignment.CenterVertically) { Text("${entry.count}x", Modifier.padding(end = 8.dp)) diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/components/cards/Item.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/components/cards/Item.kt index 9c6fb2d808861665579ecae7ea3ed65d5364a85d..746ad62f788708cb2cf5a2492754aae23f7a6702 100644 --- a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/components/cards/Item.kt +++ b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/components/cards/Item.kt @@ -146,35 +146,63 @@ private fun ItemInformation( } Spacer(Modifier.weight(1f)) - // Very bad code + val exactPrice = item.price + if (exactPrice != null) ItemPrice( + price = exactPrice, + estimated = false, + highlighted = false + ) else EstimatedItemPrice(item) +} + + +@Composable +private fun ItemPrice( + price: Double, + estimated: Boolean, + highlighted: Boolean, + modifier: Modifier = Modifier +) = Text( + remember(price, estimated) { + listOfNotNull( + if (estimated) "≈" else null, + String.format("%.2f€", price) + ).joinToString(" ") + }, + modifier, + color = if (highlighted) Color.Yellow else Color.Unspecified, + style = MaterialTheme.typography.bodyMedium +) + +@Composable +private fun EstimatedItemPrice( + item: Item, + modifier: Modifier = Modifier +) { val app = app() val session = app.portalRepository.session.collectAsState().value - if (session is Session.WithRealUser) { - val transactionsState = app.kaffeekasseRepository.transactions[session.realUser].collectAsRichState() - val lastUnitPrice by remember { - derivedStateOf { - transactionsState.dataOrNull?.findLastUnitPrice(item) - } - } - (item.price ?: lastUnitPrice ?: item.estimatedPrice)?.let { - val highlighted = it != item.estimatedPrice && App.developerMode - Text( - remember(it) { - listOfNotNull( - if (item.price != null) null else "≈", - String.format("%.2f€", it) - ).joinToString(" ") - }, - color = if (highlighted) Color.Yellow else Color.Unspecified, - style = MaterialTheme.typography.bodyMedium - ) + if (session !is Session.WithRealUser) return + val transactionsState = remember { app.kaffeekasseRepository.transactions[session.realUser] }.collectAsRichState() + val lastUnitPrice by remember(transactionsState) { + derivedStateOf { + transactionsState.dataOrNull?.findLastUnitPrice(item) } } + val estimatedPrice = lastUnitPrice ?: item.estimatedPrice ?: return + val priceDiverges = lastUnitPrice != item.estimatedPrice + ItemPrice( + price = estimatedPrice, + estimated = true, + highlighted = priceDiverges && App.developerMode, + modifier = modifier + ) } + // TODO move private fun List<Transaction>.findLastUnitPrice(item: Item): Double? { val lastPurchase = this + .asSequence() + .take(100) .map { it.purpose } .filterIsInstance<Transaction.Purpose.Purchase>() .firstOrNull { diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/navigation/AppScaffold.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/navigation/AppScaffold.kt index dd42d516ba64052647b5f3a57347e889f10d1819..50f2aea0f84a85d5565ef6c52e683155e88e0556 100644 --- a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/navigation/AppScaffold.kt +++ b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/navigation/AppScaffold.kt @@ -23,6 +23,8 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.unit.dp import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.navigator.Navigator +import net.novagamestudios.kaffeekasse.ui.theme.LocalAnimationSwitch +import net.novagamestudios.kaffeekasse.ui.theme.ifAnimationsEnabled import net.novagamestudios.kaffeekasse.ui.util.BackNavigationHandler import net.novagamestudios.kaffeekasse.ui.util.debugNavigation @@ -79,7 +81,7 @@ private fun AppScaffold( content: @Composable () -> Unit ) = Scaffold( modifier.run { - if (topAppBarScrollBehavior == null) this + if (topAppBarScrollBehavior == null || !LocalAnimationSwitch.current) this else nestedScroll(topAppBarScrollBehavior.nestedScrollConnection) }, topBar = topBar @@ -108,7 +110,7 @@ fun AppTopBar( colors = TopAppBarDefaults.topAppBarColors( scrolledContainerColor = MaterialTheme.colorScheme.surface ), - scrollBehavior = scrollBehavior + scrollBehavior = scrollBehavior.takeIf { LocalAnimationSwitch.current } ) @Composable @@ -120,8 +122,8 @@ fun DefaultBackNavigation( ) { AnimatedVisibility( visible = handler.canNavigateBack(), - enter = expandHorizontally(), - exit = shrinkHorizontally() + enter = expandHorizontally().ifAnimationsEnabled(), + exit = shrinkHorizontally().ifAnimationsEnabled() ) { IconButton(onClick = { handler.onNavigateBack() }) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/navigation/Transitions.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/navigation/Transitions.kt index d12e61c25bc6be5c1821c4c44d3d95700c95b6b1..863245160673bcdcb3836475acdd87473c233add 100644 --- a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/navigation/Transitions.kt +++ b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/navigation/Transitions.kt @@ -15,6 +15,7 @@ import cafe.adriel.voyager.navigator.Navigator import cafe.adriel.voyager.navigator.tab.Tab import cafe.adriel.voyager.navigator.tab.TabNavigator import cafe.adriel.voyager.transitions.ScreenTransitionContent +import net.novagamestudios.kaffeekasse.ui.theme.ifAnimationsEnabled private fun appTransitionSpec() = ContentTransform( targetContentEnter = fadeIn( @@ -37,7 +38,7 @@ fun AppScreenTransition( content: ScreenTransitionContent = { it.Content() } ) = AnimatedContent( targetState = navigator.lastItem, - transitionSpec = transitionSpec, + transitionSpec = ifAnimationsEnabled(transitionSpec), modifier = modifier, label = key ) { screen -> @@ -55,7 +56,7 @@ fun AppTabTransition( content: @Composable AnimatedVisibilityScope.(Tab) -> Unit = { it.Content() } ) = AnimatedContent( targetState = tabNavigator.current, - transitionSpec = transitionSpec, + transitionSpec = ifAnimationsEnabled(transitionSpec), modifier = modifier, label = key ) { tab -> diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/theme/Theme.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/theme/Theme.kt index 03d33efdcd8b0b41c85d16cd02eacefa96b49c39..f52b1ff97dfa64c39309b4a318cdbf09b07bcd8c 100644 --- a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/theme/Theme.kt +++ b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/theme/Theme.kt @@ -2,20 +2,30 @@ package net.novagamestudios.kaffeekasse.ui.theme import android.app.Activity import android.os.Build +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.compositionLocalOf import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.core.view.WindowCompat import net.novagamestudios.kaffeekasse.App.Companion.settings +import net.novagamestudios.kaffeekasse.repositories.Settings import net.novagamestudios.kaffeekasse.repositories.Settings.Companion.isDarkMode +import net.novagamestudios.kaffeekasse.ui.util.derived private val DarkColorScheme = darkColorScheme( primary = AppYellow, @@ -31,7 +41,8 @@ private val LightColorScheme = lightColorScheme( @Composable fun KaffeekasseTheme( - darkTheme: Boolean = settings().value.isDarkMode, + settings: State<Settings> = settings(), + darkTheme: Boolean = settings.isDarkMode, // Dynamic color is available on Android 12+ dynamicColor: Boolean = true, content: @Composable () -> Unit @@ -58,9 +69,14 @@ fun KaffeekasseTheme( MaterialTheme( colorScheme = colorScheme, - typography = Typography, - content = content - ) + typography = Typography + ) { + CompositionLocalProvider( + LocalAnimationSwitch provides settings.derived { animationsEnabled }.value + ) { + content() + } + } } @@ -68,3 +84,16 @@ fun KaffeekasseTheme( fun Color.enabled(enabled: Boolean = true) = disabled(!enabled) fun Color.disabled(disabled: Boolean = true) = if (disabled) copy(alpha = 0.38f) else this + +val LocalAnimationSwitch: ProvidableCompositionLocal<Boolean> = compositionLocalOf { true } + +typealias TransitionSpec<T> = AnimatedContentTransitionScope<T>.() -> ContentTransform +@Composable +fun <T> ifAnimationsEnabled(transitionSpec: TransitionSpec<T>): TransitionSpec<T> = if (LocalAnimationSwitch.current) transitionSpec else { + { ContentTransform(EnterTransition.None, ExitTransition.None) } +} + +@Composable +fun EnterTransition.ifAnimationsEnabled(): EnterTransition = if (LocalAnimationSwitch.current) this else EnterTransition.None +@Composable +fun ExitTransition.ifAnimationsEnabled(): ExitTransition = if (LocalAnimationSwitch.current) this else ExitTransition.None diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/util/Helpers.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/util/Helpers.kt index 3228432299b39b6318d0d4235b18c41e5d2cbd73..419e30be826faae3db3b973a2c1f7e0beb4f2316 100644 --- a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/util/Helpers.kt +++ b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/util/Helpers.kt @@ -6,7 +6,9 @@ import androidx.compose.animation.core.VectorConverter import androidx.compose.animation.core.animateValueAsState import androidx.compose.animation.core.spring import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisallowComposableCalls import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.remember import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter @@ -102,3 +104,15 @@ fun Navigator.requireWithKey(key: String): Navigator { return parent.requireWithKey(key) } + + + +@Composable +fun <T, R> State<T>.derived( + calculation: @DisallowComposableCalls T.() -> R +) = remember(this) { + derivedStateOf { + value.calculation() + } +} +