Implement source filters

This commit is contained in:
Syer10
2021-11-27 12:11:40 -05:00
parent 95db01f4d0
commit 634e44fcfa
28 changed files with 1442 additions and 490 deletions

View File

@@ -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
}

View File

@@ -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<Boolean>
}

View File

@@ -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<SourceFilter>
) : Props<List<SourceFilter>>
}

View File

@@ -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<Int>
}

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.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<String>
) : Props<Int>
}

View File

@@ -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<Int>
}

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.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<String>
) : Props<Selection?>
@Serializable
data class Selection(
val index: Int,
val ascending: Boolean
)
}

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.sourcefilters
import kotlinx.serialization.Serializable
@Serializable
sealed class SourceFilter {
abstract val filter: Props<*>
}
interface Props<T> {
val name: String
val state: T
}

View File

@@ -0,0 +1,21 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package ca.gosyer.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()
)
}

View File

@@ -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<String>
}

View File

@@ -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<Int>
}

View File

@@ -77,7 +77,7 @@ internal class HttpProvider @Inject constructor(
serializer = KotlinxSerializer(
Json {
isLenient = false
ignoreUnknownKeys = !BuildConfig.DEBUG
ignoreUnknownKeys = true
allowSpecialFloatingPointValues = true
useArrayPolymorphism = false
}

View File

@@ -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<HttpResponse>(
suspend fun getFilterList(sourceId: Long, reset: Boolean = false) = withIOContext {
client.get<List<SourceFilter>>(
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<HttpResponse>(
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<List<SourcePreference>>(

View File

@@ -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) =

View File

@@ -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 <R> 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
}

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.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<String>, 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)
}
}
}
}
}

View File

@@ -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

View File

@@ -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,52 +81,32 @@ fun SourcesMenu(bundle: Bundle, onSourceSettingsClick: (Long) -> Unit, onMangaCl
val vm = viewModel<SourcesMenuViewModel> {
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
)
SourceTab(
onLoadSources = vm::setLoadedSources,
onSourceClicked = vm::addTab,
selectedSourceTab = selectedSourceTab,
onMangaClick = onMangaClick,
onCloseSourceTabClick = vm::closeTab,
onSourceSettingsClick = onSourceSettingsClick
)
}
}
@Composable
fun SourcesSideMenu(
sourceTabs: List<Source?>,
onSourceTabClick: (Source?) -> Unit,
onCloseSourceTabClick: (Source) -> Unit
) {
Surface(elevation = 1.dp) {
LazyColumn(Modifier.fillMaxHeight().width(64.dp)) {
items(sourceTabs) { source ->
@@ -155,11 +129,11 @@ fun SourcesMenu(bundle: Bundle, onSourceSettingsClick: (Long) -> Unit, onMangaCl
val modifier = Modifier
.combinedMouseClickable(
onClick = {
vm.selectTab(source)
onSourceTabClick(source)
},
onMiddleClick = {
if (source != null) {
vm.closeTab(source)
onCloseSourceTabClick(source)
}
}
)
@@ -181,15 +155,33 @@ fun SourcesMenu(bundle: Bundle, onSourceSettingsClick: (Long) -> Unit, onMangaCl
}
}
}
}
@Composable
fun SourceTab(
onLoadSources: (List<Source>) -> 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(it, selectedSource, onMangaClick, vm::enableSearch, vm::setSearch)
SourceScreen(
bundle = it,
source = selectedSource,
onMangaClick = onMangaClick,
onCloseSourceTabClick = onCloseSourceTabClick,
onSourceSettingsClick = onSourceSettingsClick
)
} else {
SourceHomeScreen(isLoading, sources, vm::addTab)
}
}
SourceHomeScreen(
bundle = it,
onAddSource = onSourceClicked,
onLoadSources = onLoadSources
)
}
}
}

View File

@@ -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<Source>()
private val _sources = MutableStateFlow(emptyList<Source>())
val sources = _sources.asStateFlow()
private val _sourceTabs = MutableStateFlow<List<Source?>>(listOf(null))
val sourceTabs = _sourceTabs.asStateFlow()
private val _selectedSourceTab = MutableStateFlow<Source?>(null)
val selectedSourceTab = _selectedSourceTab.asStateFlow()
private val _sourceSearchEnabled = MutableStateFlow(false)
val sourceSearchEnabled = _sourceSearchEnabled.asStateFlow()
private val _sourceSearchQuery = MutableStateFlow<String?>(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<String>) {
_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 setLoadedSources(sources: List<Source>) {
val sourceTabs = bundle.getLongArray(SOURCE_TABS_KEY)
if (sourceTabs != null) {
_sourceTabs.value = listOf(null) + sourceTabs.toList()
.mapNotNull { sourceId ->
sources.find { it.id == sourceId }
}
fun enableSearch(enabled: Boolean) {
_sourceSearchEnabled.value = enabled
_selectedSourceTab.value = bundle.getLong(SELECTED_SOURCE_TAB, -1).let { id ->
if (id != -1L) {
sources.find { it.id == id }
} else null
}
fun search(query: String) {
_sourceSearchQuery.value = query.takeUnless { it.isBlank() }
}
fun submitSearch() {
searchSource?.invoke(sourceSearchQuery.value)
}
fun getSourceLanguages(): Set<String> {
return installedSources.map { it.lang }.toSet() - setOf(Source.LOCAL_SOURCE_LANG)
}
fun setEnabledLanguages(langs: Set<String>) {
info { langs }
_languages.value = langs
setSources(langs)
}
private companion object : CKLogger({}) {

View File

@@ -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,20 +44,45 @@ 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<Source>,
onSourceClicked: (Source) -> Unit
bundle: Bundle,
onAddSource: (Source) -> Unit,
onLoadSources: (List<Source>) -> Unit
) {
val vm = viewModel<SourceHomeScreenViewModel> {
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 {
Column {
SourceHomeScreenToolbar(
vm.languages,
vm::getSourceLanguages,
vm::setEnabledLanguages
)
Box(Modifier.fillMaxSize(), Alignment.TopCenter) {
val state = rememberLazyListState()
SourceCategory(sources, onSourceClicked, state)
SourceCategory(sources, onAddSource, state)
/*val sourcesByLang = sources.groupBy { it.lang.toLowerCase() }.toList()
LazyColumn(state = state) {
items(sourcesByLang) { (lang, sources) ->
@@ -71,6 +101,31 @@ fun SourceHomeScreen(
)
}
}
}
}
@Composable
fun SourceHomeScreenToolbar(
sourceLanguages: StateFlow<Set<String>>,
onGetEnabledLanguages: () -> Set<String>,
onSetEnabledLanguages: (Set<String>) -> 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

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.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<Source>())
val sources = _sources.asStateFlow()
private var installedSources = emptyList<Source>()
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<String>) {
_sources.value = installedSources.filter { it.lang in langs || it.lang == Source.LOCAL_SOURCE_LANG }
}
fun getSourceLanguages(): Set<String> {
return installedSources.map { it.lang }.toSet() - setOf(Source.LOCAL_SOURCE_LANG)
}
fun setEnabledLanguages(langs: Set<String>) {
info { langs }
_languages.value = langs
setSources(langs)
}
private companion object : CKLogger({})
}

View File

@@ -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<SourceScreenViewModel>(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)
}
DisposableEffect(isLatest) {
enableSearch(!isLatest)
onDispose {
enableSearch(false)
}
LaunchedEffect(vm to source) {
vm.enableLatest(source.supportsLatest)
}
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,
mangas,
loading,
hasNextPage,
source.supportsLatest,
isLatest,
bundle = bundle,
mangas = mangas,
isLoading = loading,
hasNextPage = hasNextPage,
onLoadNextPage = vm::loadNextPage,
onMangaClick = onMangaClick,
onClickMode = vm::setMode
)
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
)
}
}
}
@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,28 +179,12 @@ private fun MangaTable(
mangas: List<Manga>,
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 ->
@@ -118,5 +201,4 @@ private fun MangaTable(
}
}
}
}
}

View File

@@ -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<String?>(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"
}
}

View File

@@ -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<SourceFiltersViewModel>(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<Int>() }
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<FocusInteraction.Unfocus>().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
)
}
)
}

View File

@@ -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<List<SourceFiltersView<*, *>>>(emptyList())
val filters = _filters.asStateFlow()
private val _resetFilters = MutableSharedFlow<Unit>()
val resetFilters = _resetFilters.asSharedFlow()
private val subscriptions: CopyOnWriteArrayList<Job> = 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<SourceFilter>.toView() = mapIndexed { index, sourcePreference ->
SourceFiltersView(index, sourcePreference)
}
private companion object : CKLogger({}) {
const val FILTERING = "filtering"
}
}

View File

@@ -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<T, R : Any?> {
abstract val index: Int
abstract val name: String
abstract val state: StateFlow<R>
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<Boolean> = MutableStateFlow(filter.state)
) : SourceFiltersView<CheckBoxFilter.CheckBoxProps, Boolean>() {
override val state: StateFlow<Boolean> = _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<HeaderFilter.HeaderProps, Any>() {
override val state: StateFlow<Any> = 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<SeparatorFilter.SeparatorProps, Any>() {
override val state: StateFlow<Any> = 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<TextFilter.TextProps, String>() {
private val _state = MutableStateFlow(filter.state)
override val state: StateFlow<String> = _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<Int> = MutableStateFlow(filter.state)
) : SourceFiltersView<TriStateFilter.TriStateProps, Int>() {
override val state: StateFlow<Int> = _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<Int> = MutableStateFlow(filter.state)
) : SourceFiltersView<SelectFilter.SelectProps, Int>() {
override val state: StateFlow<Int> = _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<SortFilter.Selection?> = MutableStateFlow(filter.state)
) : SourceFiltersView<SortFilter.SortProps, SortFilter.Selection?>() {
override val state: StateFlow<SortFilter.Selection?> = _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<GroupFilter.GroupProps, List<SourceFiltersView<*, *>>>() {
override val state: StateFlow<List<SourceFiltersView<*, *>>> = MutableStateFlow(
filter.state.mapIndexed { itemIndex, sourceFilter ->
SourceFiltersView(itemIndex, sourceFilter)
}
).asStateFlow()
override fun updateState(value: List<SourceFiltersView<*, *>>) {
// 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)
}
}

View File

@@ -71,8 +71,10 @@
<!-- Sources Menu -->
<string name="sources_home">Home</string>
<string name="move_to_browse">To browse</string>
<string name="move_to_latest">To latest</string>
<string name="move_to_browse">Browse</string>
<string name="move_to_latest">Latest</string>
<string name="reset_filters">Reset</string>
<string name="filter_source">Filter</string>
<!-- Reader Menu -->
<string name="no_pages_found">No pages found</string>