Implement source configuration

This commit is contained in:
Syer10
2021-08-05 20:24:51 -04:00
parent d84aa6a481
commit edd2dc086f
17 changed files with 617 additions and 21 deletions

View File

@@ -14,7 +14,8 @@ data class Source(
val name: String,
val lang: String,
val iconUrl: String,
val supportsLatest: Boolean
val supportsLatest: Boolean,
val isConfigurable: Boolean
) {
fun iconUrl(serverUrl: String) = serverUrl + iconUrl
}

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.data.models.sourcepreference
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
@SerialName("CheckBoxPreference")
data class CheckBoxPreference(
override val props: TwoStateProps
) : SourcePreference()

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.data.models.sourcepreference
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
@SerialName("EditTextPreference")
data class EditTextPreference(
override val props: EditTextProps
) : SourcePreference() {
@Serializable
data class EditTextProps(
override val key: String,
override val title: String?,
override val summary: String?,
override val currentValue: String?,
override val defaultValue: String?,
override val defaultValueType: String,
val dialogTitle: String?,
val dialogMessage: String?,
val text: String?
) : Props<String?>
}

View File

@@ -0,0 +1,26 @@
/*
* 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.data.models.sourcepreference
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
@SerialName("ListPreference")
data class ListPreference(override val props: ListProps) : SourcePreference() {
@Serializable
data class ListProps(
override val key: String,
override val title: String,
override val summary: String?,
override val currentValue: String?,
override val defaultValue: String?,
override val defaultValueType: String,
val entries: List<String>,
val entryValues: List<String>
) : Props<String?>
}

View File

@@ -0,0 +1,23 @@
/*
* 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.data.models.sourcepreference
import kotlinx.serialization.Serializable
@Serializable
sealed class SourcePreference {
abstract val props: Props<*>
}
interface Props<T> {
val key: String
val title: String?
val summary: String?
val currentValue: T
val defaultValue: T
val defaultValueType: String
}

View File

@@ -0,0 +1,12 @@
/*
* 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.data.models.sourcepreference
import kotlinx.serialization.Serializable
@Serializable
data class SourcePreferenceChange(val position: Int, val value: String)

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.data.models.sourcepreference
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
@SerialName("SwitchPreferenceCompat")
data class SwitchPreference(
override val props: TwoStateProps
) : SourcePreference()

View File

@@ -0,0 +1,19 @@
/*
* 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.data.models.sourcepreference
import kotlinx.serialization.Serializable
@Serializable
data class TwoStateProps(
override val key: String,
override val title: String?,
override val summary: String?,
override val currentValue: Boolean?,
override val defaultValue: Boolean?,
override val defaultValueType: String
) : Props<Boolean?>

View File

@@ -8,18 +8,25 @@ package ca.gosyer.data.server.interactions
import ca.gosyer.data.models.MangaPage
import ca.gosyer.data.models.Source
import ca.gosyer.data.models.sourcepreference.SourcePreference
import ca.gosyer.data.models.sourcepreference.SourcePreferenceChange
import ca.gosyer.data.server.Http
import ca.gosyer.data.server.ServerPreferences
import ca.gosyer.data.server.requests.getFilterListQuery
import ca.gosyer.data.server.requests.getSourceSettingsQuery
import ca.gosyer.data.server.requests.globalSearchQuery
import ca.gosyer.data.server.requests.sourceInfoQuery
import ca.gosyer.data.server.requests.sourceLatestQuery
import ca.gosyer.data.server.requests.sourceListQuery
import ca.gosyer.data.server.requests.sourcePopularQuery
import ca.gosyer.data.server.requests.sourceSearchQuery
import ca.gosyer.data.server.requests.updateSourceSettingQuery
import ca.gosyer.util.lang.withIOContext
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.statement.HttpResponse
import io.ktor.http.ContentType
import io.ktor.http.contentType
import javax.inject.Inject
class SourceInteractionHandler @Inject constructor(
@@ -90,4 +97,26 @@ class SourceInteractionHandler @Inject constructor(
}
suspend fun getFilterList(source: Source) = getFilterList(source.id)
suspend fun getSourceSettings(sourceId: Long) = withIOContext {
client.get<List<SourcePreference>>(
serverUrl + getSourceSettingsQuery(sourceId)
)
}
suspend fun getSourceSettings(source: Source) = getSourceSettings(source.id)
suspend fun setSourceSetting(sourceId: Long, sourcePreference: SourcePreferenceChange) = withIOContext {
client.post<HttpResponse>(
serverUrl + updateSourceSettingQuery(sourceId)
) {
contentType(ContentType.Application.Json)
body = sourcePreference
}
}
suspend fun setSourceSetting(sourceId: Long, position: Int, value: Any) = setSourceSetting(
sourceId,
SourcePreferenceChange(position, value.toString())
)
}

View File

@@ -14,5 +14,6 @@ fun mangaQuery(mangaId: Long) =
fun mangaThumbnailQuery(mangaId: Long) =
"/api/v1/manga/$mangaId/thumbnail"
@Post
fun updateMangaMetaRequest(mangaId: Long) =
"/api/v1/manga/$mangaId/meta"

View File

@@ -33,3 +33,11 @@ fun sourceSearchQuery(sourceId: Long, searchTerm: String, pageNum: Int) =
@Get
fun getFilterListQuery(sourceId: Long) =
"/api/v1/source/$sourceId/filters/"
@Get
fun getSourceSettingsQuery(sourceId: Long) =
"/api/v1/source/$sourceId/preferences"
@Post
fun updateSourceSettingQuery(sourceId: Long) =
"/api/v1/source/$sourceId/preferences"

View File

@@ -214,7 +214,7 @@ fun <Key> ChoicePreference(
)
}
private fun <T> ChoiceDialog(
fun <T> ChoiceDialog(
items: List<Pair<T, String>>,
selected: T?,
onDismissRequest: () -> Unit = {},

View File

@@ -71,6 +71,7 @@ import ca.gosyer.ui.settings.SettingsScreen
import ca.gosyer.ui.settings.SettingsServerScreen
import ca.gosyer.ui.sources.SourcesMenu
import ca.gosyer.ui.sources.openSourcesMenu
import ca.gosyer.ui.sources.settings.SourceSettingsMenu
import com.github.zsoltk.compose.router.BackStack
import com.github.zsoltk.compose.router.Router
import com.github.zsoltk.compose.savedinstancestate.Bundle
@@ -140,19 +141,25 @@ fun SideMenuItem(topLevelMenu: TopLevelMenus, backStack: BackStack<Route>) {
@Composable
fun MainWindow(rootBundle: Bundle, backStack: BackStack<Route>) {
Column(Modifier.fillMaxSize()) {
Box(Modifier.fillMaxSize()) {
BundleScope("K${backStack.lastIndex}", rootBundle, false) {
when (val routing = backStack.last()) {
is Route.Library -> LibraryScreen {
backStack.push(Route.Manga(it))
}
is Route.Sources -> SourcesMenu {
is Route.Sources -> SourcesMenu(
{
backStack.push(Route.SourceSettings(it))
}
) {
backStack.push(Route.Manga(it))
}
is Route.Extensions -> ExtensionsMenu()
is Route.Manga -> MangaMenu(routing.mangaId, backStack)
is Route.Downloads -> DownloadsMenu()
is Route.SourceSettings -> SourceSettingsMenu(routing.sourceId, backStack)
is Route.Settings -> SettingsScreen(backStack)
is Route.SettingsGeneral -> SettingsGeneralScreen(backStack)
is Route.SettingsAppearance -> SettingsAppearance(backStack)
@@ -168,6 +175,20 @@ fun MainWindow(rootBundle: Bundle, backStack: BackStack<Route>) {
is Route.SettingsAdvanced -> SettingsAdvancedScreen(backStack)
}
}
/*Box(Modifier.padding(bottom = 32.dp).align(Alignment.BottomCenter)) {
val shape = RoundedCornerShape(50.dp)
Box(
Modifier
.width(200.dp)
.defaultMinSize(minHeight = 64.dp)
.shadow(4.dp, shape)
.background(SolidColor(Color.Gray), alpha = 0.2F)
.clip(shape),
contentAlignment = Alignment.Center
) {
Text("Test text")
}
}*/
}
}
@@ -283,6 +304,8 @@ sealed class Route {
data class Manga(val mangaId: Long) : Route()
object Downloads : Route()
data class SourceSettings(val sourceId: Long) : Route()
object Settings : Route()
object SettingsGeneral : Route()
object SettingsAppearance : Route()

View File

@@ -22,6 +22,7 @@ import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Home
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material.icons.rounded.Translate
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
@@ -43,6 +44,7 @@ import ca.gosyer.ui.extensions.LanguageDialog
import ca.gosyer.ui.manga.openMangaMenu
import ca.gosyer.ui.sources.components.SourceHomeScreen
import ca.gosyer.ui.sources.components.SourceScreen
import ca.gosyer.ui.sources.settings.openSourceSettingsMenu
import ca.gosyer.util.compose.ThemedWindow
import com.github.zsoltk.compose.savedinstancestate.Bundle
import com.github.zsoltk.compose.savedinstancestate.BundleScope
@@ -54,20 +56,21 @@ fun openSourcesMenu() {
CompositionLocalProvider(
LocalSavedInstanceState provides Bundle()
) {
SourcesMenu {
openMangaMenu(it)
}
SourcesMenu(
::openSourceSettingsMenu,
::openMangaMenu
)
}
}
}
@Composable
fun SourcesMenu(onMangaClick: (Long) -> Unit) {
SourcesMenu(LocalSavedInstanceState.current, onMangaClick)
fun SourcesMenu(onSourceSettingsClick: (Long) -> Unit, onMangaClick: (Long) -> Unit) {
SourcesMenu(LocalSavedInstanceState.current, onSourceSettingsClick, onMangaClick)
}
@Composable
fun SourcesMenu(bundle: Bundle, onMangaClick: (Long) -> Unit) {
fun SourcesMenu(bundle: Bundle, onSourceSettingsClick: (Long) -> Unit, onMangaClick: (Long) -> Unit) {
val vm = viewModel<SourcesMenuViewModel> {
bundle
}
@@ -93,7 +96,8 @@ fun SourcesMenu(bundle: Bundle, onMangaClick: (Long) -> Unit) {
search = if (sourceSearchEnabled) vm::search else null,
searchSubmit = vm::submitSearch,
actions = {
if (selectedSourceTab == null) {
selectedSourceTab.let { selectedSource ->
if (selectedSource == null) {
ActionIcon(
{
val enabledLangs = MutableStateFlow(vm.languages.value)
@@ -104,6 +108,17 @@ fun SourcesMenu(bundle: Bundle, onMangaClick: (Long) -> Unit) {
stringResource("enabled_languages"),
Icons.Rounded.Translate
)
} else {
if (selectedSource.isConfigurable) {
ActionIcon(
{
onSourceSettingsClick(selectedSource.id)
},
stringResource("location_settings"),
Icons.Rounded.Settings
)
}
}
}
}
)

View File

@@ -0,0 +1,163 @@
/*
* 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.ui.sources.settings
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.Checkbox
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Switch
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import ca.gosyer.BuildConfig
import ca.gosyer.ui.base.WindowDialog
import ca.gosyer.ui.base.components.Toolbar
import ca.gosyer.ui.base.prefs.ChoiceDialog
import ca.gosyer.ui.base.prefs.PreferenceRow
import ca.gosyer.ui.base.resources.stringResource
import ca.gosyer.ui.base.vm.viewModel
import ca.gosyer.ui.main.Route
import ca.gosyer.ui.sources.settings.model.SourceSettingsView.CheckBox
import ca.gosyer.ui.sources.settings.model.SourceSettingsView.EditText
import ca.gosyer.ui.sources.settings.model.SourceSettingsView.List
import ca.gosyer.ui.sources.settings.model.SourceSettingsView.Switch
import ca.gosyer.ui.sources.settings.model.SourceSettingsView.TwoState
import ca.gosyer.util.compose.ThemedWindow
import com.github.zsoltk.compose.router.BackStack
import kotlinx.coroutines.flow.MutableStateFlow
fun openSourceSettingsMenu(sourceId: Long) {
ThemedWindow(BuildConfig.NAME) {
SourceSettingsMenu(sourceId)
}
}
@Composable
fun SourceSettingsMenu(sourceId: Long, backStack: BackStack<Route>? = null) {
val vm = viewModel<SourceSettingsViewModel> {
SourceSettingsViewModel.Params(sourceId)
}
val settings by vm.sourceSettings.collectAsState()
Column {
Toolbar(stringResource("location_settings"), backStack, backStack != null)
LazyColumn {
items(settings, { it.props.hashCode() }) {
when (it) {
is CheckBox, is Switch -> {
TwoStatePreference(it as TwoState, it is CheckBox)
}
is List -> {
ListPreference(it)
}
is EditText -> {
EditTextPreference(it)
}
else -> Unit
}
}
}
}
}
@Composable
private fun TwoStatePreference(twoState: TwoState, checkbox: Boolean) {
val state by twoState.state.collectAsState()
val title = remember(state) { twoState.title ?: twoState.summary ?: "No title" }
val subtitle = remember(state) {
if (twoState.title == null) {
null
} else {
twoState.summary
}
}
PreferenceRow(
title,
subtitle = subtitle,
onClick = { twoState.updateState(!state) },
action = {
if (checkbox) {
Checkbox(checked = state, onCheckedChange = null)
} else {
Switch(checked = state, onCheckedChange = null)
}
}
)
}
@Composable
private fun ListPreference(list: List) {
val state by list.state.collectAsState()
val title = remember(state) { list.title ?: list.summary ?: "No title" }
val subtitle = remember(state) {
if (list.title == null) {
null
} else {
list.summary
}
}
PreferenceRow(
title,
subtitle = subtitle,
onClick = {
ChoiceDialog(
list.getOptions(),
state.first,
onSelected = list::setValue,
title = "Select choice"
)
}
)
}
@Composable
private fun EditTextPreference(editText: EditText) {
val state by editText.state.collectAsState()
val title = remember(state) { editText.title ?: editText.summary ?: "No title" }
val subtitle = remember(state) {
if (editText.title == null) {
null
} else {
editText.summary
}
}
PreferenceRow(
title,
subtitle = subtitle,
onClick = {
val editTextFlow = MutableStateFlow(TextFieldValue(state))
WindowDialog(
editText.dialogTitle ?: BuildConfig.NAME,
onPositiveButton = {
editText.updateState(editTextFlow.value.text)
}
) {
if (editText.dialogMessage != null) {
Text(editText.dialogMessage)
Spacer(Modifier.height(8.dp))
}
val text by editTextFlow.collectAsState()
OutlinedTextField(
text,
onValueChange = {
editTextFlow.value = it
}
)
}
}
)
}

View File

@@ -0,0 +1,70 @@
/*
* 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.ui.sources.settings
import ca.gosyer.data.models.sourcepreference.SourcePreference
import ca.gosyer.data.server.interactions.SourceInteractionHandler
import ca.gosyer.ui.base.vm.ViewModel
import ca.gosyer.ui.sources.settings.model.SourceSettingsView
import ca.gosyer.util.lang.throwIfCancellation
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import java.util.concurrent.CopyOnWriteArrayList
import javax.inject.Inject
class SourceSettingsViewModel @Inject constructor(
private val params: Params,
private val sourceHandler: SourceInteractionHandler
) : ViewModel() {
private val _loading = MutableStateFlow(true)
val loading = _loading.asStateFlow()
private val _sourceSettings = MutableStateFlow<List<SourceSettingsView<*, *>>>(emptyList())
val sourceSettings = _sourceSettings.asStateFlow()
private val subscriptions: CopyOnWriteArrayList<Job> = CopyOnWriteArrayList()
init {
getSourceSettings()
sourceSettings.onEach { settings ->
subscriptions.forEach { it.cancel() }
subscriptions.clear()
subscriptions += settings.map { setting ->
setting.state.drop(1).onEach {
if (it != null) {
sourceHandler.setSourceSetting(params.sourceId, setting.index, it)
getSourceSettings()
}
}.launchIn(scope)
}
}.launchIn(scope)
}
private fun getSourceSettings() {
scope.launch {
try {
_sourceSettings.value = sourceHandler.getSourceSettings(params.sourceId).toView()
} catch (e: Exception) {
e.throwIfCancellation()
} finally {
_loading.value = false
}
}
}
data class Params(val sourceId: Long)
private fun List<SourcePreference>.toView() = mapIndexed { index, sourcePreference ->
SourceSettingsView(index, sourcePreference)
}
}

View File

@@ -0,0 +1,145 @@
/*
* 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.ui.sources.settings.model
import ca.gosyer.data.models.sourcepreference.CheckBoxPreference
import ca.gosyer.data.models.sourcepreference.EditTextPreference
import ca.gosyer.data.models.sourcepreference.ListPreference
import ca.gosyer.data.models.sourcepreference.SourcePreference
import ca.gosyer.data.models.sourcepreference.SwitchPreference
import ca.gosyer.data.models.sourcepreference.TwoStateProps
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.util.Formatter
sealed class SourceSettingsView<T, R : Any?> {
abstract val index: Int
abstract val title: String?
abstract val subtitle: String?
abstract val state: StateFlow<R>
abstract fun updateState(value: R)
abstract val props: T
open val summary: String?
get() = subtitle?.let { withFormat(it, state.value) }
abstract class TwoState(
props: TwoStateProps,
private val _state: MutableStateFlow<Boolean> = MutableStateFlow(
props.currentValue
?: props.defaultValue
?: false
)
) : SourceSettingsView<TwoStateProps, Boolean>() {
override val state: StateFlow<Boolean> = _state.asStateFlow()
override fun updateState(value: Boolean) {
_state.value = value
}
}
data class CheckBox internal constructor(
override val index: Int,
override val title: String?,
override val subtitle: String?,
override val props: TwoStateProps
) : TwoState(props) {
internal constructor(index: Int, preference: CheckBoxPreference) : this(
index,
preference.props.title,
preference.props.summary,
preference.props
)
}
data class Switch internal constructor(
override val index: Int,
override val title: String?,
override val subtitle: String?,
override val props: TwoStateProps
) : TwoState(props) {
internal constructor(index: Int, preference: SwitchPreference) : this(
index,
preference.props.title,
preference.props.summary,
preference.props
)
}
data class List internal constructor(
override val index: Int,
override val title: String?,
override val subtitle: String?,
override val props: ListPreference.ListProps
) : SourceSettingsView<ListPreference.ListProps, Pair<String, String>>() {
private val _state = MutableStateFlow(
(props.currentValue ?: props.defaultValue ?: "0") to props.entries[props.entryValues.indexOf(props.currentValue ?: props.defaultValue ?: "0")]
)
override val state: StateFlow<Pair<String, String>> = _state.asStateFlow()
override fun updateState(value: Pair<String, String>) {
_state.value = value
}
internal constructor(index: Int, preference: ListPreference) : this(
index,
preference.props.title,
preference.props.summary,
preference.props
)
override val summary: String?
get() = subtitle?.let { withFormat(it, state.value.second) }
fun getOptions() = props.entryValues.mapIndexed { index, s ->
s to props.entries[index]
}
fun setValue(value: String) {
updateState(value to props.entries[props.entryValues.indexOf(value)])
}
}
data class EditText internal constructor(
override val index: Int,
override val title: String?,
override val subtitle: String?,
val dialogTitle: String?,
val dialogMessage: String?,
override val props: EditTextPreference.EditTextProps
) : SourceSettingsView<EditTextPreference.EditTextProps, String>() {
private val _state = MutableStateFlow(props.currentValue ?: props.defaultValue.orEmpty())
override val state: StateFlow<String> = _state.asStateFlow()
override fun updateState(value: String) {
_state.value = value
}
internal constructor(index: Int, preference: EditTextPreference) : this(
index,
preference.props.title,
preference.props.summary,
preference.props.dialogTitle,
preference.props.dialogMessage,
preference.props
)
}
}
fun withFormat(text: String, value: Any?): String {
return Formatter().format(text, value)
.let { formatter ->
formatter.toString()
.also { formatter.close() }
}
}
fun SourceSettingsView(index: Int, preference: SourcePreference): SourceSettingsView<*, *> {
return when (preference) {
is CheckBoxPreference -> SourceSettingsView.CheckBox(index, preference)
is SwitchPreference -> SourceSettingsView.Switch(index, preference)
is ListPreference -> SourceSettingsView.List(index, preference)
is EditTextPreference -> SourceSettingsView.EditText(index, preference)
}
}