saving & restoring settings with KStore
This commit is contained in:
@@ -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() {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
+13
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+55
-10
@@ -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 = ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user