From 00c1e75bd18916efbd0cd64c925740c87e2e4eb8 Mon Sep 17 00:00:00 2001
From: Janne Mareike Koschinski <mail@justjanne.de>
Date: Sun, 5 Nov 2023 17:09:00 +0100
Subject: [PATCH] wip: cleanup structure and navigation

---
 .../de/chaosdorf/mete/v1/UserModelV1.kt       |   3 +
 .../de/chaosdorf/meteroid/MainActivity.kt     | 330 +-----------------
 .../chaosdorf/meteroid/di/PreferenceModule.kt |   4 +-
 .../{ => storage}/AccountPreferences.kt       |   8 +-
 .../{ => storage}/AccountPreferencesImpl.kt   |  14 +-
 .../{ => storage}/DrinkSyncHandler.kt         |   3 +-
 .../meteroid/{ => storage}/SyncHandler.kt     |  26 +-
 .../meteroid/storage/UserSyncHandler.kt       |  64 ++++
 .../de/chaosdorf/meteroid/ui/AppRouter.kt     | 142 ++++++++
 .../de/chaosdorf/meteroid/ui/AppViewModel.kt  |  97 +++++
 .../meteroid/ui/MeteroidNavSection.kt         |  57 +++
 .../chaosdorf/meteroid/ui/MeteroidScaffold.kt |  74 ++++
 .../kotlin/de/chaosdorf/meteroid/ui/Routes.kt |  47 +++
 .../meteroid/ui/home/DrinkListScreen.kt       |  62 ++++
 .../meteroid/ui/home/DrinkListViewModel.kt    |  58 +++
 .../meteroid/ui/home/HomeSections.kt          |  42 +++
 .../meteroid/ui/servers/AddServerScreen.kt    |  70 ++++
 .../meteroid/ui/servers/AddServerViewModel.kt |  81 +++++
 .../meteroid/ui/servers/ServerListScreen.kt   |  62 ++++
 .../ui/servers/ServerListViewModel.kt         |  43 +++
 .../meteroid/ui/users/UserListScreen.kt       | 101 ++++++
 .../meteroid/ui/users/UserListViewModel.kt    |  56 +++
 .../1.json                                    |  12 +-
 .../de/chaosdorf/meteroid/model/User.kt       |  29 +-
 24 files changed, 1118 insertions(+), 367 deletions(-)
 rename app/src/main/kotlin/de/chaosdorf/meteroid/{ => storage}/AccountPreferences.kt (91%)
 rename app/src/main/kotlin/de/chaosdorf/meteroid/{ => storage}/AccountPreferencesImpl.kt (88%)
 rename app/src/main/kotlin/de/chaosdorf/meteroid/{ => storage}/DrinkSyncHandler.kt (96%)
 rename app/src/main/kotlin/de/chaosdorf/meteroid/{ => storage}/SyncHandler.kt (75%)
 create mode 100644 app/src/main/kotlin/de/chaosdorf/meteroid/storage/UserSyncHandler.kt
 create mode 100644 app/src/main/kotlin/de/chaosdorf/meteroid/ui/AppRouter.kt
 create mode 100644 app/src/main/kotlin/de/chaosdorf/meteroid/ui/AppViewModel.kt
 create mode 100644 app/src/main/kotlin/de/chaosdorf/meteroid/ui/MeteroidNavSection.kt
 create mode 100644 app/src/main/kotlin/de/chaosdorf/meteroid/ui/MeteroidScaffold.kt
 create mode 100644 app/src/main/kotlin/de/chaosdorf/meteroid/ui/Routes.kt
 create mode 100644 app/src/main/kotlin/de/chaosdorf/meteroid/ui/home/DrinkListScreen.kt
 create mode 100644 app/src/main/kotlin/de/chaosdorf/meteroid/ui/home/DrinkListViewModel.kt
 create mode 100644 app/src/main/kotlin/de/chaosdorf/meteroid/ui/home/HomeSections.kt
 create mode 100644 app/src/main/kotlin/de/chaosdorf/meteroid/ui/servers/AddServerScreen.kt
 create mode 100644 app/src/main/kotlin/de/chaosdorf/meteroid/ui/servers/AddServerViewModel.kt
 create mode 100644 app/src/main/kotlin/de/chaosdorf/meteroid/ui/servers/ServerListScreen.kt
 create mode 100644 app/src/main/kotlin/de/chaosdorf/meteroid/ui/servers/ServerListViewModel.kt
 create mode 100644 app/src/main/kotlin/de/chaosdorf/meteroid/ui/users/UserListScreen.kt
 create mode 100644 app/src/main/kotlin/de/chaosdorf/meteroid/ui/users/UserListViewModel.kt

diff --git a/api/src/main/kotlin/de/chaosdorf/mete/v1/UserModelV1.kt b/api/src/main/kotlin/de/chaosdorf/mete/v1/UserModelV1.kt
index a7feac1..33c76ca 100644
--- a/api/src/main/kotlin/de/chaosdorf/mete/v1/UserModelV1.kt
+++ b/api/src/main/kotlin/de/chaosdorf/mete/v1/UserModelV1.kt
@@ -26,6 +26,7 @@ package de.chaosdorf.mete.v1
 
 import de.chaosdorf.mete.UserId
 import kotlinx.datetime.Instant
+import kotlinx.serialization.SerialName
 import kotlinx.serialization.Serializable
 
 @Serializable
@@ -33,7 +34,9 @@ data class UserModelV1(
   val id: UserId,
   val name: String,
   val email: String,
+  @SerialName("created_at")
   val createdAt: Instant,
+  @SerialName("updated_at")
   val updatedAt: Instant,
   val balance: Double,
   val active: Boolean,
diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/MainActivity.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/MainActivity.kt
index f2dd9cf..81f13ca 100644
--- a/app/src/main/kotlin/de/chaosdorf/meteroid/MainActivity.kt
+++ b/app/src/main/kotlin/de/chaosdorf/meteroid/MainActivity.kt
@@ -27,99 +27,15 @@ package de.chaosdorf.meteroid
 import android.os.Bundle
 import androidx.activity.ComponentActivity
 import androidx.activity.compose.setContent
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.items
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.filled.ArrowBack
-import androidx.compose.material.icons.filled.Close
-import androidx.compose.material.icons.twotone.Money
-import androidx.compose.material3.BottomAppBar
-import androidx.compose.material3.Button
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.ListItem
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.Text
-import androidx.compose.material3.TextField
-import androidx.compose.material3.TopAppBar
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.collectAsState
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.rememberCoroutineScope
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.tooling.preview.Preview
-import androidx.hilt.navigation.compose.hiltViewModel
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.lifecycleScope
-import androidx.lifecycle.viewModelScope
-import androidx.lifecycle.viewmodel.compose.viewModel
-import androidx.navigation.NavDestination.Companion.hierarchy
-import androidx.navigation.compose.NavHost
-import androidx.navigation.compose.composable
-import androidx.navigation.compose.currentBackStackEntryAsState
-import androidx.navigation.compose.navigation
-import androidx.navigation.compose.rememberNavController
-import coil.compose.AsyncImage
 import dagger.hilt.android.AndroidEntryPoint
-import dagger.hilt.android.lifecycle.HiltViewModel
-import de.chaosdorf.mete.v1.MeteApiV1Factory
-import de.chaosdorf.meteroid.icons.MeteroidIcons
-import de.chaosdorf.meteroid.icons.twotone.WaterFull
-import de.chaosdorf.meteroid.model.Drink
-import de.chaosdorf.meteroid.model.DrinkRepository
-import de.chaosdorf.meteroid.model.Server
-import de.chaosdorf.meteroid.model.ServerId
-import de.chaosdorf.meteroid.model.ServerRepository
+import de.chaosdorf.meteroid.ui.AppRouter
 import de.chaosdorf.meteroid.ui.theme.MeteroidTheme
-import de.chaosdorf.meteroid.util.findBestIcon
-import de.chaosdorf.meteroid.util.resolve
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.SharingStarted
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.collectLatest
-import kotlinx.coroutines.flow.debounce
-import kotlinx.coroutines.flow.flatMapLatest
-import kotlinx.coroutines.flow.flowOf
-import kotlinx.coroutines.flow.mapLatest
-import kotlinx.coroutines.flow.stateIn
-import kotlinx.coroutines.launch
-import javax.inject.Inject
-import kotlin.time.Duration.Companion.milliseconds
 
 @AndroidEntryPoint
 class MainActivity : ComponentActivity() {
-  @Inject
-  lateinit var accountPreferences: AccountPreferences
-
-  @Inject
-  lateinit var serverRepository: ServerRepository
-
-  @Inject
-  lateinit var drinkSyncHandler: DrinkSyncHandler
-
   override fun onCreate(savedInstanceState: Bundle?) {
     super.onCreate(savedInstanceState)
 
-    val server = accountPreferences.state
-      .mapLatest { it.server }
-      .flatMapLatest {
-        it?.let { serverRepository.getFlow(it) }
-          ?: flowOf<Server?>(null)
-      }
-
-    lifecycleScope.launch {
-      server.collectLatest {
-        it?.let { server ->
-          drinkSyncHandler.sync(server)
-        }
-      }
-    }
-
     setContent {
       MeteroidTheme {
         AppRouter()
@@ -127,247 +43,3 @@ class MainActivity : ComponentActivity() {
     }
   }
 }
-
-@HiltViewModel
-class AppViewModel @Inject constructor(
-  private val accountPreferences: AccountPreferences,
-  private val serverRepository: ServerRepository,
-  private val drinkSyncHandler: DrinkSyncHandler
-) : ViewModel() {
-  val server: StateFlow<Server?> = accountPreferences.state
-    .mapLatest { it.server }
-    .flatMapLatest { serverId ->
-      serverRepository.getAllFlow()
-        .mapLatest { list ->
-          list.firstOrNull { server -> server.serverId == serverId }
-        }
-    }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null)
-
-  init {
-    viewModelScope.launch {
-      server.collectLatest {
-        it?.let { server ->
-          drinkSyncHandler.sync(server)
-        }
-      }
-    }
-  }
-
-  suspend fun selectServer(server: ServerId) {
-    accountPreferences.setServer(server)
-  }
-}
-
-@Composable
-fun AppRouter(viewModel: AppViewModel = viewModel()) {
-  val scope = rememberCoroutineScope()
-  val server by viewModel.server.collectAsState()
-  val navController = rememberNavController()
-
-  LaunchedEffect(server) {
-    if (server == null) {
-      navController.navigate("accounts/list")
-    }
-  }
-
-  Scaffold(topBar = {
-    val navBackStackEntry by navController.currentBackStackEntryAsState()
-    val currentDestination = navBackStackEntry?.destination
-    if (currentDestination?.hierarchy?.any { it.route == "main" } == true) {
-      TopAppBar(title = { Text("Meteroid") }, navigationIcon = {
-        IconButton(onClick = { navController.navigate("accounts") }) {
-          Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = null)
-        }
-      })
-    } else if (currentDestination?.hierarchy?.any { it.route == "accounts/list" } == true) {
-      TopAppBar(title = { Text("Meteroid") }, navigationIcon = {
-        IconButton(onClick = { navController.navigate("main") }) {
-          Icon(Icons.Default.Close, contentDescription = null)
-        }
-      })
-    }
-  }, bottomBar = {
-    val navBackStackEntry by navController.currentBackStackEntryAsState()
-    val currentDestination = navBackStackEntry?.destination
-    if (currentDestination?.hierarchy?.any { it.route == "main" } == true) {
-      BottomAppBar(actions = {
-        IconButton(onClick = { navController.navigate("main/purchase") }) {
-          Icon(MeteroidIcons.TwoTone.WaterFull, contentDescription = null)
-        }
-        IconButton(onClick = { navController.navigate("main/deposit") }) {
-          Icon(Icons.TwoTone.Money, contentDescription = null)
-        }
-      })
-    }
-  }) { padding ->
-    NavHost(navController, startDestination = "main", Modifier.padding(padding)) {
-      navigation(route = "accounts", startDestination = "accounts/list") {
-        composable("accounts/list") { backStackEntry ->
-          ServerListScreen(
-            hiltViewModel(),
-            onAddServer = { navController.navigate("accounts/addServer") },
-            onSelectServer = {
-              scope.launch {
-                viewModel.selectServer(it)
-                navController.navigate("main")
-              }
-            }
-          )
-        }
-        composable("accounts/addServer") { backStackEntry ->
-          AddServerScreen(
-            hiltViewModel(),
-            onAddServer = { navController.navigate("accounts/list") }
-          )
-        }
-      }
-      navigation(route = "main", startDestination = "main/purchase") {
-        /*
-        composable("users") { backStackEntry ->
-          val viewModel = hiltViewModel<UserListViewModel>()
-          UserListScreen(viewModel)
-        }
-        */
-        composable("main/purchase") { backStackEntry ->
-          DrinkListScreen(hiltViewModel())
-        }
-        composable("main/deposit") { backStackEntry ->
-
-        }
-      }
-    }
-  }
-}
-
-@HiltViewModel
-class AddServerViewModel @Inject constructor(
-  private val repository: ServerRepository
-) : ViewModel() {
-  val url = MutableStateFlow("")
-
-  private suspend fun buildServer(
-    id: ServerId,
-    url: String
-  ): Server? = try {
-    val api = MeteApiV1Factory.newInstance(url)
-    val manifest = api.getManifest()
-    val icon = manifest?.findBestIcon()
-    Server(
-      id,
-      manifest?.name,
-      url,
-      icon?.resolve(url)
-    )
-  } catch (_: Exception) {
-    null
-  }
-
-  val server: StateFlow<Server?> = url
-    .debounce(300.milliseconds)
-    .mapLatest { buildServer(ServerId(-1), it) }
-    .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null)
-
-  suspend fun addServer() {
-    val highestServerId = repository.getAll().maxOfOrNull { it.serverId.value } ?: 0
-    val serverId = ServerId(highestServerId + 1)
-    val server = buildServer(serverId, url.value)
-    if (server != null) {
-      repository.save(server)
-    }
-  }
-}
-
-@Composable
-fun AddServerScreen(viewModel: AddServerViewModel, onAddServer: () -> Unit) {
-  val scope = rememberCoroutineScope()
-  val url by viewModel.url.collectAsState()
-  val server by viewModel.server.collectAsState()
-
-  Column {
-    TextField(
-      label = { Text("Server URL") },
-      value = url,
-      onValueChange = { viewModel.url.value = it }
-    )
-
-    Button(onClick = {
-      scope.launch {
-        viewModel.addServer()
-        onAddServer()
-      }
-    }) {
-      Text("Add Server")
-    }
-
-    server?.let { server ->
-      Text(server.url)
-      Text(server.name ?: "null1")
-      AsyncImage(model = server.logoUrl, contentDescription = null)
-    }
-  }
-}
-
-@Preview
-@Composable
-fun ServerListScreen(
-  viewModel: ServerListViewModel = viewModel(),
-  onAddServer: () -> Unit = {},
-  onSelectServer: (ServerId) -> Unit = {}
-) {
-  val servers by viewModel.servers.collectAsState()
-
-  LazyColumn {
-    items(servers) { server ->
-      ListItem(
-        headlineContent = { Text(server.name ?: server.url) },
-        modifier = Modifier.clickable {
-          onSelectServer(server.serverId)
-        }
-      )
-    }
-    item {
-      ListItem(
-        headlineContent = { Text("Add Server") },
-        modifier = Modifier.clickable {
-          onAddServer()
-        }
-      )
-    }
-  }
-}
-
-@Preview
-@Composable
-fun DrinkListScreen(
-  viewModel: DrinkListViewModel = viewModel()
-) {
-  val drinks by viewModel.drinks.collectAsState()
-
-  LazyColumn {
-    items(drinks) { drink ->
-      ListItem(headlineContent = { Text(drink.name) },
-        supportingContent = { Text("${drink.volume}l · ${drink.price}€") })
-    }
-  }
-}
-
-@HiltViewModel
-class DrinkListViewModel @Inject constructor(
-  drinkRepository: DrinkRepository,
-  accountPreferences: AccountPreferences
-) : ViewModel() {
-  private val serverId: Flow<ServerId?> = accountPreferences.state.mapLatest { it.server }
-  val drinks: StateFlow<List<Drink>> = serverId.flatMapLatest {
-    it?.let { serverId ->
-      drinkRepository.getAllFlow(serverId)
-    } ?: flowOf(emptyList())
-  }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList())
-}
-
-@HiltViewModel
-class ServerListViewModel @Inject constructor(
-  serverRepository: ServerRepository
-) : ViewModel() {
-  val servers: StateFlow<List<Server>> = serverRepository.getAllFlow()
-    .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList())
-}
diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/di/PreferenceModule.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/di/PreferenceModule.kt
index b985bb9..97df1b9 100644
--- a/app/src/main/kotlin/de/chaosdorf/meteroid/di/PreferenceModule.kt
+++ b/app/src/main/kotlin/de/chaosdorf/meteroid/di/PreferenceModule.kt
@@ -34,8 +34,8 @@ import dagger.Provides
 import dagger.hilt.InstallIn
 import dagger.hilt.android.qualifiers.ApplicationContext
 import dagger.hilt.components.SingletonComponent
-import de.chaosdorf.meteroid.AccountPreferences
-import de.chaosdorf.meteroid.AccountPreferencesImpl
+import de.chaosdorf.meteroid.storage.AccountPreferences
+import de.chaosdorf.meteroid.storage.AccountPreferencesImpl
 import javax.inject.Singleton
 
 val Context.accountDataStore: DataStore<Preferences> by preferencesDataStore(name = "account")
diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/AccountPreferences.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/storage/AccountPreferences.kt
similarity index 91%
rename from app/src/main/kotlin/de/chaosdorf/meteroid/AccountPreferences.kt
rename to app/src/main/kotlin/de/chaosdorf/meteroid/storage/AccountPreferences.kt
index 37723f5..75fdfb2 100644
--- a/app/src/main/kotlin/de/chaosdorf/meteroid/AccountPreferences.kt
+++ b/app/src/main/kotlin/de/chaosdorf/meteroid/storage/AccountPreferences.kt
@@ -22,7 +22,7 @@
  * THE SOFTWARE.
  */
 
-package de.chaosdorf.meteroid
+package de.chaosdorf.meteroid.storage
 
 import de.chaosdorf.mete.UserId
 import de.chaosdorf.meteroid.model.ServerId
@@ -30,12 +30,12 @@ import kotlinx.coroutines.flow.Flow
 
 interface AccountPreferences {
   data class State(
-      val server: ServerId?,
-      val user: UserId?
+    val server: ServerId?,
+    val user: UserId?
   )
 
   val state: Flow<State>
 
   suspend fun setServer(server: ServerId?)
-  suspend fun setUser(userId: UserId?)
+  suspend fun setUser(user: UserId?)
 }
diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/AccountPreferencesImpl.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/storage/AccountPreferencesImpl.kt
similarity index 88%
rename from app/src/main/kotlin/de/chaosdorf/meteroid/AccountPreferencesImpl.kt
rename to app/src/main/kotlin/de/chaosdorf/meteroid/storage/AccountPreferencesImpl.kt
index 33e61c4..71a6be1 100644
--- a/app/src/main/kotlin/de/chaosdorf/meteroid/AccountPreferencesImpl.kt
+++ b/app/src/main/kotlin/de/chaosdorf/meteroid/storage/AccountPreferencesImpl.kt
@@ -22,7 +22,7 @@
  * THE SOFTWARE.
  */
 
-package de.chaosdorf.meteroid
+package de.chaosdorf.meteroid.storage
 
 import androidx.datastore.core.DataStore
 import androidx.datastore.preferences.core.Preferences
@@ -43,10 +43,10 @@ class AccountPreferencesImpl @Inject constructor(
       val serverId = it[SERVER_KEY] ?: -1L
       val userId = it[USER_KEY] ?: -1L
 
-        AccountPreferences.State(
-            if (serverId >= 0) ServerId(serverId) else null,
-            if (userId >= 0) UserId(userId) else null,
-        )
+      AccountPreferences.State(
+        if (serverId >= 0) ServerId(serverId) else null,
+        if (userId >= 0) UserId(userId) else null,
+      )
     }
 
   override suspend fun setServer(server: ServerId?) {
@@ -55,9 +55,9 @@ class AccountPreferencesImpl @Inject constructor(
     }
   }
 
-  override suspend fun setUser(userId: UserId?) {
+  override suspend fun setUser(user: UserId?) {
     dataStore.edit {
-      it[SERVER_KEY] = userId?.value ?: -1L
+      it[USER_KEY] = user?.value ?: -1L
     }
   }
 
diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/DrinkSyncHandler.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/storage/DrinkSyncHandler.kt
similarity index 96%
rename from app/src/main/kotlin/de/chaosdorf/meteroid/DrinkSyncHandler.kt
rename to app/src/main/kotlin/de/chaosdorf/meteroid/storage/DrinkSyncHandler.kt
index ab6d703..732f3ed 100644
--- a/app/src/main/kotlin/de/chaosdorf/meteroid/DrinkSyncHandler.kt
+++ b/app/src/main/kotlin/de/chaosdorf/meteroid/storage/DrinkSyncHandler.kt
@@ -22,11 +22,12 @@
  * THE SOFTWARE.
  */
 
-package de.chaosdorf.meteroid
+package de.chaosdorf.meteroid.storage
 
 import androidx.room.withTransaction
 import de.chaosdorf.mete.DrinkId
 import de.chaosdorf.mete.v1.MeteApiV1Factory
+import de.chaosdorf.meteroid.MeteroidDatabase
 import de.chaosdorf.meteroid.model.Drink
 import de.chaosdorf.meteroid.model.DrinkRepository
 import de.chaosdorf.meteroid.model.Server
diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/SyncHandler.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/storage/SyncHandler.kt
similarity index 75%
rename from app/src/main/kotlin/de/chaosdorf/meteroid/SyncHandler.kt
rename to app/src/main/kotlin/de/chaosdorf/meteroid/storage/SyncHandler.kt
index dc77704..40be77a 100644
--- a/app/src/main/kotlin/de/chaosdorf/meteroid/SyncHandler.kt
+++ b/app/src/main/kotlin/de/chaosdorf/meteroid/storage/SyncHandler.kt
@@ -22,8 +22,9 @@
  * THE SOFTWARE.
  */
 
-package de.chaosdorf.meteroid
+package de.chaosdorf.meteroid.storage
 
+import android.util.Log
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.StateFlow
 
@@ -31,7 +32,17 @@ abstract class SyncHandler<Context, Entry, Key> {
   sealed class State {
     data object Idle : State()
     data object Loading : State()
-    data class Error(val message: String) : State()
+    data class Error(val message: String = "") : State() {
+      override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (javaClass != other?.javaClass) return false
+        return true
+      }
+
+      override fun hashCode(): Int {
+        return javaClass.hashCode()
+      }
+    }
   }
 
   abstract suspend fun withTransaction(block: suspend () -> Unit)
@@ -48,7 +59,12 @@ abstract class SyncHandler<Context, Entry, Key> {
   val state: StateFlow<State> = _state
 
   suspend fun sync(context: Context) {
-    if (_state.compareAndSet(State.Idle, State.Loading)) {
+    if (_state.compareAndSet(State.Idle, State.Loading) || _state.compareAndSet(
+        State.Error(),
+        State.Loading
+      )
+    ) {
+      Log.w(this::class.simpleName, "Started sync")
       try {
         val loadedEntries = loadCurrent(context)
         withTransaction {
@@ -64,9 +80,13 @@ abstract class SyncHandler<Context, Entry, Key> {
           }
         }
         _state.value = State.Idle
+        Log.w(this::class.simpleName, "Finished sync")
       } catch (e: Exception) {
+        Log.e(this::class.simpleName, "Error while syncing data", e)
         _state.value = State.Error("Error while syncing data: $e")
       }
+    } else {
+      Log.w(this::class.simpleName, "Already syncing, disregarding sync request")
     }
   }
 }
diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/storage/UserSyncHandler.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/storage/UserSyncHandler.kt
new file mode 100644
index 0000000..906771a
--- /dev/null
+++ b/app/src/main/kotlin/de/chaosdorf/meteroid/storage/UserSyncHandler.kt
@@ -0,0 +1,64 @@
+/*
+ * 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.storage
+
+import androidx.room.withTransaction
+import de.chaosdorf.mete.UserId
+import de.chaosdorf.mete.v1.MeteApiV1Factory
+import de.chaosdorf.meteroid.MeteroidDatabase
+import de.chaosdorf.meteroid.model.Server
+import de.chaosdorf.meteroid.model.ServerId
+import de.chaosdorf.meteroid.model.User
+import de.chaosdorf.meteroid.model.UserRepository
+import javax.inject.Inject
+
+class UserSyncHandler @Inject constructor(
+  private val db: MeteroidDatabase,
+  private val repository: UserRepository
+) : SyncHandler<Server, User, UserSyncHandler.Key>() {
+  data class Key(
+    val server: ServerId, val user: UserId
+  )
+
+  override suspend fun withTransaction(block: suspend () -> Unit) =
+    db.withTransaction(block)
+
+  override suspend fun store(entry: User) =
+    repository.save(entry)
+
+  override suspend fun delete(key: Key) =
+    repository.delete(key.server, key.user)
+
+  override fun entryToKey(entry: User) = Key(entry.serverId, entry.userId)
+
+  override suspend fun loadStored(context: Server): List<User> =
+    repository.getAll(context.serverId)
+
+  override suspend fun loadCurrent(context: Server): List<User> {
+    val api = MeteApiV1Factory.newInstance(context.url)
+    val loadedEntries = api.listUsers()
+    return loadedEntries.map { User.fromModelV1(context.serverId, it) }
+  }
+}
diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/AppRouter.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/AppRouter.kt
new file mode 100644
index 0000000..b22f4ce
--- /dev/null
+++ b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/AppRouter.kt
@@ -0,0 +1,142 @@
+/*
+ * The MIT License (MIT)
+ *
+ * Copyright (c) 2013-2023 Chaosdorf e.V.
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package de.chaosdorf.meteroid.ui
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Modifier
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.viewmodel.compose.viewModel
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.navigation
+import androidx.navigation.compose.rememberNavController
+import de.chaosdorf.meteroid.ui.home.DrinkListScreen
+import de.chaosdorf.meteroid.ui.home.DrinkListViewModel
+import de.chaosdorf.meteroid.ui.home.HomeSections
+import de.chaosdorf.meteroid.ui.servers.AddServerScreen
+import de.chaosdorf.meteroid.ui.servers.ServerListScreen
+import de.chaosdorf.meteroid.ui.users.UserListScreen
+import kotlinx.coroutines.launch
+
+@Composable
+fun AppRouter(viewModel: AppViewModel = viewModel()) {
+  val scope = rememberCoroutineScope()
+  val navController = rememberNavController()
+  val initState by viewModel.initState.collectAsState()
+
+  LaunchedEffect(initState) {
+    when (initState) {
+      AppViewModel.InitState.LOADING -> navController.navigate(Routes.Init)
+      AppViewModel.InitState.CREATE_SERVER -> navController.navigate(Routes.Servers.Add)
+      AppViewModel.InitState.SELECT_SERVER -> navController.navigate(Routes.Servers.List)
+      AppViewModel.InitState.SELECT_USER -> navController.navigate(Routes.Users.List)
+      AppViewModel.InitState.HOME -> navController.navigate(Routes.Home.Root)
+    }
+  }
+
+  NavHost(navController, startDestination = Routes.Init) {
+    composable(route = Routes.Init) { _ ->
+      Box {
+        Text("Loading")
+      }
+    }
+
+    navigation(route = Routes.Servers.Root, startDestination = Routes.Servers.List) {
+      composable(Routes.Servers.List) { _ ->
+        ServerListScreen(
+          hiltViewModel(),
+          onAdd = { navController.navigate(Routes.Servers.Add) },
+          onSelect = {
+            scope.launch {
+              viewModel.selectServer(it)
+              navController.navigate(Routes.Users.List)
+            }
+          }
+        )
+      }
+      composable(Routes.Servers.Add) { _ ->
+        AddServerScreen(
+          hiltViewModel(),
+          onAdd = { navController.navigate(Routes.Servers.List) }
+        )
+      }
+    }
+    navigation(route = Routes.Users.Root, startDestination = Routes.Users.List) {
+      composable(Routes.Users.List) { _ ->
+        UserListScreen(
+          hiltViewModel(),
+          onAdd = { TODO() },
+          onSelect = {
+            scope.launch {
+              viewModel.selectUser(it)
+              navController.navigate(Routes.Home.Root)
+            }
+          },
+          onBack = { navController.navigate(Routes.Servers.Root) },
+        )
+      }
+      /*
+      composable(Routes.Users.Add) { _ ->
+        AddUserScreen(
+          hiltViewModel(),
+          onAdd = { navController.navigate(Routes.Users.List) }
+        )
+      }
+       */
+    }
+    navigation(route = Routes.Home.Root, startDestination = Routes.Home.Purchase) {
+      composable(Routes.Home.Purchase) { _ ->
+        val drinkListViewModel = hiltViewModel<DrinkListViewModel>()
+        MeteroidScaffold(
+          routes = HomeSections.entries,
+          currentRoute = HomeSections.PURCHASE,
+          navigateTo = navController::navigate,
+          onBack = { navController.navigate(Routes.Users.Root) },
+        ) { paddingValues ->
+          DrinkListScreen(drinkListViewModel, Modifier.padding(paddingValues))
+        }
+      }
+      composable(Routes.Home.Deposit) { _ ->
+        MeteroidScaffold(
+          routes = HomeSections.entries,
+          currentRoute = HomeSections.DEPOSIT,
+          navigateTo = navController::navigate,
+          onBack = { navController.navigate(Routes.Users.Root) },
+        ) { paddingValues ->
+          Box(Modifier.padding(paddingValues)) {
+            Text("TODO: Deposit")
+          }
+        }
+      }
+    }
+  }
+}
diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/AppViewModel.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/AppViewModel.kt
new file mode 100644
index 0000000..3781fd4
--- /dev/null
+++ b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/AppViewModel.kt
@@ -0,0 +1,97 @@
+/*
+ * The MIT License (MIT)
+ *
+ * Copyright (c) 2013-2023 Chaosdorf e.V.
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package de.chaosdorf.meteroid.ui
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import de.chaosdorf.mete.UserId
+import de.chaosdorf.meteroid.model.Server
+import de.chaosdorf.meteroid.model.ServerId
+import de.chaosdorf.meteroid.model.ServerRepository
+import de.chaosdorf.meteroid.storage.AccountPreferences
+import de.chaosdorf.meteroid.storage.DrinkSyncHandler
+import de.chaosdorf.meteroid.storage.UserSyncHandler
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.mapLatest
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.stateIn
+import javax.inject.Inject
+
+@HiltViewModel
+class AppViewModel @Inject constructor(
+  private val accountPreferences: AccountPreferences,
+  private val serverRepository: ServerRepository,
+  private val userSyncHandler: UserSyncHandler,
+  private val drinkSyncHandler: DrinkSyncHandler,
+) : ViewModel() {
+  val initState: StateFlow<InitState> = accountPreferences.state
+    .flatMapLatest { preferences ->
+      serverRepository.getAllFlow()
+        .mapLatest { it.map(Server::serverId) }
+        .mapLatest { servers ->
+          if (servers.isEmpty()) InitState.CREATE_SERVER
+          else if (!servers.contains(preferences.server)) InitState.SELECT_SERVER
+          else if (preferences.user == null) InitState.SELECT_USER
+          else InitState.HOME
+        }
+    }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), InitState.LOADING)
+
+  private val server: StateFlow<Server?> =
+    accountPreferences.state.flatMapLatest { preferences ->
+      if (preferences.server == null) flowOf(null)
+      else serverRepository.getFlow(preferences.server)
+    }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null)
+
+  init {
+    server.onEach { server ->
+      if (server != null) {
+        userSyncHandler.sync(server)
+        drinkSyncHandler.sync(server)
+      }
+    }.launchIn(viewModelScope)
+  }
+
+  suspend fun selectServer(server: ServerId) {
+    accountPreferences.setServer(server)
+    accountPreferences.setUser(null)
+  }
+
+  suspend fun selectUser(user: UserId) {
+    accountPreferences.setUser(user)
+  }
+
+  enum class InitState {
+    LOADING,
+    CREATE_SERVER,
+    SELECT_SERVER,
+    SELECT_USER,
+    HOME
+  }
+}
diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/MeteroidNavSection.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/MeteroidNavSection.kt
new file mode 100644
index 0000000..4a3a609
--- /dev/null
+++ b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/MeteroidNavSection.kt
@@ -0,0 +1,57 @@
+/*
+ * The MIT License (MIT)
+ *
+ * Copyright (c) 2013-2023 Chaosdorf e.V.
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package de.chaosdorf.meteroid.ui
+
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.material3.Icon
+import androidx.compose.material3.NavigationBarItem
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+
+interface MeteroidNavSection {
+  val title: String
+  val icon: ImageVector
+  val route: String
+}
+
+@Composable
+fun <T : MeteroidNavSection> RowScope.MeteroidNavSections(
+  routes: Iterable<T>,
+  currentRoute: T,
+  navigateTo: (String) -> Unit,
+  modifier: Modifier = Modifier
+) {
+  for (route in routes) {
+    NavigationBarItem(
+      icon = { Icon(route.icon, contentDescription = route.title) },
+      label = { Text(route.title) },
+      selected = route == currentRoute,
+      onClick = { navigateTo(route.route) },
+      modifier = modifier
+    )
+  }
+}
diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/MeteroidScaffold.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/MeteroidScaffold.kt
new file mode 100644
index 0000000..61340f6
--- /dev/null
+++ b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/MeteroidScaffold.kt
@@ -0,0 +1,74 @@
+/*
+ * The MIT License (MIT)
+ *
+ * Copyright (c) 2013-2023 Chaosdorf e.V.
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package de.chaosdorf.meteroid.ui
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Menu
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.NavigationBar
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.shadow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.zIndex
+
+@Composable
+fun <T : MeteroidNavSection> MeteroidScaffold(
+  routes: Iterable<T>,
+  currentRoute: T,
+  navigateTo: (String) -> Unit,
+  onBack: () -> Unit,
+  content: @Composable (PaddingValues) -> Unit
+) {
+  Scaffold(
+    topBar = {
+      TopAppBar(
+        title = { Text("Meteroid") },
+        navigationIcon = {
+          IconButton(onClick = onBack) {
+            Icon(Icons.Default.Menu, contentDescription = "Menu")
+          }
+        },
+        modifier = Modifier
+          .padding(8.dp)
+          .shadow(4.dp, shape = RoundedCornerShape(8.dp))
+          .zIndex(1.0f)
+      )
+    },
+    bottomBar = {
+      NavigationBar {
+        MeteroidNavSections(routes, currentRoute, navigateTo)
+      }
+    },
+    content = content
+  )
+}
diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/Routes.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/Routes.kt
new file mode 100644
index 0000000..e06f5c2
--- /dev/null
+++ b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/Routes.kt
@@ -0,0 +1,47 @@
+/*
+ * The MIT License (MIT)
+ *
+ * Copyright (c) 2013-2023 Chaosdorf e.V.
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package de.chaosdorf.meteroid.ui
+
+object Routes {
+  const val Init = "init"
+
+  object Servers {
+    const val Root = "servers"
+    const val List = "$Root/list"
+    const val Add = "$Root/new"
+  }
+
+  object Users {
+    const val Root = "users"
+    const val List = "${Root}/list"
+    //const val Add = "${Root}/new"
+  }
+
+  object Home {
+    const val Root = "home"
+    const val Deposit = "home/deposit"
+    const val Purchase = "home/purchase"
+  }
+}
diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/home/DrinkListScreen.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/home/DrinkListScreen.kt
new file mode 100644
index 0000000..f80918e
--- /dev/null
+++ b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/home/DrinkListScreen.kt
@@ -0,0 +1,62 @@
+/*
+ * The MIT License (MIT)
+ *
+ * Copyright (c) 2013-2023 Chaosdorf e.V.
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package de.chaosdorf.meteroid.ui.home
+
+import android.annotation.SuppressLint
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.LinearProgressIndicator
+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.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.lifecycle.viewmodel.compose.viewModel
+import de.chaosdorf.meteroid.storage.SyncHandler
+
+@Preview
+@Composable
+fun DrinkListScreen(
+  viewModel: DrinkListViewModel = viewModel(),
+  @SuppressLint("ModifierParameter") modifier: Modifier = Modifier
+) {
+  val drinks by viewModel.drinks.collectAsState()
+  val syncState by viewModel.syncState.collectAsState()
+
+  Column(modifier = modifier) {
+    if (syncState == SyncHandler.State.Loading) {
+      LinearProgressIndicator()
+    }
+    LazyColumn {
+      items(drinks) { drink ->
+        ListItem(headlineContent = { Text(drink.name) },
+          supportingContent = { Text("${drink.volume}l · ${drink.price}€") })
+      }
+    }
+  }
+}
diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/home/DrinkListViewModel.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/home/DrinkListViewModel.kt
new file mode 100644
index 0000000..331771d
--- /dev/null
+++ b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/home/DrinkListViewModel.kt
@@ -0,0 +1,58 @@
+/*
+ * The MIT License (MIT)
+ *
+ * Copyright (c) 2013-2023 Chaosdorf e.V.
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package de.chaosdorf.meteroid.ui.home
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import de.chaosdorf.meteroid.model.Drink
+import de.chaosdorf.meteroid.model.DrinkRepository
+import de.chaosdorf.meteroid.model.ServerId
+import de.chaosdorf.meteroid.storage.AccountPreferences
+import de.chaosdorf.meteroid.storage.DrinkSyncHandler
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.mapLatest
+import kotlinx.coroutines.flow.stateIn
+import javax.inject.Inject
+
+@HiltViewModel
+class DrinkListViewModel @Inject constructor(
+  drinkRepository: DrinkRepository,
+  accountPreferences: AccountPreferences,
+  syncHandler: DrinkSyncHandler
+) : ViewModel() {
+  private val serverId: Flow<ServerId?> = accountPreferences.state.mapLatest { it.server }
+  val drinks: StateFlow<List<Drink>> = serverId.flatMapLatest {
+    it?.let { serverId ->
+      drinkRepository.getAllFlow(serverId)
+    } ?: flowOf(emptyList())
+  }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList())
+
+  val syncState = syncHandler.state
+}
diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/home/HomeSections.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/home/HomeSections.kt
new file mode 100644
index 0000000..80d9d5e
--- /dev/null
+++ b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/home/HomeSections.kt
@@ -0,0 +1,42 @@
+/*
+ * The MIT License (MIT)
+ *
+ * Copyright (c) 2013-2023 Chaosdorf e.V.
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package de.chaosdorf.meteroid.ui.home
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.twotone.Money
+import androidx.compose.ui.graphics.vector.ImageVector
+import de.chaosdorf.meteroid.icons.MeteroidIcons
+import de.chaosdorf.meteroid.icons.twotone.WaterFull
+import de.chaosdorf.meteroid.ui.MeteroidNavSection
+import de.chaosdorf.meteroid.ui.Routes
+
+enum class HomeSections(
+  override val title: String,
+  override val icon: ImageVector,
+  override val route: String
+) : MeteroidNavSection {
+  PURCHASE("Drinks", MeteroidIcons.TwoTone.WaterFull, Routes.Home.Purchase),
+  DEPOSIT("Money", Icons.TwoTone.Money, Routes.Home.Deposit);
+}
diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/servers/AddServerScreen.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/servers/AddServerScreen.kt
new file mode 100644
index 0000000..be30261
--- /dev/null
+++ b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/servers/AddServerScreen.kt
@@ -0,0 +1,70 @@
+/*
+ * The MIT License (MIT)
+ *
+ * Copyright (c) 2013-2023 Chaosdorf e.V.
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package de.chaosdorf.meteroid.ui.servers
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.material3.Button
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextField
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.lifecycle.viewmodel.compose.viewModel
+import coil.compose.AsyncImage
+import kotlinx.coroutines.launch
+
+@Composable
+fun AddServerScreen(
+  viewModel: AddServerViewModel = viewModel(),
+  onAdd: () -> Unit = {}
+) {
+  val scope = rememberCoroutineScope()
+  val url by viewModel.url.collectAsState()
+  val server by viewModel.server.collectAsState()
+
+  Column {
+    TextField(
+      label = { Text("Server URL") },
+      value = url,
+      onValueChange = { viewModel.url.value = it }
+    )
+
+    Button(onClick = {
+      scope.launch {
+        viewModel.addServer()
+        onAdd()
+      }
+    }) {
+      Text("Add Server")
+    }
+
+    server?.let { server ->
+      Text(server.url)
+      Text(server.name ?: "null1")
+      AsyncImage(model = server.logoUrl, contentDescription = null)
+    }
+  }
+}
diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/servers/AddServerViewModel.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/servers/AddServerViewModel.kt
new file mode 100644
index 0000000..3689ee0
--- /dev/null
+++ b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/servers/AddServerViewModel.kt
@@ -0,0 +1,81 @@
+/*
+ * The MIT License (MIT)
+ *
+ * Copyright (c) 2013-2023 Chaosdorf e.V.
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package de.chaosdorf.meteroid.ui.servers
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import de.chaosdorf.mete.v1.MeteApiV1Factory
+import de.chaosdorf.meteroid.model.Server
+import de.chaosdorf.meteroid.model.ServerId
+import de.chaosdorf.meteroid.model.ServerRepository
+import de.chaosdorf.meteroid.util.findBestIcon
+import de.chaosdorf.meteroid.util.resolve
+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 javax.inject.Inject
+import kotlin.time.Duration.Companion.milliseconds
+
+@HiltViewModel
+class AddServerViewModel @Inject constructor(
+  private val repository: ServerRepository
+) : ViewModel() {
+  val url = MutableStateFlow("")
+
+  private suspend fun buildServer(
+    id: ServerId,
+    url: String
+  ): Server? = try {
+    val api = MeteApiV1Factory.newInstance(url)
+    val manifest = api.getManifest()
+    val icon = manifest?.findBestIcon()
+    Server(
+      id,
+      manifest?.name,
+      url,
+      icon?.resolve(url)
+    )
+  } catch (_: Exception) {
+    null
+  }
+
+  val server: StateFlow<Server?> = url
+    .debounce(300.milliseconds)
+    .mapLatest { buildServer(ServerId(-1), it) }
+    .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null)
+
+  suspend fun addServer() {
+    val highestServerId = repository.getAll().maxOfOrNull { it.serverId.value } ?: 0
+    val serverId = ServerId(highestServerId + 1)
+    val server = buildServer(serverId, url.value)
+    if (server != null) {
+      repository.save(server)
+    }
+  }
+}
diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/servers/ServerListScreen.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/servers/ServerListScreen.kt
new file mode 100644
index 0000000..b30db84
--- /dev/null
+++ b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/servers/ServerListScreen.kt
@@ -0,0 +1,62 @@
+/*
+ * The MIT License (MIT)
+ *
+ * Copyright (c) 2013-2023 Chaosdorf e.V.
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package de.chaosdorf.meteroid.ui.servers
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+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.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.lifecycle.viewmodel.compose.viewModel
+import de.chaosdorf.meteroid.model.ServerId
+
+@Preview
+@Composable
+fun ServerListScreen(
+  viewModel: ServerListViewModel = viewModel(),
+  onAdd: () -> Unit = {},
+  onSelect: (ServerId) -> Unit = {}
+) {
+  val servers by viewModel.servers.collectAsState()
+  LazyColumn {
+    items(servers) { server ->
+      ListItem(
+        headlineContent = { Text(server.name ?: server.url) },
+        modifier = Modifier.clickable { onSelect(server.serverId) }
+      )
+    }
+    item {
+      ListItem(
+        headlineContent = { Text("Add Server") },
+        modifier = Modifier.clickable { onAdd() }
+      )
+    }
+  }
+}
diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/servers/ServerListViewModel.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/servers/ServerListViewModel.kt
new file mode 100644
index 0000000..32fb347
--- /dev/null
+++ b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/servers/ServerListViewModel.kt
@@ -0,0 +1,43 @@
+/*
+ * The MIT License (MIT)
+ *
+ * Copyright (c) 2013-2023 Chaosdorf e.V.
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package de.chaosdorf.meteroid.ui.servers
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import de.chaosdorf.meteroid.model.Server
+import de.chaosdorf.meteroid.model.ServerRepository
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.stateIn
+import javax.inject.Inject
+
+@HiltViewModel
+class ServerListViewModel @Inject constructor(
+  serverRepository: ServerRepository
+) : ViewModel() {
+  val servers: StateFlow<List<Server>> = serverRepository.getAllFlow()
+    .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList())
+}
diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/users/UserListScreen.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/users/UserListScreen.kt
new file mode 100644
index 0000000..678516d
--- /dev/null
+++ b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/users/UserListScreen.kt
@@ -0,0 +1,101 @@
+/*
+ * The MIT License (MIT)
+ *
+ * Copyright (c) 2013-2023 Chaosdorf e.V.
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package de.chaosdorf.meteroid.ui.users
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.shadow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.zIndex
+import androidx.lifecycle.viewmodel.compose.viewModel
+import de.chaosdorf.mete.UserId
+import de.chaosdorf.meteroid.storage.SyncHandler
+
+@Preview
+@Composable
+fun UserListScreen(
+  viewModel: UserListViewModel = viewModel(),
+  onAdd: () -> Unit = {},
+  onSelect: (UserId) -> Unit = {},
+  onBack: () -> Unit = {},
+) {
+  val users by viewModel.users.collectAsState()
+  val syncState by viewModel.syncState.collectAsState()
+
+  Scaffold(
+    topBar = {
+      TopAppBar(
+        title = { Text("Meteroid") },
+        navigationIcon = {
+          IconButton(onClick = onBack) {
+            Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back")
+          }
+        },
+        modifier = Modifier
+          .padding(8.dp)
+          .shadow(4.dp, shape = RoundedCornerShape(8.dp))
+          .zIndex(1.0f)
+      )
+    }
+  ) { paddingValues ->
+    Column(modifier = Modifier.padding(paddingValues)) {
+      if (syncState == SyncHandler.State.Loading) {
+        LinearProgressIndicator()
+      }
+      LazyColumn {
+        items(users) { user ->
+          ListItem(
+            headlineContent = { Text(user.name) },
+            modifier = Modifier.clickable { onSelect(user.userId) }
+          )
+        }
+        item {
+          ListItem(
+            headlineContent = { Text("Add User") },
+            modifier = Modifier.clickable { onAdd() }
+          )
+        }
+      }
+    }
+  }
+}
diff --git a/app/src/main/kotlin/de/chaosdorf/meteroid/ui/users/UserListViewModel.kt b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/users/UserListViewModel.kt
new file mode 100644
index 0000000..ef0f243
--- /dev/null
+++ b/app/src/main/kotlin/de/chaosdorf/meteroid/ui/users/UserListViewModel.kt
@@ -0,0 +1,56 @@
+/*
+ * The MIT License (MIT)
+ *
+ * Copyright (c) 2013-2023 Chaosdorf e.V.
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package de.chaosdorf.meteroid.ui.users
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import de.chaosdorf.meteroid.model.User
+import de.chaosdorf.meteroid.model.UserRepository
+import de.chaosdorf.meteroid.storage.AccountPreferences
+import de.chaosdorf.meteroid.storage.UserSyncHandler
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.mapLatest
+import kotlinx.coroutines.flow.stateIn
+import javax.inject.Inject
+
+@HiltViewModel
+class UserListViewModel @Inject constructor(
+  accountPreferences: AccountPreferences,
+  userRepository: UserRepository,
+  syncHandler: UserSyncHandler
+) : ViewModel() {
+  private val serverId = accountPreferences.state.mapLatest { it.server }
+
+  val users: StateFlow<List<User>> = serverId.flatMapLatest { serverId ->
+    if (serverId == null) flowOf(emptyList())
+    else userRepository.getAllFlow(serverId)
+  }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList())
+
+  val syncState = syncHandler.state
+}
diff --git a/persistence/room/schemas/de.chaosdorf.meteroid.MeteroidDatabase/1.json b/persistence/room/schemas/de.chaosdorf.meteroid.MeteroidDatabase/1.json
index e1642ac..f78e563 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": "74b0d505a7abb27362a1ee0986b700e0",
+    "identityHash": "bee2a6e9f2b7a72c80a0e97b71f55e51",
     "entities": [
       {
         "tableName": "Drink",
@@ -155,7 +155,7 @@
       },
       {
         "tableName": "User",
-        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` INTEGER NOT NULL, `drinkId` INTEGER NOT NULL, `active` INTEGER NOT NULL, `name` TEXT NOT NULL, `email` TEXT NOT NULL, `balance` REAL NOT NULL, `audit` INTEGER NOT NULL, `redirect` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `drinkId`), FOREIGN KEY(`serverId`) REFERENCES `Server`(`serverId`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` INTEGER NOT NULL, `userId` INTEGER NOT NULL, `active` INTEGER NOT NULL, `name` TEXT NOT NULL, `email` TEXT NOT NULL, `balance` REAL NOT NULL, `audit` INTEGER NOT NULL, `redirect` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `userId`), FOREIGN KEY(`serverId`) REFERENCES `Server`(`serverId`) ON UPDATE NO ACTION ON DELETE CASCADE )",
         "fields": [
           {
             "fieldPath": "serverId",
@@ -164,8 +164,8 @@
             "notNull": true
           },
           {
-            "fieldPath": "drinkId",
-            "columnName": "drinkId",
+            "fieldPath": "userId",
+            "columnName": "userId",
             "affinity": "INTEGER",
             "notNull": true
           },
@@ -222,7 +222,7 @@
           "autoGenerate": false,
           "columnNames": [
             "serverId",
-            "drinkId"
+            "userId"
           ]
         },
         "indices": [],
@@ -244,7 +244,7 @@
     "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, '74b0d505a7abb27362a1ee0986b700e0')"
+      "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'bee2a6e9f2b7a72c80a0e97b71f55e51')"
     ]
   }
 }
\ No newline at end of file
diff --git a/persistence/src/main/kotlin/de/chaosdorf/meteroid/model/User.kt b/persistence/src/main/kotlin/de/chaosdorf/meteroid/model/User.kt
index c01e4b1..1c669a6 100644
--- a/persistence/src/main/kotlin/de/chaosdorf/meteroid/model/User.kt
+++ b/persistence/src/main/kotlin/de/chaosdorf/meteroid/model/User.kt
@@ -30,21 +30,20 @@ import androidx.room.ForeignKey
 import androidx.room.Insert
 import androidx.room.OnConflictStrategy
 import androidx.room.Query
-import de.chaosdorf.mete.DrinkId
 import de.chaosdorf.mete.UserId
 import de.chaosdorf.mete.v1.UserModelV1
 import kotlinx.coroutines.flow.Flow
 import kotlinx.datetime.Instant
 
 @Entity(
-  primaryKeys = ["serverId", "drinkId"],
+  primaryKeys = ["serverId", "userId"],
   foreignKeys = [
     ForeignKey(Server::class, ["serverId"], ["serverId"], onDelete = ForeignKey.CASCADE)
   ]
 )
 data class User(
   val serverId: ServerId,
-  val drinkId: UserId,
+  val userId: UserId,
   val active: Boolean,
   val name: String,
   val email: String,
@@ -72,24 +71,24 @@ data class User(
 
 @Dao
 interface UserRepository {
-  @Query("SELECT * FROM Drink WHERE serverId = :serverId AND drinkId = :drinkId LIMIT 1")
-  suspend fun get(serverId: ServerId, drinkId: DrinkId): Drink?
+  @Query("SELECT * FROM User WHERE serverId = :serverId AND userId = :userId LIMIT 1")
+  suspend fun get(serverId: ServerId, userId: UserId): User?
 
-  @Query("SELECT * FROM Drink WHERE serverId = :serverId AND drinkId = :drinkId LIMIT 1")
-  fun getFlow(serverId: ServerId, drinkId: DrinkId): Flow<Drink?>
+  @Query("SELECT * FROM User WHERE serverId = :serverId AND userId = :userId LIMIT 1")
+  fun getFlow(serverId: ServerId, userId: UserId): Flow<User?>
 
-  @Query("SELECT * FROM Drink WHERE serverId = :serverId")
-  suspend fun getAll(serverId: ServerId): List<Drink>
+  @Query("SELECT * FROM User WHERE serverId = :serverId")
+  suspend fun getAll(serverId: ServerId): List<User>
 
-  @Query("SELECT * FROM Drink WHERE serverId = :serverId")
-  fun getAllFlow(serverId: ServerId): Flow<List<Drink>>
+  @Query("SELECT * FROM User WHERE serverId = :serverId")
+  fun getAllFlow(serverId: ServerId): Flow<List<User>>
 
   @Insert(onConflict = OnConflictStrategy.REPLACE)
-  suspend fun save(drink: Drink)
+  suspend fun save(user: User)
 
-  @Query("DELETE FROM Drink WHERE serverId = :serverId AND drinkId = :drinkId")
-  suspend fun delete(serverId: ServerId, drinkId: DrinkId)
+  @Query("DELETE FROM User WHERE serverId = :serverId AND userId = :userId")
+  suspend fun delete(serverId: ServerId, userId: UserId)
 
-  @Query("DELETE FROM Drink WHERE serverId = :serverId")
+  @Query("DELETE FROM User WHERE serverId = :serverId")
   suspend fun deleteAll(serverId: ServerId)
 }
-- 
GitLab