diff --git a/buildSrc/src/main/kotlin/Config.kt b/buildSrc/src/main/kotlin/Config.kt index 9aa2e6ce..cfac2ffc 100644 --- a/buildSrc/src/main/kotlin/Config.kt +++ b/buildSrc/src/main/kotlin/Config.kt @@ -3,9 +3,9 @@ import org.gradle.api.JavaVersion object Config { const val tachideskVersion = "v0.5.4" // Match this to the Tachidesk-Server commit count - const val serverCode = 1013 + const val serverCode = 1031 const val preview = true - const val previewCommit = "1ee37da720abd8d017f3c443f0b7e2dc543ee1ef" + const val previewCommit = "420d14fc37a18269a9d7232519e3f9a21c6302a2" val jvmTarget = JavaVersion.VERSION_15 } \ No newline at end of file diff --git a/src/main/kotlin/ca/gosyer/data/models/Updates.kt b/src/main/kotlin/ca/gosyer/data/models/Updates.kt new file mode 100644 index 00000000..e69de29b diff --git a/src/main/kotlin/ca/gosyer/data/models/sourcefilters/CheckBoxFilter.kt b/src/main/kotlin/ca/gosyer/data/models/sourcefilters/CheckBoxFilter.kt new file mode 100644 index 00000000..269eaedc --- /dev/null +++ b/src/main/kotlin/ca/gosyer/data/models/sourcefilters/CheckBoxFilter.kt @@ -0,0 +1,22 @@ +/* + * 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.sourcefilters + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +@SerialName("CheckBox") +data class CheckBoxFilter( + override val filter: CheckBoxProps +) : SourceFilter() { + @Serializable + data class CheckBoxProps( + override val name: String, + override val state: Boolean + ) : Props +} diff --git a/src/main/kotlin/ca/gosyer/data/models/sourcefilters/GroupFilter.kt b/src/main/kotlin/ca/gosyer/data/models/sourcefilters/GroupFilter.kt new file mode 100644 index 00000000..8719f35d --- /dev/null +++ b/src/main/kotlin/ca/gosyer/data/models/sourcefilters/GroupFilter.kt @@ -0,0 +1,22 @@ +/* + * 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.sourcefilters + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +@SerialName("Group") +data class GroupFilter( + override val filter: GroupProps +) : SourceFilter() { + @Serializable + data class GroupProps( + override val name: String, + override val state: List + ) : Props> +} diff --git a/src/main/kotlin/ca/gosyer/data/models/sourcefilters/HeaderFilter.kt b/src/main/kotlin/ca/gosyer/data/models/sourcefilters/HeaderFilter.kt new file mode 100644 index 00000000..0b7b6560 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/data/models/sourcefilters/HeaderFilter.kt @@ -0,0 +1,24 @@ +/* + * 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.sourcefilters + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient + +@Serializable +@SerialName("Header") +data class HeaderFilter( + override val filter: HeaderProps +) : SourceFilter() { + @Serializable + data class HeaderProps( + override val name: String, + @Transient + override val state: Int = 0 + ) : Props +} diff --git a/src/main/kotlin/ca/gosyer/data/models/sourcefilters/SelectFilter.kt b/src/main/kotlin/ca/gosyer/data/models/sourcefilters/SelectFilter.kt new file mode 100644 index 00000000..fb76cff2 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/data/models/sourcefilters/SelectFilter.kt @@ -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.sourcefilters + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +@SerialName("Select") +data class SelectFilter( + override val filter: SelectProps +) : SourceFilter() { + @Serializable + data class SelectProps( + override val name: String, + override val state: Int, + val values: List + ) : Props +} diff --git a/src/main/kotlin/ca/gosyer/data/models/sourcefilters/SeparatorFilter.kt b/src/main/kotlin/ca/gosyer/data/models/sourcefilters/SeparatorFilter.kt new file mode 100644 index 00000000..37b10db2 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/data/models/sourcefilters/SeparatorFilter.kt @@ -0,0 +1,24 @@ +/* + * 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.sourcefilters + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient + +@Serializable +@SerialName("Separator") +data class SeparatorFilter( + override val filter: SeparatorProps +) : SourceFilter() { + @Serializable + data class SeparatorProps( + override val name: String, + @Transient + override val state: Int = 0 + ) : Props +} diff --git a/src/main/kotlin/ca/gosyer/data/models/sourcefilters/SortFilter.kt b/src/main/kotlin/ca/gosyer/data/models/sourcefilters/SortFilter.kt new file mode 100644 index 00000000..713cf1c0 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/data/models/sourcefilters/SortFilter.kt @@ -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.sourcefilters + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +@SerialName("Sort") +data class SortFilter( + override val filter: SortProps +) : SourceFilter() { + @Serializable + data class SortProps( + override val name: String, + override val state: Selection?, + val values: List + ) : Props + + @Serializable + data class Selection( + val index: Int, + val ascending: Boolean + ) +} diff --git a/src/main/kotlin/ca/gosyer/data/models/sourcefilters/SourceFilter.kt b/src/main/kotlin/ca/gosyer/data/models/sourcefilters/SourceFilter.kt new file mode 100644 index 00000000..6d9f7675 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/data/models/sourcefilters/SourceFilter.kt @@ -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.sourcefilters + +import kotlinx.serialization.Serializable + +@Serializable +sealed class SourceFilter { + abstract val filter: Props<*> +} + +interface Props { + val name: String + val state: T +} diff --git a/src/main/kotlin/ca/gosyer/data/models/sourcefilters/SourceFilterChange.kt b/src/main/kotlin/ca/gosyer/data/models/sourcefilters/SourceFilterChange.kt new file mode 100644 index 00000000..26b1d09b --- /dev/null +++ b/src/main/kotlin/ca/gosyer/data/models/sourcefilters/SourceFilterChange.kt @@ -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.data.models.sourcefilters + +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +@Serializable +data class SourceFilterChange(val position: Int, val state: String) { + constructor(position: Int, state: Any) : this( + position, + if (state is SortFilter.Selection) { + Json.encodeToString(state) + } else state.toString() + ) +} diff --git a/src/main/kotlin/ca/gosyer/data/models/sourcefilters/TextFilter.kt b/src/main/kotlin/ca/gosyer/data/models/sourcefilters/TextFilter.kt new file mode 100644 index 00000000..e8f8fc8f --- /dev/null +++ b/src/main/kotlin/ca/gosyer/data/models/sourcefilters/TextFilter.kt @@ -0,0 +1,22 @@ +/* + * 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.sourcefilters + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +@SerialName("Text") +data class TextFilter( + override val filter: TextProps +) : SourceFilter() { + @Serializable + data class TextProps( + override val name: String, + override val state: String + ) : Props +} diff --git a/src/main/kotlin/ca/gosyer/data/models/sourcefilters/TriStateFilter.kt b/src/main/kotlin/ca/gosyer/data/models/sourcefilters/TriStateFilter.kt new file mode 100644 index 00000000..162a0e8e --- /dev/null +++ b/src/main/kotlin/ca/gosyer/data/models/sourcefilters/TriStateFilter.kt @@ -0,0 +1,22 @@ +/* + * 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.sourcefilters + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +@SerialName("TriState") +data class TriStateFilter( + override val filter: TriStateProps +) : SourceFilter() { + @Serializable + data class TriStateProps( + override val name: String, + override val state: Int + ) : Props +} diff --git a/src/main/kotlin/ca/gosyer/data/server/HttpClient.kt b/src/main/kotlin/ca/gosyer/data/server/HttpClient.kt index d3d3e482..bc662b35 100644 --- a/src/main/kotlin/ca/gosyer/data/server/HttpClient.kt +++ b/src/main/kotlin/ca/gosyer/data/server/HttpClient.kt @@ -77,7 +77,7 @@ internal class HttpProvider @Inject constructor( serializer = KotlinxSerializer( Json { isLenient = false - ignoreUnknownKeys = !BuildConfig.DEBUG + ignoreUnknownKeys = true allowSpecialFloatingPointValues = true useArrayPolymorphism = false } diff --git a/src/main/kotlin/ca/gosyer/data/server/interactions/SourceInteractionHandler.kt b/src/main/kotlin/ca/gosyer/data/server/interactions/SourceInteractionHandler.kt index 6aa77cd8..0b1b02f7 100644 --- a/src/main/kotlin/ca/gosyer/data/server/interactions/SourceInteractionHandler.kt +++ b/src/main/kotlin/ca/gosyer/data/server/interactions/SourceInteractionHandler.kt @@ -8,6 +8,8 @@ package ca.gosyer.data.server.interactions import ca.gosyer.data.models.MangaPage import ca.gosyer.data.models.Source +import ca.gosyer.data.models.sourcefilters.SourceFilter +import ca.gosyer.data.models.sourcefilters.SourceFilterChange import ca.gosyer.data.models.sourcepreference.SourcePreference import ca.gosyer.data.models.sourcepreference.SourcePreferenceChange import ca.gosyer.data.server.Http @@ -15,6 +17,7 @@ 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.setFilterRequest import ca.gosyer.data.server.requests.sourceInfoQuery import ca.gosyer.data.server.requests.sourceLatestQuery import ca.gosyer.data.server.requests.sourceListQuery @@ -23,10 +26,14 @@ 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.parameter +import io.ktor.client.request.patch import io.ktor.client.request.post import io.ktor.client.statement.HttpResponse import io.ktor.http.ContentType import io.ktor.http.contentType +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json import javax.inject.Inject class SourceInteractionHandler @Inject constructor( @@ -89,14 +96,41 @@ class SourceInteractionHandler @Inject constructor( pageNum ) - // TODO: 2021-03-14 - suspend fun getFilterList(sourceId: Long) = withIOContext { - client.get( + suspend fun getFilterList(sourceId: Long, reset: Boolean = false) = withIOContext { + client.get>( serverUrl + getFilterListQuery(sourceId) - ) + ) { + url { + if (reset) { + parameter("reset", true) + } + } + } } - suspend fun getFilterList(source: Source) = getFilterList(source.id) + suspend fun getFilterList(source: Source, reset: Boolean = false) = getFilterList(source.id, reset) + + suspend fun setFilter(sourceId: Long, sourceFilter: SourceFilterChange) = withIOContext { + client.patch( + serverUrl + setFilterRequest(sourceId) + ) { + contentType(ContentType.Application.Json) + body = sourceFilter + } + } + + suspend fun setFilter(sourceId: Long, position: Int, value: Any) = setFilter( + sourceId, + SourceFilterChange(position, value) + ) + + suspend fun setFilter(sourceId: Long, parentPosition: Int, childPosition: Int, value: Any) = setFilter( + sourceId, + SourceFilterChange( + parentPosition, + Json.encodeToString(SourceFilterChange(childPosition, value)) + ) + ) suspend fun getSourceSettings(sourceId: Long) = withIOContext { client.get>( diff --git a/src/main/kotlin/ca/gosyer/data/server/requests/Sources.kt b/src/main/kotlin/ca/gosyer/data/server/requests/Sources.kt index 958fc709..7489b5f5 100644 --- a/src/main/kotlin/ca/gosyer/data/server/requests/Sources.kt +++ b/src/main/kotlin/ca/gosyer/data/server/requests/Sources.kt @@ -32,7 +32,11 @@ fun sourceSearchQuery(sourceId: Long, searchTerm: String, pageNum: Int) = @Get fun getFilterListQuery(sourceId: Long) = - "/api/v1/source/$sourceId/filters/" + "/api/v1/source/$sourceId/filters" + +@Patch +fun setFilterRequest(sourceId: Long) = + "/api/v1/source/$sourceId/filters" @Get fun getSourceSettingsQuery(sourceId: Long) = diff --git a/src/main/kotlin/ca/gosyer/ui/base/components/Pager.kt b/src/main/kotlin/ca/gosyer/ui/base/components/Pager.kt deleted file mode 100644 index b4e3e1b9..00000000 --- a/src/main/kotlin/ca/gosyer/ui/base/components/Pager.kt +++ /dev/null @@ -1,218 +0,0 @@ -/* - * 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.base.components - -import androidx.compose.animation.core.Animatable -import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.gestures.draggable -import androidx.compose.foundation.gestures.rememberDraggableState -import androidx.compose.foundation.layout.Box -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Immutable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.key -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.runtime.structuralEqualityPolicy -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.Layout -import androidx.compose.ui.layout.Measurable -import androidx.compose.ui.layout.ParentDataModifier -import androidx.compose.ui.unit.Density -import androidx.compose.ui.util.fastForEach -import kotlinx.coroutines.launch -import kotlin.math.roundToInt - -/** - * This is a modified version of: - * https://gist.github.com/adamp/07d468f4bcfe632670f305ce3734f511 - */ - -class PagerState( - currentPage: Int = 0, - minPage: Int = 0, - maxPage: Int = 0 -) { - private var _minPage by mutableStateOf(minPage) - var minPage: Int - get() = _minPage - set(value) { - _minPage = value.coerceAtMost(_maxPage) - _currentPage = _currentPage.coerceIn(_minPage, _maxPage) - } - - private var _maxPage by mutableStateOf(maxPage, structuralEqualityPolicy()) - var maxPage: Int - get() = _maxPage - set(value) { - _maxPage = value.coerceAtLeast(_minPage) - _currentPage = _currentPage.coerceIn(_minPage, maxPage) - } - - private var _currentPage by mutableStateOf(currentPage.coerceIn(minPage, maxPage)) - var currentPage: Int - get() = _currentPage - set(value) { - _currentPage = value.coerceIn(minPage, maxPage) - } - - enum class SelectionState { Selected, Undecided } - - var selectionState by mutableStateOf(SelectionState.Selected) - - suspend inline fun selectPage(block: PagerState.() -> R): R = try { - selectionState = SelectionState.Undecided - block() - } finally { - selectPage() - } - - suspend fun selectPage() { - currentPage -= currentPageOffset.roundToInt() - snapToOffset(0f) - selectionState = SelectionState.Selected - } - - private var _currentPageOffset = Animatable(0f).apply { - updateBounds(-1f, 1f) - } - val currentPageOffset: Float - get() = _currentPageOffset.value - - suspend fun snapToOffset(offset: Float) { - val max = if (currentPage == minPage) 0f else 1f - val min = if (currentPage == maxPage) 0f else -1f - _currentPageOffset.snapTo(offset.coerceIn(min, max)) - } - - suspend fun fling(velocity: Float) { - if (velocity < 0 && currentPage == maxPage) return - if (velocity > 0 && currentPage == minPage) return - - _currentPageOffset.animateTo(currentPageOffset.roundToInt().toFloat()) - selectPage() - } - - override fun toString(): String = "PagerState{minPage=$minPage, maxPage=$maxPage, " + - "currentPage=$currentPage, currentPageOffset=$currentPageOffset}" -} - -@Immutable -private data class PageData(val page: Int) : ParentDataModifier { - override fun Density.modifyParentData(parentData: Any?): Any = this@PageData -} - -private val Measurable.page: Int - get() = (parentData as? PageData)?.page ?: error("no PageData for measurable $this") - -@Composable -fun Pager( - state: PagerState, - modifier: Modifier = Modifier, - offscreenLimit: Int = 2, - pageContent: @Composable PagerScope.() -> Unit -) { - var pageSize by remember { mutableStateOf(0) } - val coroutineScope = rememberCoroutineScope() - Layout( - content = { - val minPage = (state.currentPage - offscreenLimit).coerceAtLeast(state.minPage) - val maxPage = (state.currentPage + offscreenLimit).coerceAtMost(state.maxPage) - - for (page in minPage..maxPage) { - val pageData = PageData(page) - val scope = PagerScope(state, page) - key(pageData) { - Box(contentAlignment = Alignment.Center, modifier = pageData) { - scope.pageContent() - } - } - } - }, - modifier = modifier.draggable( - orientation = Orientation.Horizontal, - onDragStarted = { - state.selectionState = PagerState.SelectionState.Undecided - }, - onDragStopped = { velocity -> - coroutineScope.launch { - // Velocity is in pixels per second, but we deal in percentage offsets, so we - // need to scale the velocity to match - state.fling(velocity / pageSize) - } - }, - state = rememberDraggableState { dy -> - coroutineScope.launch { - with(state) { - val pos = pageSize * currentPageOffset - val max = if (currentPage == minPage) 0 else pageSize * offscreenLimit - val min = if (currentPage == maxPage) 0 else -pageSize * offscreenLimit - val newPos = (pos + dy).coerceIn(min.toFloat(), max.toFloat()) - snapToOffset(newPos / pageSize) - } - } - }, - ) - ) { measurables, constraints -> - layout(constraints.maxWidth, constraints.maxHeight) { - val currentPage = state.currentPage - val offset = state.currentPageOffset - val childConstraints = constraints.copy(minWidth = 0, minHeight = 0) - - measurables - .map { - it.measure(childConstraints) to it.page - } - .fastForEach { (placeable, page) -> - // TODO: current this centers each page. We should investigate reading - // gravity modifiers on the child, or maybe as a param to Pager. - val xCenterOffset = (constraints.maxWidth - placeable.width) / 2 - val yCenterOffset = (constraints.maxHeight - placeable.height) / 2 - - if (currentPage == page) { - pageSize = placeable.width - } - - val xItemOffset = ((page + offset - currentPage) * placeable.width).roundToInt() - - placeable.place( - x = xCenterOffset + xItemOffset, - y = yCenterOffset - ) - } - } - } -} - -/** - * Scope for [Pager] content. - */ -class PagerScope( - private val state: PagerState, - val page: Int -) { - /** - * Returns the current selected page - */ - val currentPage: Int - get() = state.currentPage - - /** - * Returns the current selected page offset - */ - val currentPageOffset: Float - get() = state.currentPageOffset - - /** - * Returns the current selection state - */ - val selectionState: PagerState.SelectionState - get() = state.selectionState -} diff --git a/src/main/kotlin/ca/gosyer/ui/base/components/Spinner.kt b/src/main/kotlin/ca/gosyer/ui/base/components/Spinner.kt new file mode 100644 index 00000000..a418673c --- /dev/null +++ b/src/main/kotlin/ca/gosyer/ui/base/components/Spinner.kt @@ -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.base.components + +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.DropdownMenu +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun Spinner(modifier: Modifier, items: List, selectedItemIndex: Int, onSelectItem: (Int) -> Unit) { + var expanded by remember { mutableStateOf(false) } + val shape = RoundedCornerShape(4.dp) + Box( + modifier then Modifier.border(1.dp, MaterialTheme.colors.primary, shape) + .clip(shape) + ) { + Row( + modifier = Modifier.fillMaxWidth() + .padding(16.dp) + .clickable { expanded = true }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(text = items[selectedItemIndex], modifier = Modifier.padding(end = 8.dp), fontSize = 12.sp) + Icon(imageVector = Icons.Filled.ArrowDropDown, contentDescription = null) + } + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + modifier = Modifier.align(Alignment.CenterEnd) + ) { + items.forEachIndexed { index, item -> + DropdownMenuItem( + onClick = { + expanded = false + onSelectItem(index) + } + ) { + Text(text = item) + } + } + } + } +} diff --git a/src/main/kotlin/ca/gosyer/ui/base/prefs/PreferencesUiBuilder.kt b/src/main/kotlin/ca/gosyer/ui/base/prefs/PreferencesUiBuilder.kt index 875866d8..850432eb 100644 --- a/src/main/kotlin/ca/gosyer/ui/base/prefs/PreferencesUiBuilder.kt +++ b/src/main/kotlin/ca/gosyer/ui/base/prefs/PreferencesUiBuilder.kt @@ -52,6 +52,7 @@ import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.ArrowDropDown import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -310,9 +311,16 @@ const val FADE_OUT_ANIMATION_DURATION = 300 @Composable fun ExpandablePreference( title: String, + startExpanded: Boolean = false, + onExpandedChanged: ((Boolean) -> Unit)? = null, expandedContent: @Composable ColumnScope.() -> Unit, ) { - var expanded by remember { mutableStateOf(false) } + var expanded by remember { mutableStateOf(startExpanded) } + LaunchedEffect(expanded) { + if (onExpandedChanged != null) { + onExpandedChanged(expanded) + } + } val transitionState = remember { MutableTransitionState(expanded).apply { targetState = !expanded diff --git a/src/main/kotlin/ca/gosyer/ui/sources/SourcesMenu.kt b/src/main/kotlin/ca/gosyer/ui/sources/SourcesMenu.kt index 52c51c7c..2d4787af 100644 --- a/src/main/kotlin/ca/gosyer/ui/sources/SourcesMenu.kt +++ b/src/main/kotlin/ca/gosyer/ui/sources/SourcesMenu.kt @@ -10,7 +10,6 @@ import androidx.compose.animation.Crossfade import androidx.compose.foundation.TooltipArea import androidx.compose.foundation.TooltipPlacement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize @@ -26,8 +25,6 @@ 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 import androidx.compose.runtime.collectAsState @@ -39,13 +36,11 @@ import androidx.compose.ui.graphics.FilterQuality import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import ca.gosyer.build.BuildConfig -import ca.gosyer.ui.base.components.ActionIcon +import ca.gosyer.data.models.Source import ca.gosyer.ui.base.components.KamelImage -import ca.gosyer.ui.base.components.Toolbar import ca.gosyer.ui.base.components.combinedMouseClickable import ca.gosyer.ui.base.resources.stringResource import ca.gosyer.ui.base.vm.viewModel -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 @@ -57,7 +52,6 @@ import com.github.zsoltk.compose.savedinstancestate.BundleScope import com.github.zsoltk.compose.savedinstancestate.LocalSavedInstanceState import io.kamel.image.lazyPainterResource import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.flow.MutableStateFlow @OptIn(DelicateCoroutinesApi::class) fun openSourcesMenu() { @@ -87,110 +81,108 @@ fun SourcesMenu(bundle: Bundle, onSourceSettingsClick: (Long) -> Unit, onMangaCl val vm = viewModel { bundle } - val isLoading by vm.isLoading.collectAsState() - val sources by vm.sources.collectAsState() val sourceTabs by vm.sourceTabs.collectAsState() val selectedSourceTab by vm.selectedSourceTab.collectAsState() - val sourceSearchEnabled by vm.sourceSearchEnabled.collectAsState() - val sourceSearchQuery by vm.sourceSearchQuery.collectAsState() - Column { - Toolbar( - selectedSourceTab?.name ?: stringResource("location_sources"), - closable = selectedSourceTab != null, - onClose = { - selectedSourceTab?.let { vm.closeTab(it) } - }, - searchText = if (sourceSearchEnabled) { - sourceSearchQuery - } else null, - search = if (sourceSearchEnabled) vm::search else null, - searchSubmit = vm::submitSearch, - actions = { - Crossfade(selectedSourceTab) { selectedSource -> - if (selectedSource == null) { - ActionIcon( - { - val enabledLangs = MutableStateFlow(vm.languages.value) - LanguageDialog(enabledLangs, vm.getSourceLanguages().toList()) { - vm.setEnabledLanguages(enabledLangs.value) - } - }, - stringResource("enabled_languages"), - Icons.Rounded.Translate - ) - } else { - if (selectedSource.isConfigurable) { - ActionIcon( - { - onSourceSettingsClick(selectedSource.id) - }, - stringResource("location_settings"), - Icons.Rounded.Settings - ) - } - } - } - } + Row { + SourcesSideMenu( + sourceTabs = sourceTabs, + onSourceTabClick = vm::selectTab, + onCloseSourceTabClick = vm::closeTab ) - Row { - Surface(elevation = 1.dp) { - LazyColumn(Modifier.fillMaxHeight().width(64.dp)) { - items(sourceTabs) { source -> - TooltipArea( - { - Surface( - modifier = Modifier.shadow(4.dp), - shape = RoundedCornerShape(4.dp), - elevation = 4.dp - ) { - Text(source?.name ?: stringResource("sources_home"), modifier = Modifier.padding(10.dp)) - } - }, - modifier = Modifier.size(64.dp), - tooltipPlacement = TooltipPlacement.CursorPoint( - offset = DpOffset(0.dp, 16.dp) - ) - ) { - Box(Modifier.fillMaxSize()) { - val modifier = Modifier - .combinedMouseClickable( - onClick = { - vm.selectTab(source) - }, - onMiddleClick = { - if (source != null) { - vm.closeTab(source) - } - } - ) - .requiredSize(50.dp) - .align(Alignment.Center) - if (source != null) { - Box(Modifier.align(Alignment.Center)) { - KamelImage( - lazyPainterResource(source, filterQuality = FilterQuality.Medium), - source.displayName, - modifier - ) - } - } else { - Icon(Icons.Rounded.Home, stringResource("sources_home"), modifier = modifier) - } - } - } - } - } - } - Crossfade(selectedSourceTab) { selectedSource -> - BundleScope(selectedSource?.id.toString(), autoDispose = false) { - if (selectedSource != null) { - SourceScreen(it, selectedSource, onMangaClick, vm::enableSearch, vm::setSearch) - } else { - SourceHomeScreen(isLoading, sources, vm::addTab) + SourceTab( + onLoadSources = vm::setLoadedSources, + onSourceClicked = vm::addTab, + selectedSourceTab = selectedSourceTab, + onMangaClick = onMangaClick, + onCloseSourceTabClick = vm::closeTab, + onSourceSettingsClick = onSourceSettingsClick + ) + } +} + +@Composable +fun SourcesSideMenu( + sourceTabs: List, + onSourceTabClick: (Source?) -> Unit, + onCloseSourceTabClick: (Source) -> Unit +) { + Surface(elevation = 1.dp) { + LazyColumn(Modifier.fillMaxHeight().width(64.dp)) { + items(sourceTabs) { source -> + TooltipArea( + { + Surface( + modifier = Modifier.shadow(4.dp), + shape = RoundedCornerShape(4.dp), + elevation = 4.dp + ) { + Text(source?.name ?: stringResource("sources_home"), modifier = Modifier.padding(10.dp)) + } + }, + modifier = Modifier.size(64.dp), + tooltipPlacement = TooltipPlacement.CursorPoint( + offset = DpOffset(0.dp, 16.dp) + ) + ) { + Box(Modifier.fillMaxSize()) { + val modifier = Modifier + .combinedMouseClickable( + onClick = { + onSourceTabClick(source) + }, + onMiddleClick = { + if (source != null) { + onCloseSourceTabClick(source) + } + } + ) + .requiredSize(50.dp) + .align(Alignment.Center) + if (source != null) { + Box(Modifier.align(Alignment.Center)) { + KamelImage( + lazyPainterResource(source, filterQuality = FilterQuality.Medium), + source.displayName, + modifier + ) + } + } else { + Icon(Icons.Rounded.Home, stringResource("sources_home"), modifier = modifier) + } } } } } } } + +@Composable +fun SourceTab( + onLoadSources: (List) -> Unit, + onSourceClicked: (Source) -> Unit, + selectedSourceTab: Source?, + onMangaClick: (Long) -> Unit, + onCloseSourceTabClick: (Source) -> Unit, + onSourceSettingsClick: (Long) -> Unit +) { + Crossfade(selectedSourceTab) { selectedSource -> + BundleScope(selectedSource?.id.toString(), autoDispose = false) { + if (selectedSource != null) { + SourceScreen( + bundle = it, + source = selectedSource, + onMangaClick = onMangaClick, + onCloseSourceTabClick = onCloseSourceTabClick, + onSourceSettingsClick = onSourceSettingsClick + ) + } else { + SourceHomeScreen( + bundle = it, + onAddSource = onSourceClicked, + onLoadSources = onLoadSources + ) + } + } + } +} diff --git a/src/main/kotlin/ca/gosyer/ui/sources/SourcesMenuViewModel.kt b/src/main/kotlin/ca/gosyer/ui/sources/SourcesMenuViewModel.kt index 191d169e..01b1cc0b 100644 --- a/src/main/kotlin/ca/gosyer/ui/sources/SourcesMenuViewModel.kt +++ b/src/main/kotlin/ca/gosyer/ui/sources/SourcesMenuViewModel.kt @@ -6,11 +6,8 @@ package ca.gosyer.ui.sources -import ca.gosyer.data.catalog.CatalogPreferences import ca.gosyer.data.models.Source -import ca.gosyer.data.server.interactions.SourceInteractionHandler import ca.gosyer.ui.base.vm.ViewModel -import ca.gosyer.util.lang.throwIfCancellation import ca.gosyer.util.system.CKLogger import com.github.zsoltk.compose.savedinstancestate.Bundle import kotlinx.coroutines.flow.MutableStateFlow @@ -18,39 +15,17 @@ 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 javax.inject.Inject class SourcesMenuViewModel @Inject constructor( - private val bundle: Bundle, - private val sourceHandler: SourceInteractionHandler, - catalogPreferences: CatalogPreferences + private val bundle: Bundle ) : ViewModel() { - private val _languages = catalogPreferences.languages().asStateFlow() - val languages = _languages.asStateFlow() - - private val _isLoading = MutableStateFlow(true) - val isLoading = _isLoading.asStateFlow() - - private var installedSources = emptyList() - - private val _sources = MutableStateFlow(emptyList()) - val sources = _sources.asStateFlow() - private val _sourceTabs = MutableStateFlow>(listOf(null)) val sourceTabs = _sourceTabs.asStateFlow() private val _selectedSourceTab = MutableStateFlow(null) val selectedSourceTab = _selectedSourceTab.asStateFlow() - private val _sourceSearchEnabled = MutableStateFlow(false) - val sourceSearchEnabled = _sourceSearchEnabled.asStateFlow() - - private val _sourceSearchQuery = MutableStateFlow(null) - val sourceSearchQuery = _sourceSearchQuery.asStateFlow() - - private var searchSource: ((String?) -> Unit)? = null - init { _sourceTabs.drop(1) .onEach { sources -> @@ -67,38 +42,6 @@ class SourcesMenuViewModel @Inject constructor( } } .launchIn(scope) - - getSources() - } - - private fun setSources(langs: Set) { - _sources.value = installedSources.filter { it.lang in langs || it.lang == Source.LOCAL_SOURCE_LANG } - } - - private fun getSources() { - scope.launch { - try { - installedSources = sourceHandler.getSourceList() - setSources(_languages.value) - info { _sources.value } - } catch (e: Exception) { - e.throwIfCancellation() - } finally { - val sourceTabs = bundle.getLongArray(SOURCE_TABS_KEY) - if (sourceTabs != null) { - _sourceTabs.value = listOf(null) + sourceTabs.toList() - .mapNotNull { sourceId -> - _sources.value.find { it.id == sourceId } - } - _selectedSourceTab.value = bundle.getLong(SELECTED_SOURCE_TAB, -1).let { id -> - if (id != -1L) { - _sources.value.find { it.id == id } - } else null - } - } - _isLoading.value = false - } - } } fun selectTab(source: Source?) { @@ -120,30 +63,19 @@ class SourcesMenuViewModel @Inject constructor( bundle.remove(source.id.toString()) } - fun setSearch(block: (String?) -> Unit) { - searchSource = block - } - - fun enableSearch(enabled: Boolean) { - _sourceSearchEnabled.value = enabled - } - - fun search(query: String) { - _sourceSearchQuery.value = query.takeUnless { it.isBlank() } - } - - fun submitSearch() { - searchSource?.invoke(sourceSearchQuery.value) - } - - fun getSourceLanguages(): Set { - return installedSources.map { it.lang }.toSet() - setOf(Source.LOCAL_SOURCE_LANG) - } - - fun setEnabledLanguages(langs: Set) { - info { langs } - _languages.value = langs - setSources(langs) + fun setLoadedSources(sources: List) { + val sourceTabs = bundle.getLongArray(SOURCE_TABS_KEY) + if (sourceTabs != null) { + _sourceTabs.value = listOf(null) + sourceTabs.toList() + .mapNotNull { sourceId -> + sources.find { it.id == sourceId } + } + _selectedSourceTab.value = bundle.getLong(SELECTED_SOURCE_TAB, -1).let { id -> + if (id != -1L) { + sources.find { it.id == id } + } else null + } + } } private companion object : CKLogger({}) { diff --git a/src/main/kotlin/ca/gosyer/ui/sources/components/SourceHomeScreen.kt b/src/main/kotlin/ca/gosyer/ui/sources/components/SourceHomeScreen.kt index 4f67ed39..1c775f69 100644 --- a/src/main/kotlin/ca/gosyer/ui/sources/components/SourceHomeScreen.kt +++ b/src/main/kotlin/ca/gosyer/ui/sources/components/SourceHomeScreen.kt @@ -29,7 +29,12 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Translate import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow @@ -39,40 +44,90 @@ import androidx.compose.ui.unit.dp import ca.gosyer.data.models.Source import ca.gosyer.ui.base.components.KamelImage import ca.gosyer.ui.base.components.LoadingScreen +import ca.gosyer.ui.base.components.TextActionIcon +import ca.gosyer.ui.base.components.Toolbar +import ca.gosyer.ui.base.resources.stringResource +import ca.gosyer.ui.base.vm.viewModel +import ca.gosyer.ui.extensions.LanguageDialog +import com.github.zsoltk.compose.savedinstancestate.Bundle import io.kamel.image.lazyPainterResource +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow @Composable fun SourceHomeScreen( - isLoading: Boolean, - sources: List, - onSourceClicked: (Source) -> Unit + bundle: Bundle, + onAddSource: (Source) -> Unit, + onLoadSources: (List) -> Unit ) { + val vm = viewModel { + bundle + } + val sources by vm.sources.collectAsState() + val isLoading by vm.isLoading.collectAsState() + LaunchedEffect(sources) { + if (sources.isNotEmpty()) { + onLoadSources(sources) + } + } + if (sources.isEmpty()) { LoadingScreen(isLoading) } else { - Box(Modifier.fillMaxSize(), Alignment.TopCenter) { - val state = rememberLazyListState() - SourceCategory(sources, onSourceClicked, state) - /*val sourcesByLang = sources.groupBy { it.lang.toLowerCase() }.toList() - LazyColumn(state = state) { - items(sourcesByLang) { (lang, sources) -> - SourceCategory( - lang, - sources, - onSourceClicked = sourceClicked - ) - Spacer(Modifier.height(8.dp)) - } - }*/ - - VerticalScrollbar( - modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(), - adapter = rememberScrollbarAdapter(state) + Column { + SourceHomeScreenToolbar( + vm.languages, + vm::getSourceLanguages, + vm::setEnabledLanguages ) + Box(Modifier.fillMaxSize(), Alignment.TopCenter) { + val state = rememberLazyListState() + SourceCategory(sources, onAddSource, state) + /*val sourcesByLang = sources.groupBy { it.lang.toLowerCase() }.toList() + LazyColumn(state = state) { + items(sourcesByLang) { (lang, sources) -> + SourceCategory( + lang, + sources, + onSourceClicked = sourceClicked + ) + Spacer(Modifier.height(8.dp)) + } + }*/ + + VerticalScrollbar( + modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(), + adapter = rememberScrollbarAdapter(state) + ) + } } } } +@Composable +fun SourceHomeScreenToolbar( + sourceLanguages: StateFlow>, + onGetEnabledLanguages: () -> Set, + onSetEnabledLanguages: (Set) -> Unit +) { + Toolbar( + stringResource("location_sources"), + closable = false, + actions = { + TextActionIcon( + { + val enabledLangs = MutableStateFlow(sourceLanguages.value) + LanguageDialog(enabledLangs, onGetEnabledLanguages().toList()) { + onSetEnabledLanguages(enabledLangs.value) + } + }, + stringResource("enabled_languages"), + Icons.Rounded.Translate + ) + } + ) +} + @Composable fun SourceCategory( sources: List, diff --git a/src/main/kotlin/ca/gosyer/ui/sources/components/SourceHomeScreenViewModel.kt b/src/main/kotlin/ca/gosyer/ui/sources/components/SourceHomeScreenViewModel.kt new file mode 100644 index 00000000..620109ae --- /dev/null +++ b/src/main/kotlin/ca/gosyer/ui/sources/components/SourceHomeScreenViewModel.kt @@ -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.components + +import ca.gosyer.data.catalog.CatalogPreferences +import ca.gosyer.data.models.Source +import ca.gosyer.data.server.interactions.SourceInteractionHandler +import ca.gosyer.ui.base.vm.ViewModel +import ca.gosyer.util.lang.throwIfCancellation +import ca.gosyer.util.system.CKLogger +import com.github.zsoltk.compose.savedinstancestate.Bundle +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +class SourceHomeScreenViewModel @Inject constructor( + private val bundle: Bundle, + private val sourceHandler: SourceInteractionHandler, + catalogPreferences: CatalogPreferences +) : ViewModel() { + private val _isLoading = MutableStateFlow(true) + val isLoading = _isLoading.asStateFlow() + + private val _languages = catalogPreferences.languages().asStateFlow() + val languages = _languages.asStateFlow() + + private val _sources = MutableStateFlow(emptyList()) + val sources = _sources.asStateFlow() + + private var installedSources = emptyList() + + init { + getSources() + } + + private fun getSources() { + scope.launch { + try { + installedSources = sourceHandler.getSourceList() + setSources(_languages.value) + info { _sources.value } + } catch (e: Exception) { + e.throwIfCancellation() + } finally { + _isLoading.value = false + } + } + } + + private fun setSources(langs: Set) { + _sources.value = installedSources.filter { it.lang in langs || it.lang == Source.LOCAL_SOURCE_LANG } + } + + fun getSourceLanguages(): Set { + return installedSources.map { it.lang }.toSet() - setOf(Source.LOCAL_SOURCE_LANG) + } + + fun setEnabledLanguages(langs: Set) { + info { langs } + _languages.value = langs + setSources(langs) + } + + private companion object : CKLogger({}) +} diff --git a/src/main/kotlin/ca/gosyer/ui/sources/components/SourceScreen.kt b/src/main/kotlin/ca/gosyer/ui/sources/components/SourceScreen.kt index 48542bbf..40dc064f 100644 --- a/src/main/kotlin/ca/gosyer/ui/sources/components/SourceScreen.kt +++ b/src/main/kotlin/ca/gosyer/ui/sources/components/SourceScreen.kt @@ -8,14 +8,15 @@ package ca.gosyer.ui.sources.components import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.lazy.GridCells import androidx.compose.foundation.lazy.LazyVerticalGrid import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material.Button -import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Explore +import androidx.compose.material.icons.rounded.FilterList +import androidx.compose.material.icons.rounded.NewReleases +import androidx.compose.material.icons.rounded.Settings import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -27,8 +28,11 @@ import ca.gosyer.data.models.Manga import ca.gosyer.data.models.Source import ca.gosyer.ui.base.components.LoadingScreen import ca.gosyer.ui.base.components.MangaGridItem +import ca.gosyer.ui.base.components.TextActionIcon +import ca.gosyer.ui.base.components.Toolbar import ca.gosyer.ui.base.resources.stringResource import ca.gosyer.ui.base.vm.viewModel +import ca.gosyer.ui.sources.components.filter.SourceFiltersMenu import ca.gosyer.util.compose.persistentLazyListState import com.github.zsoltk.compose.savedinstancestate.Bundle import io.kamel.image.lazyPainterResource @@ -38,8 +42,8 @@ fun SourceScreen( bundle: Bundle, source: Source, onMangaClick: (Long) -> Unit, - enableSearch: (Boolean) -> Unit, - setSearch: ((String?) -> Unit) -> Unit + onCloseSourceTabClick: (Source) -> Unit, + onSourceSettingsClick: (Long) -> Unit ) { val vm = viewModel(source.id) { SourceScreenViewModel.Params(source, bundle) @@ -48,29 +52,124 @@ fun SourceScreen( val hasNextPage by vm.hasNextPage.collectAsState() val loading by vm.loading.collectAsState() val isLatest by vm.isLatest.collectAsState() + val showingFilters by vm.showingFilters.collectAsState() + val showFilterButton by vm.filterButtonEnabled.collectAsState() + val showLatestButton by vm.latestButtonEnabled.collectAsState() + val sourceSearchQuery by vm.sourceSearchQuery.collectAsState() - LaunchedEffect(Unit) { - setSearch(vm::search) + LaunchedEffect(vm to source) { + vm.enableLatest(source.supportsLatest) } - DisposableEffect(isLatest) { - enableSearch(!isLatest) - - onDispose { - enableSearch(false) + Column { + SourceToolbar( + source = source, + onCloseSourceTabClick = onCloseSourceTabClick, + sourceSearchQuery = sourceSearchQuery, + onSearch = vm::search, + onSubmitSearch = vm::submitSearch, + onSourceSettingsClick = onSourceSettingsClick, + showFilterButton = showFilterButton, + showLatestButton = showLatestButton, + isLatest = isLatest, + showingFilters = showingFilters, + onClickMode = vm::setMode, + onToggleFiltersClick = vm::showingFilters, + ) + Box { + MangaTable( + bundle = bundle, + mangas = mangas, + isLoading = loading, + hasNextPage = hasNextPage, + onLoadNextPage = vm::loadNextPage, + onMangaClick = onMangaClick, + ) + SourceFiltersMenu( + bundle = bundle, + modifier = Modifier.align(Alignment.TopEnd), + sourceId = source.id, + showFilters = showingFilters && !isLatest, + onSearchClicked = { + vm.setUsingFilters(true) + vm.showingFilters(false) + vm.submitSearch() + }, + onResetClicked = { + vm.setUsingFilters(false) + vm.showingFilters(false) + vm.submitSearch() + }, + showFiltersButton = vm::enableFilters + ) } } +} - MangaTable( - bundle, - mangas, - loading, - hasNextPage, - source.supportsLatest, - isLatest, - onLoadNextPage = vm::loadNextPage, - onMangaClick = onMangaClick, - onClickMode = vm::setMode +@Composable +fun SourceToolbar( + source: Source, + onCloseSourceTabClick: (Source) -> Unit, + sourceSearchQuery: String?, + onSearch: (String) -> Unit, + onSubmitSearch: () -> Unit, + onSourceSettingsClick: (Long) -> Unit, + showFilterButton: Boolean, + showLatestButton: Boolean, + isLatest: Boolean, + showingFilters: Boolean, + onClickMode: (Boolean) -> Unit, + onToggleFiltersClick: (Boolean) -> Unit +) { + Toolbar( + source.name, + closable = true, + onClose = { + onCloseSourceTabClick(source) + }, + searchText = sourceSearchQuery, + search = onSearch, + searchSubmit = onSubmitSearch, + actions = { + if (source.isConfigurable) { + TextActionIcon( + { + onSourceSettingsClick(source.id) + }, + stringResource("location_settings"), + Icons.Rounded.Settings + ) + } + if (showFilterButton) { + TextActionIcon( + { + onToggleFiltersClick(!showingFilters) + }, + stringResource("filter_source"), + Icons.Rounded.FilterList, + !isLatest + ) + } + if (showLatestButton) { + TextActionIcon( + { + onClickMode(!isLatest) + }, + stringResource( + if (isLatest) { + "move_to_browse" + } else { + "move_to_latest" + } + ), + if (isLatest) { + Icons.Rounded.Explore + } else { + Icons.Rounded.NewReleases + } + ) + } + } ) } @@ -80,42 +179,25 @@ private fun MangaTable( mangas: List, isLoading: Boolean = false, hasNextPage: Boolean = false, - supportsLatest: Boolean, - isLatest: Boolean, onLoadNextPage: () -> Unit, onMangaClick: (Long) -> Unit, - onClickMode: (Boolean) -> Unit ) { if (isLoading || mangas.isEmpty()) { LoadingScreen(isLoading) } else { - Column { - Box(modifier = Modifier.fillMaxWidth()) { - if (supportsLatest) { - Button( - onClick = { onClickMode(!isLatest) }, - enabled = !isLoading, - modifier = Modifier.align(Alignment.TopEnd) - ) { - Text(text = stringResource(if (isLatest) "move_to_browse" else "move_to_latest")) - } + val persistentState = persistentLazyListState(bundle) + LazyVerticalGrid(GridCells.Adaptive(160.dp), state = persistentState) { + itemsIndexed(mangas) { index, manga -> + if (hasNextPage && index == mangas.lastIndex) { + LaunchedEffect(Unit) { onLoadNextPage() } } - } - - val persistentState = persistentLazyListState(bundle) - LazyVerticalGrid(GridCells.Adaptive(160.dp), state = persistentState) { - itemsIndexed(mangas) { index, manga -> - if (hasNextPage && index == mangas.lastIndex) { - LaunchedEffect(Unit) { onLoadNextPage() } + MangaGridItem( + title = manga.title, + cover = lazyPainterResource(manga, filterQuality = FilterQuality.Medium), + onClick = { + onMangaClick(manga.id) } - MangaGridItem( - title = manga.title, - cover = lazyPainterResource(manga, filterQuality = FilterQuality.Medium), - onClick = { - onMangaClick(manga.id) - } - ) - } + ) } } } diff --git a/src/main/kotlin/ca/gosyer/ui/sources/components/SourceScreenViewModel.kt b/src/main/kotlin/ca/gosyer/ui/sources/components/SourceScreenViewModel.kt index bd03bf55..c49bb0d2 100644 --- a/src/main/kotlin/ca/gosyer/ui/sources/components/SourceScreenViewModel.kt +++ b/src/main/kotlin/ca/gosyer/ui/sources/components/SourceScreenViewModel.kt @@ -49,6 +49,20 @@ class SourceScreenViewModel( private val _isLatest = saveBooleanInBundle(scope, bundle, IS_LATEST_KEY, false) val isLatest = _isLatest.asStateFlow() + private val _filterButtonEnabled = saveBooleanInBundle(scope, bundle, SHOW_FILTERS, false) + val filterButtonEnabled = _filterButtonEnabled.asStateFlow() + + private val _latestButtonEnabled = saveBooleanInBundle(scope, bundle, SHOW_LATEST, false) + val latestButtonEnabled = _latestButtonEnabled.asStateFlow() + + private val _showingFilters = MutableStateFlow(false) + val showingFilters = _showingFilters.asStateFlow() + + private val _usingFilters = MutableStateFlow(false) + + private val _sourceSearchQuery = MutableStateFlow(null) + val sourceSearchQuery = _sourceSearchQuery.asStateFlow() + private val _query = saveStringInBundle(scope, bundle, QUERY_KEY) { null } private val _pageNum = saveIntInBundle(scope, bundle, PAGE_NUM_KEY, 1) @@ -115,12 +129,12 @@ class SourceScreenViewModel( private suspend fun getPage(): MangaPage { return when { isLatest.value -> sourceHandler.getLatestManga(source, pageNum.value) - _query.value != null -> sourceHandler.getSearchResults(source, _query.value!!, pageNum.value) + _query.value != null || _usingFilters.value -> sourceHandler.getSearchResults(source, _query.value.orEmpty(), pageNum.value) else -> sourceHandler.getPopularManga(source, pageNum.value) } } - fun search(query: String?) { + fun startSearch(query: String?) { cleanBundle(false) _pageNum.value = 0 _hasNextPage.value = true @@ -130,6 +144,26 @@ class SourceScreenViewModel( loadNextPage() } + fun showingFilters(show: Boolean) { + _showingFilters.value = show + } + fun setUsingFilters(usingFilters: Boolean) { + _usingFilters.value = usingFilters + } + fun enableFilters(enabled: Boolean) { + _filterButtonEnabled.value = enabled + } + fun enableLatest(enabled: Boolean) { + _latestButtonEnabled.value = enabled + } + + fun search(query: String) { + _sourceSearchQuery.value = query + } + fun submitSearch() { + startSearch(sourceSearchQuery.value) + } + data class Params(val source: Source, val bundle: Bundle) private companion object { @@ -137,6 +171,8 @@ class SourceScreenViewModel( const val NEXT_PAGE_KEY = "next_page" const val PAGE_NUM_KEY = "page_num" const val IS_LATEST_KEY = "is_latest" + const val SHOW_FILTERS = "show_filters" + const val SHOW_LATEST = "show_latest" const val QUERY_KEY = "query" } } diff --git a/src/main/kotlin/ca/gosyer/ui/sources/components/filter/SourceFiltersMenu.kt b/src/main/kotlin/ca/gosyer/ui/sources/components/filter/SourceFiltersMenu.kt new file mode 100644 index 00000000..8382de39 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/ui/sources/components/filter/SourceFiltersMenu.kt @@ -0,0 +1,363 @@ +/* + * 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.components.filter + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.FocusInteraction +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.Button +import androidx.compose.material.Checkbox +import androidx.compose.material.ContentAlpha +import androidx.compose.material.Divider +import androidx.compose.material.Icon +import androidx.compose.material.LocalContentColor +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.material.TriStateCheckbox +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ArrowUpward +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.state.ToggleableState +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEach +import ca.gosyer.data.models.sourcefilters.SortFilter +import ca.gosyer.ui.base.components.Spinner +import ca.gosyer.ui.base.prefs.ExpandablePreference +import ca.gosyer.ui.base.resources.stringResource +import ca.gosyer.ui.base.vm.viewModel +import ca.gosyer.ui.sources.components.filter.model.SourceFiltersView +import com.github.zsoltk.compose.savedinstancestate.Bundle +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.launch + +@Composable +fun SourceFiltersMenu( + bundle: Bundle, + modifier: Modifier, + sourceId: Long, + showFilters: Boolean, + onSearchClicked: () -> Unit, + onResetClicked: () -> Unit, + showFiltersButton: (Boolean) -> Unit +) { + val vm = viewModel(sourceId) { + SourceFiltersViewModel.Params(bundle, sourceId) + } + val filters by vm.filters.collectAsState() + DisposableEffect(filters) { + showFiltersButton(filters.isNotEmpty()) + onDispose { showFiltersButton(false) } + } + LaunchedEffect(vm) { + launch { + vm.resetFilters.collect { + onResetClicked() + } + } + } + + AnimatedVisibility( + showFilters, + enter = fadeIn() + slideInHorizontally(initialOffsetX = { it * 2 }), + exit = fadeOut() + slideOutHorizontally(targetOffsetX = { it * 2 }), + modifier = modifier + ) { + Surface(elevation = 1.dp) { + Column(Modifier.width(360.dp).fillMaxHeight()) { + Surface(elevation = 4.dp) { + Row( + Modifier.height(56.dp).fillMaxWidth().padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + TextButton(vm::resetFilters) { + Text(stringResource("reset_filters")) + } + Button(onSearchClicked) { + Text(stringResource("filter_source")) + } + } + } + val expandedGroups = remember { mutableStateListOf() } + LazyColumn(Modifier.fillMaxSize()) { + items( + items = filters, + key = { it.filter.hashCode() } + ) { item -> + item.toView(startExpanded = item.index in expandedGroups) { expanded, index -> + if (expanded) { + expandedGroups += index + } else { + expandedGroups -= index + } + } + } + } + } + } + } +} + +@Composable +fun SourceFiltersView<*, *>.toView(startExpanded: Boolean = false, onExpandChanged: ((Boolean, Int) -> Unit)? = null) { + when (this) { + is SourceFiltersView.CheckBox -> CheckboxView(this) + is SourceFiltersView.Group -> GroupView(this, startExpanded, onExpandChanged) + is SourceFiltersView.Header -> HeaderView(this) + is SourceFiltersView.Select -> SelectView(this) + is SourceFiltersView.Separator -> SeparatorView() + is SourceFiltersView.Sort -> SortView(this, startExpanded, onExpandChanged) + is SourceFiltersView.Text -> TextView(this) + is SourceFiltersView.TriState -> TriStateView(this) + } +} + +@Composable +fun SourceFilterAction( + name: String, + onClick: () -> Unit, + action: @Composable () -> Unit +) { + Row( + Modifier.fillMaxWidth().defaultMinSize(minHeight = 56.dp) + .clickable(onClick = onClick) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + action() + Box(Modifier.padding(horizontal = 16.dp).weight(1f)) { + Text( + text = name, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + style = MaterialTheme.typography.subtitle1, + ) + } + } +} + +@Composable +fun GroupView(group: SourceFiltersView.Group, startExpanded: Boolean, onExpandChanged: ((Boolean, Int) -> Unit)? = null) { + val state by group.state.collectAsState() + ExpandablePreference( + group.name, + startExpanded = startExpanded, + onExpandedChanged = { + onExpandChanged?.invoke(it, group.index) + } + ) { + state.fastForEach { + it.toView() + } + } +} + +@Composable +fun CheckboxView(checkBox: SourceFiltersView.CheckBox) { + val state by checkBox.state.collectAsState() + SourceFilterAction( + checkBox.name, + onClick = { checkBox.updateState(!state) }, + action = { + Checkbox(checked = state, onCheckedChange = null) + } + ) +} + +@Composable +fun HeaderView(header: SourceFiltersView.Header) { + Box(Modifier.padding(horizontal = 16.dp, vertical = 8.dp).fillMaxWidth()) { + Text( + header.name, + fontWeight = FontWeight.Bold, + color = LocalContentColor.current.copy(alpha = ContentAlpha.disabled), + maxLines = 1, + style = MaterialTheme.typography.subtitle1, + ) + } +} + +@Composable +fun SelectView(select: SourceFiltersView.Select) { + val state by select.state.collectAsState() + Row( + Modifier.fillMaxWidth().defaultMinSize(minHeight = 56.dp) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = select.name, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + style = MaterialTheme.typography.subtitle1, + modifier = Modifier.weight(1f) + ) + Spinner(Modifier.weight(1f), select.filter.values, state) { + select.updateState(it) + } + } +} + +@Composable +fun SeparatorView() { + Divider(Modifier.fillMaxWidth()) +} + +@Composable +fun SortRow(name: String, selected: Boolean, asc: Boolean, onClick: () -> Unit) { + SourceFilterAction(name, onClick) { + if (selected) { + val rotation = if (asc) { + 0F + } else { + 180F + } + val angle: Float by animateFloatAsState( + targetValue = if (rotation > 360 - rotation) { -(360 - rotation) } else rotation, + animationSpec = tween( + durationMillis = 500, // rotation is retrieved with this frequency + easing = LinearEasing + ) + ) + + Icon( + Icons.Rounded.ArrowUpward, + contentDescription = null, + modifier = Modifier.rotate(angle), + tint = MaterialTheme.colors.primary + ) + } else { + Box(Modifier.size(24.dp)) + } + } +} + +@Composable +fun SortView(sort: SourceFiltersView.Sort, startExpanded: Boolean, onExpandChanged: ((Boolean, Int) -> Unit)?) { + val state by sort.state.collectAsState() + ExpandablePreference( + sort.name, + startExpanded = startExpanded, + onExpandedChanged = { + onExpandChanged?.invoke(it, sort.index) + } + ) { + Column(Modifier.fillMaxWidth()) { + sort.filter.values.forEachIndexed { index, name -> + SortRow(name, state?.index == index, state?.ascending ?: false) { + sort.updateState( + value = SortFilter.Selection( + index, + if (state?.index == index) { + state?.ascending?.not() ?: false + } else false + ) + ) + } + } + } + } +} + +@Composable +fun TextView(text: SourceFiltersView.Text) { + val placeholderText = remember { text.filter.state } + val state by text.state.collectAsState() + var stateText by remember(state) { + mutableStateOf( + if (state == placeholderText) { + "" + } else state + ) + } + val interactionSource = remember { MutableInteractionSource() } + LaunchedEffect(interactionSource) { + interactionSource.interactions.filterIsInstance().collect { + text.updateState(stateText) + } + } + + OutlinedTextField( + stateText, + onValueChange = { stateText = it }, + singleLine = true, + maxLines = 1, + interactionSource = interactionSource, + modifier = Modifier.fillMaxWidth(), + placeholder = if (placeholderText.isNotEmpty()) { + { Text(placeholderText) } + } else { + null + } + ) +} + +@Composable +fun TriStateView(triState: SourceFiltersView.TriState) { + val state by triState.state.collectAsState() + SourceFilterAction( + triState.name, + onClick = { + triState.updateState( + when (state) { + 0 -> 1 + 1 -> 2 + else -> 0 + } + ) + }, + action = { + TriStateCheckbox( + state = when (state) { + 1 -> ToggleableState.On + 2 -> ToggleableState.Indeterminate + else -> ToggleableState.Off + }, + onClick = null + ) + } + ) +} diff --git a/src/main/kotlin/ca/gosyer/ui/sources/components/filter/SourceFiltersViewModel.kt b/src/main/kotlin/ca/gosyer/ui/sources/components/filter/SourceFiltersViewModel.kt new file mode 100644 index 00000000..25cc1295 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/ui/sources/components/filter/SourceFiltersViewModel.kt @@ -0,0 +1,117 @@ +/* + * 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.components.filter + +import ca.gosyer.data.models.sourcefilters.SourceFilter +import ca.gosyer.data.server.interactions.SourceInteractionHandler +import ca.gosyer.ui.base.vm.ViewModel +import ca.gosyer.ui.sources.components.filter.model.SourceFiltersView +import ca.gosyer.util.lang.throwIfCancellation +import ca.gosyer.util.system.CKLogger +import com.github.zsoltk.compose.savedinstancestate.Bundle +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import java.util.concurrent.CopyOnWriteArrayList +import javax.inject.Inject + +class SourceFiltersViewModel( + private val bundle: Bundle, + private val sourceId: Long, + private val sourceHandler: SourceInteractionHandler +) : ViewModel() { + @Inject constructor( + params: Params, + sourceHandler: SourceInteractionHandler + ) : this( + params.bundle, + params.sourceId, + sourceHandler + ) + + private val _loading = MutableStateFlow(true) + val loading = _loading.asStateFlow() + + private val _filters = MutableStateFlow>>(emptyList()) + val filters = _filters.asStateFlow() + + private val _resetFilters = MutableSharedFlow() + val resetFilters = _resetFilters.asSharedFlow() + + private val subscriptions: CopyOnWriteArrayList = CopyOnWriteArrayList() + + init { + getFilters(initialLoad = !bundle.getBoolean(FILTERING, false)) + + filters.onEach { settings -> + subscriptions.forEach { it.cancel() } + subscriptions.clear() + subscriptions += settings.flatMap { filter -> + if (filter is SourceFiltersView.Group) { + filter.state.value.map { childFilter -> + childFilter.state.drop(1).filterNotNull().onEach { + sourceHandler.setFilter( + sourceId, + filter.index, + childFilter.index, + it + ) + getFilters() + }.launchIn(scope) + } + } else { + filter.state.drop(1).filterNotNull().onEach { + sourceHandler.setFilter(sourceId, filter.index, it) + getFilters() + }.launchIn(scope) + .let { listOf(it) } + } + } + }.launchIn(scope) + } + + private fun getFilters(initialLoad: Boolean = false) { + scope.launch { + try { + _filters.value = sourceHandler.getFilterList(sourceId, reset = initialLoad).toView() + if (!initialLoad) { + bundle.putBoolean(FILTERING, true) + } else { + _resetFilters.emit(Unit) + } + } catch (e: Exception) { + e.throwIfCancellation() + } finally { + _loading.value = false + } + } + } + + fun resetFilters() { + scope.launch { + bundle.remove(FILTERING) + getFilters(initialLoad = true) + } + } + + data class Params(val bundle: Bundle, val sourceId: Long) + + private fun List.toView() = mapIndexed { index, sourcePreference -> + SourceFiltersView(index, sourcePreference) + } + + private companion object : CKLogger({}) { + const val FILTERING = "filtering" + } +} diff --git a/src/main/kotlin/ca/gosyer/ui/sources/components/filter/model/SourceFiltersView.kt b/src/main/kotlin/ca/gosyer/ui/sources/components/filter/model/SourceFiltersView.kt new file mode 100644 index 00000000..4e655ffd --- /dev/null +++ b/src/main/kotlin/ca/gosyer/ui/sources/components/filter/model/SourceFiltersView.kt @@ -0,0 +1,177 @@ +/* + * 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.components.filter.model + +import ca.gosyer.data.models.sourcefilters.CheckBoxFilter +import ca.gosyer.data.models.sourcefilters.GroupFilter +import ca.gosyer.data.models.sourcefilters.HeaderFilter +import ca.gosyer.data.models.sourcefilters.SelectFilter +import ca.gosyer.data.models.sourcefilters.SeparatorFilter +import ca.gosyer.data.models.sourcefilters.SortFilter +import ca.gosyer.data.models.sourcefilters.SourceFilter +import ca.gosyer.data.models.sourcefilters.TextFilter +import ca.gosyer.data.models.sourcefilters.TriStateFilter +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +sealed class SourceFiltersView { + abstract val index: Int + abstract val name: String + abstract val state: StateFlow + abstract fun updateState(value: R) + + abstract val filter: T + + data class CheckBox internal constructor( + override val index: Int, + override val name: String, + override val filter: CheckBoxFilter.CheckBoxProps, + private val _state: MutableStateFlow = MutableStateFlow(filter.state) + ) : SourceFiltersView() { + override val state: StateFlow = _state.asStateFlow() + override fun updateState(value: Boolean) { + _state.value = value + } + internal constructor(index: Int, filter: CheckBoxFilter) : this( + index, + filter.filter.name, + filter.filter + ) + } + + data class Header internal constructor( + override val index: Int, + override val name: String, + override val filter: HeaderFilter.HeaderProps + ) : SourceFiltersView() { + override val state: StateFlow = MutableStateFlow(filter.state).asStateFlow() + override fun updateState(value: Any) { + // NO-OP + } + internal constructor(index: Int, filter: HeaderFilter) : this( + index, + filter.filter.name, + filter.filter + ) + } + data class Separator internal constructor( + override val index: Int, + override val name: String, + override val filter: SeparatorFilter.SeparatorProps + ) : SourceFiltersView() { + override val state: StateFlow = MutableStateFlow(filter.state).asStateFlow() + override fun updateState(value: Any) { + // NO-OP + } + internal constructor(index: Int, filter: SeparatorFilter) : this( + index, + filter.filter.name, + filter.filter + ) + } + + data class Text internal constructor( + override val index: Int, + override val name: String, + override val filter: TextFilter.TextProps + ) : SourceFiltersView() { + private val _state = MutableStateFlow(filter.state) + override val state: StateFlow = _state.asStateFlow() + override fun updateState(value: String) { + _state.value = value + } + internal constructor(index: Int, preference: TextFilter) : this( + index, + preference.filter.name, + preference.filter + ) + } + + data class TriState internal constructor( + override val index: Int, + override val name: String, + override val filter: TriStateFilter.TriStateProps, + private val _state: MutableStateFlow = MutableStateFlow(filter.state) + ) : SourceFiltersView() { + override val state: StateFlow = _state.asStateFlow() + override fun updateState(value: Int) { + _state.value = value + } + internal constructor(index: Int, filter: TriStateFilter) : this( + index, + filter.filter.name, + filter.filter + ) + } + + data class Select internal constructor( + override val index: Int, + override val name: String, + override val filter: SelectFilter.SelectProps, + private val _state: MutableStateFlow = MutableStateFlow(filter.state) + ) : SourceFiltersView() { + override val state: StateFlow = _state.asStateFlow() + override fun updateState(value: Int) { + _state.value = value + } + internal constructor(index: Int, filter: SelectFilter) : this( + index, + filter.filter.name, + filter.filter + ) + } + data class Sort internal constructor( + override val index: Int, + override val name: String, + override val filter: SortFilter.SortProps, + private val _state: MutableStateFlow = MutableStateFlow(filter.state) + ) : SourceFiltersView() { + override val state: StateFlow = _state.asStateFlow() + override fun updateState(value: SortFilter.Selection?) { + _state.value = value + } + internal constructor(index: Int, filter: SortFilter) : this( + index, + filter.filter.name, + filter.filter + ) + } + + data class Group internal constructor( + override val index: Int, + override val name: String, + override val filter: GroupFilter.GroupProps, + ) : SourceFiltersView>>() { + override val state: StateFlow>> = MutableStateFlow( + filter.state.mapIndexed { itemIndex, sourceFilter -> + SourceFiltersView(itemIndex, sourceFilter) + } + ).asStateFlow() + override fun updateState(value: List>) { + // NO-OP + } + internal constructor(index: Int, filter: GroupFilter) : this( + index, + filter.filter.name, + filter.filter + ) + } +} + +fun SourceFiltersView(index: Int, sourceFilter: SourceFilter): SourceFiltersView<*, *> { + return when (sourceFilter) { + is CheckBoxFilter -> SourceFiltersView.CheckBox(index, sourceFilter) + is HeaderFilter -> SourceFiltersView.Header(index, sourceFilter) + is SeparatorFilter -> SourceFiltersView.Separator(index, sourceFilter) + is TextFilter -> SourceFiltersView.Text(index, sourceFilter) + is TriStateFilter -> SourceFiltersView.TriState(index, sourceFilter) + is SelectFilter -> SourceFiltersView.Select(index, sourceFilter) + is SortFilter -> SourceFiltersView.Sort(index, sourceFilter) + is GroupFilter -> SourceFiltersView.Group(index, sourceFilter) + } +} diff --git a/src/main/resources/values/values/strings.xml b/src/main/resources/values/values/strings.xml index 1f7cdacc..2e66804a 100644 --- a/src/main/resources/values/values/strings.xml +++ b/src/main/resources/values/values/strings.xml @@ -71,8 +71,10 @@ Home - To browse - To latest + Browse + Latest + Reset + Filter No pages found