saving & restoring settings with KStore

This commit is contained in:
2024-08-05 05:41:22 +03:00
parent 3b65d44f2f
commit e0543fd2bf
12 changed files with 220 additions and 14 deletions
+3
View File
@@ -72,6 +72,8 @@ kotlin {
implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.haze) implementation(libs.haze)
implementation(libs.haze.materials) implementation(libs.haze.materials)
implementation(libs.kstore)
implementation(libs.kstore.file)
} }
commonTest.dependencies { commonTest.dependencies {
@@ -92,6 +94,7 @@ kotlin {
implementation(compose.desktop.currentOs) implementation(compose.desktop.currentOs)
implementation(libs.kotlinx.coroutines.swing) implementation(libs.kotlinx.coroutines.swing)
implementation(libs.ktor.client.okhttp) implementation(libs.ktor.client.okhttp)
implementation(libs.appdirs)
} }
iosMain.dependencies { iosMain.dependencies {
@@ -8,8 +8,10 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
class AppActivity : ComponentActivity() { class AppActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
appDir = filesDir.path
enableEdgeToEdge() enableEdgeToEdge()
setContent { App() } setContent { App() }
} }
@@ -17,4 +19,6 @@ class AppActivity : ComponentActivity() {
@Preview @Preview
@Composable @Composable
fun AppPreview() { App() } fun AppPreview() {
App()
}
@@ -3,15 +3,27 @@ package dev.meloda.overseerr
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import cafe.adriel.voyager.navigator.Navigator import cafe.adriel.voyager.navigator.Navigator
import cafe.adriel.voyager.transitions.FadeTransition import cafe.adriel.voyager.transitions.FadeTransition
import dev.meloda.overseerr.screens.main.MainScreen import dev.meloda.overseerr.screens.main.MainScreen
import dev.meloda.overseerr.settings.SettingsController
import dev.meloda.overseerr.theme.AppTheme import dev.meloda.overseerr.theme.AppTheme
import org.koin.compose.KoinContext import org.koin.compose.KoinContext
import org.koin.compose.koinInject
var appDir: String = ""
@Composable @Composable
internal fun App() = KoinContext { internal fun App() = KoinContext {
val settingsController: SettingsController = koinInject()
LaunchedEffect(true) {
settingsController.loadAppSettings()
}
AppTheme { AppTheme {
Surface(modifier = Modifier.fillMaxSize()) { Surface(modifier = Modifier.fillMaxSize()) {
Navigator(MainScreen()) { navigator -> Navigator(MainScreen()) { navigator ->
@@ -4,6 +4,7 @@ import dev.meloda.overseerr.model.Platform
import dev.meloda.overseerr.network.di.networkModule import dev.meloda.overseerr.network.di.networkModule
import dev.meloda.overseerr.screens.login.di.loginModule import dev.meloda.overseerr.screens.login.di.loginModule
import dev.meloda.overseerr.screens.url.di.urlModule import dev.meloda.overseerr.screens.url.di.urlModule
import dev.meloda.overseerr.settings.di.settingsModule
import org.koin.core.module.dsl.singleOf import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module import org.koin.dsl.module
@@ -11,6 +12,7 @@ val appModule = module {
singleOf(::Platform) singleOf(::Platform)
includes( includes(
settingsModule,
networkModule, networkModule,
loginModule, loginModule,
urlModule urlModule
@@ -1,11 +1,58 @@
package dev.meloda.overseerr.screens.url package dev.meloda.overseerr.screens.url
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.meloda.overseerr.ext.setValue
import dev.meloda.overseerr.screens.url.model.UrlScreenState
import dev.meloda.overseerr.settings.SettingsController
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
interface UrlViewModel { interface UrlViewModel {
val screenState: StateFlow<UrlScreenState>
fun onUrlInputChanged(newText: String)
fun onLoadButtonClicked()
fun onSaveButtonClicked()
fun onTestButtonClicked()
} }
class UrlViewModelImpl : ViewModel(), UrlViewModel { class UrlViewModelImpl(
private val settingsController: SettingsController
) : ViewModel(), UrlViewModel {
override val screenState = MutableStateFlow(UrlScreenState.EMPTY)
init {
settingsController.settings
.onEach { settings -> screenState.setValue { old -> old.copy(url = settings.url) } }
.launchIn(viewModelScope)
}
override fun onUrlInputChanged(newText: String) {
screenState.setValue { old -> old.copy(url = newText) }
}
override fun onLoadButtonClicked() {
viewModelScope.launch {
val settings = settingsController.loadAppSettings()
screenState.setValue { old -> old.copy(url = settings.url) }
}
}
override fun onSaveButtonClicked() {
viewModelScope.launch {
settingsController.updateAppSettings { settings ->
settings.copy(url = screenState.value.url)
}
}
}
override fun onTestButtonClicked() {
}
} }
@@ -0,0 +1,13 @@
package dev.meloda.overseerr.screens.url.model
data class UrlScreenState(
val url: String,
val isWrongUrlError: Boolean
) {
companion object {
val EMPTY: UrlScreenState = UrlScreenState(
url = "",
isWrongUrlError = false
)
}
}
@@ -1,19 +1,26 @@
package dev.meloda.overseerr.screens.url.presentation package dev.meloda.overseerr.screens.url.presentation
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow import cafe.adriel.voyager.navigator.currentOrThrow
import dev.meloda.overseerr.screens.login.presentation.LoginScreen
import dev.meloda.overseerr.screens.url.UrlViewModel import dev.meloda.overseerr.screens.url.UrlViewModel
import dev.meloda.overseerr.screens.url.UrlViewModelImpl import dev.meloda.overseerr.screens.url.UrlViewModelImpl
import dev.meloda.overseerr.settings.SettingsController
import org.koin.compose.koinInject
class UrlScreen : Screen { class UrlScreen : Screen {
@@ -21,7 +28,11 @@ class UrlScreen : Screen {
@Composable @Composable
override fun Content() { override fun Content() {
val navigator = LocalNavigator.currentOrThrow val navigator = LocalNavigator.currentOrThrow
val viewModel: UrlViewModel = viewModel { UrlViewModelImpl() }
val settingsController: SettingsController = koinInject()
val viewModel: UrlViewModel = viewModel { UrlViewModelImpl(settingsController) }
val screenState by viewModel.screenState.collectAsState()
Scaffold( Scaffold(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@@ -39,18 +50,52 @@ class UrlScreen : Screen {
) )
} }
) { padding -> ) { padding ->
Box( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(start = padding.calculateStartPadding(LayoutDirection.Ltr)) .padding(padding)
.padding(end = padding.calculateEndPadding(LayoutDirection.Ltr)) .padding(horizontal = 16.dp),
.padding(bottom = padding.calculateBottomPadding()) horizontalAlignment = Alignment.CenterHorizontally
) {
TextField(
modifier = Modifier.fillMaxWidth(),
value = screenState.url,
onValueChange = viewModel::onUrlInputChanged,
placeholder = { Text(text = "Url") },
isError = screenState.isWrongUrlError,
singleLine = true,
keyboardOptions = KeyboardOptions.Default.copy(
imeAction = ImeAction.Go,
keyboardType = KeyboardType.Uri
)
)
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
Text(text = "Input url screen")
Button( Button(
onClick = { navigator.push(LoginScreen()) } onClick = viewModel::onLoadButtonClicked,
modifier = Modifier.weight(0.3f)
) { ) {
Text(text = "Next") Text(text = "Load")
}
Button(
onClick = viewModel::onSaveButtonClicked,
modifier = Modifier.weight(0.3f)
) {
Text(text = "Save")
}
Button(
onClick = viewModel::onTestButtonClicked,
modifier = Modifier.weight(0.3f)
) {
Text(text = "Test")
}
} }
} }
} }
@@ -0,0 +1,34 @@
package dev.meloda.overseerr.settings
import dev.meloda.overseerr.ext.setValue
import dev.meloda.overseerr.settings.model.AppSettings
import io.github.xxfast.kstore.KStore
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.withContext
interface SettingsController {
val settings: StateFlow<AppSettings>
suspend fun updateAppSettings(update: (AppSettings) -> AppSettings)
suspend fun loadAppSettings(): AppSettings
}
class SettingsControllerImpl(
private val store: KStore<AppSettings>
) : SettingsController {
override val settings = MutableStateFlow(AppSettings.EMPTY)
override suspend fun updateAppSettings(update: (AppSettings) -> AppSettings) = withContext(Dispatchers.IO) {
store.set(update(settings.value))
}
override suspend fun loadAppSettings(): AppSettings = withContext(Dispatchers.IO) {
val loadedSettings = store.get() ?: AppSettings.EMPTY
settings.setValue { loadedSettings }
loadedSettings
}
}
@@ -0,0 +1,18 @@
package dev.meloda.overseerr.settings.di
import dev.meloda.overseerr.appDir
import dev.meloda.overseerr.settings.SettingsController
import dev.meloda.overseerr.settings.SettingsControllerImpl
import dev.meloda.overseerr.settings.model.AppSettings
import io.github.xxfast.kstore.file.storeOf
import okio.Path.Companion.toPath
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.bind
import org.koin.dsl.module
val settingsModule = module {
single {
storeOf<AppSettings>(file = "$appDir/app_settings.json".toPath())
}
singleOf(::SettingsControllerImpl) bind SettingsController::class
}
@@ -0,0 +1,14 @@
package dev.meloda.overseerr.settings.model
import kotlinx.serialization.Serializable
@Serializable
data class AppSettings(
val url: String
) {
companion object {
val EMPTY: AppSettings = AppSettings(
url = ""
)
}
}
+10
View File
@@ -3,11 +3,21 @@ import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberWindowState import androidx.compose.ui.window.rememberWindowState
import dev.meloda.overseerr.App import dev.meloda.overseerr.App
import dev.meloda.overseerr.appDir
import dev.meloda.overseerr.di.appModule import dev.meloda.overseerr.di.appModule
import net.harawata.appdirs.AppDirsFactory
import org.koin.core.context.startKoin import org.koin.core.context.startKoin
import java.awt.Dimension import java.awt.Dimension
import java.io.File
fun main() = application { fun main() = application {
appDir = AppDirsFactory.getInstance()
.getUserDataDir("Overseerr-KMP", "1.0.0", "dev.meloda")
println("appDir: $appDir")
File(appDir).mkdirs()
startKoin { startKoin {
modules(appModule) modules(appModule)
} }
+5 -1
View File
@@ -14,6 +14,8 @@ multiplatformSettings = "1.1.1"
koin = "4.0.0-RC1" koin = "4.0.0-RC1"
viewmodel-compose = "2.8.0" viewmodel-compose = "2.8.0"
haze = "0.7.3" haze = "0.7.3"
kstore = "0.8.0"
appdirs = "1.2.2"
[libraries] [libraries]
@@ -39,7 +41,9 @@ koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" }
koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin" } koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin" }
haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" } haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" }
haze-materials = { module = "dev.chrisbanes.haze:haze-materials", version.ref = "haze" } haze-materials = { module = "dev.chrisbanes.haze:haze-materials", version.ref = "haze" }
kstore = { module = "io.github.xxfast:kstore", version.ref = "kstore" }
kstore-file = { module = "io.github.xxfast:kstore-file", version.ref = "kstore" }
appdirs = { module = "net.harawata:appdirs", version.ref = "appdirs" }
[plugins] [plugins]