Implement configuration of server settings during runtime

This commit is contained in:
Syer10
2024-03-30 16:22:50 -04:00
parent a04762842b
commit 52ea0f1c37
34 changed files with 1544 additions and 289 deletions

View File

@@ -31,6 +31,7 @@ dependencies {
implementation(libs.imageloader.core)
implementation(libs.imageloader.moko)
implementation(libs.materialDialogs.core)
implementation(libs.materialDialogs.datetime)
// Android
implementation(libs.androidx.core)
@@ -111,6 +112,9 @@ android {
buildTypes {
getByName("debug") {
applicationIdSuffix = ".debug"
isMinifyEnabled = true
isShrinkResources = true
setProguardFiles(listOf(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"))
}
getByName("release") {
isMinifyEnabled = true

View File

@@ -1,46 +1,146 @@
fragment SettingsTypeFragment on SettingsType {
autoDownloadNewChapters
autoDownloadNewChaptersLimit
backupInterval
backupPath
backupTTL
backupTime
basicAuthEnabled
basicAuthPassword
basicAuthUsername
debugLogsEnabled
downloadAsCbz
downloadsPath
electronPath
excludeCompleted
excludeEntryWithUnreadChapters
excludeNotStarted
excludeUnreadChapters
extensionRepos
flareSolverrEnabled
flareSolverrSessionName
flareSolverrSessionTtl
flareSolverrTimeout
flareSolverrUrl
globalUpdateInterval
gqlDebugLogsEnabled
initialOpenInBrowserEnabled
ip
localSourcePath
maxSourcesInParallel
port
socksProxyEnabled
socksProxyHost
socksProxyPassword
socksProxyPort
socksProxyUsername
socksProxyVersion
systemTrayEnabled
updateMangas
webUIChannel
webUIFlavor
webUIInterface
webUIUpdateCheckInterval
}
query AllSettings {
settings {
autoDownloadNewChapters
autoDownloadNewChaptersLimit
backupInterval
backupPath
backupTTL
backupTime
basicAuthEnabled
basicAuthPassword
basicAuthUsername
debugLogsEnabled
downloadAsCbz
downloadsPath
electronPath
excludeCompleted
excludeEntryWithUnreadChapters
excludeNotStarted
excludeUnreadChapters
extensionRepos
flareSolverrEnabled
flareSolverrSessionName
flareSolverrSessionTtl
flareSolverrTimeout
flareSolverrUrl
globalUpdateInterval
gqlDebugLogsEnabled
initialOpenInBrowserEnabled
ip
localSourcePath
maxSourcesInParallel
port
socksProxyEnabled
socksProxyHost
socksProxyPassword
socksProxyPort
socksProxyUsername
socksProxyVersion
systemTrayEnabled
updateMangas
webUIChannel
webUIFlavor
webUIInterface
webUIUpdateCheckInterval
...SettingsTypeFragment
}
}
mutation SetSettings(
$autoDownloadNewChapters: Boolean = null,
$autoDownloadNewChaptersLimit: Int = null,
$backupInterval: Int = null,
$backupPath: String = null,
$backupTTL: Int = null,
$backupTime: String = null,
$basicAuthEnabled: Boolean = null,
$basicAuthPassword: String = null,
$basicAuthUsername: String = null,
$debugLogsEnabled: Boolean = null,
$downloadAsCbz: Boolean = null,
$downloadsPath: String = null,
$electronPath: String = null,
$excludeCompleted: Boolean = null,
$excludeEntryWithUnreadChapters: Boolean = null,
$excludeNotStarted: Boolean = null,
$excludeUnreadChapters: Boolean = null,
$extensionRepos: [String!] = null,
$flareSolverrEnabled: Boolean = null,
$flareSolverrSessionName: String = null,
$flareSolverrSessionTtl: Int = null,
$flareSolverrTimeout: Int = null,
$flareSolverrUrl: String = null,
$globalUpdateInterval: Float = null,
$gqlDebugLogsEnabled: Boolean = null,
$initialOpenInBrowserEnabled: Boolean = null,
$ip: String = null,
$localSourcePath: String = null,
$maxSourcesInParallel: Int = null,
$port: Int = null,
$socksProxyEnabled: Boolean = null,
$socksProxyHost: String = null,
$socksProxyPassword: String = null,
$socksProxyPort: String = null,
$socksProxyUsername: String = null,
$socksProxyVersion: Int = null,
$systemTrayEnabled: Boolean = null,
$updateMangas: Boolean = null,
$webUIChannel: WebUIChannel = null,
$webUIFlavor: WebUIFlavor = null,
$webUIInterface: WebUIInterface = null,
$webUIUpdateCheckInterval: Float = null
) {
setSettings(
input: {
settings: {
autoDownloadNewChapters: $autoDownloadNewChapters,
autoDownloadNewChaptersLimit: $autoDownloadNewChaptersLimit,
backupInterval: $backupInterval,
backupPath: $backupPath,
backupTTL: $backupTTL,
backupTime: $backupTime,
basicAuthEnabled: $basicAuthEnabled,
basicAuthPassword: $basicAuthPassword,
basicAuthUsername: $basicAuthUsername,
debugLogsEnabled: $debugLogsEnabled,
downloadAsCbz: $downloadAsCbz,
downloadsPath: $downloadsPath,
electronPath: $electronPath,
excludeCompleted: $excludeCompleted,
excludeEntryWithUnreadChapters: $excludeEntryWithUnreadChapters,
excludeNotStarted: $excludeNotStarted,
excludeUnreadChapters: $excludeUnreadChapters,
extensionRepos: $extensionRepos,
flareSolverrEnabled: $flareSolverrEnabled,
flareSolverrSessionName: $flareSolverrSessionName,
flareSolverrSessionTtl: $flareSolverrSessionTtl,
flareSolverrTimeout: $flareSolverrTimeout,
flareSolverrUrl: $flareSolverrUrl,
globalUpdateInterval: $globalUpdateInterval,
gqlDebugLogsEnabled: $gqlDebugLogsEnabled,
initialOpenInBrowserEnabled: $initialOpenInBrowserEnabled,
ip: $ip,
localSourcePath: $localSourcePath,
maxSourcesInParallel: $maxSourcesInParallel,
port: $port,
socksProxyEnabled: $socksProxyEnabled,
socksProxyHost: $socksProxyHost,
socksProxyPassword: $socksProxyPassword,
socksProxyPort: $socksProxyPort,
socksProxyUsername: $socksProxyUsername,
socksProxyVersion: $socksProxyVersion,
systemTrayEnabled: $systemTrayEnabled,
updateMangas: $updateMangas,
webUIChannel: $webUIChannel,
webUIFlavor: $webUIFlavor,
webUIInterface: $webUIInterface,
webUIUpdateCheckInterval: $webUIUpdateCheckInterval
}
}
) {
clientMutationId
}
}

View File

@@ -7,6 +7,7 @@
package ca.gosyer.jui.data
import ca.gosyer.jui.core.lang.addSuffix
import ca.gosyer.jui.data.settings.SettingsRepositoryImpl
import ca.gosyer.jui.domain.backup.service.BackupRepository
import ca.gosyer.jui.domain.category.service.CategoryRepository
import ca.gosyer.jui.domain.chapter.service.ChapterRepository
@@ -18,6 +19,7 @@ import ca.gosyer.jui.domain.manga.service.MangaRepository
import ca.gosyer.jui.domain.server.Http
import ca.gosyer.jui.domain.server.service.ServerPreferences
import ca.gosyer.jui.domain.settings.service.SettingsRepository
import ca.gosyer.jui.domain.settings.service.SettingsRepositoryOld
import ca.gosyer.jui.domain.source.service.SourceRepository
import ca.gosyer.jui.domain.updates.service.UpdatesRepository
import com.apollographql.apollo3.ApolloClient
@@ -79,11 +81,15 @@ interface DataComponent {
fun mangaRepository(ktorfit: Ktorfit) = ktorfit.create<MangaRepository>()
@Provides
fun settingsRepository(ktorfit: Ktorfit) = ktorfit.create<SettingsRepository>()
fun settingsRepositoryOld(ktorfit: Ktorfit) = ktorfit.create<SettingsRepositoryOld>()
@Provides
fun sourceRepository(ktorfit: Ktorfit) = ktorfit.create<SourceRepository>()
@Provides
fun updatesRepository(ktorfit: Ktorfit) = ktorfit.create<UpdatesRepository>()
@Provides
fun settingsRepository(apolloClient: ApolloClient): SettingsRepository =
SettingsRepositoryImpl(apolloClient)
}

View File

@@ -0,0 +1,179 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package ca.gosyer.jui.data.settings
import ca.gosyer.jui.data.graphql.AllSettingsQuery
import ca.gosyer.jui.data.graphql.SetSettingsMutation
import ca.gosyer.jui.data.graphql.fragment.SettingsTypeFragment
import ca.gosyer.jui.data.graphql.type.WebUIChannel
import ca.gosyer.jui.data.graphql.type.WebUIFlavor
import ca.gosyer.jui.data.graphql.type.WebUIInterface
import ca.gosyer.jui.data.util.toOptional
import ca.gosyer.jui.domain.settings.model.SetSettingsInput
import ca.gosyer.jui.domain.settings.model.Settings
import ca.gosyer.jui.domain.settings.service.SettingsRepository
import com.apollographql.apollo3.ApolloClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import ca.gosyer.jui.domain.settings.model.WebUIChannel as DomainWebUIChannel
import ca.gosyer.jui.domain.settings.model.WebUIFlavor as DomainWebUIFlavor
import ca.gosyer.jui.domain.settings.model.WebUIInterface as DomainWebUIInterface
class SettingsRepositoryImpl(private val apolloClient: ApolloClient) : SettingsRepository {
private fun SettingsTypeFragment.toSettings() = Settings(
autoDownloadNewChapters = autoDownloadNewChapters,
autoDownloadNewChaptersLimit = autoDownloadNewChaptersLimit,
backupInterval = backupInterval,
backupPath = backupPath,
backupTTL = backupTTL,
backupTime = backupTime,
basicAuthEnabled = basicAuthEnabled,
basicAuthPassword = basicAuthPassword,
basicAuthUsername = basicAuthUsername,
debugLogsEnabled = debugLogsEnabled,
downloadAsCbz = downloadAsCbz,
downloadsPath = downloadsPath,
electronPath = electronPath,
excludeCompleted = excludeCompleted,
excludeEntryWithUnreadChapters = excludeEntryWithUnreadChapters,
excludeNotStarted = excludeNotStarted,
excludeUnreadChapters = excludeUnreadChapters,
extensionRepos = extensionRepos,
flareSolverrEnabled = flareSolverrEnabled,
flareSolverrSessionName = flareSolverrSessionName,
flareSolverrSessionTtl = flareSolverrSessionTtl,
flareSolverrTimeout = flareSolverrTimeout,
flareSolverrUrl = flareSolverrUrl,
globalUpdateInterval = globalUpdateInterval,
gqlDebugLogsEnabled = gqlDebugLogsEnabled,
initialOpenInBrowserEnabled = initialOpenInBrowserEnabled,
ip = ip,
localSourcePath = localSourcePath,
maxSourcesInParallel = maxSourcesInParallel,
port = port,
socksProxyEnabled = socksProxyEnabled,
socksProxyHost = socksProxyHost,
socksProxyPassword = socksProxyPassword,
socksProxyPort = socksProxyPort,
socksProxyUsername = socksProxyUsername,
socksProxyVersion = socksProxyVersion,
systemTrayEnabled = systemTrayEnabled,
updateMangas = updateMangas,
webUIChannel = webUIChannel.toDomain(),
webUIFlavor = webUIFlavor.toDomain(),
webUIInterface = webUIInterface.toDomain(),
webUIUpdateCheckInterval = webUIUpdateCheckInterval
)
private fun WebUIChannel.toDomain() = when (this) {
WebUIChannel.BUNDLED -> DomainWebUIChannel.BUNDLED
WebUIChannel.STABLE -> DomainWebUIChannel.STABLE
WebUIChannel.PREVIEW -> DomainWebUIChannel.PREVIEW
WebUIChannel.UNKNOWN__ -> DomainWebUIChannel.UNKNOWN__
}
private fun WebUIFlavor.toDomain() = when (this) {
WebUIFlavor.WEBUI -> DomainWebUIFlavor.WEBUI
WebUIFlavor.VUI -> DomainWebUIFlavor.VUI
WebUIFlavor.CUSTOM -> DomainWebUIFlavor.CUSTOM
WebUIFlavor.UNKNOWN__ -> DomainWebUIFlavor.UNKNOWN__
}
private fun WebUIInterface.toDomain() = when (this) {
WebUIInterface.BROWSER -> DomainWebUIInterface.BROWSER
WebUIInterface.ELECTRON -> DomainWebUIInterface.ELECTRON
WebUIInterface.UNKNOWN__ -> DomainWebUIInterface.UNKNOWN__
}
private fun DomainWebUIChannel.toGraphQL() = when (this) {
DomainWebUIChannel.BUNDLED -> WebUIChannel.BUNDLED
DomainWebUIChannel.STABLE -> WebUIChannel.STABLE
DomainWebUIChannel.PREVIEW -> WebUIChannel.PREVIEW
DomainWebUIChannel.UNKNOWN__ -> WebUIChannel.UNKNOWN__
}
private fun DomainWebUIFlavor.toGraphQL() = when (this) {
DomainWebUIFlavor.WEBUI -> WebUIFlavor.WEBUI
DomainWebUIFlavor.VUI -> WebUIFlavor.VUI
DomainWebUIFlavor.CUSTOM -> WebUIFlavor.CUSTOM
DomainWebUIFlavor.UNKNOWN__ -> WebUIFlavor.UNKNOWN__
}
private fun DomainWebUIInterface.toGraphQL() = when (this) {
DomainWebUIInterface.BROWSER -> WebUIInterface.BROWSER
DomainWebUIInterface.ELECTRON -> WebUIInterface.ELECTRON
DomainWebUIInterface.UNKNOWN__ -> WebUIInterface.UNKNOWN__
}
override fun getSettings(): Flow<Settings> {
return apolloClient.query(AllSettingsQuery()).toFlow()
.map {
it.dataOrThrow().settings.settingsTypeFragment.toSettings()
}
.flowOn(Dispatchers.IO)
}
private fun SetSettingsInput.toMutation() = SetSettingsMutation(
autoDownloadNewChapters = autoDownloadNewChapters.toOptional(),
autoDownloadNewChaptersLimit = autoDownloadNewChaptersLimit.toOptional(),
backupInterval = backupInterval.toOptional(),
backupPath = backupPath.toOptional(),
backupTTL = backupTTL.toOptional(),
backupTime = backupTime.toOptional(),
basicAuthEnabled = basicAuthEnabled.toOptional(),
basicAuthPassword = basicAuthPassword.toOptional(),
basicAuthUsername = basicAuthUsername.toOptional(),
debugLogsEnabled = debugLogsEnabled.toOptional(),
downloadAsCbz = downloadAsCbz.toOptional(),
downloadsPath = downloadsPath.toOptional(),
electronPath = electronPath.toOptional(),
excludeCompleted = excludeCompleted.toOptional(),
excludeEntryWithUnreadChapters = excludeEntryWithUnreadChapters.toOptional(),
excludeNotStarted = excludeNotStarted.toOptional(),
excludeUnreadChapters = excludeUnreadChapters.toOptional(),
extensionRepos = extensionRepos.toOptional(),
flareSolverrEnabled = flareSolverrEnabled.toOptional(),
flareSolverrSessionName = flareSolverrSessionName.toOptional(),
flareSolverrSessionTtl = flareSolverrSessionTtl.toOptional(),
flareSolverrTimeout = flareSolverrTimeout.toOptional(),
flareSolverrUrl = flareSolverrUrl.toOptional(),
globalUpdateInterval = globalUpdateInterval.toOptional(),
gqlDebugLogsEnabled = gqlDebugLogsEnabled.toOptional(),
initialOpenInBrowserEnabled = initialOpenInBrowserEnabled.toOptional(),
ip = ip.toOptional(),
localSourcePath = localSourcePath.toOptional(),
maxSourcesInParallel = maxSourcesInParallel.toOptional(),
port = port.toOptional(),
socksProxyEnabled = socksProxyEnabled.toOptional(),
socksProxyHost = socksProxyHost.toOptional(),
socksProxyPassword = socksProxyPassword.toOptional(),
socksProxyPort = socksProxyPort.toOptional(),
socksProxyUsername = socksProxyUsername.toOptional(),
socksProxyVersion = socksProxyVersion.toOptional(),
systemTrayEnabled = systemTrayEnabled.toOptional(),
updateMangas = updateMangas.toOptional(),
webUIChannel = webUIChannel?.toGraphQL().toOptional(),
webUIFlavor = webUIFlavor?.toGraphQL().toOptional(),
webUIInterface = webUIInterface?.toGraphQL().toOptional(),
webUIUpdateCheckInterval = webUIUpdateCheckInterval.toOptional(),
)
override fun setSettings(input: SetSettingsInput): Flow<Unit> {
return apolloClient.mutation(input.toMutation())
.toFlow()
.map {
it.dataOrThrow()
Unit
}
.flowOn(Dispatchers.IO)
}
}

View File

@@ -0,0 +1,11 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package ca.gosyer.jui.data.util
import com.apollographql.apollo3.api.Optional
internal fun <T> T?.toOptional() = Optional.presentIfNotNull(this)

View File

@@ -39,6 +39,7 @@ dependencies {
implementation(libs.imageloader.core)
implementation(libs.imageloader.moko)
implementation(libs.materialDialogs.core)
implementation(libs.materialDialogs.datetime)
// UI (Swing)
implementation(libs.darklaf)

View File

@@ -36,7 +36,6 @@ const val filePattern =
'$' +
"{LOG_EXCEPTION_CONVERSION_WORD:-%xEx}"
@Suppress("UPPER_BOUND_VIOLATED_WARNING")
fun initializeLogger(loggingLocation: Path) {
val ctx = LogManager.getContext(false) as LoggerContext
val builder = ConfigurationBuilderFactory.newConfigurationBuilder()

View File

@@ -0,0 +1,29 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package ca.gosyer.jui.domain.server.service
import ca.gosyer.jui.core.prefs.Preference
import ca.gosyer.jui.core.prefs.PreferenceStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
actual class ServerHostPreferences actual constructor(
@Suppress("unused") private val preferenceStore: PreferenceStore,
) {
actual fun host(): Preference<Boolean> = object : Preference<Boolean> {
override fun key(): String = "host"
override fun get(): Boolean = false
override fun isSet(): Boolean = false
override fun delete() {}
override fun defaultValue(): Boolean = false
override fun changes(): Flow<Boolean> = MutableStateFlow(false)
override fun stateIn(scope: CoroutineScope): StateFlow<Boolean> = MutableStateFlow(false)
override fun set(value: Boolean) {}
}
}

View File

@@ -17,6 +17,7 @@ import ca.gosyer.jui.domain.migration.service.MigrationPreferences
import ca.gosyer.jui.domain.reader.service.ReaderPreferences
import ca.gosyer.jui.domain.server.Http
import ca.gosyer.jui.domain.server.httpClient
import ca.gosyer.jui.domain.server.service.ServerHostPreferences
import ca.gosyer.jui.domain.server.service.ServerPreferences
import ca.gosyer.jui.domain.source.service.CatalogPreferences
import ca.gosyer.jui.domain.ui.service.UiPreferences
@@ -107,6 +108,11 @@ interface SharedDomainComponent : CoreComponent {
val updatePreferencesFactory: UpdatePreferences
get() = UpdatePreferences(preferenceFactory.create("update"))
@get:AppScope
@get:Provides
val serverHostPreferencesFactory: ServerHostPreferences
get() = ServerHostPreferences(preferenceFactory.create("host"))
@get:AppScope
@get:Provides
val libraryUpdateServiceFactory: LibraryUpdateService

View File

@@ -0,0 +1,16 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package ca.gosyer.jui.domain.server.service
import ca.gosyer.jui.core.prefs.Preference
import ca.gosyer.jui.core.prefs.PreferenceStore
expect class ServerHostPreferences(
preferenceStore: PreferenceStore,
) {
fun host(): Preference<Boolean>
}

View File

@@ -6,7 +6,7 @@
package ca.gosyer.jui.domain.settings.interactor
import ca.gosyer.jui.domain.settings.service.SettingsRepository
import ca.gosyer.jui.domain.settings.service.SettingsRepositoryOld
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.singleOrNull
import me.tatarka.inject.annotations.Inject
@@ -15,7 +15,7 @@ import org.lighthousegames.logging.logging
class AboutServer
@Inject
constructor(
private val settingsRepository: SettingsRepository,
private val settingsRepositoryOld: SettingsRepositoryOld,
) {
suspend fun await(onError: suspend (Throwable) -> Unit = {}) =
asFlow()
@@ -25,7 +25,7 @@ class AboutServer
}
.singleOrNull()
fun asFlow() = settingsRepository.aboutServer()
fun asFlow() = settingsRepositoryOld.aboutServer()
companion object {
private val log = logging()

View File

@@ -6,7 +6,7 @@
package ca.gosyer.jui.domain.settings.interactor
import ca.gosyer.jui.domain.settings.service.SettingsRepository
import ca.gosyer.jui.domain.settings.service.SettingsRepositoryOld
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.singleOrNull
import me.tatarka.inject.annotations.Inject
@@ -15,7 +15,7 @@ import org.lighthousegames.logging.logging
class CheckUpdate
@Inject
constructor(
private val settingsRepository: SettingsRepository,
private val settingsRepositoryOld: SettingsRepositoryOld,
) {
suspend fun await(onError: suspend (Throwable) -> Unit = {}) =
asFlow()
@@ -25,7 +25,7 @@ class CheckUpdate
}
.singleOrNull()
fun asFlow() = settingsRepository.checkUpdate()
fun asFlow() = settingsRepositoryOld.checkUpdate()
companion object {
private val log = logging()

View File

@@ -0,0 +1,33 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package ca.gosyer.jui.domain.settings.interactor
import ca.gosyer.jui.domain.settings.service.SettingsRepository
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.singleOrNull
import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
class GetSettings
@Inject
constructor(
private val settingsRepository: SettingsRepository,
) {
suspend fun await(onError: suspend (Throwable) -> Unit = {}) =
asFlow()
.catch {
onError(it)
log.warn(it) { "Failed to check for server updates" }
}
.singleOrNull()
fun asFlow() = settingsRepository.getSettings()
companion object {
private val log = logging()
}
}

View File

@@ -0,0 +1,34 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package ca.gosyer.jui.domain.settings.interactor
import ca.gosyer.jui.domain.settings.model.SetSettingsInput
import ca.gosyer.jui.domain.settings.service.SettingsRepository
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.singleOrNull
import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
class SetSettings
@Inject
constructor(
private val settingsRepository: SettingsRepository,
) {
suspend fun await(input: SetSettingsInput, onError: suspend (Throwable) -> Unit = {}) =
asFlow(input)
.catch {
onError(it)
log.warn(it) { "Failed to check for server updates" }
}
.singleOrNull()
fun asFlow(input: SetSettingsInput) = settingsRepository.setSettings(input)
companion object {
private val log = logging()
}
}

View File

@@ -0,0 +1,52 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package ca.gosyer.jui.domain.settings.model
class SetSettingsInput(
val autoDownloadNewChapters: Boolean? = null,
val autoDownloadNewChaptersLimit: Int? = null,
val backupInterval: Int? = null,
val backupPath: String? = null,
val backupTTL: Int? = null,
val backupTime: String? = null,
val basicAuthEnabled: Boolean? = null,
val basicAuthPassword: String? = null,
val basicAuthUsername: String? = null,
val debugLogsEnabled: Boolean? = null,
val downloadAsCbz: Boolean? = null,
val downloadsPath: String? = null,
val electronPath: String? = null,
val excludeCompleted: Boolean? = null,
val excludeEntryWithUnreadChapters: Boolean? = null,
val excludeNotStarted: Boolean? = null,
val excludeUnreadChapters: Boolean? = null,
val extensionRepos: List<String>? = null,
val flareSolverrEnabled: Boolean? = null,
val flareSolverrSessionName: String? = null,
val flareSolverrSessionTtl: Int? = null,
val flareSolverrTimeout: Int? = null,
val flareSolverrUrl: String? = null,
val globalUpdateInterval: Double? = null,
val gqlDebugLogsEnabled: Boolean? = null,
val initialOpenInBrowserEnabled: Boolean? = null,
val ip: String? = null,
val localSourcePath: String? = null,
val maxSourcesInParallel: Int? = null,
val port: Int? = null,
val socksProxyEnabled: Boolean? = null,
val socksProxyHost: String? = null,
val socksProxyPassword: String? = null,
val socksProxyPort: String? = null,
val socksProxyUsername: String? = null,
val socksProxyVersion: Int? = null,
val systemTrayEnabled: Boolean? = null,
val updateMangas: Boolean? = null,
val webUIChannel: WebUIChannel? = null,
val webUIFlavor: WebUIFlavor? = null,
val webUIInterface: WebUIInterface? = null,
val webUIUpdateCheckInterval: Double? = null,
)

View File

@@ -0,0 +1,55 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package ca.gosyer.jui.domain.settings.model
import androidx.compose.runtime.Stable
@Stable
class Settings(
val autoDownloadNewChapters: Boolean,
val autoDownloadNewChaptersLimit: Int,
val backupInterval: Int,
val backupPath: String,
val backupTTL: Int,
val backupTime: String,
val basicAuthEnabled: Boolean,
val basicAuthPassword: String,
val basicAuthUsername: String,
val debugLogsEnabled: Boolean,
val downloadAsCbz: Boolean,
val downloadsPath: String,
val electronPath: String,
val excludeCompleted: Boolean,
val excludeEntryWithUnreadChapters: Boolean,
val excludeNotStarted: Boolean,
val excludeUnreadChapters: Boolean,
val extensionRepos: List<String>,
val flareSolverrEnabled: Boolean,
val flareSolverrSessionName: String,
val flareSolverrSessionTtl: Int,
val flareSolverrTimeout: Int,
val flareSolverrUrl: String,
val globalUpdateInterval: Double,
val gqlDebugLogsEnabled: Boolean,
val initialOpenInBrowserEnabled: Boolean,
val ip: String,
val localSourcePath: String,
val maxSourcesInParallel: Int,
val port: Int,
val socksProxyEnabled: Boolean,
val socksProxyHost: String,
val socksProxyPassword: String,
val socksProxyPort: String,
val socksProxyUsername: String,
val socksProxyVersion: Int,
val systemTrayEnabled: Boolean,
val updateMangas: Boolean,
val webUIChannel: WebUIChannel,
val webUIFlavor: WebUIFlavor,
val webUIInterface: WebUIInterface,
val webUIUpdateCheckInterval: Double,
)

View File

@@ -0,0 +1,14 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package ca.gosyer.jui.domain.settings.model
enum class WebUIChannel {
BUNDLED,
STABLE,
PREVIEW,
UNKNOWN__;
}

View File

@@ -0,0 +1,14 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package ca.gosyer.jui.domain.settings.model
enum class WebUIFlavor {
WEBUI,
VUI,
CUSTOM,
UNKNOWN__;
}

View File

@@ -0,0 +1,13 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package ca.gosyer.jui.domain.settings.model
enum class WebUIInterface {
BROWSER,
ELECTRON,
UNKNOWN__;
}

View File

@@ -6,16 +6,13 @@
package ca.gosyer.jui.domain.settings.service
import ca.gosyer.jui.domain.settings.model.About
import de.jensklingenberg.ktorfit.http.GET
import de.jensklingenberg.ktorfit.http.POST
import io.ktor.client.statement.HttpResponse
import ca.gosyer.jui.domain.settings.model.SetSettingsInput
import ca.gosyer.jui.domain.settings.model.Settings
import kotlinx.coroutines.flow.Flow
interface SettingsRepository {
@GET("api/v1/settings/about")
fun aboutServer(): Flow<About>
@POST("api/v1/settings/check-update")
fun checkUpdate(): Flow<HttpResponse>
fun getSettings(): Flow<Settings>
fun setSettings(input: SetSettingsInput): Flow<Unit>
}

View File

@@ -0,0 +1,21 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package ca.gosyer.jui.domain.settings.service
import ca.gosyer.jui.domain.settings.model.About
import de.jensklingenberg.ktorfit.http.GET
import de.jensklingenberg.ktorfit.http.POST
import io.ktor.client.statement.HttpResponse
import kotlinx.coroutines.flow.Flow
interface SettingsRepositoryOld {
@GET("api/v1/settings/about")
fun aboutServer(): Flow<About>
@POST("api/v1/settings/check-update")
fun checkUpdate(): Flow<HttpResponse>
}

View File

@@ -18,11 +18,6 @@ actual interface DomainComponent : SharedDomainComponent {
val serverHostPreferences: ServerHostPreferences
@get:AppScope
@get:Provides
val serverHostPreferencesFactory: ServerHostPreferences
get() = ServerHostPreferences(preferenceFactory.create("host"))
@get:AppScope
@get:Provides
val serverServiceFactory: ServerService

View File

@@ -10,10 +10,10 @@ import ca.gosyer.jui.core.prefs.Preference
import ca.gosyer.jui.core.prefs.PreferenceStore
import ca.gosyer.jui.domain.server.service.host.ServerHostPreference
class ServerHostPreferences(
actual class ServerHostPreferences actual constructor(
private val preferenceStore: PreferenceStore,
) {
fun host(): Preference<Boolean> = preferenceStore.getBoolean("host", true)
actual fun host(): Preference<Boolean> = preferenceStore.getBoolean("host", true)
private val ip = ServerHostPreference.IP(preferenceStore)
@@ -23,45 +23,20 @@ class ServerHostPreferences(
fun port(): Preference<Int> = port.preference()
// Proxy
private val socksProxyEnabled = ServerHostPreference.SocksProxyEnabled(preferenceStore)
fun socksProxyEnabled(): Preference<Boolean> = socksProxyEnabled.preference()
private val socksProxyHost = ServerHostPreference.SocksProxyHost(preferenceStore)
fun socksProxyHost(): Preference<String> = socksProxyHost.preference()
private val socksProxyPort = ServerHostPreference.SocksProxyPort(preferenceStore)
fun socksProxyPort(): Preference<Int> = socksProxyPort.preference()
// Misc
private val debugLogsEnabled = ServerHostPreference.DebugLogsEnabled(preferenceStore)
fun debugLogsEnabled(): Preference<Boolean> = debugLogsEnabled.preference()
private val systemTrayEnabled = ServerHostPreference.SystemTrayEnabled(preferenceStore)
fun systemTrayEnabled(): Preference<Boolean> = systemTrayEnabled.preference()
// Downloader
private val downloadPath = ServerHostPreference.DownloadPath(preferenceStore)
fun downloadPath(): Preference<String> = downloadPath.preference()
private val downloadAsCbz = ServerHostPreference.DownloadAsCbz(preferenceStore)
// Backup
private val backupPath = ServerHostPreference.BackupPath(preferenceStore)
fun downloadAsCbz(): Preference<Boolean> = downloadAsCbz.preference()
fun backupPath(): Preference<String> = backupPath.preference()
// WebUI
private val webUIEnabled = ServerHostPreference.WebUIEnabled(preferenceStore)
// LocalSource
private val localSourcePath = ServerHostPreference.LocalSourcePath(preferenceStore)
fun webUIEnabled(): Preference<Boolean> = webUIEnabled.preference()
private val openInBrowserEnabled = ServerHostPreference.OpenInBrowserEnabled(preferenceStore)
fun openInBrowserEnabled(): Preference<Boolean> = openInBrowserEnabled.preference()
fun localSourcePath(): Preference<String> = localSourcePath.preference()
// Authentication
private val basicAuthEnabled = ServerHostPreference.BasicAuthEnabled(preferenceStore)
@@ -80,19 +55,15 @@ class ServerHostPreferences(
listOf(
ip,
port,
socksProxyEnabled,
socksProxyHost,
socksProxyPort,
debugLogsEnabled,
systemTrayEnabled,
downloadPath,
downloadAsCbz,
webUIEnabled,
openInBrowserEnabled,
backupPath,
localSourcePath,
basicAuthEnabled,
basicAuthUsername,
basicAuthPassword,
).mapNotNull {
it.getProperty()
}.toTypedArray()
}.plus(
"-Dsuwayomi.tachidesk.config.server.initialOpenInBrowserEnabled=false"
).toTypedArray()
}

View File

@@ -79,49 +79,6 @@ sealed class ServerHostPreference<T : Any> {
4567,
)
// Proxy
class SocksProxyEnabled(
preferenceStore: PreferenceStore,
) : BooleanServerHostPreference(
preferenceStore,
"socksProxyEnabled",
false,
)
class SocksProxyHost(
preferenceStore: PreferenceStore,
) : StringServerHostPreference(
preferenceStore,
"socksProxyHost",
"",
)
class SocksProxyPort(
override val preferenceStore: PreferenceStore,
) : IntServerHostPreference(
preferenceStore,
"socksProxyPort",
0,
)
// Misc
class DebugLogsEnabled(
preferenceStore: PreferenceStore,
) : BooleanServerHostPreference(
preferenceStore,
"debugLogsEnabled",
false,
)
class SystemTrayEnabled(
preferenceStore: PreferenceStore,
) : BooleanServerHostPreference(
preferenceStore,
"systemTrayEnabled",
false,
true,
)
// Downloader
class DownloadPath(
preferenceStore: PreferenceStore,
@@ -131,32 +88,23 @@ sealed class ServerHostPreference<T : Any> {
"",
)
class DownloadAsCbz(
// Backup
class BackupPath(
preferenceStore: PreferenceStore,
) : BooleanServerHostPreference(
preferenceStore,
"downloadAsCbz",
false,
)
) : StringServerHostPreference(
preferenceStore,
"backupPath",
"",
)
// WebUI
class WebUIEnabled(
// LocalSource
class LocalSourcePath(
preferenceStore: PreferenceStore,
) : BooleanServerHostPreference(
preferenceStore,
"webUIEnabled",
false,
true,
)
class OpenInBrowserEnabled(
preferenceStore: PreferenceStore,
) : BooleanServerHostPreference(
preferenceStore,
"initialOpenInBrowserEnabled",
false,
true,
)
) : StringServerHostPreference(
preferenceStore,
"localSourcePath",
"",
)
// Authentication
class BasicAuthEnabled(

View File

@@ -0,0 +1,29 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package ca.gosyer.jui.domain.server.service
import ca.gosyer.jui.core.prefs.Preference
import ca.gosyer.jui.core.prefs.PreferenceStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
actual class ServerHostPreferences actual constructor(
@Suppress("unused") private val preferenceStore: PreferenceStore,
) {
actual fun host(): Preference<Boolean> = object : Preference<Boolean> {
override fun key(): String = "host"
override fun get(): Boolean = false
override fun isSet(): Boolean = false
override fun delete() {}
override fun defaultValue(): Boolean = false
override fun changes(): Flow<Boolean> = MutableStateFlow(false)
override fun stateIn(scope: CoroutineScope): StateFlow<Boolean> = MutableStateFlow(false)
override fun set(value: Boolean) {}
}
}

View File

@@ -99,6 +99,7 @@ accompanist-systemUIController = { module = "com.google.accompanist:accompanist-
imageloader-core = { module = "io.github.qdsfdhvh:image-loader", version.ref = "imageloader" }
imageloader-moko = { module = "io.github.qdsfdhvh:image-loader-extension-moko-resources", version.ref = "imageloader" }
materialDialogs-core = { module = "ca.gosyer:compose-material-dialogs-core", version.ref = "materialDialogs" }
materialDialogs-datetime = { module = "ca.gosyer:compose-material-dialogs-datetime", version.ref = "materialDialogs" }
# Android
androidx-core = { module = "androidx.core:core-ktx", version.ref = "core" }

View File

@@ -220,9 +220,9 @@
<!-- Backup Settings -->
<string name="backup_restore">Restore Backup</string>
<string name="backup_restore_sub">Restore a backup into Tachidesk</string>
<string name="backup_restore_sub">Restore a backup to your library</string>
<string name="backup_create">Create Backup</string>
<string name="backup_create_sub">Create a backup from Tachidesk</string>
<string name="backup_create_sub">Create a backup from your library</string>
<string name="missing_sources">Missing sources:</string>
<!-- General Settings -->
@@ -282,17 +282,21 @@
<!-- Server Settings -->
<string name="host_server">Host server inside Tachidesk-JUI</string>
<string name="host_settings">Tachidesk-Server settings</string>
<string name="host_settings_sub">The below settings configure the internal Tachidesk-Server</string>
<string name="host_settings">Suwayomi-Server settings</string>
<string name="host_settings_sub">The below settings configure the internal Suwayomi-Server</string>
<string name="host_ip">Server IP</string>
<string name="host_ip_sub">Current server IP: %1$s</string>
<string name="host_port">Server PORT</string>
<string name="host_port_sub">Current server PORT: %1$s</string>
<string name="host_socks_enabled">Server SOCKS5 Proxy</string>
<string name="host_socks_host">Server SOCKS5 Proxy HOST</string>
<string name="host_socks_enabled">Server SOCKS Proxy</string>
<string name="host_socks_host">Server SOCKS Proxy HOST</string>
<string name="host_socks_host_sub">Current Proxy HOST: %1$s</string>
<string name="host_socks_port">Server SOCKS5 Proxy PORT</string>
<string name="host_socks_port">Server SOCKS Proxy PORT</string>
<string name="host_socks_port_sub">Current Proxy PORT: %1$s</string>
<string name="host_socks_username">Server SOCKS Proxy username</string>
<string name="host_socks_password">Server SOCKS Proxy password</string>
<string name="host_socks_version">Server SOCKS Proxy version</string>
<string name="host_debug_logging">Server debug logs</string>
<string name="host_debug_logging_sub">Output debug logs from the server to JUI</string>
<string name="host_system_tray">Server system tray icon</string>
@@ -300,10 +304,16 @@
<string name="host_download_path">Download path</string>
<string name="host_download_path_sub">Current download path: %1$s</string>
<string name="host_download_path_sub_empty">Using default download path</string>
<string name="host_backup_path">Backup path</string>
<string name="host_backup_path_sub">Current backup path: %1$s</string>
<string name="host_backup_path_sub_empty">Using default backup path</string>
<string name="host_local_source_path">Local source path</string>
<string name="host_local_source_path_sub">Current local source path: %1$s</string>
<string name="host_local_source_path_sub_empty">Using default local source path</string>
<string name="host_download_as_cbz">Download as CBZ</string>
<string name="host_download_as_cbz_sub">Download chapters into CBZ archives</string>
<string name="host_webui">Server WebUI</string>
<string name="host_webui_sub">Whether the server\'s default WebUI is enabled, makes you able to use Tachidesk in your browser</string>
<string name="host_webui_sub">Whether the server\'s default WebUI is enabled, makes you able to use Suwayomi in your browser</string>
<string name="host_open_in_browser">Open Server WebUI on startup</string>
<string name="host_open_in_browser_sub">Open the WebUI inside your browser on server startup. Requires the WebUI be enabled</string>
<string name="host_basic_auth_sub">Use basic auth to protect your library, requires username and password</string>
@@ -327,6 +337,37 @@
<string name="digest_auth">Digest auth</string>
<string name="auth_username">Auth username</string>
<string name="auth_password">Auth password</string>
<string name="server_settings_sub">The below settings configure the connected Suwayomi-Server</string>
<string name="extension_repos">Extension repos</string>
<string name="extension_repos_sub">Configure your extension repos to allow extensions to be found</string>
<string name="global_update_interval">Global update interval</string>
<string name="global_update_interval_sub">Update interval in hours, 0 to disable, values under 6 hours will be ignored</string>
<string name="update_manga_info">Update manga info</string>
<string name="update_manga_info_sub">Update manga info alongside manga chapters in the library update</string>
<string name="exclude_completed">Exclude completed</string>
<string name="exclude_completed_sub">Exclude completed manga from library update</string>
<string name="exclude_unread">Exclude unread</string>
<string name="exclude_unread_sub">Exclude manga with unread chapters from library update</string>
<string name="exclude_not_started">Exclude not started</string>
<string name="exclude_not_started_sub">Exclude manga with no read chapters from library update</string>
<string name="max_sources_parallel">Max sources in parallel</string>
<string name="max_sources_parallel_sub">How many sources can be updated or downloaded in parallel</string>
<string name="graphql_debug_logs">Server GraphQL debug logs</string>
<string name="graphql_debug_logs_sub">Output GraphQL debug logs from the server to JUI</string>
<string name="download_new_chapters">Download new chapters</string>
<string name="download_chapter_limit">Chapter download limit</string>
<string name="download_chapter_limit_sub">Limit the amount of new chapters that are going to get downloaded</string>
<string name="ignore_unread_entries">Ignore entries with unread chapters</string>
<string name="backup_interval">Backup interval</string>
<string name="backup_interval_sub">Automatic backup interval in days, 0 to disable it</string>
<string name="backup_ttl">Backup TTL</string>
<string name="backup_ttl_sub">How long to keep a specific backup, in days, 0 to disable</string>
<string name="backup_time">Backup time</string>
<string name="backup_time_sub">When to run the automatic backup</string>
<string name="flaresolverr_url">FlareSolverr server url</string>
<string name="flaresolverr_timeout">FlareSolverr request timeout</string>
<string name="flaresolverr_session_name">FlareSolverr session name</string>
<string name="flaresolverr_session_ttl">FlareSolverr session TTL</string>
<!-- Advanced Settings -->
<string name="update_checker">Check for updates</string>

View File

@@ -60,6 +60,7 @@ kotlin {
implementation(libs.imageloader.core)
implementation(libs.imageloader.moko)
implementation(libs.materialDialogs.core)
implementation(libs.materialDialogs.datetime)
// Threading
implementation(libs.coroutines.core)

View File

@@ -66,6 +66,7 @@ kotlin {
api(libs.voyager.transitions)
api(libs.voyager.screenmodel)
api(libs.materialDialogs.core)
api(libs.materialDialogs.datetime)
api(libs.accompanist.pager)
api(libs.accompanist.pagerIndicators)
api(libs.accompanist.flowLayout)

View File

@@ -71,7 +71,6 @@ import ca.gosyer.jui.uicore.components.VerticalScrollbar
import ca.gosyer.jui.uicore.components.keyboardHandler
import ca.gosyer.jui.uicore.components.rememberScrollbarAdapter
import ca.gosyer.jui.uicore.components.scrollbarPadding
import ca.gosyer.jui.uicore.prefs.PreferenceMutableStateFlow
import ca.gosyer.jui.uicore.resources.stringResource
import com.vanpra.composematerialdialogs.MaterialDialog
import com.vanpra.composematerialdialogs.MaterialDialogButtons
@@ -85,6 +84,7 @@ import com.vanpra.composematerialdialogs.title
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableStateFlow
@Composable
fun PreferenceRow(
@@ -151,7 +151,7 @@ fun PreferenceRow(
@Composable
fun SwitchPreference(
preference: PreferenceMutableStateFlow<Boolean>,
preference: MutableStateFlow<Boolean>,
title: String,
subtitle: String? = null,
icon: ImageVector? = null,
@@ -176,7 +176,7 @@ fun SwitchPreference(
@Composable
fun EditTextPreference(
preference: PreferenceMutableStateFlow<String>,
preference: MutableStateFlow<String>,
title: String,
subtitle: String? = null,
icon: ImageVector? = null,
@@ -221,7 +221,7 @@ fun EditTextPreference(
@Composable
fun <Key> ChoicePreference(
preference: PreferenceMutableStateFlow<Key>,
preference: MutableStateFlow<Key>,
choices: ImmutableMap<Key, String>,
title: String,
subtitle: String? = null,
@@ -344,7 +344,7 @@ fun <T> MultiSelectDialog(
@Composable
fun ColorPreference(
preference: PreferenceMutableStateFlow<Color>,
preference: MutableStateFlow<Color>,
title: String,
subtitle: String? = null,
enabled: Boolean = true,

View File

@@ -121,7 +121,7 @@ class SettingsLibraryViewModel
@Composable
fun getDisplayModeChoices() =
DisplayMode.values()
DisplayMode.entries
.associateWith { stringResource(it.res) }
.toImmutableMap()
}

View File

@@ -6,40 +6,72 @@
package ca.gosyer.jui.ui.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.add
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Info
import androidx.compose.material.icons.rounded.Warning
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import ca.gosyer.jui.core.lang.launchIO
import ca.gosyer.jui.domain.server.model.Auth
import ca.gosyer.jui.domain.server.model.Proxy
import ca.gosyer.jui.domain.server.service.ServerHostPreferences
import ca.gosyer.jui.domain.server.service.ServerPreferences
import ca.gosyer.jui.domain.settings.interactor.GetSettings
import ca.gosyer.jui.domain.settings.interactor.SetSettings
import ca.gosyer.jui.domain.settings.model.SetSettingsInput
import ca.gosyer.jui.domain.settings.model.Settings
import ca.gosyer.jui.i18n.MR
import ca.gosyer.jui.ui.base.dialog.getMaterialDialogProperties
import ca.gosyer.jui.ui.base.navigation.Toolbar
import ca.gosyer.jui.ui.base.prefs.ChoicePreference
import ca.gosyer.jui.ui.base.prefs.EditTextPreference
import ca.gosyer.jui.ui.base.prefs.PreferenceRow
import ca.gosyer.jui.ui.base.prefs.SwitchPreference
import ca.gosyer.jui.ui.main.components.bottomNav
import ca.gosyer.jui.ui.viewModel
import ca.gosyer.jui.uicore.components.VerticalScrollbar
import ca.gosyer.jui.uicore.components.keyboardHandler
import ca.gosyer.jui.uicore.components.rememberScrollbarAdapter
import ca.gosyer.jui.uicore.components.scrollbarPadding
import ca.gosyer.jui.uicore.prefs.PreferenceMutableStateFlow
@@ -51,10 +83,24 @@ import ca.gosyer.jui.uicore.vm.ViewModel
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.core.screen.ScreenKey
import cafe.adriel.voyager.core.screen.uniqueScreenKey
import com.vanpra.composematerialdialogs.MaterialDialog
import com.vanpra.composematerialdialogs.MaterialDialogState
import com.vanpra.composematerialdialogs.datetime.time.timepicker
import com.vanpra.composematerialdialogs.listItems
import com.vanpra.composematerialdialogs.rememberMaterialDialogState
import com.vanpra.composematerialdialogs.title
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.datetime.LocalTime
import kotlinx.datetime.format.char
import me.tatarka.inject.annotations.Inject
class SettingsServerScreen : Screen {
@@ -80,6 +126,8 @@ class SettingsServerScreen : Screen {
authChoices = connectionVM.getAuthChoices(),
authUsername = connectionVM.authUsername,
authPassword = connectionVM.authPassword,
serverSettings = connectionVM.serverSettings.collectAsState().value,
hosted = connectionVM.host.collectAsState().value,
)
}
}
@@ -89,10 +137,238 @@ expect class SettingsServerHostViewModel : ViewModel
@Composable
expect fun getServerHostItems(viewModel: @Composable () -> SettingsServerHostViewModel): LazyListScope.() -> Unit
private class ServerSettingMutableStateFlow<T>(
parent: StateFlow<Settings>,
getSetting: (Settings) -> T,
private val setSetting: (T) -> Unit,
scope: CoroutineScope,
private val state: MutableStateFlow<T> = MutableStateFlow(getSetting(parent.value)),
) : MutableStateFlow<T> by state {
init {
parent
.onEach { state.value = getSetting(it) }
.launchIn(scope)
}
override var value: T
get() = state.value
set(value) {
setSetting(value)
}
}
@Stable
class ServerSettings(
private val getSettings: GetSettings,
private val setSettings: SetSettings,
private val scope: CoroutineScope,
initial: Settings,
private val onError: (String) -> Unit
) {
val settings = MutableStateFlow(initial)
val autoDownloadNewChapters = getServerFlow(
getSetting = { it.autoDownloadNewChapters },
getInput = { SetSettingsInput(autoDownloadNewChapters = it) }
)
val autoDownloadNewChaptersLimit = getServerFlow(
getSetting = { it.autoDownloadNewChaptersLimit.toString() },
getInput = { SetSettingsInput(autoDownloadNewChaptersLimit = it.toIntOrNull()) }
)
val backupInterval = getServerFlow(
getSetting = { it.backupInterval.toString() },
getInput = { SetSettingsInput(backupInterval = it.toIntOrNull()) }
)
val backupPath = getServerFlow(
getSetting = { it.backupPath },
getInput = { SetSettingsInput(backupPath = it) }
)
val backupTTL = getServerFlow(
getSetting = { it.backupTTL.toString() },
getInput = { SetSettingsInput(backupTTL = it.toIntOrNull()) }
)
val backupTime = getServerFlow(
getSetting = { it.backupTime },
getInput = { SetSettingsInput(backupTime = it) }
)
val basicAuthEnabled = getServerFlow(
getSetting = { it.basicAuthEnabled },
getInput = { SetSettingsInput(basicAuthEnabled = it) }
)
val basicAuthPassword = getServerFlow(
getSetting = { it.basicAuthPassword },
getInput = { SetSettingsInput(basicAuthPassword = it) }
)
val basicAuthUsername = getServerFlow(
getSetting = { it.basicAuthUsername },
getInput = { SetSettingsInput(basicAuthUsername = it) }
)
val debugLogsEnabled = getServerFlow(
getSetting = { it.debugLogsEnabled },
getInput = { SetSettingsInput(debugLogsEnabled = it) }
)
val downloadAsCbz = getServerFlow(
getSetting = { it.downloadAsCbz },
getInput = { SetSettingsInput(downloadAsCbz = it) }
)
val downloadsPath = getServerFlow(
getSetting = { it.downloadsPath },
getInput = { SetSettingsInput(downloadsPath = it) }
)
val electronPath = getServerFlow(
getSetting = { it.electronPath },
getInput = { SetSettingsInput(electronPath = it) }
)
val excludeCompleted = getServerFlow(
getSetting = { it.excludeCompleted },
getInput = { SetSettingsInput(excludeCompleted = it) }
)
val excludeEntryWithUnreadChapters = getServerFlow(
getSetting = { it.excludeEntryWithUnreadChapters },
getInput = { SetSettingsInput(excludeEntryWithUnreadChapters = it) }
)
val excludeNotStarted = getServerFlow(
getSetting = { it.excludeNotStarted },
getInput = { SetSettingsInput(excludeNotStarted = it) }
)
val excludeUnreadChapters = getServerFlow(
getSetting = { it.excludeUnreadChapters },
getInput = { SetSettingsInput(excludeUnreadChapters = it) }
)
val extensionRepos = getServerFlow(
getSetting = { it.extensionRepos },
getInput = { SetSettingsInput(extensionRepos = it) }
)
val flareSolverrEnabled = getServerFlow(
getSetting = { it.flareSolverrEnabled },
getInput = { SetSettingsInput(flareSolverrEnabled = it) }
)
val flareSolverrSessionName = getServerFlow(
getSetting = { it.flareSolverrSessionName },
getInput = { SetSettingsInput(flareSolverrSessionName = it) }
)
val flareSolverrSessionTtl = getServerFlow(
getSetting = { it.flareSolverrSessionTtl.toString() },
getInput = { SetSettingsInput(flareSolverrSessionTtl = it.toIntOrNull()) }
)
val flareSolverrTimeout = getServerFlow(
getSetting = { it.flareSolverrTimeout.toString() },
getInput = { SetSettingsInput(flareSolverrTimeout = it.toIntOrNull()) }
)
val flareSolverrUrl = getServerFlow(
getSetting = { it.flareSolverrUrl },
getInput = { SetSettingsInput(flareSolverrUrl = it) }
)
val globalUpdateInterval = getServerFlow(
getSetting = { it.globalUpdateInterval.toString() },
getInput = { SetSettingsInput(globalUpdateInterval = it.toDoubleOrNull()?.takeIf { it !in 0.01..5.99 }) }
)
val gqlDebugLogsEnabled = getServerFlow(
getSetting = { it.gqlDebugLogsEnabled },
getInput = { SetSettingsInput(gqlDebugLogsEnabled = it) }
)
val initialOpenInBrowserEnabled = getServerFlow(
getSetting = { it.initialOpenInBrowserEnabled },
getInput = { SetSettingsInput(initialOpenInBrowserEnabled = it) }
)
val ip = getServerFlow(
getSetting = { it.ip },
getInput = { SetSettingsInput(ip = it) }
)
val localSourcePath = getServerFlow(
getSetting = { it.localSourcePath },
getInput = { SetSettingsInput(localSourcePath = it) }
)
val maxSourcesInParallel = getServerFlow(
getSetting = { it.maxSourcesInParallel.toString() },
getInput = { SetSettingsInput(maxSourcesInParallel = it.toIntOrNull()) }
)
val port = getServerFlow(
getSetting = { it.port.toString() },
getInput = { SetSettingsInput(port = it.toIntOrNull()) }
)
val socksProxyEnabled = getServerFlow(
getSetting = { it.socksProxyEnabled },
getInput = { SetSettingsInput(socksProxyEnabled = it) }
)
val socksProxyHost = getServerFlow(
getSetting = { it.socksProxyHost },
getInput = { SetSettingsInput(socksProxyHost = it) }
)
val socksProxyPassword = getServerFlow(
getSetting = { it.socksProxyPassword },
getInput = { SetSettingsInput(socksProxyPassword = it) }
)
val socksProxyPort = getServerFlow(
getSetting = { it.socksProxyPort },
getInput = { SetSettingsInput(socksProxyPort = it) }
)
val socksProxyUsername = getServerFlow(
getSetting = { it.socksProxyUsername },
getInput = { SetSettingsInput(socksProxyUsername = it) }
)
val socksProxyVersion = getServerFlow(
getSetting = { it.socksProxyVersion },
getInput = { SetSettingsInput(socksProxyVersion = it) }
)
val systemTrayEnabled = getServerFlow(
getSetting = { it.systemTrayEnabled },
getInput = { SetSettingsInput(systemTrayEnabled = it) }
)
val updateMangas = getServerFlow(
getSetting = { it.updateMangas },
getInput = { SetSettingsInput(updateMangas = it) }
)
val webUIChannel = getServerFlow(
getSetting = { it.webUIChannel },
getInput = { SetSettingsInput(webUIChannel = it) }
)
val webUIFlavor = getServerFlow(
getSetting = { it.webUIFlavor },
getInput = { SetSettingsInput(webUIFlavor = it) }
)
val webUIInterface = getServerFlow(
getSetting = { it.webUIInterface },
getInput = { SetSettingsInput(webUIInterface = it) }
)
val webUIUpdateCheckInterval = getServerFlow(
getSetting = { it.webUIUpdateCheckInterval },
getInput = { SetSettingsInput(webUIUpdateCheckInterval = it) }
)
private fun <T> getServerFlow(
getSetting: (Settings) -> T,
getInput: (T) -> SetSettingsInput,
): MutableStateFlow<T> {
return ServerSettingMutableStateFlow(
parent = settings,
getSetting = getSetting,
setSetting = {
scope.launch {
val input = getInput(it)
setSettings.await(
input,
onError = { onError(it.message.orEmpty()) }
)
val response = getSettings.await(onError = { onError(it.message.orEmpty()) })
if (response != null) {
settings.value = response
}
}
},
scope = scope,
)
}
}
class SettingsServerViewModel
@Inject
constructor(
private val getSettings: GetSettings,
private val setSettings: SetSettings,
serverPreferences: ServerPreferences,
serverHostPreferences: ServerHostPreferences,
contextWrapper: ContextWrapper,
) : ViewModel(contextWrapper) {
val serverUrl = serverPreferences.server().asStateIn(scope)
@@ -101,6 +377,8 @@ class SettingsServerViewModel
val proxy = serverPreferences.proxy().asStateIn(scope)
val host = serverHostPreferences.host().asStateIn(scope)
@Composable
fun getProxyChoices(): ImmutableMap<Proxy, String> =
persistentMapOf(
@@ -127,11 +405,22 @@ class SettingsServerViewModel
val authUsername = serverPreferences.authUsername().asStateIn(scope)
val authPassword = serverPreferences.authPassword().asStateIn(scope)
private val _serverSettingChanged = MutableStateFlow(false)
val serverSettingChanged = _serverSettingChanged.asStateFlow()
private val _serverSettings = MutableStateFlow<ServerSettings?>(null)
val serverSettings = _serverSettings.asStateFlow()
fun serverSettingChanged() {
_serverSettingChanged.value = true
init {
scope.launchIO {
val initialSettings = getSettings.await(onError = { toast(it.message.orEmpty()) })
if (initialSettings != null) {
_serverSettings.value = ServerSettings(
getSettings,
setSettings,
scope,
initialSettings,
onError = { toast(it) },
)
}
}
}
}
@@ -153,6 +442,8 @@ fun SettingsServerScreenContent(
authChoices: ImmutableMap<Auth, String>,
authUsername: PreferenceMutableStateFlow<String>,
authPassword: PreferenceMutableStateFlow<String>,
hosted: Boolean,
serverSettings: ServerSettings?,
) {
Scaffold(
modifier = Modifier.windowInsetsPadding(
@@ -260,6 +551,18 @@ fun SettingsServerScreenContent(
)
}
}
item {
Divider()
}
if (serverSettings != null) {
ServerSettingsItems(hosted, serverSettings)
} else {
item {
Box(Modifier.fillMaxWidth().height(48.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
}
}
VerticalScrollbar(
rememberScrollbarAdapter(state),
@@ -277,3 +580,433 @@ fun SettingsServerScreenContent(
}
}
}
fun LazyListScope.ServerSettingsItems(
hosted: Boolean,
serverSettings: ServerSettings,
) {
item {
PreferenceRow(
stringResource(MR.strings.host_settings),
Icons.Rounded.Info,
subtitle = stringResource(MR.strings.server_settings_sub),
)
}
item {
val ipValue by serverSettings.ip.collectAsState()
EditTextPreference(
preference = serverSettings.ip,
title = stringResource(MR.strings.host_ip),
subtitle = stringResource(MR.strings.host_ip_sub, ipValue),
enabled = !hosted
)
}
item {
val portValue by serverSettings.port.collectAsState()
EditTextPreference(
preference = serverSettings.port,
title = stringResource(MR.strings.host_port),
subtitle = stringResource(MR.strings.host_port_sub, portValue),
enabled = !hosted
)
}
item {
val dialog = rememberMaterialDialogState()
PreferenceRow(
stringResource(MR.strings.extension_repos),
subtitle = stringResource(MR.strings.extension_repos_sub),
onClick = dialog::show
)
val repos by serverSettings.extensionRepos.collectAsState()
ExtensionReposDialog(
dialog,
repos,
onSetRepos = {
serverSettings.extensionRepos.value = it
}
)
}
item {
SwitchPreference(
preference = serverSettings.socksProxyEnabled,
title = stringResource(MR.strings.host_socks_enabled),
)
}
item {
val socksProxyEnabled by serverSettings.socksProxyEnabled.collectAsState()
val proxyHost by serverSettings.socksProxyHost.collectAsState()
EditTextPreference(
preference = serverSettings.socksProxyHost,
title = stringResource(MR.strings.host_socks_host),
subtitle = stringResource(MR.strings.host_socks_host_sub, proxyHost),
enabled = socksProxyEnabled,
)
}
item {
val socksProxyEnabled by serverSettings.socksProxyEnabled.collectAsState()
val proxyPort by serverSettings.socksProxyPort.collectAsState()
EditTextPreference(
preference = serverSettings.socksProxyPort,
title = stringResource(MR.strings.host_socks_port),
subtitle = stringResource(MR.strings.host_socks_port_sub, proxyPort),
enabled = socksProxyEnabled,
)
}
item {
val socksProxyEnabled by serverSettings.socksProxyEnabled.collectAsState()
EditTextPreference(
preference = serverSettings.socksProxyUsername,
title = stringResource(MR.strings.host_socks_username),
enabled = socksProxyEnabled,
)
}
item {
val socksProxyEnabled by serverSettings.socksProxyEnabled.collectAsState()
EditTextPreference(
preference = serverSettings.socksProxyPassword,
title = stringResource(MR.strings.host_socks_password),
visualTransformation = PasswordVisualTransformation(),
enabled = socksProxyEnabled,
)
}
item {
val socksProxyEnabled by serverSettings.socksProxyEnabled.collectAsState()
ChoicePreference(
preference = serverSettings.socksProxyVersion,
choices = mapOf(
4 to "SOCKS4",
5 to "SOCKS5"
).toImmutableMap(),
title = stringResource(MR.strings.host_socks_version),
enabled = socksProxyEnabled,
)
}
item {
EditTextPreference(
preference = serverSettings.globalUpdateInterval,
title = stringResource(MR.strings.global_update_interval),
subtitle = stringResource(MR.strings.global_update_interval_sub)
)
}
item {
SwitchPreference(
preference = serverSettings.updateMangas,
title = stringResource(MR.strings.update_manga_info),
subtitle = stringResource(MR.strings.update_manga_info_sub),
)
}
item {
SwitchPreference(
preference = serverSettings.excludeCompleted,
title = stringResource(MR.strings.exclude_completed),
subtitle = stringResource(MR.strings.exclude_completed_sub),
)
}
item {
SwitchPreference(
preference = serverSettings.excludeUnreadChapters,
title = stringResource(MR.strings.exclude_unread),
subtitle = stringResource(MR.strings.exclude_unread_sub),
)
}
item {
SwitchPreference(
preference = serverSettings.excludeNotStarted,
title = stringResource(MR.strings.exclude_not_started),
subtitle = stringResource(MR.strings.exclude_not_started_sub),
)
}
item {
EditTextPreference(
preference = serverSettings.maxSourcesInParallel,
title = stringResource(MR.strings.max_sources_parallel),
subtitle = stringResource(MR.strings.max_sources_parallel_sub)
)
}
item {
SwitchPreference(
preference = serverSettings.downloadAsCbz,
title = stringResource(MR.strings.host_download_as_cbz),
subtitle = stringResource(MR.strings.host_download_as_cbz_sub),
)
}
item {
SwitchPreference(
preference = serverSettings.autoDownloadNewChapters,
title = stringResource(MR.strings.download_new_chapters),
)
}
item {
EditTextPreference(
preference = serverSettings.autoDownloadNewChaptersLimit,
title = stringResource(MR.strings.download_chapter_limit),
subtitle = stringResource(MR.strings.download_chapter_limit_sub),
)
}
item {
SwitchPreference(
preference = serverSettings.excludeEntryWithUnreadChapters,
title = stringResource(MR.strings.ignore_unread_entries),
)
}
item {
SwitchPreference(
preference = serverSettings.debugLogsEnabled,
title = stringResource(MR.strings.host_debug_logging),
subtitle = stringResource(MR.strings.host_debug_logging_sub),
)
}
item {
SwitchPreference(
preference = serverSettings.gqlDebugLogsEnabled,
title = stringResource(MR.strings.graphql_debug_logs),
subtitle = stringResource(MR.strings.graphql_debug_logs_sub),
)
}
item {
SwitchPreference(
preference = serverSettings.systemTrayEnabled,
title = stringResource(MR.strings.host_system_tray),
subtitle = stringResource(MR.strings.host_system_tray_sub),
)
}
item {
// val webUIEnabledValue by serverSettings.webUIEnabled.collectAsState()
SwitchPreference(
preference = serverSettings.initialOpenInBrowserEnabled,
title = stringResource(MR.strings.host_open_in_browser),
subtitle = stringResource(MR.strings.host_open_in_browser_sub),
enabled = !hosted, //webUIEnabledValue,
)
}
item {
EditTextPreference(
preference = serverSettings.backupInterval,
title = stringResource(MR.strings.backup_interval),
subtitle = stringResource(MR.strings.backup_interval_sub),
)
}
item {
EditTextPreference(
preference = serverSettings.backupTTL,
title = stringResource(MR.strings.backup_ttl),
subtitle = stringResource(MR.strings.backup_ttl_sub),
)
}
item {
val dialog = rememberMaterialDialogState()
val backupTime by serverSettings.backupTime.collectAsState()
PreferenceRow(
title = stringResource(MR.strings.backup_time),
subtitle = stringResource(MR.strings.backup_time_sub),
onClick = dialog::show
)
BackupTimeDialog(
dialog,
backupTime,
onSetTime = {
serverSettings.backupTime.value = it
}
)
}
item {
SwitchPreference(
preference = serverSettings.basicAuthEnabled,
title = stringResource(MR.strings.basic_auth),
subtitle = stringResource(MR.strings.host_basic_auth_sub),
enabled = !hosted
)
}
item {
val basicAuthEnabledValue by serverSettings.basicAuthEnabled.collectAsState()
EditTextPreference(
preference = serverSettings.basicAuthUsername,
title = stringResource(MR.strings.host_basic_auth_username),
enabled = basicAuthEnabledValue && !hosted,
)
}
item {
val basicAuthEnabledValue by serverSettings.basicAuthEnabled.collectAsState()
EditTextPreference(
preference = serverSettings.basicAuthPassword,
title = stringResource(MR.strings.host_basic_auth_password),
visualTransformation = PasswordVisualTransformation(),
enabled = basicAuthEnabledValue && !hosted,
)
}
item {
SwitchPreference(
preference = serverSettings.flareSolverrEnabled,
title = "FlareSolverr enabled",
subtitle = "Use a FlareSolverr instance to bypass CloudFlare. Manual setup required",
)
}
item {
val flareSolverrEnabled by serverSettings.flareSolverrEnabled.collectAsState()
EditTextPreference(
preference = serverSettings.flareSolverrUrl,
title = stringResource(MR.strings.flaresolverr_url),
enabled = flareSolverrEnabled,
)
}
item {
val flareSolverrEnabled by serverSettings.flareSolverrEnabled.collectAsState()
EditTextPreference(
preference = serverSettings.flareSolverrTimeout,
title = stringResource(MR.strings.flaresolverr_timeout),
enabled = flareSolverrEnabled,
)
}
item {
val flareSolverrEnabled by serverSettings.flareSolverrEnabled.collectAsState()
EditTextPreference(
preference = serverSettings.flareSolverrSessionName,
title = stringResource(MR.strings.flaresolverr_session_name),
enabled = flareSolverrEnabled,
)
}
item {
val flareSolverrEnabled by serverSettings.flareSolverrEnabled.collectAsState()
EditTextPreference(
preference = serverSettings.flareSolverrSessionTtl,
title = stringResource(MR.strings.flaresolverr_session_ttl),
enabled = flareSolverrEnabled,
)
}
item {
Divider()
}
}
private val repoRegex =
(
"https:\\/\\/(?>www\\.|raw\\.)?(github|githubusercontent)\\.com" +
"\\/([^\\/]+)\\/([^\\/]+)(?>(?>\\/tree|\\/blob)?\\/([^\\/\\n]*))?(?>\\/([^\\/\\n]*\\.json)?)?"
).toRegex()
@Composable
fun ExtensionReposDialog(
state: MaterialDialogState,
extensionRepos: List<String>,
onSetRepos: (List<String>) -> Unit
) {
val repos = remember(state.showing) {
extensionRepos.toMutableStateList()
}
var newRepo by remember(state.showing) { mutableStateOf("") }
MaterialDialog(
state,
properties = getMaterialDialogProperties(),
buttons = {
negativeButton(stringResource(MR.strings.action_cancel))
positiveButton(stringResource(MR.strings.action_ok), onClick = { onSetRepos(repos.toList()) })
},
) {
title(stringResource(MR.strings.extension_repos))
Row(
Modifier.fillMaxWidth().padding(vertical = 12.dp, horizontal = 24.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
val repoMatches by derivedStateOf {
newRepo.matches(repoRegex)
}
OutlinedTextField(
value = newRepo,
onValueChange = { newRepo = it },
modifier = Modifier.weight(4f)
.keyboardHandler(
singleLine = true,
enterAction = {
if (repoMatches) {
repos.add(newRepo)
newRepo = ""
}
}
),
isError = newRepo.isNotBlank() && !repoMatches,
)
IconButton(
onClick = {
repos.add(newRepo)
newRepo = ""
},
enabled = repoMatches,
modifier = Modifier.weight(1f, fill = false)
) {
Icon(
Icons.Rounded.Add,
contentDescription = stringResource(MR.strings.action_add)
)
}
}
listItems(
modifier = Modifier.padding(bottom = 8.dp),
list = repos,
closeOnClick = false,
) { _, item ->
Row(
Modifier.fillMaxWidth().padding(vertical = 12.dp, horizontal = 24.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
item,
color = MaterialTheme.colors.onSurface,
style = MaterialTheme.typography.body1,
modifier = Modifier
.weight(4f)
.wrapContentWidth(Alignment.Start)
)
IconButton(
onClick = { repos.remove(item) },
modifier = Modifier.weight(1f, fill = false)
) {
Icon(
Icons.Rounded.Delete,
contentDescription = stringResource(MR.strings.action_delete)
)
}
}
}
}
}
val formatter = LocalTime.Format {
hour()
char(':')
minute()
}
@Composable
fun BackupTimeDialog(
state: MaterialDialogState,
backupTime: String,
onSetTime: (String) -> Unit
) {
val time = remember(state.showing) {
LocalTime.parse(backupTime, formatter)
}
MaterialDialog(
state,
properties = getMaterialDialogProperties(),
buttons = {
negativeButton(stringResource(MR.strings.action_cancel))
positiveButton(stringResource(MR.strings.action_ok))
},
) {
timepicker(
time,
title = stringResource(MR.strings.backup_time),
onTimeChange = {
onSetTime(formatter.format(it))
},
)
}
}

View File

@@ -82,7 +82,6 @@ fun SourceSettingsScreenContent(settings: ImmutableList<SourceSettingsView<*, *>
).asPaddingValues(),
) {
items(settings, { it.props.hashCode() }) {
@Suppress("UNCHECKED_CAST")
when (it) {
is CheckBox, is Switch -> {
TwoStatePreference(it as TwoState, it is CheckBox)

View File

@@ -24,7 +24,6 @@ import ca.gosyer.jui.ui.base.prefs.EditTextPreference
import ca.gosyer.jui.ui.base.prefs.PreferenceRow
import ca.gosyer.jui.ui.base.prefs.SwitchPreference
import ca.gosyer.jui.ui.util.system.folderPicker
import ca.gosyer.jui.uicore.prefs.PreferenceMutableStateFlow
import ca.gosyer.jui.uicore.prefs.asStateIn
import ca.gosyer.jui.uicore.prefs.asStringStateIn
import ca.gosyer.jui.uicore.resources.stringResource
@@ -56,15 +55,9 @@ actual fun getServerHostItems(viewModel: @Composable () -> SettingsServerHostVie
host = serverVm.host,
ip = serverVm.ip,
port = serverVm.port,
socksProxyEnabled = serverVm.socksProxyEnabled,
socksProxyHost = serverVm.socksProxyHost,
socksProxyPort = serverVm.socksProxyPort,
debugLogsEnabled = serverVm.debugLogsEnabled,
systemTrayEnabled = serverVm.systemTrayEnabled,
downloadPath = serverVm.downloadPath,
downloadAsCbz = serverVm.downloadAsCbz,
webUIEnabled = serverVm.webUIEnabled,
openInBrowserEnabled = serverVm.openInBrowserEnabled,
backupPath = serverVm.backupPath,
localSourcePath = serverVm.localSourcePath,
basicAuthEnabled = serverVm.basicAuthEnabled,
basicAuthUsername = serverVm.basicAuthUsername,
basicAuthPassword = serverVm.basicAuthPassword,
@@ -84,22 +77,12 @@ actual class SettingsServerHostViewModel
val ip = serverHostPreferences.ip().asStateIn(scope)
val port = serverHostPreferences.port().asStringStateIn(scope)
// Proxy
val socksProxyEnabled = serverHostPreferences.socksProxyEnabled().asStateIn(scope)
val socksProxyHost = serverHostPreferences.socksProxyHost().asStateIn(scope)
val socksProxyPort = serverHostPreferences.socksProxyPort().asStringStateIn(scope)
// Misc
val debugLogsEnabled = serverHostPreferences.debugLogsEnabled().asStateIn(scope)
val systemTrayEnabled = serverHostPreferences.systemTrayEnabled().asStateIn(scope)
// Downloader
val downloadPath = serverHostPreferences.downloadPath().asStateIn(scope)
val downloadAsCbz = serverHostPreferences.downloadAsCbz().asStateIn(scope)
// WebUI
val webUIEnabled = serverHostPreferences.webUIEnabled().asStateIn(scope)
val openInBrowserEnabled = serverHostPreferences.openInBrowserEnabled().asStateIn(scope)
// Backup
val backupPath = serverHostPreferences.backupPath().asStateIn(scope)
// LocalSource
val localSourcePath = serverHostPreferences.localSourcePath().asStateIn(scope)
// Authentication
val basicAuthEnabled = serverHostPreferences.basicAuthEnabled().asStateIn(scope)
@@ -145,21 +128,15 @@ fun LazyListScope.ServerHostItems(
hostValue: Boolean,
basicAuthEnabledValue: Boolean,
serverSettingChanged: () -> Unit,
host: PreferenceMutableStateFlow<Boolean>,
ip: PreferenceMutableStateFlow<String>,
port: PreferenceMutableStateFlow<String>,
socksProxyEnabled: PreferenceMutableStateFlow<Boolean>,
socksProxyHost: PreferenceMutableStateFlow<String>,
socksProxyPort: PreferenceMutableStateFlow<String>,
debugLogsEnabled: PreferenceMutableStateFlow<Boolean>,
systemTrayEnabled: PreferenceMutableStateFlow<Boolean>,
downloadPath: PreferenceMutableStateFlow<String>,
downloadAsCbz: PreferenceMutableStateFlow<Boolean>,
webUIEnabled: PreferenceMutableStateFlow<Boolean>,
openInBrowserEnabled: PreferenceMutableStateFlow<Boolean>,
basicAuthEnabled: PreferenceMutableStateFlow<Boolean>,
basicAuthUsername: PreferenceMutableStateFlow<String>,
basicAuthPassword: PreferenceMutableStateFlow<String>,
host: MutableStateFlow<Boolean>,
ip: MutableStateFlow<String>,
port: MutableStateFlow<String>,
downloadPath: MutableStateFlow<String>,
backupPath: MutableStateFlow<String>,
localSourcePath: MutableStateFlow<String>,
basicAuthEnabled: MutableStateFlow<Boolean>,
basicAuthUsername: MutableStateFlow<String>,
basicAuthPassword: MutableStateFlow<String>,
) {
item {
SwitchPreference(preference = host, title = stringResource(MR.strings.host_server))
@@ -190,47 +167,6 @@ fun LazyListScope.ServerHostItems(
changeListener = serverSettingChanged,
)
}
item {
SwitchPreference(
preference = socksProxyEnabled,
title = stringResource(MR.strings.host_socks_enabled),
changeListener = serverSettingChanged,
)
}
item {
val proxyHost by socksProxyHost.collectAsState()
EditTextPreference(
preference = socksProxyHost,
title = stringResource(MR.strings.host_socks_host),
subtitle = stringResource(MR.strings.host_socks_host_sub, proxyHost),
changeListener = serverSettingChanged,
)
}
item {
val proxyPort by socksProxyPort.collectAsState()
EditTextPreference(
preference = socksProxyPort,
title = stringResource(MR.strings.host_socks_port),
subtitle = stringResource(MR.strings.host_socks_port_sub, proxyPort),
changeListener = serverSettingChanged,
)
}
item {
SwitchPreference(
preference = debugLogsEnabled,
title = stringResource(MR.strings.host_debug_logging),
subtitle = stringResource(MR.strings.host_debug_logging_sub),
changeListener = serverSettingChanged,
)
}
item {
SwitchPreference(
preference = systemTrayEnabled,
title = stringResource(MR.strings.host_system_tray),
subtitle = stringResource(MR.strings.host_system_tray_sub),
changeListener = serverSettingChanged,
)
}
item {
val downloadPathValue by downloadPath.collectAsState()
PreferenceRow(
@@ -253,29 +189,45 @@ fun LazyListScope.ServerHostItems(
)
}
item {
SwitchPreference(
preference = downloadAsCbz,
title = stringResource(MR.strings.host_download_as_cbz),
subtitle = stringResource(MR.strings.host_download_as_cbz_sub),
changeListener = serverSettingChanged,
val backupPathValue by backupPath.collectAsState()
PreferenceRow(
title = stringResource(MR.strings.host_backup_path),
subtitle = if (backupPathValue.isEmpty()) {
stringResource(MR.strings.host_backup_path_sub_empty)
} else {
stringResource(MR.strings.host_backup_path_sub, backupPathValue)
},
onClick = {
folderPicker {
backupPath.value = it.toString()
serverSettingChanged()
}
},
onLongClick = {
backupPath.value = ""
serverSettingChanged()
},
)
}
item {
SwitchPreference(
preference = webUIEnabled,
title = stringResource(MR.strings.host_webui),
subtitle = stringResource(MR.strings.host_webui_sub),
changeListener = serverSettingChanged,
)
}
item {
val webUIEnabledValue by webUIEnabled.collectAsState()
SwitchPreference(
preference = openInBrowserEnabled,
title = stringResource(MR.strings.host_open_in_browser),
subtitle = stringResource(MR.strings.host_open_in_browser_sub),
changeListener = serverSettingChanged,
enabled = webUIEnabledValue,
val localSourcePathValue by localSourcePath.collectAsState()
PreferenceRow(
title = stringResource(MR.strings.host_local_source_path),
subtitle = if (localSourcePathValue.isEmpty()) {
stringResource(MR.strings.host_local_source_path_sub_empty)
} else {
stringResource(MR.strings.host_local_source_path_sub, localSourcePathValue)
},
onClick = {
folderPicker {
localSourcePath.value = it.toString()
serverSettingChanged()
}
},
onLongClick = {
localSourcePath.value = ""
serverSettingChanged()
},
)
}
item {