From 18e00af3ed8fce1296431a78f8cf6ce2cf932fd0 Mon Sep 17 00:00:00 2001
From: Janne Mareike Koschinski <mail@justjanne.de>
Date: Tue, 10 Jun 2025 04:57:47 +0200
Subject: [PATCH] refactor: navigation

---
 app/build.gradle.kts                          |   6 +-
 .../de/chaosdorf/meteroid/MainActivity.kt     | 177 ++----------------
 ...vBackStackExtensions.kt => MeteroidApp.kt} |  53 +++---
 .../de/chaosdorf/meteroid/MeteroidRouter.kt   | 138 ++++++++++++++
 .../de/chaosdorf/meteroid/sync/SyncManager.kt |   2 +-
 .../de/chaosdorf/meteroid/ui/SetupRoute.kt    |  30 +--
 .../meteroid/ui/common/ErrorBanner.kt         |  71 +++++++
 .../ui/navigation/NavigationUserListItem.kt   |   1 -
 .../ui/navigation/PersistentNavigation.kt     |   1 -
 .../meteroid/util/MeteApiFactoryExtensions.kt |  10 +-
 .../meteroid/viewmodel/NavigationViewModel.kt |   3 -
 .../meteroid/viewmodel/SetupViewModel.kt      |  37 ++--
 gradle/libs.versions.toml                     |  22 +--
 .../1.json                                    |  34 ++--
 .../de/chaosdorf/meteroid/model/Server.kt     |   4 +-
 15 files changed, 313 insertions(+), 276 deletions(-)
 rename app/src/main/kotlin/de/chaosdorf/meteroid/{util/NavBackStackExtensions.kt => MeteroidApp.kt} (50%)
 create mode 100644 app/src/main/kotlin/de/chaosdorf/meteroid/MeteroidRouter.kt
 create mode 100644 app/src/main/kotlin/de/chaosdorf/meteroid/ui/common/ErrorBanner.kt

diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index fc70942..25cde5c 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -68,6 +68,10 @@ dependencies {
   testImplementation(libs.junit.params)
   testRuntimeOnly(libs.junit.engine)
 
+  implementation(platform(libs.androidx.compose.bom))
+  testImplementation(platform(libs.androidx.compose.bom))
+  androidTestImplementation(platform(libs.androidx.compose.bom))
+
   implementation(libs.androidx.appcompat)
   implementation(libs.androidx.appcompat.resources)
 
@@ -75,9 +79,7 @@ dependencies {
   implementation(libs.androidx.activity.compose)
 
   implementation(libs.androidx.splashscreen)
-
   implementation(libs.androidx.compose.animation)
-  implementation(libs.androidx.compose.compiler)
   implementation(libs.androidx.compose.foundation)
   implementation(libs.androidx.compose.material)
   implementation(libs.androidx.compose.material.icons)
diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/MainActivity.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/MainActivity.kt
index 15ee94a..aa51d65 100644
--- a/app/src/main/kotlin/de/chaosdorf/meteroid/MainActivity.kt
+++ b/app/src/main/kotlin/de/chaosdorf/meteroid/MainActivity.kt
@@ -28,184 +28,31 @@ import android.os.Bundle
 import androidx.activity.ComponentActivity
 import androidx.activity.compose.setContent
 import androidx.activity.viewModels
-import androidx.compose.animation.ContentTransform
-import androidx.compose.animation.core.snap
-import androidx.compose.animation.fadeIn
-import androidx.compose.animation.fadeOut
-import androidx.compose.material3.Scaffold
-import androidx.compose.runtime.collectAsState
-import androidx.compose.runtime.getValue
 import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
-import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
-import androidx.navigation3.runtime.*
-import androidx.navigation3.ui.NavDisplay
-import androidx.navigation3.ui.rememberSceneSetupNavEntryDecorator
 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.navigation.TopNavigation
-import de.chaosdorf.meteroid.ui.purchase.PurchaseRoute
-import de.chaosdorf.meteroid.viewmodel.NavigationViewModel
-import de.chaosdorf.meteroid.viewmodel.*
-import kotlinx.coroutines.flow.update
+import de.chaosdorf.meteroid.viewmodel.InitViewModel
+import de.chaosdorf.meteroid.viewmodel.InitViewModelFactory
 
 @AndroidEntryPoint
 class MainActivity : ComponentActivity() {
+  val initViewModel by viewModels<InitViewModel>(
+    extrasProducer = {
+      defaultViewModelCreationExtras.withCreationCallback<InitViewModelFactory> { factory ->
+        factory.create()
+      }
+    }
+  )
+
   override fun onCreate(savedInstanceState: Bundle?) {
     super.onCreate(savedInstanceState)
 
-    val viewModel by viewModels<InitViewModel>(
-      extrasProducer = {
-        defaultViewModelCreationExtras.withCreationCallback<InitViewModelFactory> { factory ->
-          factory.create()
-        }
-      }
-    )
-
     installSplashScreen().setKeepOnScreenCondition {
-      viewModel.initialRoute.value == null
+      initViewModel.initialRoute.value == null
     }
 
     setContent {
-      val initialRoute = viewModel.initialRoute.collectAsState().value
-
-      MeteroidTheme {
-        if (initialRoute != null) {
-          val navigationViewModel by viewModels<NavigationViewModel>(
-            extrasProducer = {
-              defaultViewModelCreationExtras.withCreationCallback<NavigationViewModelFactory> { factory ->
-                factory.create(initialRoute)
-              }
-            }
-          )
-
-          val backStack by navigationViewModel.backStack.collectAsState()
-
-          Scaffold(
-            topBar = { TopNavigation(navigationViewModel) },
-            bottomBar = { BottomBar(navigationViewModel) }
-          ) { paddingValues ->
-            NavDisplay(
-              backStack = backStack,
-              onBack = { count ->
-                navigationViewModel.backStack.update {
-                  it.dropLast(count)
-                }
-              },
-              entryDecorators = listOf(
-                rememberSceneSetupNavEntryDecorator(),
-                rememberSavedStateNavEntryDecorator(),
-                rememberViewModelStoreNavEntryDecorator(),
-              ),
-              // BEGIN WORKAROUND
-              // TODO FIXED IN navigation3 1.0.0-alpha04 Jun 18
-              transitionSpec = {
-                ContentTransform(
-                  fadeIn(animationSpec = snap()),
-                  fadeOut(animationSpec = snap())
-                )
-              },
-              popTransitionSpec = {
-                ContentTransform(
-                  fadeIn(animationSpec = snap()),
-                  fadeOut(animationSpec = snap())
-                )
-              },
-              predictivePopTransitionSpec = {
-                ContentTransform(
-                  fadeIn(animationSpec = snap()),
-                  fadeOut(animationSpec = snap())
-                )
-              },
-              // END
-              entryProvider = entryProvider {
-                entry<MeteroidRoute.Setup> {
-                  val viewModel by viewModels<SetupViewModel>(
-                    extrasProducer = {
-                      defaultViewModelCreationExtras.withCreationCallback<SetupViewModelFactory> { factory ->
-                        factory.create()
-                      }
-                    }
-                  )
-                  SetupRoute(viewModel, navigationViewModel, paddingValues)
-                }
-                entry<MeteroidRoute.Settings> {
-                  val viewModel by viewModels<SettingsViewModel>(
-                    extrasProducer = {
-                      defaultViewModelCreationExtras.withCreationCallback<SettingsViewModelFactory> { factory ->
-                        factory.create()
-                      }
-                    }
-                  )
-                  SettingsRoute(viewModel, navigationViewModel, paddingValues)
-                }
-                entry<MeteroidRoute.ServerList> {
-                  val viewModel by viewModels<ServerListViewModel>(
-                    extrasProducer = {
-                      defaultViewModelCreationExtras.withCreationCallback<ServerListViewModelFactory> { factory ->
-                        factory.create()
-                      }
-                    }
-                  )
-                  ServerListRoute(viewModel, navigationViewModel, paddingValues)
-                }
-                entry<MeteroidRoute.UserList> {
-                  val viewModel by viewModels<UserListViewModel>(
-                    extrasProducer = {
-                      defaultViewModelCreationExtras.withCreationCallback<UserListViewModelFactory> { factory ->
-                        factory.create(it.serverId.value)
-                      }
-                    }
-                  )
-                  UserListRoute(viewModel, navigationViewModel, paddingValues)
-                }
-                entry<MeteroidRoute.Purchase> {
-                  val viewModel by viewModels<PurchaseViewModel>(
-                    extrasProducer = {
-                      defaultViewModelCreationExtras.withCreationCallback<PurchaseViewModelFactory> { factory ->
-                        factory.create(it.serverId.value, it.userId.value)
-                      }
-                    }
-                  )
-                  PurchaseRoute(viewModel, navigationViewModel, paddingValues)
-                }
-                entry<MeteroidRoute.Deposit> {
-                  val viewModel by viewModels<DepositViewModel>(
-                    extrasProducer = {
-                      defaultViewModelCreationExtras.withCreationCallback<DepositViewModelFactory> { factory ->
-                        factory.create(it.serverId.value, it.userId.value)
-                      }
-                    }
-                  )
-                  DepositRoute(viewModel, navigationViewModel, paddingValues)
-                }
-                entry<MeteroidRoute.History> {
-                  val viewModel by viewModels<HistoryViewModel>(
-                    extrasProducer = {
-                      defaultViewModelCreationExtras.withCreationCallback<HistoryViewModelFactory> { factory ->
-                        factory.create(it.serverId.value, it.userId.value)
-                      }
-                    }
-                  )
-                  HistoryRoute(viewModel, navigationViewModel, paddingValues)
-                }
-                entry<MeteroidRoute.Wrapped> {
-                  val viewModel by viewModels<WrappedViewModel>(
-                    extrasProducer = {
-                      defaultViewModelCreationExtras.withCreationCallback<WrappedViewModelFactory> { factory ->
-                        factory.create(it.serverId.value, it.userId.value)
-                      }
-                    }
-                  )
-                  WrappedRoute(viewModel, navigationViewModel, paddingValues)
-                }
-              }
-            )
-          }
-        }
-      }
+      MeteroidApp(initViewModel)
     }
   }
 }
diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/util/NavBackStackExtensions.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/MeteroidApp.kt
similarity index 50%
rename from app/src/main/kotlin/de/chaosdorf/meteroid/util/NavBackStackExtensions.kt
rename to app/src/main/kotlin/de/chaosdorf/meteroid/MeteroidApp.kt
index f96bb7a..d516e1d 100644
--- a/app/src/main/kotlin/de/chaosdorf/meteroid/util/NavBackStackExtensions.kt
+++ b/app/src/main/kotlin/de/chaosdorf/meteroid/MeteroidApp.kt
@@ -22,34 +22,37 @@
  * THE SOFTWARE.
  */
 
-package de.chaosdorf.meteroid.util
+package de.chaosdorf.meteroid
 
-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
+import androidx.compose.material3.Scaffold
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.hilt.navigation.compose.hiltViewModel
+import de.chaosdorf.meteroid.theme.MeteroidTheme
+import de.chaosdorf.meteroid.ui.common.BottomBar
+import de.chaosdorf.meteroid.ui.navigation.TopNavigation
+import de.chaosdorf.meteroid.viewmodel.InitViewModel
+import de.chaosdorf.meteroid.viewmodel.NavigationViewModel
+import de.chaosdorf.meteroid.viewmodel.NavigationViewModelFactory
 
-@PublishedApi
-internal fun <T> List<T>.toSnapshotStateList(): SnapshotStateList<T> =
-  SnapshotStateList<T>().also { it.addAll(this) }
+@Composable
+fun MeteroidApp(
+  initViewModel: InitViewModel,
+) {
+  val initialRoute = initViewModel.initialRoute.collectAsState().value
 
-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)
-}
+  MeteroidTheme {
+    if (initialRoute != null) {
+      val navigationViewModel = hiltViewModel<NavigationViewModel, NavigationViewModelFactory> { factory ->
+        factory.create(initialRoute)
+      }
 
-inline fun NavBackStack.update(elements: List<NavKey>) {
-  Log.i("Navigator", "Updating backstack: ${this.toList()} -> $elements")
-  require(elements.isNotEmpty()) {
-    "Error: backstack cannot be empty"
+      Scaffold(
+        topBar = { TopNavigation(navigationViewModel) },
+        bottomBar = { BottomBar(navigationViewModel) },
+      ) { paddingValues ->
+        MeteroidRouter(navigationViewModel, paddingValues)
+      }
+    }
   }
-  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/MeteroidRouter.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/MeteroidRouter.kt
new file mode 100644
index 0000000..f4890d2
--- /dev/null
+++ b/app/src/main/kotlin/de/chaosdorf/meteroid/MeteroidRouter.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
+
+import androidx.compose.animation.ContentTransform
+import androidx.compose.animation.core.snap
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
+import androidx.navigation3.runtime.entry
+import androidx.navigation3.runtime.entryProvider
+import androidx.navigation3.runtime.rememberSavedStateNavEntryDecorator
+import androidx.navigation3.ui.NavDisplay
+import androidx.navigation3.ui.rememberSceneSetupNavEntryDecorator
+import de.chaosdorf.meteroid.ui.*
+import de.chaosdorf.meteroid.ui.purchase.PurchaseRoute
+import de.chaosdorf.meteroid.viewmodel.*
+import kotlinx.coroutines.flow.update
+
+@Composable
+fun MeteroidRouter(
+  navigator: Navigator,
+  contentPadding: PaddingValues,
+) {
+  val backStack by navigator.backStack.collectAsState()
+
+  NavDisplay(
+    backStack = backStack,
+    onBack = { count ->
+      navigator.backStack.update {
+        it.dropLast(count)
+      }
+    },
+    entryDecorators = listOf(
+      rememberSceneSetupNavEntryDecorator(),
+      rememberSavedStateNavEntryDecorator(),
+      rememberViewModelStoreNavEntryDecorator(),
+    ),
+    // BEGIN WORKAROUND
+    // TODO FIXED IN navigation3 1.0.0-alpha04 Jun 18
+    transitionSpec = {
+      ContentTransform(
+        fadeIn(animationSpec = snap()),
+        fadeOut(animationSpec = snap())
+      )
+    },
+    popTransitionSpec = {
+      ContentTransform(
+        fadeIn(animationSpec = snap()),
+        fadeOut(animationSpec = snap())
+      )
+    },
+    predictivePopTransitionSpec = {
+      ContentTransform(
+        fadeIn(animationSpec = snap()),
+        fadeOut(animationSpec = snap())
+      )
+    },
+    // END
+    entryProvider = entryProvider {
+      entry<MeteroidRoute.Setup> {
+        val viewModel = hiltViewModel<SetupViewModel, SetupViewModelFactory> { factory ->
+          factory.create()
+        }
+        SetupRoute(viewModel, navigator, contentPadding)
+      }
+      entry<MeteroidRoute.Settings> {
+        val viewModel = hiltViewModel<SettingsViewModel, SettingsViewModelFactory> { factory ->
+          factory.create()
+        }
+        SettingsRoute(viewModel, navigator, contentPadding)
+      }
+      entry<MeteroidRoute.ServerList> {
+        val viewModel = hiltViewModel<ServerListViewModel, ServerListViewModelFactory> { factory ->
+          factory.create()
+        }
+        ServerListRoute(viewModel, navigator, contentPadding)
+      }
+      entry<MeteroidRoute.UserList> {
+        val viewModel = hiltViewModel<UserListViewModel, UserListViewModelFactory> { factory ->
+          factory.create(it.serverId.value)
+        }
+        UserListRoute(viewModel, navigator, contentPadding)
+      }
+      entry<MeteroidRoute.Purchase> {
+        val viewModel = hiltViewModel<PurchaseViewModel, PurchaseViewModelFactory> { factory ->
+          factory.create(it.serverId.value, it.userId.value)
+        }
+        PurchaseRoute(viewModel, navigator, contentPadding)
+      }
+      entry<MeteroidRoute.Deposit> {
+        val viewModel = hiltViewModel<DepositViewModel, DepositViewModelFactory> { factory ->
+          factory.create(it.serverId.value, it.userId.value)
+        }
+        DepositRoute(viewModel, navigator, contentPadding)
+      }
+      entry<MeteroidRoute.History> {
+        val viewModel = hiltViewModel<HistoryViewModel, HistoryViewModelFactory> { factory ->
+          factory.create(it.serverId.value, it.userId.value)
+        }
+        HistoryRoute(viewModel, navigator, contentPadding)
+      }
+      entry<MeteroidRoute.Wrapped> {
+        val viewModel = hiltViewModel<WrappedViewModel, WrappedViewModelFactory> { factory ->
+          factory.create(it.serverId.value, it.userId.value)
+        }
+        WrappedRoute(viewModel, navigator, contentPadding)
+      }
+    }
+  )
+}
diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/sync/SyncManager.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/sync/SyncManager.kt
index 359efb1..86fa2db 100644
--- a/app/src/main/kotlin/de/chaosdorf/meteroid/sync/SyncManager.kt
+++ b/app/src/main/kotlin/de/chaosdorf/meteroid/sync/SyncManager.kt
@@ -51,7 +51,7 @@ class SyncManager @Inject constructor(
   }
 
   suspend fun checkOffline(server: Server): Boolean {
-    val updated = factory.newServer(server.serverId, server.url)
+    val updated = factory.newServer(server.url)
     return if (updated == null) {
       true
     } else {
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 59334ef..de2b53f 100644
--- a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/SetupRoute.kt
+++ b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/SetupRoute.kt
@@ -31,18 +31,16 @@ 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.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.ui.common.ErrorBanner
 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
@@ -51,7 +49,6 @@ fun SetupRoute(
   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()
@@ -59,9 +56,10 @@ fun SetupRoute(
   Column(
     Modifier
       .padding(contentPadding)
-      .padding(16.dp, 8.dp)
+      .padding(16.dp, 8.dp),
+    verticalArrangement = Arrangement.spacedBy(8.dp),
   ) {
-    TextField(
+    OutlinedTextField(
       label = { Text("Server URL") },
       value = serverUrl,
       onValueChange = { viewModel.serverUrl.value = it },
@@ -69,18 +67,11 @@ fun SetupRoute(
     )
 
     error?.let {
-      Surface(
-        color = MaterialTheme.colorScheme.errorContainer,
-        contentColor = MaterialTheme.colorScheme.onErrorContainer,
-      ) {
-        Text(it)
-      }
+      ErrorBanner(it)
     }
 
     server?.let { server ->
-      Card(
-        modifier = Modifier.padding(vertical = 8.dp)
-      ) {
+      Card {
         Row(modifier = Modifier.padding(16.dp, 8.dp)) {
           AsyncImage(
             server.logoUrl,
@@ -110,17 +101,16 @@ fun SetupRoute(
           )
 
           IconButton(onClick = {
-            coroutineScope.launch(Dispatchers.IO) {
-              val server = viewModel.add()
-              if (server != null) {
+            viewModel.add(
+              onSuccess = { new ->
                 navigator.backStack.update {
                   listOf(
                     MeteroidRoute.ServerList,
-                    MeteroidRoute.UserList(server.serverId)
+                    MeteroidRoute.UserList(new.serverId)
                   )
                 }
               }
-            }
+            )
           }) {
             Icon(Icons.Default.Add, contentDescription = "Add Server")
           }
diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/common/ErrorBanner.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/common/ErrorBanner.kt
new file mode 100644
index 0000000..427f86f
--- /dev/null
+++ b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/common/ErrorBanner.kt
@@ -0,0 +1,71 @@
+/*
+ * 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.*
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.LocalTextStyle
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+
+@Preview(showBackground = true)
+@Composable
+fun ErrorBanner(
+  title: String = "An error has occurred",
+  content: @Composable () -> Unit = {},
+) {
+  Surface(
+    color = MaterialTheme.colorScheme.errorContainer,
+    contentColor = MaterialTheme.colorScheme.onErrorContainer,
+    shape = RoundedCornerShape(8.dp),
+    modifier = Modifier.fillMaxWidth()
+  ) {
+    Row(
+      horizontalArrangement = Arrangement.SpaceBetween,
+      verticalAlignment = Alignment.Companion.CenterVertically,
+      modifier = Modifier
+        .padding(horizontal = 16.dp, vertical = 8.dp)
+        .heightIn(32.dp),
+    ) {
+      Column(verticalArrangement = Arrangement.Center) {
+        Text(
+          title,
+          style = MaterialTheme.typography.bodyLarge,
+        )
+        CompositionLocalProvider(
+          LocalTextStyle provides MaterialTheme.typography.bodyMedium,
+          content = content,
+        )
+      }
+    }
+  }
+}
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
index 244d2a2..89d2476 100644
--- a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/navigation/NavigationUserListItem.kt
+++ b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/navigation/NavigationUserListItem.kt
@@ -33,7 +33,6 @@ import androidx.compose.material3.Icon
 import androidx.compose.material3.MaterialTheme
 import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.text.style.TextOverflow
diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/navigation/PersistentNavigation.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/navigation/PersistentNavigation.kt
index 78d873e..71de857 100644
--- a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/navigation/PersistentNavigation.kt
+++ b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/navigation/PersistentNavigation.kt
@@ -37,7 +37,6 @@ import androidx.compose.ui.geometry.Offset
 import androidx.compose.ui.graphics.Brush
 import androidx.compose.ui.graphics.TileMode
 import androidx.compose.ui.graphics.graphicsLayer
-import androidx.compose.ui.graphics.rememberGraphicsLayer
 import androidx.compose.ui.unit.dp
 import de.chaosdorf.meteroid.viewmodel.NavigationElement
 import de.chaosdorf.meteroid.viewmodel.NavigationViewModel
diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/util/MeteApiFactoryExtensions.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/util/MeteApiFactoryExtensions.kt
index e8371e2..1e2778a 100644
--- a/app/src/main/kotlin/de/chaosdorf/meteroid/util/MeteApiFactoryExtensions.kt
+++ b/app/src/main/kotlin/de/chaosdorf/meteroid/util/MeteApiFactoryExtensions.kt
@@ -29,7 +29,7 @@ import de.chaosdorf.mete.model.ServerId
 import de.chaosdorf.meteroid.model.Server
 import java.net.URI
 
-suspend fun MeteApiFactory.newServer(serverId: ServerId, baseUrl: String): Server? = try {
+suspend fun MeteApiFactory.newServer(baseUrl: String): Server? = try {
   val api = newInstance(baseUrl)
   val manifest = api.getManifest()
 
@@ -43,10 +43,10 @@ suspend fun MeteApiFactory.newServer(serverId: ServerId, baseUrl: String): Serve
   val iconUrl = icon?.src?.let { URI(baseUrl).resolve(it).toString() }
 
   Server(
-    serverId,
-    manifest?.name,
-    baseUrl,
-    iconUrl
+    serverId = ServerId(0),
+    name = manifest?.name,
+    url = baseUrl,
+    logoUrl = iconUrl
   )
 } catch (_: Exception) {
   null
diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/viewmodel/NavigationViewModel.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/viewmodel/NavigationViewModel.kt
index d1184ad..09a8b34 100644
--- a/app/src/main/kotlin/de/chaosdorf/meteroid/viewmodel/NavigationViewModel.kt
+++ b/app/src/main/kotlin/de/chaosdorf/meteroid/viewmodel/NavigationViewModel.kt
@@ -24,7 +24,6 @@
 
 package de.chaosdorf.meteroid.viewmodel
 
-import android.util.Log
 import androidx.lifecycle.ViewModel
 import androidx.lifecycle.viewModelScope
 import androidx.navigation3.runtime.NavKey
@@ -32,8 +31,6 @@ 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
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 f232d62..eb1a5e7 100644
--- a/app/src/main/kotlin/de/chaosdorf/meteroid/viewmodel/SetupViewModel.kt
+++ b/app/src/main/kotlin/de/chaosdorf/meteroid/viewmodel/SetupViewModel.kt
@@ -24,6 +24,7 @@
 
 package de.chaosdorf.meteroid.viewmodel
 
+import android.util.Log
 import androidx.lifecycle.ViewModel
 import androidx.lifecycle.viewModelScope
 import dagger.assisted.AssistedFactory
@@ -35,14 +36,11 @@ 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.Dispatchers
 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 kotlinx.coroutines.flow.*
+import kotlinx.coroutines.launch
 import kotlin.time.Duration.Companion.milliseconds
 
 @AssistedFactory
@@ -59,25 +57,32 @@ class SetupViewModel @AssistedInject constructor(
 ) : ViewModel() {
   val serverUrl = MutableStateFlow("")
 
-  val server: StateFlow<Server?> = serverUrl
+  private val baseUrl = serverUrl
     .debounce(300.milliseconds)
-    .mapLatest { apiFactory.newServer(ServerId(0), it) }
+    .mapLatest { if (it.startsWith("http://") || it.startsWith("https://")) it else "http://$it" }
+    .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), "")
+
+  val server: StateFlow<Server?> = baseUrl
+    .mapLatest { apiFactory.newServer(it) }
     .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null)
 
   val error = MutableStateFlow<String?>(null)
 
-  suspend fun add(): Server? {
-      val highestServerId = serverRepository.getAll().maxOfOrNull { it.serverId.value } ?: 0
-      val serverId = ServerId(highestServerId + 1)
-      val server = apiFactory.newServer(serverId, serverUrl.value)
+  fun add(onSuccess: (Server) -> Unit = {}) {
+    viewModelScope.launch(Dispatchers.IO) {
+      val server = apiFactory.newServer(baseUrl.value)
       if (server != null) {
-        serverRepository.save(server)
-        syncManager.sync(server, null, incremental = false)
+        Log.i("Setup", "Saving server $server")
+        val id = serverRepository.save(server)
+        val persistedServer = server.copy(serverId = ServerId(id))
+        Log.i("Setup", "Syncing server $persistedServer")
+        syncManager.sync(persistedServer, null, incremental = false)
         error.value = null
-        return server
+        Log.i("Setup", "Calling success handler")
+        onSuccess(persistedServer)
       } else {
         error.value = "Server not found"
-        return null
       }
+    }
   }
 }
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index f9e66ca..b34bb1d 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -8,15 +8,13 @@ kotlinxSerializationJson = "1.8.1"
 androidGradlePlugin = "8.10.1"
 androidx-activity = "1.10.1"
 androidx-appcompat = "1.7.1"
-androidx-compose = "1.8.2"
+androidx-compose-bom = "2025.06.00"
 androidx-compose-compiler = "1.5.15"
 androidx-datastore = "1.1.7"
 androidx-navigation3 = "1.0.0-alpha03"
 androidx-navigation3-viewmodel = "1.0.0-alpha01"
 androidx-room = "2.7.1"
 androidx-splashscreen = "1.0.1"
-androidx-material3 = "1.3.2"
-androidx-material-icons = "1.7.8"
 
 dagger-hilt = "2.56.2"
 androidx-hilt = "1.2.0"
@@ -34,15 +32,15 @@ androidx-activity-compose = { module = "androidx.activity:activity-compose", ver
 androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
 androidx-appcompat-resources = { module = "androidx.appcompat:appcompat-resources", version.ref = "androidx-appcompat" }
 
-androidx-compose-compiler = { module = "androidx.compose.compiler:compiler", version.ref = "androidx-compose-compiler" }
-androidx-compose-animation = { group = "androidx.compose.animation", name = "animation", version.ref = "androidx-compose" }
-androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation", version.ref = "androidx-compose" }
-androidx-compose-material-icons = { group = "androidx.compose.material", name = "material-icons-extended", version.ref = "androidx-material-icons" }
-androidx-compose-material = { group = "androidx.compose.material3", name = "material3", version.ref = "androidx-material3" }
-androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime", version.ref = "androidx-compose" }
-androidx-compose-ui-test = { group = "androidx.compose.ui", name = "ui-test", version.ref = "androidx-compose" }
-androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version.ref = "androidx-compose" }
-androidx-compose-ui-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview", version.ref = "androidx-compose" }
+androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "androidx-compose-bom" }
+androidx-compose-animation = { group = "androidx.compose.animation", name = "animation" }
+androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" }
+androidx-compose-material-icons = { group = "androidx.compose.material", name = "material-icons-extended" }
+androidx-compose-material = { group = "androidx.compose.material3", name = "material3" }
+androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime" }
+androidx-compose-ui-test = { group = "androidx.compose.ui", name = "ui-test" }
+androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
+androidx-compose-ui-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
 
 androidx-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "androidx-splashscreen" }
 
diff --git a/persistence/room/schemas/de.chaosdorf.meteroid.MeteroidDatabase/1.json b/persistence/room/schemas/de.chaosdorf.meteroid.MeteroidDatabase/1.json
index 92efcbb..bea7c68 100644
--- a/persistence/room/schemas/de.chaosdorf.meteroid.MeteroidDatabase/1.json
+++ b/persistence/room/schemas/de.chaosdorf.meteroid.MeteroidDatabase/1.json
@@ -2,7 +2,7 @@
   "formatVersion": 1,
   "database": {
     "version": 1,
-    "identityHash": "e26316f758271c58bc953e756fc16e7d",
+    "identityHash": "4783167a5b20f9424d766a24e80f54a5",
     "entities": [
       {
         "tableName": "Drink",
@@ -41,8 +41,7 @@
           {
             "fieldPath": "caffeine",
             "columnName": "caffeine",
-            "affinity": "INTEGER",
-            "notNull": false
+            "affinity": "INTEGER"
           },
           {
             "fieldPath": "price",
@@ -53,8 +52,7 @@
           {
             "fieldPath": "logoUrl",
             "columnName": "logoUrl",
-            "affinity": "TEXT",
-            "notNull": false
+            "affinity": "TEXT"
           }
         ],
         "primaryKey": {
@@ -64,7 +62,6 @@
             "drinkId"
           ]
         },
-        "indices": [],
         "foreignKeys": [
           {
             "table": "Server",
@@ -81,7 +78,7 @@
       },
       {
         "tableName": "Server",
-        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` INTEGER NOT NULL, `name` TEXT, `url` TEXT NOT NULL, `logoUrl` TEXT, PRIMARY KEY(`serverId`))",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT, `url` TEXT NOT NULL, `logoUrl` TEXT)",
         "fields": [
           {
             "fieldPath": "serverId",
@@ -92,8 +89,7 @@
           {
             "fieldPath": "name",
             "columnName": "name",
-            "affinity": "TEXT",
-            "notNull": false
+            "affinity": "TEXT"
           },
           {
             "fieldPath": "url",
@@ -104,18 +100,15 @@
           {
             "fieldPath": "logoUrl",
             "columnName": "logoUrl",
-            "affinity": "TEXT",
-            "notNull": false
+            "affinity": "TEXT"
           }
         ],
         "primaryKey": {
-          "autoGenerate": false,
+          "autoGenerate": true,
           "columnNames": [
             "serverId"
           ]
-        },
-        "indices": [],
-        "foreignKeys": []
+        }
       },
       {
         "tableName": "User",
@@ -148,8 +141,7 @@
           {
             "fieldPath": "email",
             "columnName": "email",
-            "affinity": "TEXT",
-            "notNull": false
+            "affinity": "TEXT"
           },
           {
             "fieldPath": "balance",
@@ -177,7 +169,6 @@
             "userId"
           ]
         },
-        "indices": [],
         "foreignKeys": [
           {
             "table": "Server",
@@ -216,7 +207,6 @@
             "userId"
           ]
         },
-        "indices": [],
         "foreignKeys": [
           {
             "table": "Server",
@@ -269,8 +259,7 @@
           {
             "fieldPath": "drinkId",
             "columnName": "drinkId",
-            "affinity": "INTEGER",
-            "notNull": false
+            "affinity": "INTEGER"
           },
           {
             "fieldPath": "difference",
@@ -355,10 +344,9 @@
         ]
       }
     ],
-    "views": [],
     "setupQueries": [
       "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
-      "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'e26316f758271c58bc953e756fc16e7d')"
+      "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '4783167a5b20f9424d766a24e80f54a5')"
     ]
   }
 }
\ No newline at end of file
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 1c21793..d12d18b 100644
--- a/persistence/src/main/kotlin/de/chaosdorf/meteroid/model/Server.kt
+++ b/persistence/src/main/kotlin/de/chaosdorf/meteroid/model/Server.kt
@@ -30,7 +30,7 @@ import kotlinx.coroutines.flow.Flow
 
 @Entity
 data class Server(
-  @PrimaryKey
+  @PrimaryKey(autoGenerate = true)
   val serverId: ServerId,
   val name: String?,
   val url: String,
@@ -58,7 +58,7 @@ interface ServerRepository {
   fun getAllFlow(): Flow<List<Server>>
 
   @Insert(onConflict = OnConflictStrategy.REPLACE)
-  suspend fun save(server: Server)
+  suspend fun save(server: Server): Long
 
   @Query("DELETE FROM Server WHERE serverId = :id")
   suspend fun delete(id: ServerId)
-- 
GitLab