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