From dec7381162fa49ee7128bc3f802ba0ada45e4d2c Mon Sep 17 00:00:00 2001 From: Janne Mareike Koschinski <mail@justjanne.de> Date: Mon, 9 Jun 2025 22:10:51 +0200 Subject: [PATCH] refactor: recreate app structure --- .../de/chaosdorf/meteroid/MainActivity.kt | 74 ++-- .../de/chaosdorf/meteroid/theme/Color.kt | 11 +- .../de/chaosdorf/meteroid/ui/BottomBar.kt | 112 ------- .../de/chaosdorf/meteroid/ui/DepositRoute.kt | 69 +++- .../de/chaosdorf/meteroid/ui/HistoryRoute.kt | 123 ++++++- .../de/chaosdorf/meteroid/ui/PurchaseRoute.kt | 238 ++++++++++++- .../chaosdorf/meteroid/ui/ServerListRoute.kt | 93 +++++- .../de/chaosdorf/meteroid/ui/SettingsRoute.kt | 34 +- .../de/chaosdorf/meteroid/ui/SetupRoute.kt | 94 ++++-- .../kotlin/de/chaosdorf/meteroid/ui/TopBar.kt | 161 --------- .../de/chaosdorf/meteroid/ui/UserListRoute.kt | 92 ++++- .../de/chaosdorf/meteroid/ui/WrappedRoute.kt | 204 +++++++++++- .../chaosdorf/meteroid/ui/common/BottomBar.kt | 115 +++++++ .../meteroid/ui/common/PriceBadge.kt | 61 ++++ .../de/chaosdorf/meteroid/ui/common/TopBar.kt | 315 ++++++++++++++++++ .../meteroid/ui/common/UserAvatar.kt | 138 ++++++++ .../ui/navigation/NavigationAddServerItem.kt | 57 ++++ .../ui/navigation/NavigationServerItem.kt | 72 ++++ .../ui/navigation/NavigationServerListItem.kt | 84 +++++ .../ui/navigation/NavigationSettingsItem.kt | 57 ++++ .../ui/navigation/NavigationUserItem.kt | 83 +++++ .../ui/navigation/NavigationUserListItem.kt | 58 ++++ .../meteroid/ui/preview/PreviewTile.kt | 104 ++++++ .../meteroid/util/BundleExtensions.kt | 30 -- ...ckEntryExtensions.kt => MonetaryAmount.kt} | 27 +- ...pUpToRoot.kt => NavBackStackExtensions.kt} | 32 +- .../meteroid/util/NavGraphExtensions.kt | 35 -- .../{UserViewModel.kt => DepositViewModel.kt} | 36 +- .../meteroid/viewmodel/HistoryViewModel.kt | 122 +++++++ .../meteroid/viewmodel/InitViewModel.kt | 32 +- .../Routes.kt => viewmodel/MeteroidRoute.kt} | 18 +- .../meteroid/viewmodel/NavigationViewModel.kt | 204 ++++++++++++ .../meteroid/viewmodel/PurchaseViewModel.kt | 119 +++++++ .../meteroid/viewmodel/ServerListViewModel.kt | 23 +- .../meteroid/viewmodel/SetupViewModel.kt | 18 +- .../meteroid/viewmodel/UserListViewModel.kt | 22 +- .../meteroid/viewmodel/WrappedViewModel.kt | 144 +++++++- app/src/main/res/values/themes.xml | 6 - .../de/chaosdorf/meteroid/model/Server.kt | 7 +- 39 files changed, 2807 insertions(+), 517 deletions(-) delete mode 100644 app/src/main/kotlin/de/chaosdorf/meteroid/ui/BottomBar.kt delete mode 100644 app/src/main/kotlin/de/chaosdorf/meteroid/ui/TopBar.kt create mode 100644 app/src/main/kotlin/de/chaosdorf/meteroid/ui/common/BottomBar.kt create mode 100644 app/src/main/kotlin/de/chaosdorf/meteroid/ui/common/PriceBadge.kt create mode 100644 app/src/main/kotlin/de/chaosdorf/meteroid/ui/common/TopBar.kt create mode 100644 app/src/main/kotlin/de/chaosdorf/meteroid/ui/common/UserAvatar.kt create mode 100644 app/src/main/kotlin/de/chaosdorf/meteroid/ui/navigation/NavigationAddServerItem.kt create mode 100644 app/src/main/kotlin/de/chaosdorf/meteroid/ui/navigation/NavigationServerItem.kt create mode 100644 app/src/main/kotlin/de/chaosdorf/meteroid/ui/navigation/NavigationServerListItem.kt create mode 100644 app/src/main/kotlin/de/chaosdorf/meteroid/ui/navigation/NavigationSettingsItem.kt create mode 100644 app/src/main/kotlin/de/chaosdorf/meteroid/ui/navigation/NavigationUserItem.kt create mode 100644 app/src/main/kotlin/de/chaosdorf/meteroid/ui/navigation/NavigationUserListItem.kt create mode 100644 app/src/main/kotlin/de/chaosdorf/meteroid/ui/preview/PreviewTile.kt delete mode 100644 app/src/main/kotlin/de/chaosdorf/meteroid/util/BundleExtensions.kt rename app/src/main/kotlin/de/chaosdorf/meteroid/util/{NavBackStackEntryExtensions.kt => MonetaryAmount.kt} (65%) rename app/src/main/kotlin/de/chaosdorf/meteroid/util/{PopUpToRoot.kt => NavBackStackExtensions.kt} (50%) delete mode 100644 app/src/main/kotlin/de/chaosdorf/meteroid/util/NavGraphExtensions.kt rename app/src/main/kotlin/de/chaosdorf/meteroid/viewmodel/{UserViewModel.kt => DepositViewModel.kt} (62%) create mode 100644 app/src/main/kotlin/de/chaosdorf/meteroid/viewmodel/HistoryViewModel.kt rename app/src/main/kotlin/de/chaosdorf/meteroid/{ui/Routes.kt => viewmodel/MeteroidRoute.kt} (86%) create mode 100644 app/src/main/kotlin/de/chaosdorf/meteroid/viewmodel/NavigationViewModel.kt create mode 100644 app/src/main/kotlin/de/chaosdorf/meteroid/viewmodel/PurchaseViewModel.kt diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/MainActivity.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/MainActivity.kt index c20fdae..1a581ff 100644 --- a/app/src/main/kotlin/de/chaosdorf/meteroid/MainActivity.kt +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/MainActivity.kt @@ -44,7 +44,11 @@ import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.lifecycle.withCreationCallback import de.chaosdorf.meteroid.theme.MeteroidTheme import de.chaosdorf.meteroid.ui.* +import de.chaosdorf.meteroid.ui.common.BottomBar +import de.chaosdorf.meteroid.ui.common.TopBar +import de.chaosdorf.meteroid.viewmodel.NavigationViewModel import de.chaosdorf.meteroid.viewmodel.* +import kotlinx.coroutines.flow.update @AndroidEntryPoint class MainActivity : ComponentActivity() { @@ -60,25 +64,35 @@ class MainActivity : ComponentActivity() { ) installSplashScreen().setKeepOnScreenCondition { - viewModel.setupComplete.value == null + viewModel.initialRoute.value == null } setContent { - val setupComplete by viewModel.setupComplete.collectAsState() + val initialRoute = viewModel.initialRoute.collectAsState().value MeteroidTheme { - if (setupComplete != null) { - val backStack: NavBackStack = rememberNavBackStack( - if (setupComplete == true) Routes.ServerList else Routes.Setup + if (initialRoute != null) { + val navigationViewModel by viewModels<NavigationViewModel>( + extrasProducer = { + defaultViewModelCreationExtras.withCreationCallback<NavigationViewModelFactory> { factory -> + factory.create(initialRoute) + } + } ) + val backStack by navigationViewModel.backStack.collectAsState() + Scaffold( - topBar = { TopBar(backStack) }, - bottomBar = { BottomBar(backStack) } + topBar = { TopBar(navigationViewModel) }, + bottomBar = { BottomBar(navigationViewModel) } ) { paddingValues -> NavDisplay( backStack = backStack, - onBack = { backStack.removeLastOrNull() }, + onBack = { count -> + navigationViewModel.backStack.update { + it.dropLast(count) + } + }, entryDecorators = listOf( rememberSceneSetupNavEntryDecorator(), rememberSavedStateNavEntryDecorator(), @@ -106,7 +120,7 @@ class MainActivity : ComponentActivity() { }, // END entryProvider = entryProvider { - entry<Routes.Setup> { + entry<MeteroidRoute.Setup> { val viewModel by viewModels<SetupViewModel>( extrasProducer = { defaultViewModelCreationExtras.withCreationCallback<SetupViewModelFactory> { factory -> @@ -114,9 +128,9 @@ class MainActivity : ComponentActivity() { } } ) - SetupRoute(viewModel, backStack, paddingValues) + SetupRoute(viewModel, navigationViewModel, paddingValues) } - entry<Routes.Settings> { + entry<MeteroidRoute.Settings> { val viewModel by viewModels<SettingsViewModel>( extrasProducer = { defaultViewModelCreationExtras.withCreationCallback<SettingsViewModelFactory> { factory -> @@ -124,9 +138,9 @@ class MainActivity : ComponentActivity() { } } ) - SettingsRoute(viewModel, backStack, paddingValues) + SettingsRoute(viewModel, navigationViewModel, paddingValues) } - entry<Routes.ServerList> { + entry<MeteroidRoute.ServerList> { val viewModel by viewModels<ServerListViewModel>( extrasProducer = { defaultViewModelCreationExtras.withCreationCallback<ServerListViewModelFactory> { factory -> @@ -134,9 +148,9 @@ class MainActivity : ComponentActivity() { } } ) - ServerListRoute(viewModel, backStack, paddingValues) + ServerListRoute(viewModel, navigationViewModel, paddingValues) } - entry<Routes.UserList> { + entry<MeteroidRoute.UserList> { val viewModel by viewModels<UserListViewModel>( extrasProducer = { defaultViewModelCreationExtras.withCreationCallback<UserListViewModelFactory> { factory -> @@ -144,39 +158,39 @@ class MainActivity : ComponentActivity() { } } ) - UserListRoute(viewModel, backStack, paddingValues) + UserListRoute(viewModel, navigationViewModel, paddingValues) } - entry<Routes.Purchase> { - val viewModel by viewModels<UserViewModel>( + entry<MeteroidRoute.Purchase> { + val viewModel by viewModels<PurchaseViewModel>( extrasProducer = { - defaultViewModelCreationExtras.withCreationCallback<UserViewModelFactory> { factory -> + defaultViewModelCreationExtras.withCreationCallback<PurchaseViewModelFactory> { factory -> factory.create(it.serverId.value, it.userId.value) } } ) - PurchaseRoute(viewModel, backStack, paddingValues) + PurchaseRoute(viewModel, navigationViewModel, paddingValues) } - entry<Routes.Deposit> { - val viewModel by viewModels<UserViewModel>( + entry<MeteroidRoute.Deposit> { + val viewModel by viewModels<DepositViewModel>( extrasProducer = { - defaultViewModelCreationExtras.withCreationCallback<UserViewModelFactory> { factory -> + defaultViewModelCreationExtras.withCreationCallback<DepositViewModelFactory> { factory -> factory.create(it.serverId.value, it.userId.value) } } ) - DepositRoute(viewModel, backStack, paddingValues) + DepositRoute(viewModel, navigationViewModel, paddingValues) } - entry<Routes.History> { - val viewModel by viewModels<UserViewModel>( + entry<MeteroidRoute.History> { + val viewModel by viewModels<HistoryViewModel>( extrasProducer = { - defaultViewModelCreationExtras.withCreationCallback<UserViewModelFactory> { factory -> + defaultViewModelCreationExtras.withCreationCallback<HistoryViewModelFactory> { factory -> factory.create(it.serverId.value, it.userId.value) } } ) - HistoryRoute(viewModel, backStack, paddingValues) + HistoryRoute(viewModel, navigationViewModel, paddingValues) } - entry<Routes.Wrapped> { + entry<MeteroidRoute.Wrapped> { val viewModel by viewModels<WrappedViewModel>( extrasProducer = { defaultViewModelCreationExtras.withCreationCallback<WrappedViewModelFactory> { factory -> @@ -184,7 +198,7 @@ class MainActivity : ComponentActivity() { } } ) - WrappedRoute(viewModel, backStack, paddingValues) + WrappedRoute(viewModel, navigationViewModel, paddingValues) } } ) diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/theme/Color.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/theme/Color.kt index 75577f7..6fdf1e2 100644 --- a/app/src/main/kotlin/de/chaosdorf/meteroid/theme/Color.kt +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/theme/Color.kt @@ -25,8 +25,10 @@ package de.chaosdorf.meteroid.theme import androidx.compose.material3.ColorScheme +import androidx.compose.material3.contentColorFor import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.graphics.luminance val md_theme_light_primary = Color(0xFF345CA8) val md_theme_light_onPrimary = Color(0xFFFFFFFF) @@ -94,9 +96,12 @@ val md_theme_dark_scrim = Color(0xFF000000) val ColorScheme.secondaryGradient get() = ThemeGradient( listOf( - secondaryContainer.copy(0.2f).compositeOver(surface), - secondaryContainer, - ) + secondaryContainer + .copy(alpha = 0.2f).compositeOver(surface), + secondaryContainer + .copy(alpha = 0.7f).compositeOver(secondary) + .copy(0.5f).compositeOver(surface), + ).sortedByDescending { it.luminance() } ) val ColorScheme.onPrimaryContainerTinted diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/BottomBar.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/BottomBar.kt deleted file mode 100644 index 88db136..0000000 --- a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/BottomBar.kt +++ /dev/null @@ -1,112 +0,0 @@ -/* - * The MIT License (MIT) - * - * Copyright (c) 2013-2025 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.animation.AnimatedVisibility -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Celebration -import androidx.compose.material.icons.outlined.History -import androidx.compose.material.icons.outlined.LocalAtm -import androidx.compose.material3.BottomAppBar -import androidx.compose.material3.Icon -import androidx.compose.material3.NavigationBarItem -import androidx.compose.runtime.Composable -import androidx.navigation3.runtime.NavBackStack -import de.chaosdorf.meteroid.theme.icons.MeteroidIcons -import de.chaosdorf.meteroid.theme.icons.outlined.WaterFull - -@Composable -fun BottomBar( - backStack: NavBackStack, -) { - val currentRoute = backStack.lastOrNull() - - AnimatedVisibility( - visible = currentRoute is Routes.UserScope, - enter = slideInVertically(initialOffsetY = { it }), - exit = slideOutVertically(targetOffsetY = { it }) - ) { - BottomAppBar { - NavigationBarItem( - selected = currentRoute is Routes.Purchase, - onClick = { - if (currentRoute is Routes.UserScope) { - backStack.remove(currentRoute) - backStack.add( - Routes.Purchase(currentRoute.serverId, currentRoute.userId) - ) - } - }, - icon = { - Icon(MeteroidIcons.Outlined.WaterFull, contentDescription = null) - }, - ) - NavigationBarItem( - selected = currentRoute is Routes.Deposit, - onClick = { - if (currentRoute is Routes.UserScope) { - backStack.remove(currentRoute) - backStack.add( - Routes.Deposit(currentRoute.serverId, currentRoute.userId) - ) - } - }, - icon = { - Icon(Icons.Outlined.LocalAtm, contentDescription = null) - }, - ) - NavigationBarItem( - selected = currentRoute is Routes.History, - onClick = { - if (currentRoute is Routes.UserScope) { - backStack.remove(currentRoute) - backStack.add( - Routes.History(currentRoute.serverId, currentRoute.userId) - ) - } - }, - icon = { - Icon(Icons.Outlined.History, contentDescription = null) - }, - ) - NavigationBarItem( - selected = currentRoute is Routes.Wrapped, - onClick = { - if (currentRoute is Routes.UserScope) { - backStack.remove(currentRoute) - backStack.add( - Routes.Wrapped(currentRoute.serverId, currentRoute.userId) - ) - } - }, - icon = { - Icon(Icons.Outlined.Celebration, contentDescription = null) - }, - ) - } - } -} diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/DepositRoute.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/DepositRoute.kt index 67eb647..297ba10 100644 --- a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/DepositRoute.kt +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/DepositRoute.kt @@ -24,22 +24,71 @@ package de.chaosdorf.meteroid.ui -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Text +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.navigation3.runtime.NavBackStack -import de.chaosdorf.meteroid.viewmodel.UserViewModel +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import de.chaosdorf.meteroid.ui.common.PriceBadge +import de.chaosdorf.meteroid.util.MonetaryAmount +import de.chaosdorf.meteroid.viewmodel.DepositViewModel +import de.chaosdorf.meteroid.viewmodel.Navigator @Composable fun DepositRoute( - viewModel: UserViewModel, - backStack: NavBackStack, + viewModel: DepositViewModel, + navigator: Navigator, contentPadding: PaddingValues, ) { - Column(Modifier.padding(contentPadding)) { - Text("Deposit ${viewModel.serverId} ${viewModel.userId}") + LazyVerticalGrid( + GridCells.Adaptive(104.dp), + contentPadding = contentPadding, + modifier = Modifier.padding(horizontal = 8.dp), + ) { + items( + viewModel.money, + key = { "deposit-${it.ordinal}" }, + ) { monetaryAmount -> + DepositMoneyItem(monetaryAmount) { + viewModel.deposit(it, onBack = {}) + } + } + } +} + +@Composable +fun DepositMoneyItem( + item: MonetaryAmount, + modifier: Modifier = Modifier, + onDeposit: (MonetaryAmount) -> Unit = {} +) { + Box( + modifier = modifier + .height(IntrinsicSize.Max) + .clip(RoundedCornerShape(8.dp)) + .clickable { onDeposit(item) } + ) { + Image( + painterResource(item.image), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier + .aspectRatio(1.0f) + ) + PriceBadge( + item.amount, + modifier = Modifier + .align(Alignment.BottomEnd) + .paddingFromBaseline(bottom = 24.dp) + ) } } diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/HistoryRoute.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/HistoryRoute.kt index 184bd4c..0e95ec6 100644 --- a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/HistoryRoute.kt +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/HistoryRoute.kt @@ -24,22 +24,127 @@ package de.chaosdorf.meteroid.ui -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +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.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.navigation3.runtime.NavBackStack -import de.chaosdorf.meteroid.viewmodel.UserViewModel +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import coil3.compose.rememberAsyncImagePainter +import de.chaosdorf.meteroid.model.Drink +import de.chaosdorf.meteroid.model.Transaction +import de.chaosdorf.meteroid.theme.secondaryGradient +import de.chaosdorf.meteroid.ui.common.PriceBadge +import de.chaosdorf.meteroid.viewmodel.HistoryViewModel +import de.chaosdorf.meteroid.viewmodel.Navigator +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toJavaLocalDateTime +import kotlinx.datetime.toLocalDateTime +import java.math.BigDecimal +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle @Composable fun HistoryRoute( - viewModel: UserViewModel, - backStack: NavBackStack, + viewModel: HistoryViewModel, + navigator: Navigator, contentPadding: PaddingValues, ) { - Column(Modifier.padding(contentPadding)) { - Text("History ${viewModel.serverId} ${viewModel.userId}") + val transactions by viewModel.transactions.collectAsState() + + LazyColumn(contentPadding = contentPadding) { + items( + transactions, + key = { "transaction-${it.transaction.serverId}-${it.transaction.transactionId}" }, + ) { (transaction, drink) -> + TransactionHistoryItem(transaction, drink) + } } } + +@Composable +fun TransactionHistoryItem( + transaction: Transaction, + drink: Drink?, + modifier: Modifier = Modifier +) { + val timestamp = transaction.timestamp.toLocalDateTime(TimeZone.currentSystemDefault()) + val formatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT) + + ListItem( + headlineContent = { + val label = when { + drink != null -> drink.name + transaction.difference > BigDecimal.ZERO -> "Deposit" + else -> "Unknown" + } + Text(label) + }, + supportingContent = { + Text(formatter.format(timestamp.toJavaLocalDateTime())) + }, + leadingContent = { + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .aspectRatio(1.0f) + .background(MaterialTheme.colorScheme.secondaryGradient.verticalGradient()) + ) { + if (drink != null) { + val thumbPainter = rememberAsyncImagePainter( + drink.logoUrl + ) + val originalPainter = rememberAsyncImagePainter( + drink.originalLogoUrl, + error = thumbPainter + ) + + Image( + painter = originalPainter, + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier + .align(Alignment.Center) + .fillMaxSize() + ) + } else if (transaction.difference > BigDecimal.ZERO) { + Icon( + Icons.Default.AttachMoney, + contentDescription = null, + modifier = Modifier.align(Alignment.Center) + ) + } else { + Icon( + Icons.Default.QuestionMark, + contentDescription = null, + modifier = Modifier.align(Alignment.Center) + ) + } + } + }, + trailingContent = { + PriceBadge( + transaction.difference, + modifier = Modifier.padding(horizontal = 8.dp) + ) + }, + modifier = modifier, + ) +} + diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/PurchaseRoute.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/PurchaseRoute.kt index 7a56bd1..b42942f 100644 --- a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/PurchaseRoute.kt +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/PurchaseRoute.kt @@ -24,22 +24,238 @@ package de.chaosdorf.meteroid.ui -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.navigation3.runtime.NavBackStack -import de.chaosdorf.meteroid.viewmodel.UserViewModel +import androidx.compose.ui.draw.alpha +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.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil3.compose.rememberAsyncImagePainter +import de.chaosdorf.meteroid.model.Drink +import de.chaosdorf.meteroid.theme.onPrimaryContainerTinted +import de.chaosdorf.meteroid.theme.secondaryGradient +import de.chaosdorf.meteroid.ui.common.PriceBadge +import de.chaosdorf.meteroid.viewmodel.Navigator +import de.chaosdorf.meteroid.viewmodel.PurchaseViewModel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.update +import kotlinx.datetime.Clock +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import java.math.BigDecimal +import java.util.* @Composable fun PurchaseRoute( - viewModel: UserViewModel, - backStack: NavBackStack, + viewModel: PurchaseViewModel, + navigator: Navigator, contentPadding: PaddingValues, ) { - Column(Modifier.padding(contentPadding)) { - Text("Purchase ${viewModel.serverId} ${viewModel.userId}") + val drinks by viewModel.drinks.collectAsState() + val filters by viewModel.filters.collectAsState() + + LazyVerticalGrid( + GridCells.Adaptive(104.dp), + contentPadding = contentPadding, + modifier = Modifier.padding(horizontal = 8.dp), + ) { + item("filter", span = { GridItemSpan(maxLineSpan) }) { + FlowRow( + modifier = Modifier.padding(horizontal = 12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + PurchaseFilterChip( + label = "Active", + selected = filters.contains(PurchaseViewModel.Filter.Active), + onClick = { viewModel.toggleFilter(PurchaseViewModel.Filter.Active) } + ) + PurchaseFilterChip( + label = "Coffeine Free", + selected = filters.contains(PurchaseViewModel.Filter.CaffeineFree), + onClick = { viewModel.toggleFilter(PurchaseViewModel.Filter.CaffeineFree) } + ) + } + } + + item("wrapped", span = { GridItemSpan(maxLineSpan) }) { + Surface( + color = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainerTinted, + shape = RoundedCornerShape(8.dp), + modifier = Modifier.padding(vertical = 8.dp, horizontal = 12.dp) + ) { + Row(horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 8.dp)) { + Spacer(Modifier.width(12.dp)) + Column(verticalArrangement = Arrangement.Center) { + val now = Clock.System.now().toLocalDateTime(TimeZone.UTC) + Text( + "Your ${now.year} Wrapped is here", + style = MaterialTheme.typography.bodyLarge, + ) + Text( + "Jump into your year in beverages", + style = MaterialTheme.typography.bodyMedium, + ) + } + Spacer(Modifier.width(8.dp)) + Button( + onClick = { + navigator.backStack.update { + it.plus(MeteroidRoute.Wrapped(viewModel.serverId, viewModel.userId)) + } + }, + ) { + Text("Let's go") + } + Spacer(Modifier.width(4.dp)) + } + } + } + + items( + drinks, + key = { "drink-${it.serverId}-${it.drinkId}" }, + ) { drink -> + PurchaseDrinkTile(drink) { item, count -> + viewModel.purchase(item, count, onBack = {}) + } + } + } +} + +@Composable +fun PurchaseFilterChip( + 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, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = MaterialTheme.colorScheme.secondaryContainer + ) + ) +} + +@Composable +fun PurchaseDrinkTile( + item: Drink, + modifier: Modifier = Modifier, + onPurchase: (Drink, Int) -> Unit = { _, _ -> } +) { + var purchaseCount by remember { mutableIntStateOf(0) } + val pendingPurchases = purchaseCount != 0 + + LaunchedEffect(purchaseCount) { + delay(2000L) + onPurchase(item, purchaseCount) + purchaseCount = 0 + } + + val thumbPainter = rememberAsyncImagePainter( + item.logoUrl + ) + val drinkPainter = rememberAsyncImagePainter( + item.originalLogoUrl, + error = thumbPainter + ) + + Column( + modifier = modifier + .height(IntrinsicSize.Max) + .alpha(if (item.active) 1.0f else 0.67f) + .clip(RoundedCornerShape(8.dp)) + .clickable { purchaseCount += 1 } + .padding(8.dp) + ) { + Box( + Modifier + .aspectRatio(1.0f) + .background(MaterialTheme.colorScheme.secondaryGradient.verticalGradient(), CircleShape), + contentAlignment = Alignment.Center + ) { + Image( + drinkPainter, + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier + .alpha(if (pendingPurchases) 0.0f else 1.0f) + .clip(CircleShape) + ) + PriceBadge( + item.price, + modifier = Modifier + .alpha(if (pendingPurchases) 0.0f else 1.0f) + .align(Alignment.BottomEnd) + .paddingFromBaseline(bottom = 12.dp) + ) + Text( + "×$purchaseCount", + fontSize = 36.sp, + fontWeight = FontWeight.Light, + color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.67f), + textAlign = TextAlign.Center, + modifier = Modifier.alpha(if (pendingPurchases) 1.0f else 0.0f) + ) + } + Spacer(Modifier.height(4.dp)) + Text( + item.name, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + textAlign = TextAlign.Center, + fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.labelLarge, + ) + Spacer(Modifier.height(4.dp)) + Row(modifier = Modifier.align(Alignment.CenterHorizontally)) { + val unitPrice = + if (item.volume <= BigDecimal.ZERO) null + else item.price / item.volume + + Text( + if (unitPrice == null) String.format(Locale.getDefault(), "%.02fl", item.volume) + else String.format(Locale.getDefault(), "%.02fl · %.02f€/l", item.volume, item.price / item.volume), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + textAlign = TextAlign.Center, + fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + ) + } } } diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/ServerListRoute.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/ServerListRoute.kt index b5aefc1..d2ce1a2 100644 --- a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/ServerListRoute.kt +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/ServerListRoute.kt @@ -28,36 +28,103 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.navigation3.runtime.NavBackStack -import coil3.compose.AsyncImage +import de.chaosdorf.mete.model.ServerId +import de.chaosdorf.meteroid.model.Server +import de.chaosdorf.meteroid.ui.common.ServerAvatar +import de.chaosdorf.meteroid.util.humanReadableHost +import de.chaosdorf.meteroid.viewmodel.Navigator import de.chaosdorf.meteroid.viewmodel.ServerListViewModel +import kotlinx.coroutines.flow.update +import okhttp3.HttpUrl.Companion.toHttpUrl @Composable fun ServerListRoute( viewModel: ServerListViewModel, - backStack: NavBackStack, + navigator: Navigator, contentPadding: PaddingValues, ) { val servers by viewModel.servers.collectAsState() + var deletingServer: Server? by remember { mutableStateOf(null) } LazyColumn(contentPadding = contentPadding) { items(servers, { "server-${it.serverId}" }) { server -> - ListItem( - headlineContent = { Text(server.name ?: "Unknown") }, - supportingContent = { Text(server.url) }, - leadingContent = if (server.logoUrl != null) { - @Composable { AsyncImage(server.logoUrl, contentDescription = null) } - } else null, - modifier = Modifier.clickable { - backStack.add(Routes.UserList(server.serverId)) - }, - ) + ServerListItem(server, onDelete = { + deletingServer = server + }) { serverId -> + navigator.backStack.update { + it.plus(MeteroidRoute.UserList(server.serverId)) + } + } } } + + deletingServer?.let { server -> + val host = humanReadableHost(server.url.toHttpUrl()) + + AlertDialog( + onDismissRequest = { deletingServer = null }, + text = { + Text("Are you sure you want to delete ${server.name ?: host}?") + }, + confirmButton = { + Button(onClick = { + viewModel.deleteServer(server.serverId) + deletingServer = null + }) { + Text("Delete") + } + }, + dismissButton = { + Button(onClick = { + deletingServer = null + }) { + Text("Cancel") + } + } + ) + } +} + +@Composable +fun ServerListItem( + item: Server, + onDelete: (Server) -> Unit = { }, + onSelect: (ServerId) -> Unit = { }, +) { + val host = humanReadableHost(item.url.toHttpUrl()) + + ListItem( + headlineContent = { Text(item.name ?: host) }, + supportingContent = { + if (item.name != null) { + Text(host) + } + }, + leadingContent = { + ServerAvatar(item.logoUrl) + }, + trailingContent = { + IconButton(onClick = { onDelete(item) }) { + Icon(Icons.Default.Delete, contentDescription = "Delete Server") + } + }, + modifier = Modifier.clickable { + onSelect(item.serverId) + } + ) } diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/SettingsRoute.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/SettingsRoute.kt index 6065bc8..2169453 100644 --- a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/SettingsRoute.kt +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/SettingsRoute.kt @@ -24,22 +24,50 @@ package de.chaosdorf.meteroid.ui +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import androidx.navigation3.runtime.NavBackStack +import de.chaosdorf.meteroid.theme.onPrimaryContainerTinted +import de.chaosdorf.meteroid.viewmodel.Navigator import de.chaosdorf.meteroid.viewmodel.SettingsViewModel @Composable fun SettingsRoute( viewModel: SettingsViewModel, - backStack: NavBackStack, + navigator: Navigator, contentPadding: PaddingValues, ) { - Column(Modifier.padding(contentPadding)) { - Text("Settings") + Column(Modifier.padding(contentPadding).padding(horizontal = 8.dp)) { + Surface( + color = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainerTinted, + shape = RoundedCornerShape(8.dp), + modifier = Modifier.padding(vertical = 8.dp, horizontal = 12.dp) + ) { + Column(verticalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp, horizontal = 12.dp)) { + Text( + "Under Construction", + style = MaterialTheme.typography.bodyLarge, + ) + Text( + "This section isn't finished yet", + style = MaterialTheme.typography.bodyMedium, + ) + } + } } } diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/SetupRoute.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/SetupRoute.kt index 2a1b380..59334ef 100644 --- a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/SetupRoute.kt +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/SetupRoute.kt @@ -24,30 +24,50 @@ package de.chaosdorf.meteroid.ui -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add import androidx.compose.material3.* import androidx.compose.runtime.Composable 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.navigation3.runtime.NavBackStack +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import de.chaosdorf.meteroid.viewmodel.Navigator import de.chaosdorf.meteroid.viewmodel.SetupViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import okhttp3.HttpUrl.Companion.toHttpUrl @Composable fun SetupRoute( viewModel: SetupViewModel, - backStack: NavBackStack, + navigator: Navigator, contentPadding: PaddingValues, ) { val coroutineScope = rememberCoroutineScope() val serverUrl by viewModel.serverUrl.collectAsState() + val server by viewModel.server.collectAsState() val error by viewModel.error.collectAsState() - Column(Modifier.padding(contentPadding)) { + Column( + Modifier + .padding(contentPadding) + .padding(16.dp, 8.dp) + ) { + TextField( + label = { Text("Server URL") }, + value = serverUrl, + onValueChange = { viewModel.serverUrl.value = it }, + modifier = Modifier.fillMaxWidth() + ) + error?.let { Surface( color = MaterialTheme.colorScheme.errorContainer, @@ -56,23 +76,57 @@ fun SetupRoute( Text(it) } } - TextField( - value = serverUrl, - onValueChange = { viewModel.serverUrl.value = it }, - ) - Button( - onClick = { - coroutineScope.launch { - val server = viewModel.add() - if (server != null) { - backStack.removeLastOrNull() - backStack.add(Routes.ServerList) - backStack.add(Routes.UserList(server.serverId)) + + server?.let { server -> + Card( + modifier = Modifier.padding(vertical = 8.dp) + ) { + Row(modifier = Modifier.padding(16.dp, 8.dp)) { + AsyncImage( + server.logoUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.size(48.dp) + ) + Spacer(Modifier.width(16.dp)) + Column(modifier = Modifier.align(Alignment.CenterVertically)) { + Text( + server.name!!, + fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.bodyMedium + ) + Text( + server.url.toHttpUrl().host, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.67f), + fontWeight = FontWeight.Medium, + style = MaterialTheme.typography.bodyMedium + ) + } + + Spacer( + Modifier + .width(16.dp) + .weight(1.0f) + ) + + IconButton(onClick = { + coroutineScope.launch(Dispatchers.IO) { + val server = viewModel.add() + if (server != null) { + navigator.backStack.update { + listOf( + MeteroidRoute.ServerList, + MeteroidRoute.UserList(server.serverId) + ) + } + } + } + }) { + Icon(Icons.Default.Add, contentDescription = "Add Server") } } } - ) { - Text("Save") } } } + diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/TopBar.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/TopBar.kt deleted file mode 100644 index 3bdf179..0000000 --- a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/TopBar.kt +++ /dev/null @@ -1,161 +0,0 @@ -/* - * The MIT License (MIT) - * - * Copyright (c) 2013-2025 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.animation.* -import androidx.compose.animation.core.animateDp -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.updateTransition -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import androidx.navigation3.runtime.NavBackStack -import de.chaosdorf.mete.model.ServerId -import de.chaosdorf.mete.model.UserId - -@OptIn(ExperimentalSharedTransitionApi::class) -@Composable -fun TopBar(backStack: NavBackStack) { - - val currentRoute = backStack.lastOrNull() - - if (currentRoute !is Routes.Setup) { - var open by remember { mutableStateOf(false) } - val transition = updateTransition(open, label = "transition") - - val backgroundAlpha = transition.animateFloat { if (it) .2f else 0f } - val elevation = transition.animateDp { if (it) 0.dp else 4.dp } - - Surface( - modifier = Modifier - .windowInsetsPadding(WindowInsets.statusBars) - .padding(8.dp) - .height(56.dp), - shape = RoundedCornerShape(28.dp), - shadowElevation = elevation.value, - tonalElevation = 4.dp, - onClick = { open = true } - ) { - Row( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text(currentRoute.toString()) - } - } - - if (open || transition.currentState || transition.isRunning) { - Dialog( - onDismissRequest = { open = false }, - properties = DialogProperties(usePlatformDefaultWidth = false), - ) { - Box( - Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.scrim.copy(alpha = backgroundAlpha.value)) - .clickable { open = false } - ) { - Surface( - modifier = Modifier - .windowInsetsPadding(WindowInsets.safeDrawing) - .padding(8.dp), - shape = RoundedCornerShape(28.dp), - shadowElevation = 4.dp - elevation.value, - tonalElevation = 4.dp, - ) { - Column { - transition.AnimatedVisibility( - visible = { it || currentRoute == Routes.ServerList }, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut(), - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .height(56.dp) - .clickable { - backStack.add(Routes.ServerList) - open = false - }.padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text("ServerList") - } - } - transition.AnimatedVisibility( - visible = { it || (currentRoute is Routes.UserScope && currentRoute.serverId == ServerId(1) && currentRoute.userId == UserId(1)) }, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut(), - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .height(56.dp) - .clickable { - backStack.add(Routes.Purchase(ServerId(1), UserId(1))) - open = false - }.padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text("User(ServerId(1),UserId(1))") - } - } - transition.AnimatedVisibility( - visible = { it || currentRoute == Routes.Settings }, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut(), - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .height(56.dp) - .clickable { - backStack.add(Routes.Settings) - open = false - }.padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text("Settings") - } - } - } - } - } - } - } - } -} diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/UserListRoute.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/UserListRoute.kt index 594c9a8..4741df9 100644 --- a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/UserListRoute.kt +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/UserListRoute.kt @@ -26,38 +26,104 @@ package de.chaosdorf.meteroid.ui import androidx.compose.foundation.clickable 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.ListItem +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.navigation3.runtime.NavBackStack -import coil3.compose.AsyncImage +import androidx.compose.ui.unit.dp +import de.chaosdorf.mete.model.ServerId +import de.chaosdorf.mete.model.UserId +import de.chaosdorf.meteroid.model.User +import de.chaosdorf.meteroid.ui.common.UserAvatar +import de.chaosdorf.meteroid.viewmodel.Navigator import de.chaosdorf.meteroid.viewmodel.UserListViewModel +import kotlinx.coroutines.flow.update @Composable fun UserListRoute( viewModel: UserListViewModel, - backStack: NavBackStack, + navigator: Navigator, contentPadding: PaddingValues, ) { val users by viewModel.users.collectAsState() + val pinnedUsers by viewModel.pinnedUsers.collectAsState() LazyColumn(contentPadding = contentPadding) { - items(users, { "server-${it.serverId}/user-${it.userId}"}) { - ListItem( - headlineContent = { Text(it.name) }, - supportingContent = { Text(it.email ?: "") }, - leadingContent = if (it.gravatarUrl != null) { - @Composable { AsyncImage(it.gravatarUrl, contentDescription = null) } - } else null, - modifier = Modifier.clickable { - backStack.add(Routes.Purchase(it.serverId, it.userId)) + if (pinnedUsers.isNotEmpty()) { + stickyHeader("pinned") { + ListItem(headlineContent = { + Text( + "Pinned", + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(start = 8.dp, end = 8.dp, top = 16.dp, bottom = 4.dp) + ) + }) + } + + items( + pinnedUsers, + key = { "pinned-${it.serverId}-${it.userId}" }, + ) { user -> + UserListItem(user) { serverId, userId -> + navigator.backStack.update { + it.plus(MeteroidRoute.Purchase(serverId, userId)) + } + } + } + } + + for (character in 'A'..'Z') { + val group = users.filter { it.name.startsWith(character, ignoreCase = true) } + if (group.isNotEmpty()) { + stickyHeader(character.toString()) { + ListItem(headlineContent = { + Text( + "$character", + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(start = 8.dp, end = 8.dp, top = 16.dp, bottom = 4.dp) + ) + }) + } + + items( + group, + key = { "user-${it.serverId}-${it.userId}" }, + ) { user -> + UserListItem(user) { serverId, userId -> + navigator.backStack.update { + it.plus(MeteroidRoute.Purchase(serverId, userId)) + } + } } - ) + } } } } + +@Composable +fun UserListItem( + item: User, + onSelect: (ServerId, UserId) -> Unit = { _, _ -> } +) { + ListItem( + headlineContent = { Text(item.name) }, + supportingContent = { + item.email?.let { email -> + Text(email) + } + }, + leadingContent = { + UserAvatar(item.gravatarUrl) + }, + modifier = Modifier.clickable { + onSelect(item.serverId, item.userId) + } + ) +} + diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/WrappedRoute.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/WrappedRoute.kt index 52ecd97..6f74e19 100644 --- a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/WrappedRoute.kt +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/WrappedRoute.kt @@ -24,22 +24,210 @@ package de.chaosdorf.meteroid.ui -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Text +import android.os.Build +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.* import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.navigation3.runtime.NavBackStack +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import coil3.compose.rememberAsyncImagePainter +import de.chaosdorf.meteroid.R +import de.chaosdorf.meteroid.viewmodel.Navigator +import de.chaosdorf.meteroid.viewmodel.WrappedSlide import de.chaosdorf.meteroid.viewmodel.WrappedViewModel +import kotlinx.coroutines.flow.update +import java.time.format.TextStyle @Composable fun WrappedRoute( viewModel: WrappedViewModel, - backStack: NavBackStack, + navigator: Navigator, contentPadding: PaddingValues, ) { - Column(Modifier.padding(contentPadding)) { - Text("Wrapped ${viewModel.serverId} ${viewModel.userId}") + + val slides by viewModel.slides.collectAsState() + val state = rememberPagerState { slides.size } + + Dialog( + onDismissRequest = { + navigator.backStack.update { it.dropLast(1) } + }, + properties = DialogProperties( + usePlatformDefaultWidth = false, + ), + ) { + HorizontalPager(state) { page -> + val slide = slides[page] + + when (slide) { + is WrappedSlide.MostBoughtDrink -> MostBoughtDrinkSlide(slide, contentPadding) + is WrappedSlide.Caffeine -> CaffeineSlide(slide, contentPadding) + is WrappedSlide.MostActive -> MostActiveSlide(slide, contentPadding) + } + } + } +} + +@Composable +fun MostBoughtDrinkSlide( + slide: WrappedSlide.MostBoughtDrink, + contentPadding: PaddingValues, +) { + Surface( + color = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.fillMaxSize(), + ) { + Box { + ListItem( + modifier = Modifier + .padding(contentPadding) + .align(Alignment.Center), + colors = ListItemDefaults.colors( + containerColor = Color.Transparent, + headlineColor = MaterialTheme.colorScheme.onPrimaryContainer, + supportingColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + headlineContent = { + Text("Your favorite drink is ${slide.drink.name}") + }, + supportingContent = { + Text("At least you enjoyed it ${slide.count} times this year.") + }, + leadingContent = { + val thumbPainter = rememberAsyncImagePainter( + slide.drink.logoUrl, + ) + val drinkPainter = rememberAsyncImagePainter( + slide.drink.originalLogoUrl, + error = thumbPainter + ) + Image( + drinkPainter, + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.size(72.dp) + ) + } + ) + } + } +} + +@Composable +fun CaffeineSlide( + slide: WrappedSlide.Caffeine, + contentPadding: PaddingValues, +) { + Surface( + color = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.fillMaxSize(), + ) { + Box { + ListItem( + modifier = Modifier + .padding(contentPadding) + .align(Alignment.Center), + colors = ListItemDefaults.colors( + containerColor = Color.Transparent, + headlineColor = MaterialTheme.colorScheme.onSecondaryContainer, + supportingColor = MaterialTheme.colorScheme.onSecondaryContainer, + ), + headlineContent = { + Text("You consumed ${slide.total} mg of caffeine this year.") + }, + supportingContent = { + slide.wouldKill?.let { animal -> + Text("This could kill a medium-weight ${animal.name}. Glad you're still here.") + } + }, + leadingContent = { + val painter = painterResource( + when (slide.wouldKill) { + WrappedSlide.Caffeine.Animal.Squirrel -> R.drawable.wrapped_squirrel + WrappedSlide.Caffeine.Animal.Rat -> R.drawable.wrapped_rat + WrappedSlide.Caffeine.Animal.Cat -> R.drawable.wrapped_cat + WrappedSlide.Caffeine.Animal.Koala -> R.drawable.wrapped_koala + WrappedSlide.Caffeine.Animal.Lynx -> R.drawable.wrapped_lynx + WrappedSlide.Caffeine.Animal.Jaguar -> R.drawable.wrapped_jaguar + WrappedSlide.Caffeine.Animal.Reindeer -> R.drawable.wrapped_reindeer + WrappedSlide.Caffeine.Animal.Gorilla -> R.drawable.wrapped_gorilla + WrappedSlide.Caffeine.Animal.Lion -> R.drawable.wrapped_lion + WrappedSlide.Caffeine.Animal.Bear -> R.drawable.wrapped_bear + WrappedSlide.Caffeine.Animal.Moose -> R.drawable.wrapped_moose + else -> R.drawable.wrapped_coffee_beans + } + ) + + Image( + painter, + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.size(72.dp) + ) + } + ) + } + } +} + +@Composable +fun MostActiveSlide( + slide: WrappedSlide.MostActive, + contentPadding: PaddingValues, +) { + @Suppress("DEPRECATION") + val locale = LocalConfiguration.current.let { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) it.locales.get(0) + else it.locale + } + + Surface( + color = MaterialTheme.colorScheme.tertiaryContainer, + contentColor = MaterialTheme.colorScheme.onTertiaryContainer, + modifier = Modifier.fillMaxSize(), + ) { + Box { + ListItem( + modifier = Modifier + .padding(contentPadding) + .align(Alignment.Center), + colors = ListItemDefaults.colors( + containerColor = Color.Transparent, + headlineColor = MaterialTheme.colorScheme.onTertiaryContainer, + supportingColor = MaterialTheme.colorScheme.onTertiaryContainer, + ), + headlineContent = { + Text( + "You were most active on ${ + slide.weekday.getDisplayName(TextStyle.FULL, locale) + }s at ${slide.hour} o'clock." + ) + }, + leadingContent = { + val painter = painterResource(R.drawable.wrapped_clock) + + Image( + painter, + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.size(72.dp) + ) + } + ) + } } } diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/common/BottomBar.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/common/BottomBar.kt new file mode 100644 index 0000000..4a1088c --- /dev/null +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/common/BottomBar.kt @@ -0,0 +1,115 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2013-2025 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.common + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Celebration +import androidx.compose.material.icons.outlined.History +import androidx.compose.material.icons.outlined.LocalAtm +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import de.chaosdorf.meteroid.theme.icons.MeteroidIcons +import de.chaosdorf.meteroid.theme.icons.outlined.WaterFull +import de.chaosdorf.meteroid.theme.onPrimaryContainerTinted +import de.chaosdorf.meteroid.ui.MeteroidRoute +import de.chaosdorf.meteroid.viewmodel.NavigationViewModel +import de.chaosdorf.meteroid.viewmodel.Navigator +import kotlinx.coroutines.flow.update + +@Composable +fun BottomBar( + viewModel: NavigationViewModel, +) { + val backStack = viewModel.backStack.collectAsState().value + val currentRoute = backStack.lastOrNull() + + val historyDisabled = viewModel.historyDisabled.collectAsState().value + + AnimatedVisibility( + visible = currentRoute is MeteroidRoute.UserScope, + enter = slideInVertically(initialOffsetY = { it }), + exit = slideOutVertically(targetOffsetY = { it }) + ) { + BottomAppBar( + actions = { + NavigationBarItem( + selected = currentRoute is MeteroidRoute.Purchase, + onClick = { + if (currentRoute is MeteroidRoute.UserScope) { + viewModel.backStack.update { + it.minus(currentRoute) + .plus(MeteroidRoute.Purchase(currentRoute.serverId, currentRoute.userId)) + } + } + }, + label = { Text("Purchase", color = MaterialTheme.colorScheme.onPrimaryContainerTinted) }, + icon = { + Icon(MeteroidIcons.Outlined.WaterFull, contentDescription = null) + }, + ) + NavigationBarItem( + selected = currentRoute is MeteroidRoute.Deposit, + onClick = { + if (currentRoute is MeteroidRoute.UserScope) { + viewModel.backStack.update { + it.minus(currentRoute) + .plus(MeteroidRoute.Deposit(currentRoute.serverId, currentRoute.userId)) + } + } + }, + label = { Text("Deposit", color = MaterialTheme.colorScheme.onPrimaryContainerTinted) }, + icon = { + Icon(Icons.Outlined.LocalAtm, contentDescription = null) + }, + ) + NavigationBarItem( + selected = currentRoute is MeteroidRoute.History, + enabled = !historyDisabled, + onClick = { + if (currentRoute is MeteroidRoute.UserScope) { + viewModel.backStack.update { + it.minus(currentRoute) + .plus(MeteroidRoute.History(currentRoute.serverId, currentRoute.userId)) + } + } + }, + label = { Text("History", color = MaterialTheme.colorScheme.onPrimaryContainerTinted) }, + icon = { + Icon(Icons.Outlined.History, contentDescription = null) + }, + ) + }, + ) + } +} diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/common/PriceBadge.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/common/PriceBadge.kt new file mode 100644 index 0000000..73317da --- /dev/null +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/common/PriceBadge.kt @@ -0,0 +1,61 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2013-2025 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.common + +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 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) + ) + } +} diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/common/TopBar.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/common/TopBar.kt new file mode 100644 index 0000000..0b8f26f --- /dev/null +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/common/TopBar.kt @@ -0,0 +1,315 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2013-2025 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.common + +import android.annotation.SuppressLint +import androidx.activity.compose.BackHandler +import androidx.compose.animation.* +import androidx.compose.animation.core.animateDp +import androidx.compose.animation.core.updateTransition +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.filled.StarOutline +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.TileMode +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties +import de.chaosdorf.meteroid.ui.MeteroidRoute +import de.chaosdorf.meteroid.ui.MeteroidRoute.Purchase +import de.chaosdorf.meteroid.ui.MeteroidRoute.UserList +import de.chaosdorf.meteroid.ui.navigation.* +import de.chaosdorf.meteroid.viewmodel.NavigationElement +import de.chaosdorf.meteroid.viewmodel.NavigationViewModel +import kotlinx.coroutines.flow.update + +@SuppressLint("UnusedTransitionTargetStateParameter") +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun TopBar( + viewModel: NavigationViewModel, +) { + val backStack = viewModel.backStack.collectAsState().value + val currentRoute = backStack.lastOrNull() + + val entries by viewModel.entries.collectAsState() + val currentEntry = entries.firstOrNull { it.isCurrent(currentRoute) } + var open by remember { mutableStateOf(false) } + val transition = updateTransition(open, label = "transition") + val shadowElevation by transition.animateDp { if (it) 4.dp else 0.dp } + + if (currentEntry != null) { + Box( + Modifier.background( + Brush.linearGradient( + listOf( + MaterialTheme.colorScheme.background, + MaterialTheme.colorScheme.background.copy(alpha = 0f), + ), + start = Offset(0.0f, 0.0f), + end = Offset(0.0f, Float.POSITIVE_INFINITY), + tileMode = TileMode.Clamp, + ) + ) + ) { + Surface( + modifier = Modifier + .windowInsetsPadding(WindowInsets.statusBars) + .padding(8.dp), + shape = RoundedCornerShape(26.dp), + shadowElevation = 4.dp - shadowElevation, + tonalElevation = 4.dp, + ) { + when (currentEntry) { + NavigationElement.ServerListElement -> + NavigationServerListItem( + modifier = Modifier + .requiredHeight(56.dp) + .clickable { + open = true + }, + ) + + is NavigationElement.ServerElement -> + NavigationServerItem( + currentEntry.server, + modifier = Modifier + .requiredHeight(56.dp) + .clickable { + open = true + }, + ) + + is NavigationElement.UserElement -> + NavigationUserItem( + currentEntry.user, + modifier = Modifier + .requiredHeight(56.dp) + .clickable { + open = true + }, + ) { + IconButton(onClick = { viewModel.togglePin(currentEntry.user.serverId, currentEntry.user.userId) }) { + Icon( + if (currentEntry.pinned) Icons.Default.Star else Icons.Default.StarOutline, + contentDescription = null + ) + } + } + + is NavigationElement.UserListElement -> + NavigationUserListItem( + modifier = Modifier + .requiredHeight(56.dp) + .clickable { + open = true + }, + ) + + NavigationElement.AddServerElement -> + NavigationAddServerItem( + modifier = Modifier + .requiredHeight(56.dp) + .clickable { + open = true + }, + ) + + NavigationElement.SettingsElement -> + NavigationSettingsItem( + modifier = Modifier + .requiredHeight(56.dp) + .clickable { + open = true + }, + ) + } + } + } + + if (open || transition.currentState || transition.isRunning) { + Popup( + offset = IntOffset( + x = WindowInsets.safeDrawing.getLeft(LocalDensity.current, LocalLayoutDirection.current), + y = WindowInsets.safeDrawing.getTop(LocalDensity.current), + ), + onDismissRequest = { open = false }, + properties = PopupProperties( + usePlatformDefaultWidth = false, + dismissOnClickOutside = true, + dismissOnBackPress = true, + clippingEnabled = false, + ), + ) { + BackHandler { + open = false + } + + val smallItemHeight by transition.animateDp { if (it) 48.dp else 56.dp } + val itemHeight by transition.animateDp { 56.dp } + + Box( + modifier = Modifier + .fillMaxSize() + .clickable(interactionSource = null, indication = null) { + open = false + } + ) { + Surface( + modifier = Modifier.padding(8.dp), + shape = RoundedCornerShape(26.dp), + shadowElevation = shadowElevation, + tonalElevation = 4.dp, + ) { + Column { + for (entry in entries) { + key(entry.key) { + transition.AnimatedVisibility( + visible = { it || currentEntry == entry }, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + when (entry) { + NavigationElement.ServerListElement -> + NavigationServerListItem( + modifier = Modifier + .requiredHeight(itemHeight) + .clickable { + viewModel.backStack.update { + listOf(MeteroidRoute.ServerList) + } + open = false + }, + ) + + is NavigationElement.ServerElement -> + NavigationServerItem( + server = entry.server, + modifier = Modifier + .requiredHeight(itemHeight) + .clickable { + viewModel.backStack.update { + listOf(MeteroidRoute.ServerList, UserList(entry.server.serverId)) + } + open = false + }, + ) + + is NavigationElement.UserElement -> + NavigationUserItem( + user = entry.user, + modifier = Modifier + .requiredHeight(itemHeight) + .clickable { + viewModel.backStack.update { + listOf( + MeteroidRoute.ServerList, + UserList(entry.user.serverId), + Purchase(entry.user.serverId, entry.user.userId) + ) + } + open = false + }, + ) { + transition.AnimatedVisibility( + visible = { !it && currentEntry == entry }, + enter = fadeIn(), + exit = fadeOut(), + ) { + IconButton(onClick = { viewModel.togglePin(entry.user.serverId, entry.user.userId) }) { + Icon( + if (entry.pinned) Icons.Default.Star else Icons.Default.StarOutline, + contentDescription = null + ) + } + } + } + + is NavigationElement.UserListElement -> + NavigationUserListItem( + modifier = Modifier + .requiredHeight(smallItemHeight) + .clickable { + viewModel.backStack.update { + listOf(MeteroidRoute.ServerList, UserList(entry.server.serverId)) + } + open = false + }, + ) + + NavigationElement.AddServerElement -> + NavigationAddServerItem( + modifier = Modifier + .requiredHeight(smallItemHeight) + .clickable { + viewModel.backStack.update { + it.plus(MeteroidRoute.Setup) + } + open = false + }, + ) + + NavigationElement.SettingsElement -> + NavigationSettingsItem( + modifier = Modifier + .requiredHeight(smallItemHeight) + .clickable { + viewModel.backStack.update { + it.plus(MeteroidRoute.Settings) + } + open = false + }, + ) + } + } + if (entry is NavigationElement.UserListElement || entry is NavigationElement.ServerListElement) { + transition.AnimatedVisibility( + visible = { it }, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + HorizontalDivider() + } + } + } + } + } + } + } + } + } + } +} diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/common/UserAvatar.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/common/UserAvatar.kt new file mode 100644 index 0000000..fc6cf02 --- /dev/null +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/common/UserAvatar.kt @@ -0,0 +1,138 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2013-2025 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.common + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Person +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import coil3.compose.AsyncImagePainter +import coil3.compose.rememberAsyncImagePainter +import de.chaosdorf.meteroid.theme.icons.MeteroidIcons +import de.chaosdorf.meteroid.theme.icons.filled.WaterFull + +@Preview +@Composable +fun UserAvatar( + source: String? = null, +) { + var success by remember { mutableStateOf(false) } + + val painter = rememberAsyncImagePainter(source, onState = { + success = when (it) { + AsyncImagePainter.State.Empty -> false + is AsyncImagePainter.State.Error -> false + is AsyncImagePainter.State.Loading -> false + is AsyncImagePainter.State.Success -> true + } + }) + + AvatarLayout( + Modifier + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primaryContainer) + ) { + if (!success) { + Icon( + Icons.Default.Person, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + Image( + painter, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + } +} + +@Preview +@Composable +fun ServerAvatar( + source: String? = null, +) { + var success by remember { mutableStateOf(false) } + + val painter = rememberAsyncImagePainter(source, onState = { + success = when (it) { + AsyncImagePainter.State.Empty -> false + is AsyncImagePainter.State.Error -> false + is AsyncImagePainter.State.Loading -> false + is AsyncImagePainter.State.Success -> it.result.image.size > 0 + } + }) + + AvatarLayout( + Modifier + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primaryContainer) + ) { + if (!success) { + Icon( + MeteroidIcons.Filled.WaterFull, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + Image( + painter, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + } +} + +@Composable +fun AvatarLayout( + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) { + Box( + modifier + .size(36.dp), + contentAlignment = Alignment.Center + ) { + content() + } +} diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/navigation/NavigationAddServerItem.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/navigation/NavigationAddServerItem.kt new file mode 100644 index 0000000..36679ef --- /dev/null +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/navigation/NavigationAddServerItem.kt @@ -0,0 +1,57 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2013-2025 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.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.Icon +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.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import de.chaosdorf.meteroid.ui.common.AvatarLayout + +@Composable +fun NavigationAddServerItem(modifier: Modifier = Modifier) { + Row(modifier.padding(horizontal = 12.dp), verticalAlignment = Alignment.CenterVertically) { + AvatarLayout { + Icon(Icons.Default.Add, contentDescription = null) + } + Spacer(Modifier.width(16.dp)) + Column(Modifier.weight(1f, true)) { + Text( + "Add Server", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } +} diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/navigation/NavigationServerItem.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/navigation/NavigationServerItem.kt new file mode 100644 index 0000000..fde8d7f --- /dev/null +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/navigation/NavigationServerItem.kt @@ -0,0 +1,72 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2013-2025 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.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.IconButton +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.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import de.chaosdorf.meteroid.model.Server +import de.chaosdorf.meteroid.ui.common.ServerAvatar +import de.chaosdorf.meteroid.util.humanReadableHost +import okhttp3.HttpUrl.Companion.toHttpUrl + +@Composable +fun NavigationServerItem(server: Server, modifier: Modifier = Modifier) { + val host = humanReadableHost(server.url.toHttpUrl()) + + Row(modifier.padding(horizontal = 12.dp), verticalAlignment = Alignment.CenterVertically) { + ServerAvatar(server.logoUrl) + Spacer(Modifier.width(16.dp)) + Column(Modifier.weight(1f, true)) { + Text( + server.name ?: host, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + if (server.name != null) { + Text( + host, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/navigation/NavigationServerListItem.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/navigation/NavigationServerListItem.kt new file mode 100644 index 0000000..ee34bb8 --- /dev/null +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/navigation/NavigationServerListItem.kt @@ -0,0 +1,84 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2013-2025 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.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +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.graphics.ImageBitmap +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.imageResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import de.chaosdorf.meteroid.ui.common.ServerAvatar +import de.chaosdorf.meteroid.R +import de.chaosdorf.meteroid.ui.common.AvatarLayout + +@Composable +fun NavigationServerListItem(modifier: Modifier = Modifier) { + val icon = ImageVector.vectorResource(R.drawable.ic_launcher) + + Row(modifier.padding(horizontal = 12.dp), verticalAlignment = Alignment.CenterVertically) { + AvatarLayout( + Modifier + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primaryContainer) + ) { + Image( + icon, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + } + Spacer(Modifier.width(16.dp)) + Column(Modifier.weight(1f, true)) { + Text( + "Meteroid", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } +} diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/navigation/NavigationSettingsItem.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/navigation/NavigationSettingsItem.kt new file mode 100644 index 0000000..b7ba018 --- /dev/null +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/navigation/NavigationSettingsItem.kt @@ -0,0 +1,57 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2013-2025 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.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Icon +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.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import de.chaosdorf.meteroid.ui.common.AvatarLayout + +@Composable +fun NavigationSettingsItem(modifier: Modifier = Modifier) { + Row(modifier.padding(horizontal = 12.dp), verticalAlignment = Alignment.CenterVertically) { + AvatarLayout { + Icon(Icons.Default.Settings, contentDescription = null) + } + Spacer(Modifier.width(16.dp)) + Column(Modifier.weight(1f, true)) { + Text( + "Settings", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } +} diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/navigation/NavigationUserItem.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/navigation/NavigationUserItem.kt new file mode 100644 index 0000000..684450e --- /dev/null +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/navigation/NavigationUserItem.kt @@ -0,0 +1,83 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2013-2025 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.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.filled.StarOutline +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +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.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import de.chaosdorf.mete.model.ServerId +import de.chaosdorf.mete.model.UserId +import de.chaosdorf.meteroid.model.User +import de.chaosdorf.meteroid.ui.common.PriceBadge +import de.chaosdorf.meteroid.ui.common.UserAvatar + +@Composable +fun NavigationUserItem( + user: User, + modifier: Modifier = Modifier, + actions: @Composable () -> Unit = {}, +) { + Row(modifier.padding(horizontal = 12.dp), verticalAlignment = Alignment.CenterVertically) { + UserAvatar(user.gravatarUrl) + Spacer(Modifier.width(16.dp)) + Column(Modifier.weight(1f, true)) { + Text( + user.name, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + if (user.email != null) { + Text( + user.email!!, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Spacer(Modifier.width(16.dp)) + actions() + PriceBadge(user.balance) + } +} diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/navigation/NavigationUserListItem.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/navigation/NavigationUserListItem.kt new file mode 100644 index 0000000..da5f590 --- /dev/null +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/navigation/NavigationUserListItem.kt @@ -0,0 +1,58 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2013-2025 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.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Group +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +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.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import de.chaosdorf.meteroid.ui.common.AvatarLayout + +@Composable +fun NavigationUserListItem(modifier: Modifier = Modifier) { + Row(modifier.padding(horizontal = 12.dp), verticalAlignment = Alignment.CenterVertically) { + AvatarLayout { + Icon(Icons.Default.Group, contentDescription = null) + } + Spacer(Modifier.width(16.dp)) + Column(Modifier.weight(1f, true)) { + Text( + "All Users", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } +} diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/preview/PreviewTile.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/preview/PreviewTile.kt new file mode 100644 index 0000000..00810a6 --- /dev/null +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/preview/PreviewTile.kt @@ -0,0 +1,104 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2013-2025 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.preview + + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +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.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.PreviewDynamicColors +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp +import de.chaosdorf.meteroid.theme.MeteroidTheme +import de.chaosdorf.meteroid.theme.secondaryGradient +import de.chaosdorf.meteroid.ui.common.PriceBadge +import java.util.* + +@PreviewDynamicColors +@PreviewLightDark +@Composable +fun Tile() { + MeteroidTheme(dynamicColor = false) { + val price = 1.5.toBigDecimal() + val volume = 0.5.toBigDecimal() + + Column( + modifier = Modifier + .height(IntrinsicSize.Max) + .width(140.dp) + .background(MaterialTheme.colorScheme.background) + .clip(RoundedCornerShape(8.dp)) + .padding(8.dp) + ) { + Box( + Modifier.aspectRatio(1.0f) + .background(MaterialTheme.colorScheme.secondaryGradient.verticalGradient(), CircleShape), + contentAlignment = Alignment.Center + ) { + PriceBadge( + price, + modifier = Modifier + .align(Alignment.BottomEnd) + .paddingFromBaseline(bottom = 12.dp) + ) + } + Spacer(Modifier.height(4.dp)) + Text( + "Paulaner Spezi", + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + textAlign = TextAlign.Center, + fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(Modifier.height(4.dp)) + Row(modifier = Modifier.align(Alignment.CenterHorizontally)) { + val unitPrice = price / volume + + Text( + String.format(Locale.getDefault(), "%.02fl · %.02f€/l", volume, price / volume), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + textAlign = TextAlign.Center, + fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f), + ) + } + } + } +} diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/util/BundleExtensions.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/util/BundleExtensions.kt deleted file mode 100644 index 902e0c5..0000000 --- a/app/src/main/kotlin/de/chaosdorf/meteroid/util/BundleExtensions.kt +++ /dev/null @@ -1,30 +0,0 @@ -/* - * 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.util - -import android.os.Bundle - -internal fun Bundle.toMap(): Map<String, Any?> = - keySet().associate { @Suppress("DEPRECATION") Pair(it, get(it)) } diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/util/NavBackStackEntryExtensions.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/util/MonetaryAmount.kt similarity index 65% rename from app/src/main/kotlin/de/chaosdorf/meteroid/util/NavBackStackEntryExtensions.kt rename to app/src/main/kotlin/de/chaosdorf/meteroid/util/MonetaryAmount.kt index 64ac766..a2d387b 100644 --- a/app/src/main/kotlin/de/chaosdorf/meteroid/util/NavBackStackEntryExtensions.kt +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/util/MonetaryAmount.kt @@ -1,7 +1,7 @@ /* * The MIT License (MIT) * - * Copyright (c) 2013-2023 Chaosdorf e.V. + * Copyright (c) 2013-2025 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 @@ -24,19 +24,16 @@ package de.chaosdorf.meteroid.util -import androidx.navigation.NavBackStackEntry +import androidx.annotation.DrawableRes +import de.chaosdorf.meteroid.R +import java.math.BigDecimal -internal fun NavBackStackEntry.toFancyString(): String { - val arguments = this.arguments?.toMap().orEmpty().toList() - .filter { (key, _) -> key == "server" || key == "user" } - .joinToString(", ", prefix = "(", postfix = ")") { (key, value) -> "$key=$value" } - - return "${destination.route}$arguments" +enum class MonetaryAmount(val amount: BigDecimal, @DrawableRes val image: Int) { + MONEY_50(0.50.toBigDecimal(), R.drawable.euro_50), + MONEY_100(1.00.toBigDecimal(), R.drawable.euro_100), + MONEY_200(2.00.toBigDecimal(), R.drawable.euro_200), + MONEY_500(5.00.toBigDecimal(), R.drawable.euro_500), + MONEY_1000(10.00.toBigDecimal(), R.drawable.euro_1000), + MONEY_2000(20.00.toBigDecimal(), R.drawable.euro_2000), + MONEY_5000(50.00.toBigDecimal(), R.drawable.euro_5000), } - -internal fun Iterable<NavBackStackEntry>.toFancyString(): String = joinToString( - separator = " › ", - prefix = "[", - postfix = "]", - transform = NavBackStackEntry::toFancyString -) diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/util/PopUpToRoot.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/util/NavBackStackExtensions.kt similarity index 50% rename from app/src/main/kotlin/de/chaosdorf/meteroid/util/PopUpToRoot.kt rename to app/src/main/kotlin/de/chaosdorf/meteroid/util/NavBackStackExtensions.kt index f350dde..f96bb7a 100644 --- a/app/src/main/kotlin/de/chaosdorf/meteroid/util/PopUpToRoot.kt +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/util/NavBackStackExtensions.kt @@ -1,7 +1,7 @@ /* * The MIT License (MIT) * - * Copyright (c) 2013-2023 Chaosdorf e.V. + * Copyright (c) 2013-2025 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 @@ -24,10 +24,32 @@ package de.chaosdorf.meteroid.util -import androidx.navigation.NavController +import android.util.Log +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +import de.chaosdorf.meteroid.ui.MeteroidRoute -fun NavController.popUpToRoot() { - while (popBackStack()) { - // repeat +@PublishedApi +internal fun <T> List<T>.toSnapshotStateList(): SnapshotStateList<T> = + SnapshotStateList<T>().also { it.addAll(this) } + +inline fun NavBackStack.update(vararg elements: NavKey) { + Log.i("Navigator", "Updating backstack: ${this.toList()} -> ${elements.toList()}") + require(elements.isNotEmpty()) { + "Error: backstack cannot be empty" } + prependStateRecord(mutableStateListOf(*elements).firstStateRecord) } + +inline fun NavBackStack.update(elements: List<NavKey>) { + Log.i("Navigator", "Updating backstack: ${this.toList()} -> $elements") + require(elements.isNotEmpty()) { + "Error: backstack cannot be empty" + } + prependStateRecord(elements.toSnapshotStateList().firstStateRecord) +} + +inline fun NavBackStack.update(crossinline f: (List<NavKey>) -> List<NavKey>) = + update(f(this)) diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/util/NavGraphExtensions.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/util/NavGraphExtensions.kt deleted file mode 100644 index bac65da..0000000 --- a/app/src/main/kotlin/de/chaosdorf/meteroid/util/NavGraphExtensions.kt +++ /dev/null @@ -1,35 +0,0 @@ -/* - * 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.util - -import androidx.navigation.NavDestination -import androidx.navigation.NavGraph - -private val NavGraph.startDestination: NavDestination? - get() = findNode(startDestinationId) - -tailrec fun findStartDestination(graph: NavDestination): NavDestination { - return if (graph is NavGraph) findStartDestination(graph.startDestination!!) else graph -} diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/viewmodel/UserViewModel.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/viewmodel/DepositViewModel.kt similarity index 62% rename from app/src/main/kotlin/de/chaosdorf/meteroid/viewmodel/UserViewModel.kt rename to app/src/main/kotlin/de/chaosdorf/meteroid/viewmodel/DepositViewModel.kt index b813133..a73af1a 100644 --- a/app/src/main/kotlin/de/chaosdorf/meteroid/viewmodel/UserViewModel.kt +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/viewmodel/DepositViewModel.kt @@ -25,26 +25,46 @@ package de.chaosdorf.meteroid.viewmodel import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import de.chaosdorf.mete.model.ServerId import de.chaosdorf.mete.model.UserId +import de.chaosdorf.meteroid.sync.AccountProvider +import de.chaosdorf.meteroid.sync.SyncManager +import de.chaosdorf.meteroid.util.MonetaryAmount +import kotlinx.coroutines.launch @AssistedFactory -interface UserViewModelFactory { +interface DepositViewModelFactory { fun create( @Assisted("serverId") serverId: Long, @Assisted("userId") userId: Long, - ): UserViewModel + ): DepositViewModel } -@HiltViewModel(assistedFactory = UserViewModelFactory::class) -class UserViewModel @AssistedInject constructor( - @Assisted("serverId") serverId: Long, - @Assisted("userId") userId: Long, +@HiltViewModel(assistedFactory = DepositViewModelFactory::class) +class DepositViewModel @AssistedInject constructor( + @Assisted("serverId") server: Long, + @Assisted("userId") user: Long, + private val accountProvider: AccountProvider, + private val syncManager: SyncManager, ) : ViewModel() { - val serverId: ServerId = ServerId(serverId) - val userId: UserId = UserId(userId) + val serverId: ServerId = ServerId(server) + val userId: UserId = UserId(user) + + val money: List<MonetaryAmount> = MonetaryAmount.entries + + fun deposit(item: MonetaryAmount, onBack: () -> Unit) { + viewModelScope.launch { + accountProvider.account(serverId, userId)?.let { account -> + syncManager.deposit(account, item.amount) + if (!account.pinned) { + onBack() + } + } + } + } } diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/viewmodel/HistoryViewModel.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/viewmodel/HistoryViewModel.kt new file mode 100644 index 0000000..3767c0c --- /dev/null +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/viewmodel/HistoryViewModel.kt @@ -0,0 +1,122 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2013-2025 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.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import de.chaosdorf.mete.model.ServerId +import de.chaosdorf.mete.model.UserId +import de.chaosdorf.meteroid.model.Drink +import de.chaosdorf.meteroid.model.DrinkRepository +import de.chaosdorf.meteroid.model.Transaction +import de.chaosdorf.meteroid.model.TransactionRepository +import de.chaosdorf.meteroid.sync.AccountProvider +import de.chaosdorf.meteroid.sync.SyncManager +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import java.math.BigDecimal +import kotlin.time.Duration.Companion.minutes + +@AssistedFactory +interface HistoryViewModelFactory { + fun create( + @Assisted("serverId") serverId: Long, + @Assisted("userId") userId: Long, + ): HistoryViewModel +} + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltViewModel(assistedFactory = HistoryViewModelFactory::class) +class HistoryViewModel @AssistedInject constructor( + @Assisted("serverId") server: Long, + @Assisted("userId") user: Long, + private val accountProvider: AccountProvider, + private val syncManager: SyncManager, + repository: TransactionRepository, + drinkRepository: DrinkRepository, +) : ViewModel() { + val serverId: ServerId = ServerId(server) + val userId: UserId = UserId(user) + + val transactions: StateFlow<List<TransactionInfo>> = combine( + repository.getAllFlow(serverId, userId), drinkRepository.getAllFlow(serverId) + ) { transactions, drinks -> + transactions.map { transaction -> + TransactionInfo(transaction, + drinks.firstOrNull { drink -> drink.drinkId == transaction.drinkId }) + } + }.mapLatest { list -> + list.mergeAdjecentDeposits().filter { + it.drink != null || it.transaction.difference.abs() >= 0.01.toBigDecimal() + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList()) + + fun sync() { + viewModelScope.launch { + accountProvider.account(serverId, userId)?.let { account -> + syncManager.sync(account.server, account.user, incremental = true) + } + } + } +} + +fun List<TransactionInfo>.mergeAdjecentDeposits(): List<TransactionInfo> { + val result = mutableListOf<TransactionInfo>() + for (entry in this) { + val previous = result.lastOrNull() + if (previous != null && previous.transaction.difference > BigDecimal.ZERO && + entry.transaction.difference > BigDecimal.ZERO && + previous.drink == null && + entry.drink == null && + entry.transaction.timestamp.minus(previous.transaction.timestamp) < 5.minutes + ) { + result.removeLastOrNull() + result.add( + entry.copy( + transaction = entry.transaction.copy( + difference = entry.transaction.difference + previous.transaction.difference + ) + ) + ) + } else { + result.add(entry) + } + } + return result +} + +data class TransactionInfo( + val transaction: Transaction, + val drink: Drink? +) diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/viewmodel/InitViewModel.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/viewmodel/InitViewModel.kt index 2885bc4..68abd38 100644 --- a/app/src/main/kotlin/de/chaosdorf/meteroid/viewmodel/InitViewModel.kt +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/viewmodel/InitViewModel.kt @@ -29,11 +29,20 @@ import androidx.lifecycle.viewModelScope import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel +import de.chaosdorf.mete.model.ServerId +import de.chaosdorf.meteroid.model.PinnedUserRepository import de.chaosdorf.meteroid.model.ServerRepository +import de.chaosdorf.meteroid.model.UserRepository +import de.chaosdorf.meteroid.storage.AccountPreferences +import de.chaosdorf.meteroid.sync.AccountProvider import de.chaosdorf.meteroid.sync.SyncManager +import de.chaosdorf.meteroid.ui.MeteroidRoute +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -48,13 +57,28 @@ interface InitViewModelFactory { class InitViewModel @AssistedInject constructor( serverRepository: ServerRepository, syncManager: SyncManager, + private val preferences: AccountPreferences, ) : ViewModel() { - val setupComplete = serverRepository.getAllFlow() - .mapLatest { it.isNotEmpty() } - .stateIn(viewModelScope, WhileSubscribed(), null) + val initialRoute = MutableStateFlow<List<MeteroidRoute>?>(null) init { - viewModelScope.launch { + viewModelScope.launch(Dispatchers.IO) { + val initData = preferences.state.first() + val hasServers = serverRepository.exists() + initialRoute.value = buildList { + if (hasServers) { + add(MeteroidRoute.ServerList) + if (initData.server != null) { + add(MeteroidRoute.UserList(initData.server)) + if (initData.user != null) { + add(MeteroidRoute.Purchase(initData.server, initData.user)) + } + } + } else { + add(MeteroidRoute.Setup) + } + } + serverRepository.getAllFlow().collectLatest { list -> for (server in list) { syncManager.sync(server, null, incremental = true) diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/Routes.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/viewmodel/MeteroidRoute.kt similarity index 86% rename from app/src/main/kotlin/de/chaosdorf/meteroid/ui/Routes.kt rename to app/src/main/kotlin/de/chaosdorf/meteroid/viewmodel/MeteroidRoute.kt index dacce6e..5802ae7 100644 --- a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/Routes.kt +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/viewmodel/MeteroidRoute.kt @@ -29,7 +29,7 @@ import de.chaosdorf.mete.model.ServerId import de.chaosdorf.mete.model.UserId import kotlinx.serialization.Serializable -sealed interface Routes : NavKey { +sealed interface MeteroidRoute : NavKey { interface ServerScope { val serverId: ServerId } @@ -39,40 +39,40 @@ sealed interface Routes : NavKey { } @Serializable - data object Setup : Routes + data object Setup : MeteroidRoute @Serializable - data object Settings : Routes + data object Settings : MeteroidRoute @Serializable - data object ServerList : Routes + data object ServerList : MeteroidRoute @Serializable data class UserList( override val serverId: ServerId - ) : Routes, ServerScope + ) : MeteroidRoute, ServerScope @Serializable data class Purchase( override val serverId: ServerId, override val userId: UserId - ) : Routes, UserScope + ) : MeteroidRoute, UserScope @Serializable data class Deposit( override val serverId: ServerId, override val userId: UserId - ) : Routes, UserScope + ) : MeteroidRoute, UserScope @Serializable data class History( override val serverId: ServerId, override val userId: UserId - ) : Routes, UserScope + ) : MeteroidRoute, UserScope @Serializable data class Wrapped( override val serverId: ServerId, override val userId: UserId - ) : Routes, UserScope + ) : MeteroidRoute, UserScope } diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/viewmodel/NavigationViewModel.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/viewmodel/NavigationViewModel.kt new file mode 100644 index 0000000..2a4e7b3 --- /dev/null +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/viewmodel/NavigationViewModel.kt @@ -0,0 +1,204 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2013-2025 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.viewmodel + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.navigation3.runtime.NavKey +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import de.chaosdorf.mete.model.ServerId +import de.chaosdorf.mete.model.UserId +import de.chaosdorf.meteroid.model.* +import de.chaosdorf.meteroid.storage.AccountPreferences +import de.chaosdorf.meteroid.sync.AccountProvider +import de.chaosdorf.meteroid.sync.SyncManager +import de.chaosdorf.meteroid.sync.base.SyncHandler +import de.chaosdorf.meteroid.ui.MeteroidRoute +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch + +interface Navigator { + val backStack: MutableStateFlow<List<MeteroidRoute>> +} + +@AssistedFactory +interface NavigationViewModelFactory { + fun create(initialRoute: List<MeteroidRoute>): NavigationViewModel +} + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltViewModel(assistedFactory = NavigationViewModelFactory::class) +class NavigationViewModel @AssistedInject constructor( + @Assisted initialRoute: List<MeteroidRoute>, + serverRepository: ServerRepository, + userRepository: UserRepository, + pinnedUserRepository: PinnedUserRepository, + syncManager: SyncManager, + private val accountProvider: AccountProvider, + private val preferences: AccountPreferences +) : ViewModel(), Navigator { + override val backStack = MutableStateFlow(initialRoute) + val currentRoute = backStack.mapLatest { it.last() } + .stateIn(viewModelScope, SharingStarted.Eagerly, backStack.value.last()) + + init { + viewModelScope.launch { + backStack.collectLatest { + it.filterIsInstance<MeteroidRoute.UserScope>().lastOrNull()?.let { scope -> + preferences.setUser(scope.serverId, scope.userId) + } ?: it.filterIsInstance<MeteroidRoute.ServerScope>().lastOrNull()?.let { scope -> + preferences.setServer(scope.serverId) + } ?: preferences.setServer(null) + } + } + } + + val account = MutableStateFlow<AccountPreferences.State?>(null) + + val syncState = syncManager.syncState + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), SyncHandler.State.Idle) + + private val servers: StateFlow<List<Server>> = serverRepository.getAllFlow() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList()) + + private val pinnedUsers: StateFlow<List<User>> = pinnedUserRepository.getPinnedUsersFlow() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList()) + + private val currentUser: StateFlow<User?> = + currentRoute.flatMapLatest { + if (it !is MeteroidRoute.UserScope) flowOf(null) + else userRepository.getFlow(it.serverId, it.userId) + }.stateIn(viewModelScope, SharingStarted.Eagerly, null) + + val historyDisabled = currentUser.map { + it?.audit == false + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false) + + val entries: StateFlow<List<NavigationElement>> = combine( + servers, + pinnedUsers, + currentUser, + ) { servers, pinnedUsers, currentUser -> + val entries = mutableListOf<NavigationElement>() + val userInList = currentUser != null && pinnedUsers.any { + it.serverId == currentUser.serverId && it.userId == currentUser.userId + } + entries.add(NavigationElement.ServerListElement) + + for (server in servers) { + entries.add(NavigationElement.ServerElement(server)) + if (currentUser != null && currentUser.serverId == server.serverId && !userInList) { + entries.add(NavigationElement.UserElement(currentUser, pinned = false)) + } + for (user in pinnedUsers) { + if (user.serverId == server.serverId) { + entries.add(NavigationElement.UserElement(user, pinned = true)) + } + } + entries.add(NavigationElement.UserListElement(server)) + } + + entries.add(NavigationElement.AddServerElement) + entries.add(NavigationElement.SettingsElement) + + entries + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList()) + + init { + viewModelScope.launch { + account.collectLatest { account -> + val serverId = account?.server + val userId = account?.user + if (serverId != null && userId != null) { + val server = serverRepository.get(serverId) + val user = userRepository.get(serverId, userId) + if (server != null && user != null) { + syncManager.sync(server, user, incremental = true) + } + } + } + } + } + + fun togglePin(serverId: ServerId, userId: UserId) { + viewModelScope.launch { + accountProvider.togglePin(serverId, userId) + } + } +} + +sealed class NavigationElement(val key: String) { + abstract fun isCurrent(route: NavKey?): Boolean + + data object ServerListElement : NavigationElement("navigation-serverList") { + override fun isCurrent(route: NavKey?): Boolean { + return route == MeteroidRoute.ServerList + } + } + + data class ServerElement( + val server: Server, + ) : NavigationElement("navigation-${server.serverId}") { + + override fun isCurrent(route: NavKey?): Boolean { + return route is MeteroidRoute.UserList && route.serverId == server.serverId + } + } + + data class UserElement( + val user: User, + val pinned: Boolean, + ) : NavigationElement("navigation-${user.serverId}-${user.userId}-${if (pinned) "pinned" else "current"}") { + override fun isCurrent(route: NavKey?): Boolean { + return if (route is MeteroidRoute.UserScope) route.serverId == user.serverId && route.userId == user.userId + else false + } + } + + data class UserListElement( + val server: Server, + ) : NavigationElement("navigation-${server.serverId}-userList") { + override fun isCurrent(route: NavKey?): Boolean = false + } + + data object AddServerElement : NavigationElement("navigation-addServer") { + + override fun isCurrent(route: NavKey?): Boolean { + return route == MeteroidRoute.Setup + } + } + + data object SettingsElement : NavigationElement("navigation-settings") { + + override fun isCurrent(route: NavKey?): Boolean { + return route == MeteroidRoute.Settings + } + } +} diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/viewmodel/PurchaseViewModel.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/viewmodel/PurchaseViewModel.kt new file mode 100644 index 0000000..270ecb3 --- /dev/null +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/viewmodel/PurchaseViewModel.kt @@ -0,0 +1,119 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2013-2025 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.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import de.chaosdorf.mete.model.ServerId +import de.chaosdorf.mete.model.UserId +import de.chaosdorf.meteroid.model.Drink +import de.chaosdorf.meteroid.model.DrinkRepository +import de.chaosdorf.meteroid.sync.AccountProvider +import de.chaosdorf.meteroid.sync.SyncManager +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +@AssistedFactory +interface PurchaseViewModelFactory { + fun create( + @Assisted("serverId") serverId: Long, + @Assisted("userId") userId: Long, + ): PurchaseViewModel +} + +@HiltViewModel(assistedFactory = PurchaseViewModelFactory::class) +class PurchaseViewModel @AssistedInject constructor( + @Assisted("serverId") server: Long, + @Assisted("userId") user: Long, + private val accountProvider: AccountProvider, + private val syncManager: SyncManager, + drinkRepository: DrinkRepository, +) : ViewModel() { + val serverId: ServerId = ServerId(server) + val userId: UserId = UserId(user) + + val filters/*: StateFlow<Set<Filter>>*/ = MutableStateFlow(setOf(Filter.Active)) + // savedStateHandle.getStateFlow("filters", setOf(Filter.Active)) + + val drinks: StateFlow<List<Drink>> = combine( + drinkRepository.getAllFlow(serverId), + filters + ) { drinks, filters -> + drinks.filter { item -> + filters.all { filter -> filter.matches(item) } + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList()) + + fun toggleFilter(filter: Filter) { + filters.update { + if (it.contains(filter)) it - filter + else it + filter + } + /* + savedStateHandle.update<Set<Filter>>("filters", emptySet()) { filters -> + if (filters.contains(filter)) filters - filter + else filters + filter + } + */ + } + + fun purchase(item: Drink, count: Int, onBack: () -> Unit) { + viewModelScope.launch { + accountProvider.account(serverId, userId)?.let { account -> + syncManager.purchase(account, item, count) + if (!account.pinned) { + onBack() + } + } + } + } + + fun sync() { + viewModelScope.launch { + accountProvider.account(serverId, userId)?.let { account -> + syncManager.sync(account.server, account.user, incremental = true) + } + } + } + + enum class Filter { + CaffeineFree, + Active; + + fun matches(item: Drink) = when (this) { + CaffeineFree -> item.caffeine == 0 + Active -> item.active + } + } +} diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/viewmodel/ServerListViewModel.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/viewmodel/ServerListViewModel.kt index c42af5e..dccac6f 100644 --- a/app/src/main/kotlin/de/chaosdorf/meteroid/viewmodel/ServerListViewModel.kt +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/viewmodel/ServerListViewModel.kt @@ -29,9 +29,16 @@ import androidx.lifecycle.viewModelScope import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel +import de.chaosdorf.mete.model.ServerId +import de.chaosdorf.meteroid.model.DrinkRepository +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 kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch @AssistedFactory interface ServerListViewModelFactory { @@ -40,8 +47,22 @@ interface ServerListViewModelFactory { @HiltViewModel(assistedFactory = ServerListViewModelFactory::class) class ServerListViewModel @AssistedInject constructor( - serverRepository: ServerRepository, + private val serverRepository: ServerRepository, + private val userRepository: UserRepository, + private val drinkRepository: DrinkRepository, + private val transactionRepository: TransactionRepository, + private val pinnedUserRepository: PinnedUserRepository, ) : ViewModel() { val servers = serverRepository.getAllFlow() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList()) + + fun deleteServer(serverId: ServerId) { + viewModelScope.launch(Dispatchers.IO) { + pinnedUserRepository.deleteAll(serverId) + transactionRepository.deleteAll(serverId) + userRepository.deleteAll(serverId) + drinkRepository.deleteAll(serverId) + serverRepository.delete(serverId) + } + } } diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/viewmodel/SetupViewModel.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/viewmodel/SetupViewModel.kt index 51bf79b..f232d62 100644 --- a/app/src/main/kotlin/de/chaosdorf/meteroid/viewmodel/SetupViewModel.kt +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/viewmodel/SetupViewModel.kt @@ -25,6 +25,7 @@ package de.chaosdorf.meteroid.viewmodel import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel @@ -34,20 +35,35 @@ import de.chaosdorf.meteroid.model.Server import de.chaosdorf.meteroid.model.ServerRepository import de.chaosdorf.meteroid.sync.SyncManager import de.chaosdorf.meteroid.util.newServer +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.stateIn +import kotlin.time.Duration.Companion.milliseconds @AssistedFactory interface SetupViewModelFactory { fun create(): SetupViewModel } +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) @HiltViewModel(assistedFactory = SetupViewModelFactory::class) class SetupViewModel @AssistedInject constructor( private val apiFactory: MeteApiFactory, private val serverRepository: ServerRepository, private val syncManager: SyncManager, ) : ViewModel() { - val serverUrl = MutableStateFlow("http://192.168.1.41:8080") + val serverUrl = MutableStateFlow("") + + val server: StateFlow<Server?> = serverUrl + .debounce(300.milliseconds) + .mapLatest { apiFactory.newServer(ServerId(0), it) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) + val error = MutableStateFlow<String?>(null) suspend fun add(): Server? { diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/viewmodel/UserListViewModel.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/viewmodel/UserListViewModel.kt index 42bb4c4..029efd4 100644 --- a/app/src/main/kotlin/de/chaosdorf/meteroid/viewmodel/UserListViewModel.kt +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/viewmodel/UserListViewModel.kt @@ -31,8 +31,13 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import de.chaosdorf.mete.model.ServerId +import de.chaosdorf.meteroid.model.PinnedUserRepository +import de.chaosdorf.meteroid.model.User import de.chaosdorf.meteroid.model.UserRepository +import de.chaosdorf.meteroid.storage.AccountPreferences import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn @@ -45,11 +50,20 @@ interface UserListViewModelFactory { @HiltViewModel(assistedFactory = UserListViewModelFactory::class) class UserListViewModel @AssistedInject constructor( - @Assisted("serverId") serverId: Long, + @Assisted("serverId") server: Long, userRepository: UserRepository, + pinnedUserRepository: PinnedUserRepository, + private val preferences: AccountPreferences, ) : ViewModel() { - val serverId: ServerId = ServerId(serverId) + val serverId: ServerId = ServerId(server) - val users = userRepository.getAllFlow(this.serverId) - .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + val users: StateFlow<List<User>> = userRepository.getAllFlow(serverId) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList()) + + val pinnedUsers = combine( + userRepository.getAllFlow(serverId), + pinnedUserRepository.getAllFlow(serverId) + ) { users, pinned -> + users.filter { user -> pinned.contains(user.userId) } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList()) } diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/viewmodel/WrappedViewModel.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/viewmodel/WrappedViewModel.kt index b1e18fa..fdc4c4d 100644 --- a/app/src/main/kotlin/de/chaosdorf/meteroid/viewmodel/WrappedViewModel.kt +++ b/app/src/main/kotlin/de/chaosdorf/meteroid/viewmodel/WrappedViewModel.kt @@ -25,12 +25,29 @@ package de.chaosdorf.meteroid.viewmodel import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel +import de.chaosdorf.mete.model.DrinkId import de.chaosdorf.mete.model.ServerId import de.chaosdorf.mete.model.UserId +import de.chaosdorf.meteroid.model.Drink +import de.chaosdorf.meteroid.model.DrinkRepository +import de.chaosdorf.meteroid.model.Transaction +import de.chaosdorf.meteroid.model.TransactionRepository +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.Month +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toInstant +import kotlinx.datetime.toLocalDateTime +import kotlinx.datetime.Clock @AssistedFactory interface WrappedViewModelFactory { @@ -42,9 +59,128 @@ interface WrappedViewModelFactory { @HiltViewModel(assistedFactory = WrappedViewModelFactory::class) class WrappedViewModel @AssistedInject constructor( - @Assisted("serverId") serverId: Long, - @Assisted("userId") userId: Long, + @Assisted("serverId") server: Long, + @Assisted("userId") user: Long, + repository: TransactionRepository, + drinkRepository: DrinkRepository, ) : ViewModel() { - val serverId: ServerId = ServerId(serverId) - val userId: UserId = UserId(userId) + val serverId: ServerId = ServerId(server) + val userId: UserId = UserId(user) + + private fun List<Transaction>.filterAudits(year: Int): List<Transaction> { + val yearBegin = LocalDateTime(year, Month.JANUARY, 1, 0, 0, 0) + .toInstant(TimeZone.UTC) + val yearEnd = LocalDateTime(year, Month.DECEMBER, 31, 23, 59, 59) + .toInstant(TimeZone.UTC) + return this.filter { + it.timestamp in yearBegin..yearEnd + } + } + + val slides: StateFlow<List<WrappedSlide>> = combine( + repository.getAllFlow(serverId, userId), + drinkRepository.getAllFlow(serverId) + ) { transactions, drinks -> + val drinkMap: Map<DrinkId, Drink> = drinks.associateBy(Drink::drinkId) + val factories = listOf( + WrappedSlide.MostBoughtDrink, + WrappedSlide.Caffeine, + WrappedSlide.MostActive + ) + val now = Clock.System.now().toLocalDateTime(TimeZone.UTC) + val content = transactions.filterAudits(now.year) + factories.mapNotNull { it.create(content, drinkMap) } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList()) +} + + +sealed class WrappedSlide { + interface Factory { + fun create( + transactions: List<Transaction>, + drinks: Map<DrinkId, Drink> + ): WrappedSlide? + } + + data class MostBoughtDrink( + val drink: Drink, + val count: Int, + ) : WrappedSlide() { + companion object : Factory { + override fun create( + transactions: List<Transaction>, + drinks: Map<DrinkId, Drink> + ): WrappedSlide? = transactions + .mapNotNull { drinks[it.drinkId] } + .groupingBy { it } + .eachCount() + .maxByOrNull { it.value } + ?.let { (mostBoughtDrink, count) -> + MostBoughtDrink(mostBoughtDrink, count) + } + } + } + + data class Caffeine( + val total: Double, + val wouldKill: Animal?, + ) : WrappedSlide() { + enum class Animal(val lethalDosage: Double) { + Hamster(0.25), + Squirrel(0.3), + Rat(0.4), + GuineaPig(0.9), + Lemur(2.5), + Cat(5.0), + Koala(9.0), + Coyote(13.0), + Lynx(23.0), + Capybara(55.0), + Jaguar(81.0), + Reindeer(101.0), + Gorilla(140.0), + Lion(175.0), + Bear(278.0), + Moose(368.0), + Bison(540.0) + } + + companion object : Factory { + override fun create( + transactions: List<Transaction>, + drinks: Map<DrinkId, Drink> + ): WrappedSlide = transactions + .mapNotNull { drinks[it.drinkId] } + .mapNotNull { drink -> drink.caffeine?.let { it * drink.volume.toDouble() * 10 } } + .sum() + .let { dosage -> + Caffeine( + dosage, + Animal.entries + .sortedBy(Animal::lethalDosage) + .lastOrNull { it.lethalDosage < dosage } + ) + } + } + } + + data class MostActive( + val weekday: DayOfWeek, + val hour: Int, + ) : WrappedSlide() { + companion object : Factory { + override fun create( + transactions: List<Transaction>, + drinks: Map<DrinkId, Drink> + ): WrappedSlide? = transactions + .map { it.timestamp.toLocalDateTime(TimeZone.currentSystemDefault()) } + .groupingBy { Pair(it.dayOfWeek, it.hour) } + .eachCount() + .maxByOrNull { it.value } + ?.key + ?.let { (dayOfWeek, hour) -> + MostActive(dayOfWeek, hour) + } + } + } } diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 3fcee76..076d579 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -6,16 +6,10 @@ <item name="android:colorAccent">#3a3f44</item> <item name="android:statusBarColor">@android:color/transparent</item> <item name="android:navigationBarColor">@android:color/transparent</item> - <item name="android:dialogTheme">@style/Theme.DialogFullScreen</item> </style> <style name="Theme.Material.DayNight.NoActionBar" parent="@android:style/Theme.Material.Light.NoActionBar"/> - <style name="Theme.DialogFullScreen" parent="Theme.Material.DayNight.NoActionBar"> - <item name="android:windowMinWidthMajor">100%</item> - <item name="android:windowMinWidthMinor">100%</item> - </style> - <style name="Theme.Meteroid.SplashScreen" parent="Theme.SplashScreen"> <item name="windowSplashScreenBackground">@color/ic_launcher_background</item> <item name="windowSplashScreenAnimatedIcon">@drawable/ic_splash</item> diff --git a/persistence/src/main/kotlin/de/chaosdorf/meteroid/model/Server.kt b/persistence/src/main/kotlin/de/chaosdorf/meteroid/model/Server.kt index 5537758..1c21793 100644 --- a/persistence/src/main/kotlin/de/chaosdorf/meteroid/model/Server.kt +++ b/persistence/src/main/kotlin/de/chaosdorf/meteroid/model/Server.kt @@ -45,8 +45,11 @@ interface ServerRepository { @Query("SELECT * FROM Server WHERE serverId = :id LIMIT 1") fun getFlow(id: ServerId): Flow<Server?> - @Query("SELECT count(*) FROM Server") - fun countFlow(): Flow<Int> + @Query("SELECT EXISTS(SELECT 1 FROM Server LIMIT 1)") + fun exists(): Boolean + + @Query("SELECT EXISTS(SELECT 1 FROM Server LIMIT 1)") + fun existsFlow(): Flow<Boolean> @Query("SELECT * FROM Server") suspend fun getAll(): List<Server> -- GitLab