mirror of
https://github.com/Suwayomi/TachideskJUI.git
synced 2025-12-10 06:42:05 +01:00
Implement source filters
This commit is contained in:
@@ -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
|
||||
}
|
||||
0
src/main/kotlin/ca/gosyer/data/models/Updates.kt
Normal file
0
src/main/kotlin/ca/gosyer/data/models/Updates.kt
Normal 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>
|
||||
}
|
||||
@@ -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>>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -77,7 +77,7 @@ internal class HttpProvider @Inject constructor(
|
||||
serializer = KotlinxSerializer(
|
||||
Json {
|
||||
isLenient = false
|
||||
ignoreUnknownKeys = !BuildConfig.DEBUG
|
||||
ignoreUnknownKeys = true
|
||||
allowSpecialFloatingPointValues = true
|
||||
useArrayPolymorphism = false
|
||||
}
|
||||
|
||||
@@ -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>>(
|
||||
|
||||
@@ -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) =
|
||||
|
||||
@@ -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
|
||||
}
|
||||
70
src/main/kotlin/ca/gosyer/ui/base/components/Spinner.kt
Normal file
70
src/main/kotlin/ca/gosyer/ui/base/components/Spinner.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<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
|
||||
)
|
||||
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<Source?>,
|
||||
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<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(
|
||||
bundle = it,
|
||||
source = selectedSource,
|
||||
onMangaClick = onMangaClick,
|
||||
onCloseSourceTabClick = onCloseSourceTabClick,
|
||||
onSourceSettingsClick = onSourceSettingsClick
|
||||
)
|
||||
} else {
|
||||
SourceHomeScreen(
|
||||
bundle = it,
|
||||
onAddSource = onSourceClicked,
|
||||
onLoadSources = onLoadSources
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 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<String> {
|
||||
return installedSources.map { it.lang }.toSet() - setOf(Source.LOCAL_SOURCE_LANG)
|
||||
}
|
||||
|
||||
fun setEnabledLanguages(langs: Set<String>) {
|
||||
info { langs }
|
||||
_languages.value = langs
|
||||
setSources(langs)
|
||||
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 }
|
||||
}
|
||||
_selectedSourceTab.value = bundle.getLong(SELECTED_SOURCE_TAB, -1).let { id ->
|
||||
if (id != -1L) {
|
||||
sources.find { it.id == id }
|
||||
} else null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object : CKLogger({}) {
|
||||
|
||||
@@ -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<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 {
|
||||
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<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
|
||||
fun SourceCategory(
|
||||
sources: List<Source>,
|
||||
|
||||
@@ -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({})
|
||||
}
|
||||
@@ -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)
|
||||
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<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 ->
|
||||
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)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user