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.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackConfig import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackConfig
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
import java.util.* import java.util.*
plugins { plugins {
@@ -14,6 +15,17 @@ plugins {
alias(libs.plugins.kotlinx.serialization) 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 { kotlin {
androidTarget { androidTarget {
compilerOptions { compilerOptions {
@@ -172,6 +184,7 @@ android {
} }
getByName("release") { getByName("release") {
signingConfig = signingConfigs.getByName("release") signingConfig = signingConfigs.getByName("release")
// signingConfig = signingConfigs.getByName("debug")
isMinifyEnabled = true isMinifyEnabled = true
isShrinkResources = true isShrinkResources = true
@@ -4,8 +4,6 @@ import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
class AppActivity : ComponentActivity() { class AppActivity : ComponentActivity() {
@@ -16,9 +14,3 @@ class AppActivity : ComponentActivity() {
setContent { App() } 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.appDir
import dev.meloda.overseerr.datastore.model.AppSettings
import io.github.xxfast.kstore.KStore import io.github.xxfast.kstore.KStore
import io.github.xxfast.kstore.file.storeOf import io.github.xxfast.kstore.file.storeOf
import kotlinx.io.files.Path import kotlinx.io.files.Path
actual class SettingsStoreProvider actual constructor() { actual class SettingsStoreProvider actual constructor() {
actual fun provideStore(): KStore<AppSettings> { 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() = "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.*
import io.ktor.client.engine.okhttp.* import io.ktor.client.engine.okhttp.*
@@ -7,25 +7,17 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.main.MainScreen
import dev.meloda.overseerr.screens.requests.presentation.RequestsScreen import dev.meloda.overseerr.datastore.SettingsController
import dev.meloda.overseerr.screens.url.presentation.UrlScreen
import dev.meloda.overseerr.settings.SettingsController
import dev.meloda.overseerr.theme.AppTheme import dev.meloda.overseerr.theme.AppTheme
import dev.meloda.overseerr.theme.NavigationSettings
import io.github.aakira.napier.DebugAntilog import io.github.aakira.napier.DebugAntilog
import io.github.aakira.napier.Napier import io.github.aakira.napier.Napier
import org.koin.compose.KoinContext
import org.koin.compose.koinInject import org.koin.compose.koinInject
var appDir: String = "" var appDir: String = ""
@Composable @Composable
internal fun App() = KoinContext { internal fun App() {
LaunchedEffect(true) { LaunchedEffect(true) {
Napier.base(DebugAntilog()) Napier.base(DebugAntilog())
} }
@@ -37,32 +29,9 @@ internal fun App() = KoinContext {
settingsController.loadAppSettings() settingsController.loadAppSettings()
} }
val navController = rememberNavController()
NavigationSettings(navController)
AppTheme(themeMode = settings.themeMode) { AppTheme(themeMode = settings.themeMode) {
Surface(modifier = Modifier.fillMaxSize()) { Surface(modifier = Modifier.fillMaxSize()) {
NavHost( MainScreen()
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)
}
}
} }
} }
} }
@@ -1,8 +1,8 @@
package dev.meloda.overseerr.settings package dev.meloda.overseerr.datastore
import dev.meloda.overseerr.ext.setValue import dev.meloda.overseerr.ext.setValue
import dev.meloda.overseerr.settings.model.AppSettings import dev.meloda.overseerr.datastore.model.AppSettings
import dev.meloda.overseerr.settings.model.ThemeMode import dev.meloda.overseerr.datastore.model.ThemeMode
import io.github.xxfast.kstore.KStore import io.github.xxfast.kstore.KStore
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow 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 import io.github.xxfast.kstore.KStore
expect class SettingsStoreProvider() { expect class SettingsStoreProvider() {
fun provideStore(): KStore<AppSettings> 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 import kotlinx.serialization.Serializable
@Serializable @Serializable
data class AppSettings( data class AppSettings(
val url: String = "", val url: String? = null,
val plexToken: String = "", val plexToken: String? = null,
val themeMode: ThemeMode = ThemeMode.System val themeMode: ThemeMode = ThemeMode.System,
) { ) {
companion object { companion object {
val EMPTY: AppSettings = AppSettings() val EMPTY: AppSettings = AppSettings()
@@ -1,4 +1,4 @@
package dev.meloda.overseerr.settings.model package dev.meloda.overseerr.datastore.model
enum class ThemeMode { enum class ThemeMode {
System, Dark, Light System, Dark, Light
@@ -1,22 +1,18 @@
package dev.meloda.overseerr.di 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.network.di.networkModule
import dev.meloda.overseerr.screens.login.di.loginModule import dev.meloda.overseerr.screens.login.di.loginModule
import dev.meloda.overseerr.screens.requests.di.requestsModule import dev.meloda.overseerr.screens.requests.di.requestsModule
import dev.meloda.overseerr.screens.url.di.urlModule import dev.meloda.overseerr.screens.settings.di.settingsModule
import dev.meloda.overseerr.settings.di.settingsModule
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module import org.koin.dsl.module
val appModule = module { val appModule = module {
singleOf(::Platform)
includes( includes(
settingsModule, dataStoreModule,
networkModule, networkModule,
loginModule, loginModule,
urlModule, settingsModule,
requestsModule 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() { expect class HttpClientEngineFactoryProvider() {
fun get(): HttpClientEngineFactory<*> fun get(): HttpClientEngineFactory<*>
@@ -1,6 +1,6 @@
package dev.meloda.overseerr.network.di 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.*
import io.ktor.client.plugins.contentnegotiation.* import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.serialization.kotlinx.json.* 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun LoginScreen(onBack: () -> Unit = {}) { fun LoginScreen() {
val viewModel: LoginViewModel = koinViewModel<LoginViewModelImpl>() val viewModel: LoginViewModel = koinViewModel<LoginViewModelImpl>()
val screenState: LoginScreenState by viewModel.screenState.collectAsStateWithLifecycle() val screenState: LoginScreenState by viewModel.screenState.collectAsStateWithLifecycle()
@@ -43,23 +43,7 @@ fun LoginScreen(onBack: () -> Unit = {}) {
mutableStateOf(screenState.password) mutableStateOf(screenState.password)
} }
Scaffold( Scaffold(topBar = { TopAppBar(title = { Text(text = "Log in") }) }) { padding ->
topBar = {
TopAppBar(
title = {
Text(text = "Log in")
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = null
)
}
}
)
}
) { padding ->
Column( Column(
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
.padding(padding), .padding(padding),
@@ -129,4 +113,4 @@ fun LoginScreen(onBack: () -> Unit = {}) {
} }
} }
} }
} }
@@ -1,91 +1,178 @@
package dev.meloda.overseerr.screens.main package dev.meloda.overseerr.screens.main
import androidx.compose.foundation.layout.Arrangement import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Row import androidx.compose.animation.fadeIn
import androidx.compose.foundation.layout.padding 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.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.*
import androidx.compose.runtime.getValue import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.compose.NavHost
import androidx.navigation.NavController 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.login.presentation.LoginScreen
import dev.meloda.overseerr.screens.requests.presentation.RequestsScreen import dev.meloda.overseerr.screens.requests.presentation.RequestsScreen
import dev.meloda.overseerr.screens.url.presentation.UrlScreen import dev.meloda.overseerr.screens.settings.presentation.SettingsScreen
import dev.meloda.overseerr.settings.SettingsController import dev.meloda.overseerr.theme.LocalHazeState
import dev.meloda.overseerr.settings.model.ThemeMode import dev.meloda.overseerr.theme.LocalPadding
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import org.koin.compose.koinInject
@Serializable @OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
data object MainScreen
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun MainScreen(navController: NavController) { fun MainScreen() {
val coroutineScope = rememberCoroutineScope() val hazeState = remember { HazeState(true) }
val settingsController: SettingsController = koinInject() val navController = rememberNavController()
val settings by settingsController.settings.collectAsStateWithLifecycle()
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( Scaffold(
topBar = { topBar = {
TopAppBar( Row(
title = { Text(text = "Main screen") }, modifier = Modifier
actions = { .height(64.dp)
TextButton( .fillMaxWidth()
onClick = { .hazeEffect(
val newThemeMode = ThemeMode.entries.getOrElse( state = hazeState,
ThemeMode.entries.indexOf(settings.themeMode) + 1 style = HazeMaterials.regular(NavigationBarDefaults.containerColor)
) { ThemeMode.System } )
.windowInsetsPadding(TopAppBarDefaults.windowInsets)
settingsController.updateThemeMode(newThemeMode) .padding(
coroutineScope.launch { horizontal = 8.dp,
settingsController.saveAppSettings() vertical = 10.dp
} ),
} horizontalArrangement = Arrangement.spacedBy(8.dp),
) { verticalAlignment = Alignment.CenterVertically
Text( ) {
text = when (settings.themeMode) { Row(
ThemeMode.System -> "System" modifier = Modifier
ThemeMode.Dark -> "Dark" .height(42.dp)
ThemeMode.Light -> "Light" .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 -> ) { padding ->
Row( CompositionLocalProvider(
modifier = Modifier LocalHazeState provides hazeState,
.padding(padding) LocalPadding provides padding
.padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
Button( NavHost(
onClick = { navController.navigate(UrlScreen) }, navController = navController,
modifier = Modifier.weight(0.3f) startDestination = HomeScreen,
enterTransition = { fadeIn(animationSpec = tween(0)) },
exitTransition = { fadeOut(animationSpec = tween(0)) }
) { ) {
Text(text = "Url") composable<HomeScreen> {
} HomeScreen()
}
composable<LoginScreen> {
LoginScreen()
}
Button( composable<RequestsScreen> {
onClick = { navController.navigate(LoginScreen) }, RequestsScreen()
modifier = Modifier.weight(0.3f) }
) {
Text(text = "Login")
}
Button( composable<SettingsScreen> {
onClick = { navController.navigate(RequestsScreen) }, SettingsScreen()
modifier = Modifier.weight(0.3f) }
) {
Text(text = "Requests")
} }
} }
} }
} }
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 androidx.lifecycle.viewModelScope
import dev.meloda.overseerr.ext.setValue import dev.meloda.overseerr.ext.setValue
import dev.meloda.overseerr.screens.requests.model.RequestsScreenState 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.github.aakira.napier.Napier
import io.ktor.client.* import io.ktor.client.*
import io.ktor.client.call.* import io.ktor.client.call.*
@@ -27,7 +27,7 @@ interface RequestsViewModel {
class RequestsViewModelImpl( class RequestsViewModelImpl(
private val httpClient: HttpClient, private val httpClient: HttpClient,
private val settingsController: SettingsController private val settingsController: SettingsController,
) : ViewModel(), RequestsViewModel { ) : ViewModel(), RequestsViewModel {
override val screenState = MutableStateFlow(RequestsScreenState.EMPTY) override val screenState = MutableStateFlow(RequestsScreenState.EMPTY)
@@ -54,7 +54,7 @@ class RequestsViewModelImpl(
kotlin.runCatching { kotlin.runCatching {
httpClient.get("${settingsController.settings.value.url}/api/v1") { httpClient.get("${settingsController.settings.value.url}/api/v1") {
headers { headers {
append("X-Api-Key", settingsController.settings.value.plexToken) append("X-Api-Key", settingsController.settings.value.plexToken.orEmpty())
} }
}.body() as ApiInfo }.body() as ApiInfo
}.fold( }.fold(
@@ -74,5 +74,5 @@ class RequestsViewModelImpl(
@Serializable @Serializable
data class ApiInfo( data class ApiInfo(
val api: String, 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.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material3.MaterialTheme
import androidx.compose.material.icons.rounded.Refresh import androidx.compose.material3.Text
import androidx.compose.material3.*
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults.Indicator import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults.Indicator
import androidx.compose.material3.pulltorefresh.pullToRefresh import androidx.compose.material3.pulltorefresh.pullToRefresh
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials import dev.chrisbanes.haze.materials.HazeMaterials
import dev.meloda.overseerr.screens.requests.RequestsViewModel import dev.meloda.overseerr.screens.requests.RequestsViewModel
import dev.meloda.overseerr.screens.requests.RequestsViewModelImpl import dev.meloda.overseerr.screens.requests.RequestsViewModelImpl
import dev.meloda.overseerr.screens.requests.model.RequestsScreenState 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.coroutines.delay
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
@@ -43,14 +40,12 @@ data object RequestsScreen
ExperimentalHazeMaterialsApi::class ExperimentalHazeMaterialsApi::class
) )
@Composable @Composable
fun RequestsScreen( fun RequestsScreen() {
onBack: () -> Unit = {}
) {
val viewModel: RequestsViewModel = koinViewModel<RequestsViewModelImpl>() val viewModel: RequestsViewModel = koinViewModel<RequestsViewModelImpl>()
val screenState: RequestsScreenState by viewModel.screenState.collectAsStateWithLifecycle() val screenState: RequestsScreenState by viewModel.screenState.collectAsStateWithLifecycle()
val hazeState = remember { HazeState() } val padding = LocalPadding.current
val hazeStyle = HazeMaterials.ultraThin() val hazeState = LocalHazeState.current
val refreshState = rememberPullToRefreshState() val refreshState = rememberPullToRefreshState()
@@ -66,111 +61,56 @@ fun RequestsScreen(
} }
} }
Scaffold( Box(modifier = Modifier.fillMaxSize()) {
modifier = Modifier.fillMaxSize(), LazyColumn(
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(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(start = padding.calculateStartPadding(LayoutDirection.Ltr)) .hazeSource(state = hazeState)
.padding(end = padding.calculateEndPadding(LayoutDirection.Ltr)) .pullToRefresh(
isRefreshing = screenState.isLoading,
state = refreshState,
onRefresh = viewModel::onRefresh
)
) { ) {
LazyColumn( item {
modifier = Modifier Spacer(modifier = Modifier.height(LocalPadding.current.calculateTopPadding()))
.fillMaxSize() }
.hazeSource(state = hazeState) item {
.pullToRefresh( AnimatedVisibility(screenState.apiErrorText != null || screenState.apiInfo != null) {
isRefreshing = screenState.isLoading, Box(
state = refreshState, modifier = Modifier
onRefresh = viewModel::onRefresh .fillMaxWidth()
) .background(
) { if (screenState.apiInfo != null) Color(0xffb00b69)
item { else Color.Red
Spacer(modifier = Modifier.height(padding.calculateTopPadding())) ),
} contentAlignment = Alignment.CenterStart
item { ) {
AnimatedVisibility(screenState.apiErrorText != null || screenState.apiInfo != null) { Text(
Box( text = screenState.apiErrorText ?: screenState.apiInfo.toString(),
modifier = Modifier style = MaterialTheme.typography.headlineMedium,
.fillMaxWidth() modifier = Modifier.padding(10.dp)
.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))
}
} }
items(items = screenState.dummyItems) { index ->
Indicator( Text(
state = refreshState, text = "Text #${index + 1}",
isRefreshing = screenState.isLoading, style = MaterialTheme.typography.headlineLarge,
modifier = Modifier.align(Alignment.TopCenter) modifier = Modifier.background(Color.Red)
.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()
) )
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 url: String,
val plexToken: String, val plexToken: String,
val isWrongUrlError: Boolean val isWrongUrlError: Boolean,
) { ) {
companion object { companion object {
val EMPTY: UrlScreenState = UrlScreenState( val EMPTY: SettingsScreenState = SettingsScreenState(
url = "", url = "",
plexToken = "", plexToken = "",
isWrongUrlError = false 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 package dev.meloda.overseerr.theme
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.navigation.NavController 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 @Composable
internal fun AppTheme( internal fun AppTheme(
themeMode: ThemeMode = ThemeMode.System, themeMode: ThemeMode = ThemeMode.System,
content: @Composable () -> Unit content: @Composable () -> Unit,
) { ) {
val systemIsDark = isSystemInDarkTheme() val systemIsDark = isSystemInDarkTheme()
val isDarkState = remember(themeMode, systemIsDark) { 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.appDir
import dev.meloda.overseerr.datastore.model.AppSettings
import io.github.xxfast.kstore.KStore import io.github.xxfast.kstore.KStore
import io.github.xxfast.kstore.file.storeOf import io.github.xxfast.kstore.file.storeOf
import kotlinx.io.files.Path import kotlinx.io.files.Path
actual class SettingsStoreProvider actual constructor() { actual class SettingsStoreProvider actual constructor() {
actual fun provideStore(): KStore<AppSettings> { 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.*
import io.ktor.client.engine.okhttp.* 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.unit.dp
import androidx.compose.ui.window.Window import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application 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.App
import dev.meloda.overseerr.appDir import dev.meloda.overseerr.appDir
import dev.meloda.overseerr.di.appModule import dev.meloda.overseerr.di.appModule
import io.github.aakira.napier.Napier
import net.harawata.appdirs.AppDirsFactory import net.harawata.appdirs.AppDirsFactory
import org.koin.core.context.startKoin import org.koin.compose.KoinApplication
import java.awt.Dimension import java.awt.Dimension
import java.io.File import java.io.File
fun main() = application { fun main() = application {
appDir = AppDirsFactory.getInstance() LaunchedEffect(Unit) {
.getUserDataDir("Overseerr-KMP", "1.0.0", "dev.meloda") appDir = AppDirsFactory.getInstance()
.getUserDataDir("Overseerr-KMP", "1.0.0", "dev.meloda")
println("appDir: $appDir") Napier.d("appDir: $appDir")
File(appDir).mkdirs()
File(appDir).mkdirs()
startKoin {
modules(appModule)
} }
Window( Window(
@@ -27,7 +26,10 @@ fun main() = application {
state = rememberWindowState(width = 800.dp, height = 600.dp), state = rememberWindowState(width = 800.dp, height = 600.dp),
onCloseRequest = ::exitApplication onCloseRequest = ::exitApplication
) { ) {
window.minimumSize = Dimension(360, 600) window.minimumSize = Dimension(320, 480)
App()
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.KStore
import io.github.xxfast.kstore.storage.storeOf 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.*
import io.ktor.client.engine.js.* 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.App
import dev.meloda.overseerr.di.appModule import dev.meloda.overseerr.di.appModule
import kotlinx.browser.document import kotlinx.browser.document
import org.koin.core.context.startKoin import org.koin.compose.KoinApplication
@OptIn(ExperimentalComposeUiApi::class) @OptIn(ExperimentalComposeUiApi::class)
fun main() { fun main() {
ComposeViewport(document.body!!) { ComposeViewport(document.body!!) {
startKoin { KoinApplication(application = { modules(appModule) }) {
modules(appModule) App()
} }
App()
} }
} }
+1 -3
View File
@@ -14,10 +14,8 @@ kotlin.native.ignoreDisabledTargets=true
android.useAndroidX=true android.useAndroidX=true
android.nonTransitiveRClass=true android.nonTransitiveRClass=true
#Compose
org.jetbrains.compose.experimental.jscanvas.enabled=true
include_wasm=true include_wasm=true
include_ios=false include_ios=false
#Flip this to false when including the ios targets #Flip this to false when including the ios targets
org.gradle.unsafe.configuration-cache=true org.gradle.unsafe.configuration-cache=true