add bottom navigation bar, settings screen, etc

This commit is contained in:
2025-09-28 21:21:30 +03:00
parent 864f6f8ce3
commit 5aea08ffd4
38 changed files with 538 additions and 528 deletions
+13
View File
@@ -3,6 +3,7 @@ import org.jetbrains.compose.desktop.application.dsl.TargetFormat
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackConfig
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
import java.util.*
plugins {
@@ -14,6 +15,17 @@ plugins {
alias(libs.plugins.kotlinx.serialization)
}
tasks.withType<KotlinCompile>().configureEach {
compilerOptions {
freeCompilerArgs.addAll(
"-opt-in=kotlin.RequiresOptIn",
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
"-opt-in=kotlinx.coroutines.FlowPreview",
"-Xexpect-actual-classes"
)
}
}
kotlin {
androidTarget {
compilerOptions {
@@ -172,6 +184,7 @@ android {
}
getByName("release") {
signingConfig = signingConfigs.getByName("release")
// signingConfig = signingConfigs.getByName("debug")
isMinifyEnabled = true
isShrinkResources = true
@@ -4,8 +4,6 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
class AppActivity : ComponentActivity() {
@@ -16,9 +14,3 @@ class AppActivity : ComponentActivity() {
setContent { App() }
}
}
@Preview
@Composable
fun AppPreview() {
App()
}
@@ -1,12 +1,12 @@
package dev.meloda.overseerr.settings.model
package dev.meloda.overseerr.datastore
import dev.meloda.overseerr.appDir
import dev.meloda.overseerr.datastore.model.AppSettings
import io.github.xxfast.kstore.KStore
import io.github.xxfast.kstore.file.storeOf
import kotlinx.io.files.Path
actual class SettingsStoreProvider actual constructor() {
actual fun provideStore(): KStore<AppSettings> {
return storeOf(file = Path("$appDir/app_settings.json"))
}
@@ -1,6 +0,0 @@
package dev.meloda.overseerr.model
internal actual class Platform actual constructor() {
actual val name: String
get() = "Android"
}
@@ -1,4 +1,4 @@
package dev.meloda.overseerr.network.model
package dev.meloda.overseerr.network
import io.ktor.client.engine.*
import io.ktor.client.engine.okhttp.*
@@ -7,25 +7,17 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import dev.meloda.overseerr.screens.login.presentation.LoginScreen
import dev.meloda.overseerr.screens.main.MainScreen
import dev.meloda.overseerr.screens.requests.presentation.RequestsScreen
import dev.meloda.overseerr.screens.url.presentation.UrlScreen
import dev.meloda.overseerr.settings.SettingsController
import dev.meloda.overseerr.datastore.SettingsController
import dev.meloda.overseerr.theme.AppTheme
import dev.meloda.overseerr.theme.NavigationSettings
import io.github.aakira.napier.DebugAntilog
import io.github.aakira.napier.Napier
import org.koin.compose.KoinContext
import org.koin.compose.koinInject
var appDir: String = ""
@Composable
internal fun App() = KoinContext {
internal fun App() {
LaunchedEffect(true) {
Napier.base(DebugAntilog())
}
@@ -37,32 +29,9 @@ internal fun App() = KoinContext {
settingsController.loadAppSettings()
}
val navController = rememberNavController()
NavigationSettings(navController)
AppTheme(themeMode = settings.themeMode) {
Surface(modifier = Modifier.fillMaxSize()) {
NavHost(
navController = navController,
startDestination = MainScreen
) {
composable<MainScreen> {
MainScreen(navController)
}
composable<LoginScreen> {
LoginScreen(onBack = navController::popBackStack)
}
composable<RequestsScreen> {
RequestsScreen(onBack = navController::popBackStack)
}
composable<UrlScreen> {
UrlScreen(onBack = navController::popBackStack)
}
}
MainScreen()
}
}
}
@@ -1,8 +1,8 @@
package dev.meloda.overseerr.settings
package dev.meloda.overseerr.datastore
import dev.meloda.overseerr.ext.setValue
import dev.meloda.overseerr.settings.model.AppSettings
import dev.meloda.overseerr.settings.model.ThemeMode
import dev.meloda.overseerr.datastore.model.AppSettings
import dev.meloda.overseerr.datastore.model.ThemeMode
import io.github.xxfast.kstore.KStore
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -1,8 +1,8 @@
package dev.meloda.overseerr.settings.model
package dev.meloda.overseerr.datastore
import dev.meloda.overseerr.datastore.model.AppSettings
import io.github.xxfast.kstore.KStore
expect class SettingsStoreProvider() {
fun provideStore(): KStore<AppSettings>
}
@@ -0,0 +1,13 @@
package dev.meloda.overseerr.datastore.di
import dev.meloda.overseerr.datastore.SettingsController
import dev.meloda.overseerr.datastore.SettingsControllerImpl
import dev.meloda.overseerr.datastore.SettingsStoreProvider
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.bind
import org.koin.dsl.module
val dataStoreModule = module {
single { SettingsStoreProvider().provideStore() }
singleOf(::SettingsControllerImpl) bind SettingsController::class
}
@@ -1,12 +1,12 @@
package dev.meloda.overseerr.settings.model
package dev.meloda.overseerr.datastore.model
import kotlinx.serialization.Serializable
@Serializable
data class AppSettings(
val url: String = "",
val plexToken: String = "",
val themeMode: ThemeMode = ThemeMode.System
val url: String? = null,
val plexToken: String? = null,
val themeMode: ThemeMode = ThemeMode.System,
) {
companion object {
val EMPTY: AppSettings = AppSettings()
@@ -1,4 +1,4 @@
package dev.meloda.overseerr.settings.model
package dev.meloda.overseerr.datastore.model
enum class ThemeMode {
System, Dark, Light
@@ -1,22 +1,18 @@
package dev.meloda.overseerr.di
import dev.meloda.overseerr.model.Platform
import dev.meloda.overseerr.datastore.di.dataStoreModule
import dev.meloda.overseerr.network.di.networkModule
import dev.meloda.overseerr.screens.login.di.loginModule
import dev.meloda.overseerr.screens.requests.di.requestsModule
import dev.meloda.overseerr.screens.url.di.urlModule
import dev.meloda.overseerr.settings.di.settingsModule
import org.koin.core.module.dsl.singleOf
import dev.meloda.overseerr.screens.settings.di.settingsModule
import org.koin.dsl.module
val appModule = module {
singleOf(::Platform)
includes(
settingsModule,
dataStoreModule,
networkModule,
loginModule,
urlModule,
settingsModule,
requestsModule
)
}
@@ -1,5 +0,0 @@
package dev.meloda.overseerr.model
internal expect class Platform() {
val name: String
}
@@ -1,6 +1,6 @@
package dev.meloda.overseerr.network.model
package dev.meloda.overseerr.network
import io.ktor.client.engine.*
import io.ktor.client.engine.HttpClientEngineFactory
expect class HttpClientEngineFactoryProvider() {
fun get(): HttpClientEngineFactory<*>
@@ -1,6 +1,6 @@
package dev.meloda.overseerr.network.di
import dev.meloda.overseerr.network.model.HttpClientEngineFactoryProvider
import dev.meloda.overseerr.network.HttpClientEngineFactoryProvider
import io.ktor.client.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.serialization.kotlinx.json.*
@@ -0,0 +1,22 @@
package dev.meloda.overseerr.screens.home
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import kotlinx.serialization.Serializable
@Serializable
data object HomeScreen
@Composable
fun HomeScreen() {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(text = "Home Screen")
}
}
@@ -32,7 +32,7 @@ data object LoginScreen
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LoginScreen(onBack: () -> Unit = {}) {
fun LoginScreen() {
val viewModel: LoginViewModel = koinViewModel<LoginViewModelImpl>()
val screenState: LoginScreenState by viewModel.screenState.collectAsStateWithLifecycle()
@@ -43,23 +43,7 @@ fun LoginScreen(onBack: () -> Unit = {}) {
mutableStateOf(screenState.password)
}
Scaffold(
topBar = {
TopAppBar(
title = {
Text(text = "Log in")
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = null
)
}
}
)
}
) { padding ->
Scaffold(topBar = { TopAppBar(title = { Text(text = "Log in") }) }) { padding ->
Column(
modifier = Modifier.fillMaxSize()
.padding(padding),
@@ -129,4 +113,4 @@ fun LoginScreen(onBack: () -> Unit = {}) {
}
}
}
}
}
@@ -1,91 +1,178 @@
package dev.meloda.overseerr.screens.main
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Home
import androidx.compose.material.icons.rounded.Search
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material.icons.rounded.Timer
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import coil3.ImageLoader
import coil3.compose.AsyncImage
import coil3.compose.LocalPlatformContext
import coil3.request.ImageRequest
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import dev.meloda.overseerr.screens.home.HomeScreen
import dev.meloda.overseerr.screens.login.presentation.LoginScreen
import dev.meloda.overseerr.screens.requests.presentation.RequestsScreen
import dev.meloda.overseerr.screens.url.presentation.UrlScreen
import dev.meloda.overseerr.settings.SettingsController
import dev.meloda.overseerr.settings.model.ThemeMode
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import org.koin.compose.koinInject
import dev.meloda.overseerr.screens.settings.presentation.SettingsScreen
import dev.meloda.overseerr.theme.LocalHazeState
import dev.meloda.overseerr.theme.LocalPadding
@Serializable
data object MainScreen
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@Composable
fun MainScreen(navController: NavController) {
val coroutineScope = rememberCoroutineScope()
fun MainScreen() {
val hazeState = remember { HazeState(true) }
val settingsController: SettingsController = koinInject()
val settings by settingsController.settings.collectAsStateWithLifecycle()
val navController = rememberNavController()
val navigationItems = remember {
listOf(
NavigationItem("Home", Icons.Rounded.Home, HomeScreen),
NavigationItem("Requests", Icons.Rounded.Timer, RequestsScreen),
NavigationItem("Settings", Icons.Rounded.Settings, SettingsScreen),
)
}
var selectedItemIndex by rememberSaveable {
mutableStateOf(navigationItems.indexOfFirst { it.route == HomeScreen })
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(text = "Main screen") },
actions = {
TextButton(
onClick = {
val newThemeMode = ThemeMode.entries.getOrElse(
ThemeMode.entries.indexOf(settings.themeMode) + 1
) { ThemeMode.System }
settingsController.updateThemeMode(newThemeMode)
coroutineScope.launch {
settingsController.saveAppSettings()
}
}
) {
Text(
text = when (settings.themeMode) {
ThemeMode.System -> "System"
ThemeMode.Dark -> "Dark"
ThemeMode.Light -> "Light"
}
Row(
modifier = Modifier
.height(64.dp)
.fillMaxWidth()
.hazeEffect(
state = hazeState,
style = HazeMaterials.regular(NavigationBarDefaults.containerColor)
)
.windowInsetsPadding(TopAppBarDefaults.windowInsets)
.padding(
horizontal = 8.dp,
vertical = 10.dp
),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Row(
modifier = Modifier
.height(42.dp)
.weight(1f)
.clip(RoundedCornerShape(50))
.border(
width = 1.dp,
color = Color.DarkGray.copy(alpha = 0.5f),
shape = RoundedCornerShape(50)
)
}
.background(MaterialTheme.colorScheme.background.copy(alpha = 0.5f))
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Icon(
imageVector = Icons.Rounded.Search,
contentDescription = null
)
Text(text = "Search Movies & TV")
}
)
AsyncImage(
modifier = Modifier.clip(CircleShape).size(32.dp),
model = ImageRequest.Builder(LocalPlatformContext.current)
.data("https://assets.plex.tv/avatars/f812cd60749d3776cba6c6e31cdc3bcee8c5f5d9.?1707518880")
.size(64)
.build(),
contentDescription = null,
imageLoader = ImageLoader.Builder(LocalPlatformContext.current).build(),
)
}
},
bottomBar = {
NavigationBar(
modifier = Modifier
.fillMaxWidth()
.hazeEffect(
state = hazeState,
style = HazeMaterials.regular(NavigationBarDefaults.containerColor)
),
containerColor = Color.Transparent
) {
navigationItems.forEachIndexed { index, item ->
NavigationBarItem(
selected = index == selectedItemIndex,
onClick = {
if (selectedItemIndex != index) {
val currentRoute = navigationItems[selectedItemIndex].route
selectedItemIndex = index
navController.navigate(item.route) {
popUpTo(route = currentRoute) {
inclusive = true
}
}
}
},
icon = { Icon(imageVector = item.icon, contentDescription = null) },
label = { Text(text = item.text) }
)
}
}
}
) { padding ->
Row(
modifier = Modifier
.padding(padding)
.padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
CompositionLocalProvider(
LocalHazeState provides hazeState,
LocalPadding provides padding
) {
Button(
onClick = { navController.navigate(UrlScreen) },
modifier = Modifier.weight(0.3f)
NavHost(
navController = navController,
startDestination = HomeScreen,
enterTransition = { fadeIn(animationSpec = tween(0)) },
exitTransition = { fadeOut(animationSpec = tween(0)) }
) {
Text(text = "Url")
}
composable<HomeScreen> {
HomeScreen()
}
composable<LoginScreen> {
LoginScreen()
}
Button(
onClick = { navController.navigate(LoginScreen) },
modifier = Modifier.weight(0.3f)
) {
Text(text = "Login")
}
composable<RequestsScreen> {
RequestsScreen()
}
Button(
onClick = { navController.navigate(RequestsScreen) },
modifier = Modifier.weight(0.3f)
) {
Text(text = "Requests")
composable<SettingsScreen> {
SettingsScreen()
}
}
}
}
}
}
data class NavigationItem(
val text: String,
val icon: ImageVector,
val route: Any,
)
@@ -4,7 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.meloda.overseerr.ext.setValue
import dev.meloda.overseerr.screens.requests.model.RequestsScreenState
import dev.meloda.overseerr.settings.SettingsController
import dev.meloda.overseerr.datastore.SettingsController
import io.github.aakira.napier.Napier
import io.ktor.client.*
import io.ktor.client.call.*
@@ -27,7 +27,7 @@ interface RequestsViewModel {
class RequestsViewModelImpl(
private val httpClient: HttpClient,
private val settingsController: SettingsController
private val settingsController: SettingsController,
) : ViewModel(), RequestsViewModel {
override val screenState = MutableStateFlow(RequestsScreenState.EMPTY)
@@ -54,7 +54,7 @@ class RequestsViewModelImpl(
kotlin.runCatching {
httpClient.get("${settingsController.settings.value.url}/api/v1") {
headers {
append("X-Api-Key", settingsController.settings.value.plexToken)
append("X-Api-Key", settingsController.settings.value.plexToken.orEmpty())
}
}.body() as ApiInfo
}.fold(
@@ -74,5 +74,5 @@ class RequestsViewModelImpl(
@Serializable
data class ApiInfo(
val api: String,
val version: String
val version: String,
)
@@ -5,31 +5,28 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material3.*
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults.Indicator
import androidx.compose.material3.pulltorefresh.pullToRefresh
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import dev.meloda.overseerr.screens.requests.RequestsViewModel
import dev.meloda.overseerr.screens.requests.RequestsViewModelImpl
import dev.meloda.overseerr.screens.requests.model.RequestsScreenState
import dev.meloda.overseerr.theme.LocalHazeState
import dev.meloda.overseerr.theme.LocalPadding
import kotlinx.coroutines.delay
import kotlinx.serialization.Serializable
import org.koin.compose.viewmodel.koinViewModel
@@ -43,14 +40,12 @@ data object RequestsScreen
ExperimentalHazeMaterialsApi::class
)
@Composable
fun RequestsScreen(
onBack: () -> Unit = {}
) {
fun RequestsScreen() {
val viewModel: RequestsViewModel = koinViewModel<RequestsViewModelImpl>()
val screenState: RequestsScreenState by viewModel.screenState.collectAsStateWithLifecycle()
val hazeState = remember { HazeState() }
val hazeStyle = HazeMaterials.ultraThin()
val padding = LocalPadding.current
val hazeState = LocalHazeState.current
val refreshState = rememberPullToRefreshState()
@@ -66,111 +61,56 @@ fun RequestsScreen(
}
}
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(
title = { Text(text = "Requests") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = null
)
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
modifier = Modifier
.hazeEffect(
state = hazeState,
style = hazeStyle
).fillMaxWidth(),
actions = {
IconButton(
onClick = viewModel::onRefresh
) {
Icon(
imageVector = Icons.Rounded.Refresh,
contentDescription = null
)
}
}
)
}
) { padding ->
val bottomPadding = padding.calculateBottomPadding()
Box(
Box(modifier = Modifier.fillMaxSize()) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(start = padding.calculateStartPadding(LayoutDirection.Ltr))
.padding(end = padding.calculateEndPadding(LayoutDirection.Ltr))
.hazeSource(state = hazeState)
.pullToRefresh(
isRefreshing = screenState.isLoading,
state = refreshState,
onRefresh = viewModel::onRefresh
)
) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.hazeSource(state = hazeState)
.pullToRefresh(
isRefreshing = screenState.isLoading,
state = refreshState,
onRefresh = viewModel::onRefresh
)
) {
item {
Spacer(modifier = Modifier.height(padding.calculateTopPadding()))
}
item {
AnimatedVisibility(screenState.apiErrorText != null || screenState.apiInfo != null) {
Box(
modifier = Modifier
.fillMaxWidth()
.background(
if (screenState.apiInfo != null) Color(0xffb00b69)
else Color.Red
),
contentAlignment = Alignment.CenterStart
) {
Text(
text = screenState.apiErrorText ?: screenState.apiInfo.toString(),
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(10.dp)
)
}
item {
Spacer(modifier = Modifier.height(LocalPadding.current.calculateTopPadding()))
}
item {
AnimatedVisibility(screenState.apiErrorText != null || screenState.apiInfo != null) {
Box(
modifier = Modifier
.fillMaxWidth()
.background(
if (screenState.apiInfo != null) Color(0xffb00b69)
else Color.Red
),
contentAlignment = Alignment.CenterStart
) {
Text(
text = screenState.apiErrorText ?: screenState.apiInfo.toString(),
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(10.dp)
)
}
}
items(items = screenState.dummyItems) { index ->
Text(
text = "Text #${index + 1}",
style = MaterialTheme.typography.headlineLarge,
modifier = Modifier.background(Color.Red)
)
Spacer(modifier = Modifier.height(64.dp))
}
item {
Spacer(modifier = Modifier.height(bottomPadding))
}
}
Indicator(
state = refreshState,
isRefreshing = screenState.isLoading,
modifier = Modifier.align(Alignment.TopCenter)
.padding(top = padding.calculateTopPadding())
)
if (bottomPadding.value > 0) {
Box(
modifier = Modifier
.align(Alignment.BottomCenter)
.hazeEffect(
state = hazeState,
style = hazeStyle
)
.background(Color.Transparent)
.height(bottomPadding)
.fillMaxWidth()
items(items = screenState.dummyItems) { index ->
Text(
text = "Text #${index + 1}",
style = MaterialTheme.typography.headlineLarge,
modifier = Modifier.background(Color.Red)
)
Spacer(modifier = Modifier.height(64.dp))
}
item {
Spacer(modifier = Modifier.height(padding.calculateBottomPadding()))
}
}
Indicator(
state = refreshState,
isRefreshing = screenState.isLoading,
modifier = Modifier.align(Alignment.TopCenter).padding(top = padding.calculateTopPadding())
)
}
}
}
@@ -0,0 +1,72 @@
package dev.meloda.overseerr.screens.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.meloda.overseerr.datastore.SettingsController
import dev.meloda.overseerr.ext.setValue
import dev.meloda.overseerr.screens.settings.model.SettingsScreenState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class SettingsViewModel(private val settingsController: SettingsController) : ViewModel() {
private val _screenState: MutableStateFlow<SettingsScreenState> = MutableStateFlow(SettingsScreenState.EMPTY)
val screenState: StateFlow<SettingsScreenState> = _screenState.asStateFlow()
init {
settingsController.settings
.onEach { settings ->
_screenState.emit(
screenState.value.copy(
url = settings.url.orEmpty(),
plexToken = settings.plexToken.orEmpty()
)
)
}
.launchIn(viewModelScope)
}
fun onScreenOpened() {
_screenState.setValue { old ->
old.copy(
url = settingsController.settings.value.url.orEmpty(),
plexToken = settingsController.settings.value.plexToken.orEmpty()
)
}
}
fun onUrlInputChanged(newText: String) {
_screenState.setValue { old -> old.copy(url = newText) }
}
fun onPlexTokenInputChanged(newToken: String) {
_screenState.setValue { old -> old.copy(plexToken = newToken) }
}
fun onLoadButtonClicked() {
viewModelScope.launch {
val settings = settingsController.loadAppSettings()
withContext(Dispatchers.Main) {
_screenState.emit(
screenState.value.copy(
url = settings.url.orEmpty(),
plexToken = settings.plexToken.orEmpty()
)
)
}
}
}
fun onSaveButtonClicked() {
viewModelScope.launch {
settingsController.updateAppSettings { settings ->
settings.copy(
url = screenState.value.url.trim().ifEmpty { null },
plexToken = screenState.value.plexToken.trim().ifEmpty { null }
)
}
}
}
}
@@ -0,0 +1,9 @@
package dev.meloda.overseerr.screens.settings.di
import dev.meloda.overseerr.screens.settings.SettingsViewModel
import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.module
val settingsModule = module {
viewModelOf(::SettingsViewModel)
}
@@ -1,12 +1,12 @@
package dev.meloda.overseerr.screens.url.model
package dev.meloda.overseerr.screens.settings.model
data class UrlScreenState(
data class SettingsScreenState(
val url: String,
val plexToken: String,
val isWrongUrlError: Boolean
val isWrongUrlError: Boolean,
) {
companion object {
val EMPTY: UrlScreenState = UrlScreenState(
val EMPTY: SettingsScreenState = SettingsScreenState(
url = "",
plexToken = "",
isWrongUrlError = false
@@ -0,0 +1,139 @@
package dev.meloda.overseerr.screens.settings.presentation
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.meloda.overseerr.datastore.SettingsController
import dev.meloda.overseerr.datastore.model.ThemeMode
import dev.meloda.overseerr.screens.settings.SettingsViewModel
import dev.meloda.overseerr.theme.LocalPadding
import kotlinx.serialization.Serializable
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
@Serializable
data object SettingsScreen
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen() {
val viewModel: SettingsViewModel = koinViewModel()
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val settingsController: SettingsController = koinInject()
val settings by settingsController.settings.collectAsStateWithLifecycle()
LaunchedEffect(true) {
viewModel.onScreenOpened()
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(LocalPadding.current)
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
var localUrl by rememberSaveable(screenState.url) {
mutableStateOf(screenState.url)
}
var localPlexToken by rememberSaveable(screenState.plexToken) {
mutableStateOf(screenState.plexToken)
}
Text(
text = "Dark mode",
style = MaterialTheme.typography.titleMedium
)
SingleChoiceSegmentedButtonRow {
SegmentedButton(
selected = settings.themeMode == ThemeMode.Light,
onClick = { settingsController.updateThemeMode(ThemeMode.Light) },
shape = RoundedCornerShape(
topStart = 4.dp,
bottomStart = 4.dp
),
label = { Text(text = "Light") }
)
SegmentedButton(
selected = settings.themeMode == ThemeMode.Dark,
onClick = { settingsController.updateThemeMode(ThemeMode.Dark) },
shape = RoundedCornerShape(0.dp),
label = { Text(text = "Dark") }
)
SegmentedButton(
selected = settings.themeMode == ThemeMode.System,
onClick = { settingsController.updateThemeMode(ThemeMode.System) },
shape = RoundedCornerShape(
topEnd = 4.dp,
bottomEnd = 4.dp
),
label = { Text(text = "System") }
)
}
Text(
text = "API",
style = MaterialTheme.typography.titleMedium
)
TextField(
modifier = Modifier.fillMaxWidth(),
value = localUrl,
onValueChange = { text ->
localUrl = text
viewModel.onUrlInputChanged(text)
},
placeholder = { Text(text = "Url") },
isError = screenState.isWrongUrlError,
singleLine = true,
keyboardOptions = KeyboardOptions.Default.copy(
imeAction = ImeAction.Go,
keyboardType = KeyboardType.Uri
)
)
TextField(
modifier = Modifier.fillMaxWidth(),
value = localPlexToken,
onValueChange = { text ->
localPlexToken = text
viewModel.onPlexTokenInputChanged(text)
},
placeholder = { Text(text = "Token") },
singleLine = true,
keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Go)
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Button(
onClick = viewModel::onSaveButtonClicked,
modifier = Modifier.weight(1f)
) {
Text(text = "Save")
}
Button(
onClick = viewModel::onLoadButtonClicked,
modifier = Modifier.weight(1f)
) {
Text(text = "Load")
}
}
}
}
@@ -1,79 +0,0 @@
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 io.github.aakira.napier.Napier
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<UrlScreenState>
fun onUrlInputChanged(newText: String)
fun onPlexTokenInputChanged(newToken: String)
fun onLoadButtonClicked()
fun onSaveButtonClicked()
fun onTestButtonClicked()
}
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,
plexToken = settings.plexToken
)
}
}
.launchIn(viewModelScope)
}
override fun onUrlInputChanged(newText: String) {
screenState.setValue { old -> old.copy(url = newText) }
}
override fun onPlexTokenInputChanged(newToken: String) {
screenState.setValue { old -> old.copy(plexToken = newToken) }
}
override fun onLoadButtonClicked() {
viewModelScope.launch {
val settings = settingsController.loadAppSettings()
screenState.setValue { old ->
old.copy(
url = settings.url,
plexToken = settings.plexToken
)
}
}
}
override fun onSaveButtonClicked() {
viewModelScope.launch {
settingsController.updateAppSettings { settings ->
settings.copy(
url = screenState.value.url,
plexToken = screenState.value.plexToken
)
}
}
}
override fun onTestButtonClicked() {
Napier.v("Test button clicked")
}
}
@@ -1,9 +0,0 @@
package dev.meloda.overseerr.screens.url.di
import dev.meloda.overseerr.screens.url.UrlViewModelImpl
import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.module
val urlModule = module {
viewModelOf(::UrlViewModelImpl)
}
@@ -1,108 +0,0 @@
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.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.meloda.overseerr.screens.url.UrlViewModel
import dev.meloda.overseerr.screens.url.UrlViewModelImpl
import kotlinx.serialization.Serializable
import org.koin.compose.viewmodel.koinViewModel
@Serializable
data object UrlScreen
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun UrlScreen(onBack: () -> Unit = {}) {
val viewModel: UrlViewModel = koinViewModel<UrlViewModelImpl>()
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(
title = { Text(text = "Url") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = null
)
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 16.dp),
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))
TextField(
modifier = Modifier.fillMaxWidth(),
value = screenState.plexToken,
onValueChange = viewModel::onPlexTokenInputChanged,
placeholder = { Text(text = "Token") },
singleLine = true,
keyboardOptions = KeyboardOptions.Default.copy(
imeAction = ImeAction.Go
)
)
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
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")
}
}
}
}
}
@@ -1,13 +0,0 @@
package dev.meloda.overseerr.settings.di
import dev.meloda.overseerr.settings.SettingsController
import dev.meloda.overseerr.settings.SettingsControllerImpl
import dev.meloda.overseerr.settings.model.SettingsStoreProvider
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.bind
import org.koin.dsl.module
val settingsModule = module {
single { SettingsStoreProvider().provideStore() }
singleOf(::SettingsControllerImpl) bind SettingsController::class
}
@@ -1,20 +1,24 @@
package dev.meloda.overseerr.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.*
import androidx.navigation.NavController
import dev.meloda.overseerr.settings.model.ThemeMode
import dev.chrisbanes.haze.HazeState
import dev.meloda.overseerr.datastore.model.ThemeMode
internal val LocalThemeIsDark = compositionLocalOf { mutableStateOf(true) }
val LocalThemeIsDark = compositionLocalOf { mutableStateOf(true) }
val LocalHazeState = compositionLocalOf { HazeState(true) }
val LocalPadding = compositionLocalOf { PaddingValues() }
@Composable
internal fun AppTheme(
themeMode: ThemeMode = ThemeMode.System,
content: @Composable () -> Unit
content: @Composable () -> Unit,
) {
val systemIsDark = isSystemInDarkTheme()
val isDarkState = remember(themeMode, systemIsDark) {
@@ -1,12 +1,13 @@
package dev.meloda.overseerr.settings.model
package dev.meloda.overseerr.datastore
import dev.meloda.overseerr.appDir
import dev.meloda.overseerr.datastore.model.AppSettings
import io.github.xxfast.kstore.KStore
import io.github.xxfast.kstore.file.storeOf
import kotlinx.io.files.Path
actual class SettingsStoreProvider actual constructor() {
actual fun provideStore(): KStore<AppSettings> {
return storeOf(file = Path("$appDir/app_settings.json"))
return storeOf(file = Path("${appDir}/app_settings.json"))
}
}
@@ -1,6 +0,0 @@
package dev.meloda.overseerr.model
internal actual class Platform actual constructor() {
actual val name: String
get() = "JVM"
}
@@ -1,4 +1,4 @@
package dev.meloda.overseerr.network.model
package dev.meloda.overseerr.network
import io.ktor.client.engine.*
import io.ktor.client.engine.okhttp.*
+13 -11
View File
@@ -1,3 +1,4 @@
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
@@ -5,21 +6,19 @@ import androidx.compose.ui.window.rememberWindowState
import dev.meloda.overseerr.App
import dev.meloda.overseerr.appDir
import dev.meloda.overseerr.di.appModule
import io.github.aakira.napier.Napier
import net.harawata.appdirs.AppDirsFactory
import org.koin.core.context.startKoin
import org.koin.compose.KoinApplication
import java.awt.Dimension
import java.io.File
fun main() = application {
appDir = AppDirsFactory.getInstance()
.getUserDataDir("Overseerr-KMP", "1.0.0", "dev.meloda")
LaunchedEffect(Unit) {
appDir = AppDirsFactory.getInstance()
.getUserDataDir("Overseerr-KMP", "1.0.0", "dev.meloda")
println("appDir: $appDir")
File(appDir).mkdirs()
startKoin {
modules(appModule)
Napier.d("appDir: $appDir")
File(appDir).mkdirs()
}
Window(
@@ -27,7 +26,10 @@ fun main() = application {
state = rememberWindowState(width = 800.dp, height = 600.dp),
onCloseRequest = ::exitApplication
) {
window.minimumSize = Dimension(360, 600)
App()
window.minimumSize = Dimension(320, 480)
KoinApplication(application = { modules(appModule) }) {
App()
}
}
}
@@ -1,5 +1,6 @@
package dev.meloda.overseerr.settings.model
package dev.meloda.overseerr.datastore
import dev.meloda.overseerr.datastore.model.AppSettings
import io.github.xxfast.kstore.KStore
import io.github.xxfast.kstore.storage.storeOf
@@ -1,5 +0,0 @@
package dev.meloda.overseerr.model
internal actual class Platform actual constructor() {
actual val name: String = "JS"
}
@@ -1,4 +1,4 @@
package dev.meloda.overseerr.network.model
package dev.meloda.overseerr.network
import io.ktor.client.engine.*
import io.ktor.client.engine.js.*
+3 -4
View File
@@ -3,14 +3,13 @@ import androidx.compose.ui.window.ComposeViewport
import dev.meloda.overseerr.App
import dev.meloda.overseerr.di.appModule
import kotlinx.browser.document
import org.koin.core.context.startKoin
import org.koin.compose.KoinApplication
@OptIn(ExperimentalComposeUiApi::class)
fun main() {
ComposeViewport(document.body!!) {
startKoin {
modules(appModule)
KoinApplication(application = { modules(appModule) }) {
App()
}
App()
}
}