Skip to content
Snippets Groups Projects
Unverified Commit 803d14f4 authored by Janne Mareike Koschinski's avatar Janne Mareike Koschinski
Browse files

wip: initial version with hilt

parent 489dc64a
No related branches found
No related tags found
No related merge requests found
Showing
with 580 additions and 909 deletions
......@@ -89,9 +89,12 @@ dependencies {
implementation(libs.kotlinx.serialization.json)
implementation(libs.coil.compose)
implementation(libs.hilt.navigation)
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
implementation("androidx.datastore:datastore-preferences:1.0.0")
implementation(project(":api"))
implementation(project(":persistence"))
......
......@@ -24,15 +24,18 @@
package de.chaosdorf.meteroid
import de.chaosdorf.mete.UserId
import de.chaosdorf.meteroid.model.ServerId
import kotlinx.coroutines.flow.Flow
interface Repository<K, V> {
fun getKey(value: V): K
suspend fun get(key: K): V?
fun getFlow(key: K): Flow<V?>
suspend fun getAll(): List<V>
fun getAllFlow(): Flow<List<V>>
suspend fun save(value: V)
suspend fun delete(key: K)
suspend fun deleteAll()
interface AccountPreferences {
data class State(
val server: ServerId?,
val user: UserId?
)
val state: Flow<State>
suspend fun setServer(server: ServerId?)
suspend fun setUser(userId: UserId?)
}
......@@ -24,15 +24,45 @@
package de.chaosdorf.meteroid
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import de.chaosdorf.meteroid.di.RootViewModel
import de.chaosdorf.meteroid.routes.RootRouter
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.longPreferencesKey
import de.chaosdorf.mete.UserId
import de.chaosdorf.meteroid.model.ServerId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.mapLatest
import javax.inject.Inject
@Composable
fun App(viewModel: RootViewModel) {
val route by viewModel.route.collectAsState()
class AccountPreferencesImpl @Inject constructor(
private val dataStore: DataStore<Preferences>
) : AccountPreferences {
RootRouter(route)
override val state: Flow<AccountPreferences.State> =
dataStore.data.mapLatest {
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,
)
}
override suspend fun setServer(server: ServerId?) {
dataStore.edit {
it[SERVER_KEY] = server?.value ?: -1L
}
}
override suspend fun setUser(userId: UserId?) {
dataStore.edit {
it[SERVER_KEY] = userId?.value ?: -1L
}
}
private companion object {
val SERVER_KEY = longPreferencesKey("serverId")
val USER_KEY = longPreferencesKey("userId")
}
}
......@@ -22,54 +22,42 @@
* THE SOFTWARE.
*/
package de.chaosdorf.meteroid.di
package de.chaosdorf.meteroid
import androidx.room.withTransaction
import de.chaosdorf.mete.DrinkId
import de.chaosdorf.mete.v1.MeteApiV1Factory
import de.chaosdorf.meteroid.MeteroidDatabase
import de.chaosdorf.meteroid.RepositorySyncHandler
import de.chaosdorf.meteroid.SyncHandler
import de.chaosdorf.meteroid.model.Drink
import de.chaosdorf.meteroid.model.DrinkRepository
import de.chaosdorf.meteroid.model.Server
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import de.chaosdorf.meteroid.model.ServerId
import javax.inject.Inject
class DrinkSyncHandler @Inject constructor(
private val db: MeteroidDatabase,
private val repository: DrinkRepository
) : SyncHandler<Server, Drink, DrinkSyncHandler.Key>() {
data class Key(
val server: ServerId, val drink: DrinkId
)
interface MainLayoutViewModelFactory {
fun newInstance(
scope: CoroutineScope, db: MeteroidDatabase, server: Server, onOpenServerSelection: () -> Unit
): MainLayoutViewModel = MainLayoutViewModelImpl(scope, db, server, onOpenServerSelection)
}
override suspend fun withTransaction(block: suspend () -> Unit) =
db.withTransaction(block)
interface MainLayoutViewModel {
val server: Server
val syncState: StateFlow<SyncHandler.State>
val drinkListViewModel: DrinkListViewModel
override suspend fun store(entry: Drink) =
repository.save(entry)
fun openServerSelection()
}
override suspend fun delete(key: Key) =
repository.delete(key.server, key.drink)
class MainLayoutViewModelImpl(
scope: CoroutineScope,
db: MeteroidDatabase,
override val server: Server,
private val onOpenServerSelection: () -> Unit
) : MainLayoutViewModel {
private val api = MeteApiV1Factory.newInstance(server.url)
override val drinkListViewModel = DrinkListViewModelImpl(scope, db.drinks())
private val syncHandler = RepositorySyncHandler(db::withTransaction, db.drinks()) {
api.listDrinks().map { Drink.fromModelV1(it) }
}
override val syncState: StateFlow<SyncHandler.State> = syncHandler.state
override fun entryToKey(entry: Drink) = Key(entry.serverId, entry.drinkId)
init {
scope.launch {
syncHandler.doSync()
}
}
override suspend fun loadStored(context: Server): List<Drink> =
repository.getAll(context.serverId)
override fun openServerSelection() {
onOpenServerSelection()
override suspend fun loadCurrent(context: Server): List<Drink> {
val api = MeteApiV1Factory.newInstance(context.url)
val loadedEntries = api.listDrinks()
return loadedEntries.map { Drink.fromModelV1(context.serverId, it) }
}
}
......@@ -27,28 +27,347 @@ 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 de.chaosdorf.meteroid.di.RootViewModel
import de.chaosdorf.meteroid.di.RootViewModelFactory
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.theme.MeteroidTheme
import kotlinx.coroutines.MainScope
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() {
private val rootViewModelFactory = object : RootViewModelFactory {}
private lateinit var rootViewModel: RootViewModel
@Inject
lateinit var accountPreferences: AccountPreferences
@Inject
lateinit var serverRepository: ServerRepository
@Inject
lateinit var drinkSyncHandler: DrinkSyncHandler
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val scope = MainScope()
rootViewModel =
rootViewModelFactory.newInstance(scope, applicationContext)
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 {
App(rootViewModel)
AppRouter()
}
}
}
}
@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())
}
......@@ -27,33 +27,46 @@ package de.chaosdorf.meteroid
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
class RepositorySyncHandler<K, V>(
private val withTransaction: suspend (block: suspend () -> Any?) -> Any?,
private val repository: Repository<K, V>,
private val loader: suspend () -> List<V>,
) : SyncHandler {
private val _state = MutableStateFlow<SyncHandler.State>(SyncHandler.State.Idle)
override val state: StateFlow<SyncHandler.State> = _state
override suspend fun doSync() {
_state.value = SyncHandler.State.Syncing
val values =
try {
loader()
} catch (e: Exception) {
_state.value = SyncHandler.State.Error("Error while syncing: $e")
return
abstract class SyncHandler<Context, Entry, Key> {
sealed class State {
data object Idle : State()
data object Loading : State()
data class Error(val message: String) : State()
}
abstract suspend fun withTransaction(block: suspend () -> Unit)
abstract suspend fun loadCurrent(context: Context): List<Entry>
abstract suspend fun loadStored(context: Context): List<Entry>
abstract fun entryToKey(entry: Entry): Key
abstract suspend fun delete(key: Key)
abstract suspend fun store(entry: Entry)
private val _state = MutableStateFlow<State>(State.Idle)
val state: StateFlow<State> = _state
suspend fun sync(context: Context) {
if (_state.compareAndSet(State.Idle, State.Loading)) {
try {
val loadedEntries = loadCurrent(context)
withTransaction {
val existing = repository.getAll().map(repository::getKey).toSet()
val deletedEntries = existing - values.map(repository::getKey).toSet()
for (deletedEntry in deletedEntries) {
repository.delete(deletedEntry)
val storedEntries = loadStored(context)
val storedKeys = storedEntries.map(::entryToKey).toSet()
val loadedKeys = loadedEntries.map(::entryToKey).toSet()
val removedKeys = storedKeys - loadedKeys
for (removedKey in removedKeys) {
delete(removedKey)
}
for (entry in values) {
repository.save(entry)
for (loadedEntry in loadedEntries) {
store(loadedEntry)
}
}
_state.value = State.Idle
} catch (e: Exception) {
_state.value = State.Error("Error while syncing data: $e")
}
}
_state.value = SyncHandler.State.Idle
}
}
/*
* 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.di
import android.util.Log
import de.chaosdorf.mete.PwaManifest
import de.chaosdorf.meteroid.model.Server
import de.chaosdorf.meteroid.model.ServerId
import de.chaosdorf.meteroid.util.await
import de.chaosdorf.meteroid.util.findBestIcon
import de.chaosdorf.meteroid.util.resolve
import kotlinx.coroutines.CoroutineScope
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.mapNotNull
import kotlinx.coroutines.flow.stateIn
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
interface AddServerViewModelFactory {
fun newInstance(
scope: CoroutineScope,
isFirstServer: Boolean,
onSubmit: (url: String, manifest: PwaManifest?) -> Unit,
onCancel: () -> Unit
) = AddServerViewModelImpl(scope, isFirstServer, onSubmit, onCancel)
}
interface AddServerViewModel {
val url: MutableStateFlow<String>
val server: StateFlow<Server?>
val loading: StateFlow<Boolean>
val isFirstServer: Boolean
fun submit()
fun cancel()
}
class AddServerViewModelImpl(
scope: CoroutineScope,
override val isFirstServer: Boolean,
private val onSubmit: (url: String, manifest: PwaManifest?) -> Unit,
private val onCancel: () -> Unit
) : AddServerViewModel {
private val json = Json {
ignoreUnknownKeys = true
}
private val httpClient = OkHttpClient()
override val url = MutableStateFlow("")
private val _loading = MutableStateFlow(false)
override val loading = _loading
@OptIn(ExperimentalSerializationApi::class)
private val manifest = url.debounce(300).mapNotNull { address ->
_loading.value = true
try {
val url = address.toHttpUrl().resolve("manifest.json")
val call = httpClient.newCall(Request.Builder().url(url!!).build())
val body = call.await()
val manifest = json.decodeFromStream<PwaManifest>(body.byteStream())
Pair(address, manifest)
} catch (_: Exception) {
null
} finally {
_loading.value = false
}
}.stateIn(scope, SharingStarted.WhileSubscribed(), null)
override val server = manifest.mapLatest { pair ->
pair?.let { (url, manifest) ->
Server(
id = ServerId(-1),
url = url,
name = manifest.name,
logoUrl = manifest.findBestIcon()?.resolve(url)
)
}
}.stateIn(scope, SharingStarted.WhileSubscribed(), null)
override fun submit() {
onSubmit(url.value, manifest.value?.second)
}
override fun cancel() {
onCancel()
}
}
......@@ -22,18 +22,45 @@
* THE SOFTWARE.
*/
package de.chaosdorf.meteroid
package de.chaosdorf.meteroid.di
import kotlinx.coroutines.flow.StateFlow
import android.content.Context
import androidx.room.Room
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import de.chaosdorf.meteroid.MeteroidDatabase
import de.chaosdorf.meteroid.model.DrinkRepository
import de.chaosdorf.meteroid.model.ServerRepository
import de.chaosdorf.meteroid.model.UserRepository
import javax.inject.Singleton
interface SyncHandler {
val state: StateFlow<State>
suspend fun doSync()
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@Singleton
@Provides
fun provideDatabase(
@ApplicationContext context: Context
): MeteroidDatabase = Room
.databaseBuilder(context, MeteroidDatabase::class.java, "mete")
.build()
sealed class State {
data object Idle : State()
data object Syncing : State()
data class Error(val message: String) : State()
}
@Provides
fun provideDrinkRepository(
database: MeteroidDatabase
): DrinkRepository = database.drinks()
@Provides
fun provideUserRepository(
database: MeteroidDatabase
): UserRepository = database.users()
@Provides
fun provideServerRepository(
database: MeteroidDatabase
): ServerRepository = database.server()
}
......@@ -24,29 +24,37 @@
package de.chaosdorf.meteroid.di
import de.chaosdorf.mete.DrinkId
import de.chaosdorf.meteroid.model.Drink
import de.chaosdorf.meteroid.Repository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore
import dagger.Binds
import dagger.Module
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 javax.inject.Singleton
interface DrinkViewModelFactory {
fun newInstance(
scope: CoroutineScope,
repository: Repository<DrinkId, Drink>
) = DrinkListViewModelImpl(scope, repository)
}
val Context.accountDataStore: DataStore<Preferences> by preferencesDataStore(name = "account")
interface DrinkListViewModel {
val drinks: StateFlow<List<Drink>>
@Module
@InstallIn(SingletonComponent::class)
object PreferenceModule {
@Singleton
@Provides
fun provideAccountPreferences(
@ApplicationContext context: Context
): DataStore<Preferences> = context.accountDataStore
}
class DrinkListViewModelImpl(
scope: CoroutineScope,
repository: Repository<DrinkId, Drink>
) : DrinkListViewModel {
override val drinks: StateFlow<List<Drink>> = repository.getAllFlow()
.stateIn(scope, SharingStarted.WhileSubscribed(), emptyList())
@Module
@InstallIn(SingletonComponent::class)
abstract class AccountPreferenceModule {
@Binds
abstract fun bindsAccountPreferences(
impl: AccountPreferencesImpl
): AccountPreferences
}
/*
* 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.di
import android.content.Context
import androidx.room.Room
import de.chaosdorf.mete.v1.MeteApiV1Factory
import de.chaosdorf.meteroid.MeteroidDatabase
import de.chaosdorf.meteroid.Repository
import de.chaosdorf.meteroid.SyncHandler
import de.chaosdorf.meteroid.model.Server
import de.chaosdorf.meteroid.model.ServerId
import de.chaosdorf.meteroid.util.findBestIcon
import de.chaosdorf.meteroid.util.resolve
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
interface RootViewModelFactory {
fun newInstance(
scope: CoroutineScope, context: Context
): RootViewModel = RootViewModelImpl(scope, context)
}
interface RootViewModel {
val route: StateFlow<RootRoute>
}
class ServerListSyncHandler(
private val repository: Repository<ServerId, Server>,
) : SyncHandler {
private val _state = MutableStateFlow<SyncHandler.State>(SyncHandler.State.Idle)
override val state = _state
override suspend fun doSync() {
_state.value = SyncHandler.State.Syncing
for (server in repository.getAll()) {
val api = MeteApiV1Factory.newInstance(server.url)
val manifest = api.getManifest()
val updated = server.copy(
name = manifest?.name,
logoUrl = manifest?.findBestIcon()?.resolve(server.url)
)
repository.save(updated)
}
_state.value = SyncHandler.State.Idle
}
}
class RootViewModelImpl(
scope: CoroutineScope, context: Context
) : RootViewModel {
private val db = Room.databaseBuilder(context, MeteroidDatabase::class.java, "mete").build()
private val syncHandler = ServerListSyncHandler(db.server())
private val setupViewModelFactory: SetupViewModelFactory = object : SetupViewModelFactory {}
private val mainLayoutViewModelFactory: MainLayoutViewModelFactory =
object : MainLayoutViewModelFactory {}
private val _serverSelectionOpen = MutableStateFlow(false)
private val _serverId: MutableStateFlow<ServerId?> = MutableStateFlow(null)
private val _server: StateFlow<Server?> = _serverId.flatMapLatest {
it?.let { db.server().getFlow(it) } ?: flowOf(null)
}.stateIn(scope, SharingStarted.WhileSubscribed(), null)
override val route: StateFlow<RootRoute> =
combine(_server, _serverSelectionOpen) { server, serverSelectionOpen ->
if (server == null || serverSelectionOpen) {
RootRoute.Setup(
setupViewModelFactory.newInstance(
scope,
db,
db.server(),
server != null,
::onSelect,
::onCloseServerSelection
)
)
} else {
RootRoute.MainLayout(
mainLayoutViewModelFactory.newInstance(
scope,
db,
server,
::onOpenServerSelection
)
)
}
}.stateIn(scope, SharingStarted.WhileSubscribed(), RootRoute.Init)
init {
scope.launch { syncHandler.doSync() }
}
private fun onSelect(server: ServerId) {
_serverId.value = server
_serverSelectionOpen.value = false
}
private fun onOpenServerSelection() {
_serverSelectionOpen.value = true
}
private fun onCloseServerSelection() {
_serverSelectionOpen.value = true
}
}
sealed class RootRoute {
data object Init : RootRoute()
data class Setup(val viewModel: SetupViewModel) : RootRoute()
data class MainLayout(val viewModel: MainLayoutViewModel) : RootRoute()
}
/*
* 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.di
import androidx.room.withTransaction
import de.chaosdorf.mete.PwaManifest
import de.chaosdorf.meteroid.MeteroidDatabase
import de.chaosdorf.meteroid.Repository
import de.chaosdorf.meteroid.model.Server
import de.chaosdorf.meteroid.model.ServerId
import de.chaosdorf.meteroid.util.findBestIcon
import de.chaosdorf.meteroid.util.resolve
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
interface SetupViewModelFactory {
fun newInstance(
scope: CoroutineScope,
db: MeteroidDatabase,
repository: Repository<ServerId, Server>,
hasSelectedServer: Boolean,
onSelect: (server: ServerId) -> Unit,
onClose: () -> Unit
): SetupViewModel = SetupViewModelImpl(
scope, db, repository, hasSelectedServer, onSelect, onClose
)
}
interface SetupViewModel {
val route: StateFlow<SetupRoute>
}
class SetupViewModelImpl(
private val scope: CoroutineScope,
private val db: MeteroidDatabase,
private val repository: Repository<ServerId, Server>,
private val hasSelectedServer: Boolean,
private val onSelect: (server: ServerId) -> Unit,
private val onClose: () -> Unit
) : SetupViewModel {
private val addServerViewModelFactory = object : AddServerViewModelFactory {}
private val serverSelectionViewModelFactory = object : ServerSelectionViewModelFactory {}
private val isFirstServer = repository.getAllFlow().map(List<Server>::isEmpty)
private val isAddingServer = MutableStateFlow(false)
override val route: StateFlow<SetupRoute> =
combine(isFirstServer, isAddingServer) { isFirstServer, addingServer ->
if (addingServer || isFirstServer) {
SetupRoute.AddServer(
addServerViewModelFactory.newInstance(
scope, isFirstServer, ::onSubmit, ::onCancel
)
)
} else {
SetupRoute.ServerSelection(
serverSelectionViewModelFactory.newInstance(
scope, repository, hasSelectedServer, ::onAddServer, onSelect, onClose
)
)
}
}.stateIn(
scope, SharingStarted.WhileSubscribed(), SetupRoute.ServerSelection(
serverSelectionViewModelFactory.newInstance(
scope, repository, hasSelectedServer, ::onAddServer, onSelect, onClose
)
)
)
private fun onAddServer() {
isAddingServer.value = true
}
private fun onSubmit(url: String, manifest: PwaManifest?) {
isAddingServer.value = false
scope.launch {
db.withTransaction {
val lastId = repository.getAll().map(Server::id).maxByOrNull(ServerId::value)?.value ?: 0
repository.save(
Server(
id = ServerId(lastId + 1),
url = url,
name = manifest?.name,
logoUrl = manifest?.findBestIcon()?.resolve(url)
)
)
}
}
}
private fun onCancel() {
isAddingServer.value = false
}
}
sealed class SetupRoute {
data class ServerSelection(val viewModel: ServerSelectionViewModel) : SetupRoute()
data class AddServer(val viewModel: AddServerViewModel) : SetupRoute()
}
/*
* 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.routes
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@Composable
fun InitRoute() {
Text("Loading…")
}
/*
* 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.routes
import androidx.compose.runtime.Composable
import de.chaosdorf.meteroid.di.RootRoute
import de.chaosdorf.meteroid.routes.main.MainLayoutRoute
import de.chaosdorf.meteroid.routes.setup.SetupView
@Composable
fun RootRouter(route: RootRoute) {
when (route) {
RootRoute.Init -> InitRoute()
is RootRoute.MainLayout -> MainLayoutRoute(viewModel = route.viewModel)
is RootRoute.Setup -> SetupView(viewModel = route.viewModel)
}
}
/*
* 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.routes
import androidx.compose.runtime.Composable
import de.chaosdorf.meteroid.di.SetupRoute
import de.chaosdorf.meteroid.routes.setup.AddServerRoute
import de.chaosdorf.meteroid.routes.setup.ServerSelectionRoute
@Composable
fun SetupRouter(route: SetupRoute) {
when (route) {
is SetupRoute.AddServer -> AddServerRoute(viewModel = route.viewModel)
is SetupRoute.ServerSelection -> ServerSelectionRoute(viewModel = route.viewModel)
}
}
/*
* 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.routes.main
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import de.chaosdorf.meteroid.di.DrinkListViewModel
@Composable
fun DrinkList(viewModel: DrinkListViewModel) {
val drinks by viewModel.drinks.collectAsState()
LazyColumn {
items(drinks) { drink ->
Text(drink.name)
}
}
}
/*
* 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.routes.main
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
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 de.chaosdorf.meteroid.RepositorySyncHandler
import de.chaosdorf.meteroid.SyncHandler
import de.chaosdorf.meteroid.di.MainLayoutViewModel
@Composable
fun MainLayoutRoute(viewModel: MainLayoutViewModel) {
val syncState by viewModel.syncState.collectAsState()
Scaffold(
topBar = {
TopAppBar(
title = {
Text("Meteroid")
},
navigationIcon = {
Icon(
Icons.AutoMirrored.Default.ArrowBack,
modifier = Modifier.clickable {
viewModel.openServerSelection()
},
contentDescription = "Back"
)
}
)
}
) { paddingValues ->
Column(Modifier.padding(paddingValues)) {
if (syncState == SyncHandler.State.Syncing) {
LinearProgressIndicator()
}
DrinkList(viewModel.drinkListViewModel)
}
}
}
/*
* 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.routes.setup
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.LinearProgressIndicator
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.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import coil.compose.AsyncImage
import de.chaosdorf.meteroid.di.AddServerViewModel
@Composable
fun AddServerRoute(viewModel: AddServerViewModel) {
val url by viewModel.url.collectAsState()
val server by viewModel.server.collectAsState()
val loading by viewModel.loading.collectAsState()
Scaffold(
topBar = {
TopAppBar(title = {
Text("Add Server")
})
}
) { paddingValues ->
Column(modifier = Modifier.padding(paddingValues)) {
if (loading) {
LinearProgressIndicator()
}
TextField(
label = {
Text("Server URL")
},
value = url,
onValueChange = { value ->
viewModel.url.value = value
}
)
Button(
onClick = viewModel::submit
) {
Text("Save")
}
if (!viewModel.isFirstServer) {
Button(
onClick = viewModel::cancel
) {
Text("Cancel")
}
}
server?.let {
ListItem(
headlineContent = { Text(it.name ?: it.url) },
leadingContent = {
AsyncImage(model = it.logoUrl, contentDescription = null)
}
)
}
}
}
}
/*
* 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.routes.setup
import androidx.compose.foundation.clickable
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.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
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 coil.compose.AsyncImage
import de.chaosdorf.meteroid.di.ServerSelectionViewModel
@Composable
fun ServerSelectionRoute(viewModel: ServerSelectionViewModel) {
val servers by viewModel.servers.collectAsState()
Scaffold(
topBar = {
TopAppBar(
title = { Text("Select Server") },
)
},
floatingActionButton = {
FloatingActionButton(
onClick = { viewModel.addServer() }
) {
Icon(
Icons.Default.Add,
contentDescription = "Select Server"
)
}
}
) { paddingValues ->
LazyColumn(modifier = Modifier.padding(paddingValues)) {
items(servers) { server ->
ListItem(
modifier = Modifier.clickable {
viewModel.select(server.id)
},
headlineContent = { Text(server.name ?: server.url) },
leadingContent = {
AsyncImage(model = server.logoUrl, contentDescription = null)
},
trailingContent = {
Icon(
Icons.Default.Delete,
modifier = Modifier.clickable {
viewModel.remove(server.id)
},
contentDescription = "Delete"
)
}
)
}
}
}
}
/*
* 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.routes.setup
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import de.chaosdorf.meteroid.routes.SetupRouter
import de.chaosdorf.meteroid.di.SetupViewModel
@Composable
fun SetupView(viewModel: SetupViewModel) {
val route by viewModel.route.collectAsState()
SetupRouter(route)
}
......@@ -8,6 +8,7 @@ androidx-compose-material = "1.5.0-alpha04"
androidx-compose-material3 = "1.2.0-alpha10"
androidx-compose-runtimetracing = "1.0.0-alpha04"
androidx-compose-tooling = "1.6.0-alpha08"
androidx-hilt = "1.0.0"
androidx-navigation = "2.7.4"
androidx-room = "2.6.0"
coil = "2.4.0"
......@@ -52,6 +53,7 @@ coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" }
hilt-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "dagger-hilt" }
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "dagger-hilt" }
hilt-navigation = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "androidx-hilt" }
okhttp = { module = "com.squareup.okhttp3:okhttp", version = "4.12.0" }
retrofit-core = { module = "com.squareup.retrofit2:retrofit", version = "2.9.0" }
retrofit-converter-kotlinx = { module = "com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter", version = "1.0.0" }
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment