simple ktor-client implementation

This commit is contained in:
2024-08-08 13:03:17 +03:00
parent c2f79f9007
commit efe4536ebf
22 changed files with 383 additions and 166 deletions
+5
View File
@@ -84,14 +84,18 @@ kotlin {
implementation(libs.coil.network.ktor)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.ktor.core)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.kotlinx.serialization.json)
implementation(libs.kotlinx.serialization.json)
implementation(libs.koin.core)
implementation(libs.koin.compose)
implementation(libs.koin.compose.viewmodel)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.haze)
implementation(libs.haze.materials)
implementation(libs.kstore)
implementation(libs.napier)
implementation(libs.message.bar)
}
commonTest.dependencies {
@@ -124,6 +128,7 @@ kotlin {
wasmJsMain.dependencies {
implementation(libs.kstore.storage)
implementation(libs.ktor.client.js)
}
}
}
@@ -0,0 +1,8 @@
package dev.meloda.overseerr.network.model
import io.ktor.client.engine.*
import io.ktor.client.engine.okhttp.*
actual class HttpClientEngineFactoryProvider actual constructor() {
actual fun get(): HttpClientEngineFactory<*> = OkHttp
}
@@ -3,6 +3,7 @@ package dev.meloda.overseerr.di
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.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
@@ -15,6 +16,7 @@ val appModule = module {
settingsModule,
networkModule,
loginModule,
urlModule
urlModule,
requestsModule
)
}
@@ -1,12 +1,19 @@
package dev.meloda.overseerr.network.di
import dev.meloda.overseerr.network.model.HttpClientEngineFactoryProvider
import io.ktor.client.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.serialization.kotlinx.json.*
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
val networkModule = module {
singleOf(::HttpClientEngineFactoryProvider)
single {
HttpClient {
HttpClient(engineFactory = get<HttpClientEngineFactoryProvider>().get()) {
install(ContentNegotiation) {
json()
}
}
}
}
@@ -0,0 +1,7 @@
package dev.meloda.overseerr.network.model
import io.ktor.client.engine.*
expect class HttpClientEngineFactoryProvider() {
fun get(): HttpClientEngineFactory<*>
}
@@ -1,7 +1,9 @@
package dev.meloda.overseerr.screens.login.di
import dev.meloda.overseerr.screens.login.LoginViewModelImpl
import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.module
val loginModule = module {
viewModelOf(::LoginViewModelImpl)
}
@@ -17,13 +17,13 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
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.LoginViewModel
import dev.meloda.overseerr.screens.login.LoginViewModelImpl
import dev.meloda.overseerr.screens.login.model.LoginScreenState
import org.koin.compose.viewmodel.koinViewModel
class LoginScreen : Screen {
@@ -31,7 +31,7 @@ class LoginScreen : Screen {
@Composable
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
val viewModel: LoginViewModel = viewModel { LoginViewModelImpl() }
val viewModel: LoginViewModel = koinViewModel<LoginViewModelImpl>()
val screenState: LoginScreenState by viewModel.screenState.collectAsState()
var loginValue by rememberSaveable {
@@ -11,7 +11,7 @@ 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.requests.RequestsScreen
import dev.meloda.overseerr.screens.requests.presentation.RequestsScreen
import dev.meloda.overseerr.screens.url.presentation.UrlScreen
class MainScreen : Screen {
@@ -1,143 +0,0 @@
package dev.meloda.overseerr.screens.requests
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material3.*
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults.Indicator
import androidx.compose.material3.pulltorefresh.pullToRefresh
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.*
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 cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.haze
import dev.chrisbanes.haze.hazeChild
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import kotlinx.coroutines.delay
import kotlin.time.Duration.Companion.seconds
class RequestsScreen : Screen {
@OptIn(
ExperimentalMaterial3Api::class,
ExperimentalHazeMaterialsApi::class
)
@Composable
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
var isRefreshing by remember {
mutableStateOf(false)
}
val hazeState = remember { HazeState() }
val hazeStyle = HazeMaterials.ultraThin()
val refreshState = rememberPullToRefreshState()
var isNeedToRefresh by remember {
mutableStateOf(false)
}
LaunchedEffect(isNeedToRefresh) {
if (isNeedToRefresh) {
isRefreshing = true
delay(2.seconds)
isRefreshing = false
isNeedToRefresh = false
}
}
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(
title = { Text(text = "Requests") },
navigationIcon = {
IconButton(onClick = navigator::pop) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = null
)
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
modifier = Modifier
.hazeChild(
state = hazeState,
style = hazeStyle
).fillMaxWidth()
)
}
) { padding ->
val bottomPadding = padding.calculateBottomPadding()
Box(
modifier = Modifier
.fillMaxSize()
.padding(start = padding.calculateStartPadding(LayoutDirection.Ltr))
.padding(end = padding.calculateEndPadding(LayoutDirection.Ltr))
) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.haze(
state = hazeState,
style = hazeStyle
)
.pullToRefresh(
isRefreshing = isRefreshing,
state = refreshState,
onRefresh = { isNeedToRefresh = true }
)
) {
item {
Spacer(modifier = Modifier.height(padding.calculateTopPadding()))
}
items(count = 1000) { 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))
}
}
if (bottomPadding.value > 0) {
Box(
modifier = Modifier
.align(Alignment.BottomCenter)
.hazeChild(
state = hazeState,
style = hazeStyle
)
.background(Color.Transparent)
.height(bottomPadding)
.fillMaxWidth()
)
}
Indicator(
state = refreshState,
isRefreshing = isRefreshing,
modifier = Modifier.align(Alignment.TopCenter)
.padding(top = padding.calculateTopPadding())
)
}
}
}
}
@@ -0,0 +1,78 @@
package dev.meloda.overseerr.screens.requests
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 io.github.aakira.napier.Napier
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.*
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import kotlin.time.Duration.Companion.seconds
interface RequestsViewModel {
val screenState: StateFlow<RequestsScreenState>
fun onRefresh()
fun onSuccessMessageShown()
fun onErrorMessageShown()
}
class RequestsViewModelImpl(
private val httpClient: HttpClient,
private val settingsController: SettingsController
) : ViewModel(), RequestsViewModel {
override val screenState = MutableStateFlow(RequestsScreenState.EMPTY)
override fun onRefresh() {
viewModelScope.launch {
screenState.setValue { old -> old.copy(isLoading = true) }
delay(1.seconds)
loadInfo()
screenState.setValue { old -> old.copy(isLoading = false) }
}
}
override fun onSuccessMessageShown() {
screenState.setValue { old -> old.copy(apiInfo = null) }
}
override fun onErrorMessageShown() {
screenState.setValue { old -> old.copy(apiErrorText = null) }
}
private fun loadInfo() {
viewModelScope.launch {
kotlin.runCatching {
httpClient.get("${settingsController.settings.value.url}/api/v1") {
headers {
append("X-Api-Key", settingsController.settings.value.plexToken)
}
}.body() as ApiInfo
}.fold(
onSuccess = { response ->
Napier.d { "Response: $response" }
screenState.setValue { old -> old.copy(apiInfo = response) }
},
onFailure = { error ->
Napier.e(error) { "Error occurred" }
screenState.setValue { old -> old.copy(apiErrorText = error.message.toString()) }
}
)
}
}
}
@Serializable
data class ApiInfo(
val api: String,
val version: String
)
@@ -0,0 +1,9 @@
package dev.meloda.overseerr.screens.requests.di
import dev.meloda.overseerr.screens.requests.RequestsViewModelImpl
import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.module
val requestsModule = module {
viewModelOf(::RequestsViewModelImpl)
}
@@ -0,0 +1,19 @@
package dev.meloda.overseerr.screens.requests.model
import dev.meloda.overseerr.screens.requests.ApiInfo
data class RequestsScreenState(
val dummyItems: List<Int>,
val isLoading: Boolean,
val apiInfo: ApiInfo?,
val apiErrorText: String?
) {
companion object {
val EMPTY: RequestsScreenState = RequestsScreenState(
dummyItems = List(50) { it },
isLoading = false,
apiInfo = null,
apiErrorText = null
)
}
}
@@ -0,0 +1,163 @@
package dev.meloda.overseerr.screens.requests.presentation
import ContentWithMessageBar
import MessageBarPosition
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.pulltorefresh.PullToRefreshDefaults.Indicator
import androidx.compose.material3.pulltorefresh.pullToRefresh
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.*
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 cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.haze
import dev.chrisbanes.haze.hazeChild
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 org.koin.compose.viewmodel.koinViewModel
import rememberMessageBarState
class RequestsScreen : Screen {
@OptIn(
ExperimentalMaterial3Api::class,
ExperimentalHazeMaterialsApi::class
)
@Composable
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
val viewModel: RequestsViewModel = koinViewModel<RequestsViewModelImpl>()
val screenState: RequestsScreenState by viewModel.screenState.collectAsState()
val hazeState = remember { HazeState() }
val hazeStyle = HazeMaterials.ultraThin()
val refreshState = rememberPullToRefreshState()
val messageBarState = rememberMessageBarState()
LaunchedEffect(screenState) {
if (screenState.apiErrorText != null) {
messageBarState.addError(Exception(screenState.apiErrorText))
viewModel.onErrorMessageShown()
}
if (screenState.apiInfo != null) {
messageBarState.addSuccess(screenState.apiInfo.toString())
viewModel.onSuccessMessageShown()
}
}
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(
title = { Text(text = "Requests") },
navigationIcon = {
IconButton(onClick = navigator::pop) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = null
)
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
modifier = Modifier
.hazeChild(
state = hazeState,
style = hazeStyle
).fillMaxWidth(),
actions = {
IconButton(
onClick = viewModel::onRefresh
) {
Icon(
imageVector = Icons.Rounded.Refresh,
contentDescription = null
)
}
}
)
}
) { padding ->
val bottomPadding = padding.calculateBottomPadding()
ContentWithMessageBar(
messageBarState = messageBarState,
position = MessageBarPosition.BOTTOM
) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(start = padding.calculateStartPadding(LayoutDirection.Ltr))
.padding(end = padding.calculateEndPadding(LayoutDirection.Ltr))
) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.haze(
state = hazeState,
style = hazeStyle
)
.pullToRefresh(
isRefreshing = screenState.isLoading,
state = refreshState,
onRefresh = viewModel::onRefresh
)
) {
item {
Spacer(modifier = Modifier.height(padding.calculateTopPadding()))
}
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)
.hazeChild(
state = hazeState,
style = hazeStyle
)
.background(Color.Transparent)
.height(bottomPadding)
.fillMaxWidth()
)
}
}
}
}
}
}
@@ -16,6 +16,7 @@ interface UrlViewModel {
val screenState: StateFlow<UrlScreenState>
fun onUrlInputChanged(newText: String)
fun onPlexTokenInputChanged(newToken: String)
fun onLoadButtonClicked()
fun onSaveButtonClicked()
fun onTestButtonClicked()
@@ -29,7 +30,14 @@ class UrlViewModelImpl(
init {
settingsController.settings
.onEach { settings -> screenState.setValue { old -> old.copy(url = settings.url) } }
.onEach { settings ->
screenState.setValue { old ->
old.copy(
url = settings.url,
plexToken = settings.plexToken
)
}
}
.launchIn(viewModelScope)
}
@@ -37,18 +45,30 @@ class UrlViewModelImpl(
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) }
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)
settings.copy(
url = screenState.value.url,
plexToken = screenState.value.plexToken
)
}
}
}
@@ -1,7 +1,9 @@
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)
}
@@ -2,11 +2,13 @@ package dev.meloda.overseerr.screens.url.model
data class UrlScreenState(
val url: String,
val plexToken: String,
val isWrongUrlError: Boolean
) {
companion object {
val EMPTY: UrlScreenState = UrlScreenState(
url = "",
plexToken = "",
isWrongUrlError = false
)
}
@@ -13,14 +13,12 @@ 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.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.url.UrlViewModel
import dev.meloda.overseerr.screens.url.UrlViewModelImpl
import dev.meloda.overseerr.settings.SettingsController
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
class UrlScreen : Screen {
@@ -28,10 +26,7 @@ class UrlScreen : Screen {
@Composable
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
val settingsController: SettingsController = koinInject()
val viewModel: UrlViewModel = viewModel { UrlViewModelImpl(settingsController) }
val viewModel: UrlViewModel = koinViewModel<UrlViewModelImpl>()
val screenState by viewModel.screenState.collectAsState()
Scaffold(
@@ -72,6 +67,19 @@ class UrlScreen : Screen {
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)
@@ -4,11 +4,10 @@ import kotlinx.serialization.Serializable
@Serializable
data class AppSettings(
val url: String
val url: String = "",
val plexToken: String = ""
) {
companion object {
val EMPTY: AppSettings = AppSettings(
url = ""
)
val EMPTY: AppSettings = AppSettings()
}
}
@@ -0,0 +1,8 @@
package dev.meloda.overseerr.network.model
import io.ktor.client.engine.*
import io.ktor.client.engine.darwin.*
actual class HttpClientEngineFactoryProvider actual constructor() {
actual fun get(): HttpClientEngineFactory<*> = Darwin
}
@@ -0,0 +1,8 @@
package dev.meloda.overseerr.network.model
import io.ktor.client.engine.*
import io.ktor.client.engine.okhttp.*
actual class HttpClientEngineFactoryProvider actual constructor() {
actual fun get(): HttpClientEngineFactory<*> = OkHttp
}
@@ -0,0 +1,8 @@
package dev.meloda.overseerr.network.model
import io.ktor.client.engine.*
import io.ktor.client.engine.js.*
actual class HttpClientEngineFactoryProvider actual constructor() {
actual fun get(): HttpClientEngineFactory<*> = Js
}
+6 -1
View File
@@ -16,6 +16,7 @@ haze = "0.7.3"
kstore = "0.8.0"
appdirs = "1.2.2"
napier = "2.7.1"
message-bar = "1.0.5"
[libraries]
@@ -32,12 +33,15 @@ kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutine
kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
ktor-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-client-js = { module = "io.ktor:ktor-client-js", version.ref = "ktor" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-kotlinx-serialization-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" }
koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin" }
koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel", 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" }
@@ -45,6 +49,7 @@ kstore-file = { module = "io.github.xxfast:kstore-file", version.ref = "kstore"
kstore-storage = { module = "io.github.xxfast:kstore-storage", version.ref = "kstore" }
appdirs = { module = "net.harawata:appdirs", version.ref = "appdirs" }
napier = { module = "io.github.aakira:napier", version.ref = "napier" }
message-bar = { module = "com.stevdza-san:messagebarkmp", version.ref = "message-bar" }
[plugins]