diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 3b2b092..db055e6 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -72,6 +72,8 @@ kotlin { implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.haze) implementation(libs.haze.materials) + implementation(libs.kstore) + implementation(libs.kstore.file) } commonTest.dependencies { @@ -92,6 +94,7 @@ kotlin { implementation(compose.desktop.currentOs) implementation(libs.kotlinx.coroutines.swing) implementation(libs.ktor.client.okhttp) + implementation(libs.appdirs) } iosMain.dependencies { diff --git a/composeApp/src/androidMain/kotlin/dev/meloda/overseerr/App.android.kt b/composeApp/src/androidMain/kotlin/dev/meloda/overseerr/App.android.kt index ce6e9a5..064d01a 100644 --- a/composeApp/src/androidMain/kotlin/dev/meloda/overseerr/App.android.kt +++ b/composeApp/src/androidMain/kotlin/dev/meloda/overseerr/App.android.kt @@ -8,8 +8,10 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.Preview class AppActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + appDir = filesDir.path enableEdgeToEdge() setContent { App() } } @@ -17,4 +19,6 @@ class AppActivity : ComponentActivity() { @Preview @Composable -fun AppPreview() { App() } +fun AppPreview() { + App() +} diff --git a/composeApp/src/commonMain/kotlin/dev/meloda/overseerr/App.kt b/composeApp/src/commonMain/kotlin/dev/meloda/overseerr/App.kt index edf6a1f..ec73908 100644 --- a/composeApp/src/commonMain/kotlin/dev/meloda/overseerr/App.kt +++ b/composeApp/src/commonMain/kotlin/dev/meloda/overseerr/App.kt @@ -3,15 +3,27 @@ package dev.meloda.overseerr import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.Surface import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import cafe.adriel.voyager.navigator.Navigator import cafe.adriel.voyager.transitions.FadeTransition import dev.meloda.overseerr.screens.main.MainScreen +import dev.meloda.overseerr.settings.SettingsController import dev.meloda.overseerr.theme.AppTheme import org.koin.compose.KoinContext +import org.koin.compose.koinInject + +var appDir: String = "" @Composable internal fun App() = KoinContext { + + val settingsController: SettingsController = koinInject() + + LaunchedEffect(true) { + settingsController.loadAppSettings() + } + AppTheme { Surface(modifier = Modifier.fillMaxSize()) { Navigator(MainScreen()) { navigator -> diff --git a/composeApp/src/commonMain/kotlin/dev/meloda/overseerr/di/AppModule.kt b/composeApp/src/commonMain/kotlin/dev/meloda/overseerr/di/AppModule.kt index f101a41..fd46796 100644 --- a/composeApp/src/commonMain/kotlin/dev/meloda/overseerr/di/AppModule.kt +++ b/composeApp/src/commonMain/kotlin/dev/meloda/overseerr/di/AppModule.kt @@ -4,6 +4,7 @@ import dev.meloda.overseerr.model.Platform import dev.meloda.overseerr.network.di.networkModule import dev.meloda.overseerr.screens.login.di.loginModule 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.dsl.module @@ -11,6 +12,7 @@ val appModule = module { singleOf(::Platform) includes( + settingsModule, networkModule, loginModule, urlModule diff --git a/composeApp/src/commonMain/kotlin/dev/meloda/overseerr/screens/url/UrlViewModel.kt b/composeApp/src/commonMain/kotlin/dev/meloda/overseerr/screens/url/UrlViewModel.kt index f7ff1ed..f437293 100644 --- a/composeApp/src/commonMain/kotlin/dev/meloda/overseerr/screens/url/UrlViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/meloda/overseerr/screens/url/UrlViewModel.kt @@ -1,11 +1,58 @@ package dev.meloda.overseerr.screens.url 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 { + val screenState: StateFlow + 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() { + + } } diff --git a/composeApp/src/commonMain/kotlin/dev/meloda/overseerr/screens/url/model/UrlScreenState.kt b/composeApp/src/commonMain/kotlin/dev/meloda/overseerr/screens/url/model/UrlScreenState.kt new file mode 100644 index 0000000..33d685a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/meloda/overseerr/screens/url/model/UrlScreenState.kt @@ -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 + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/meloda/overseerr/screens/url/presentation/UrlScreen.kt b/composeApp/src/commonMain/kotlin/dev/meloda/overseerr/screens/url/presentation/UrlScreen.kt index d2ef5e2..9d737b1 100644 --- a/composeApp/src/commonMain/kotlin/dev/meloda/overseerr/screens/url/presentation/UrlScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/meloda/overseerr/screens/url/presentation/UrlScreen.kt @@ -1,19 +1,26 @@ package dev.meloda.overseerr.screens.url.presentation import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material3.* 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.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 cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.navigator.LocalNavigator 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.UrlViewModelImpl +import dev.meloda.overseerr.settings.SettingsController +import org.koin.compose.koinInject class UrlScreen : Screen { @@ -21,7 +28,11 @@ class UrlScreen : Screen { @Composable override fun Content() { 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( modifier = Modifier.fillMaxSize(), @@ -39,18 +50,52 @@ class UrlScreen : Screen { ) } ) { padding -> - Box( + Column( modifier = Modifier .fillMaxSize() - .padding(start = padding.calculateStartPadding(LayoutDirection.Ltr)) - .padding(end = padding.calculateEndPadding(LayoutDirection.Ltr)) - .padding(bottom = padding.calculateBottomPadding()) + .padding(padding) + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally ) { - Text(text = "Input url screen") - Button( - onClick = { navigator.push(LoginScreen()) } + 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 = "Next") + Button( + onClick = viewModel::onLoadButtonClicked, + modifier = Modifier.weight(0.3f) + ) { + 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") + } } } } diff --git a/composeApp/src/commonMain/kotlin/dev/meloda/overseerr/settings/SettingsController.kt b/composeApp/src/commonMain/kotlin/dev/meloda/overseerr/settings/SettingsController.kt new file mode 100644 index 0000000..cdc8436 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/meloda/overseerr/settings/SettingsController.kt @@ -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 + + suspend fun updateAppSettings(update: (AppSettings) -> AppSettings) + suspend fun loadAppSettings(): AppSettings +} + +class SettingsControllerImpl( + private val store: KStore +) : 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 + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/meloda/overseerr/settings/di/SettingsModule.kt b/composeApp/src/commonMain/kotlin/dev/meloda/overseerr/settings/di/SettingsModule.kt new file mode 100644 index 0000000..5e04ea2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/meloda/overseerr/settings/di/SettingsModule.kt @@ -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(file = "$appDir/app_settings.json".toPath()) + } + singleOf(::SettingsControllerImpl) bind SettingsController::class +} diff --git a/composeApp/src/commonMain/kotlin/dev/meloda/overseerr/settings/model/AppSettings.kt b/composeApp/src/commonMain/kotlin/dev/meloda/overseerr/settings/model/AppSettings.kt new file mode 100644 index 0000000..c564c49 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/meloda/overseerr/settings/model/AppSettings.kt @@ -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 = "" + ) + } +} diff --git a/composeApp/src/jvmMain/kotlin/main.kt b/composeApp/src/jvmMain/kotlin/main.kt index 591f256..e9c9f72 100644 --- a/composeApp/src/jvmMain/kotlin/main.kt +++ b/composeApp/src/jvmMain/kotlin/main.kt @@ -3,11 +3,21 @@ import androidx.compose.ui.window.Window import androidx.compose.ui.window.application import androidx.compose.ui.window.rememberWindowState import dev.meloda.overseerr.App +import dev.meloda.overseerr.appDir import dev.meloda.overseerr.di.appModule +import net.harawata.appdirs.AppDirsFactory import org.koin.core.context.startKoin import java.awt.Dimension +import java.io.File fun main() = application { + appDir = AppDirsFactory.getInstance() + .getUserDataDir("Overseerr-KMP", "1.0.0", "dev.meloda") + + println("appDir: $appDir") + + File(appDir).mkdirs() + startKoin { modules(appModule) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7587211..1c905a3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,6 +14,8 @@ multiplatformSettings = "1.1.1" koin = "4.0.0-RC1" viewmodel-compose = "2.8.0" haze = "0.7.3" +kstore = "0.8.0" +appdirs = "1.2.2" [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" } haze = { module = "dev.chrisbanes.haze:haze", 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]