mirror of
https://github.com/Suwayomi/TachideskJUI.git
synced 2025-12-11 07:12:03 +01:00
Implement source filters
This commit is contained in:
@@ -3,9 +3,9 @@ import org.gradle.api.JavaVersion
|
|||||||
object Config {
|
object Config {
|
||||||
const val tachideskVersion = "v0.5.4"
|
const val tachideskVersion = "v0.5.4"
|
||||||
// Match this to the Tachidesk-Server commit count
|
// Match this to the Tachidesk-Server commit count
|
||||||
const val serverCode = 1013
|
const val serverCode = 1031
|
||||||
const val preview = true
|
const val preview = true
|
||||||
const val previewCommit = "1ee37da720abd8d017f3c443f0b7e2dc543ee1ef"
|
const val previewCommit = "420d14fc37a18269a9d7232519e3f9a21c6302a2"
|
||||||
|
|
||||||
val jvmTarget = JavaVersion.VERSION_15
|
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(
|
serializer = KotlinxSerializer(
|
||||||
Json {
|
Json {
|
||||||
isLenient = false
|
isLenient = false
|
||||||
ignoreUnknownKeys = !BuildConfig.DEBUG
|
ignoreUnknownKeys = true
|
||||||
allowSpecialFloatingPointValues = true
|
allowSpecialFloatingPointValues = true
|
||||||
useArrayPolymorphism = false
|
useArrayPolymorphism = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ package ca.gosyer.data.server.interactions
|
|||||||
|
|
||||||
import ca.gosyer.data.models.MangaPage
|
import ca.gosyer.data.models.MangaPage
|
||||||
import ca.gosyer.data.models.Source
|
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.SourcePreference
|
||||||
import ca.gosyer.data.models.sourcepreference.SourcePreferenceChange
|
import ca.gosyer.data.models.sourcepreference.SourcePreferenceChange
|
||||||
import ca.gosyer.data.server.Http
|
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.getFilterListQuery
|
||||||
import ca.gosyer.data.server.requests.getSourceSettingsQuery
|
import ca.gosyer.data.server.requests.getSourceSettingsQuery
|
||||||
import ca.gosyer.data.server.requests.globalSearchQuery
|
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.sourceInfoQuery
|
||||||
import ca.gosyer.data.server.requests.sourceLatestQuery
|
import ca.gosyer.data.server.requests.sourceLatestQuery
|
||||||
import ca.gosyer.data.server.requests.sourceListQuery
|
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.data.server.requests.updateSourceSettingQuery
|
||||||
import ca.gosyer.util.lang.withIOContext
|
import ca.gosyer.util.lang.withIOContext
|
||||||
import io.ktor.client.request.get
|
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.request.post
|
||||||
import io.ktor.client.statement.HttpResponse
|
import io.ktor.client.statement.HttpResponse
|
||||||
import io.ktor.http.ContentType
|
import io.ktor.http.ContentType
|
||||||
import io.ktor.http.contentType
|
import io.ktor.http.contentType
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class SourceInteractionHandler @Inject constructor(
|
class SourceInteractionHandler @Inject constructor(
|
||||||
@@ -89,14 +96,41 @@ class SourceInteractionHandler @Inject constructor(
|
|||||||
pageNum
|
pageNum
|
||||||
)
|
)
|
||||||
|
|
||||||
// TODO: 2021-03-14
|
suspend fun getFilterList(sourceId: Long, reset: Boolean = false) = withIOContext {
|
||||||
suspend fun getFilterList(sourceId: Long) = withIOContext {
|
client.get<List<SourceFilter>>(
|
||||||
client.get<HttpResponse>(
|
|
||||||
serverUrl + getFilterListQuery(sourceId)
|
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 {
|
suspend fun getSourceSettings(sourceId: Long) = withIOContext {
|
||||||
client.get<List<SourcePreference>>(
|
client.get<List<SourcePreference>>(
|
||||||
|
|||||||
@@ -32,7 +32,11 @@ fun sourceSearchQuery(sourceId: Long, searchTerm: String, pageNum: Int) =
|
|||||||
|
|
||||||
@Get
|
@Get
|
||||||
fun getFilterListQuery(sourceId: Long) =
|
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
|
@Get
|
||||||
fun getSourceSettingsQuery(sourceId: Long) =
|
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.Icons
|
||||||
import androidx.compose.material.icons.rounded.ArrowDropDown
|
import androidx.compose.material.icons.rounded.ArrowDropDown
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -310,9 +311,16 @@ const val FADE_OUT_ANIMATION_DURATION = 300
|
|||||||
@Composable
|
@Composable
|
||||||
fun ExpandablePreference(
|
fun ExpandablePreference(
|
||||||
title: String,
|
title: String,
|
||||||
|
startExpanded: Boolean = false,
|
||||||
|
onExpandedChanged: ((Boolean) -> Unit)? = null,
|
||||||
expandedContent: @Composable ColumnScope.() -> Unit,
|
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 {
|
val transitionState = remember {
|
||||||
MutableTransitionState(expanded).apply {
|
MutableTransitionState(expanded).apply {
|
||||||
targetState = !expanded
|
targetState = !expanded
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import androidx.compose.animation.Crossfade
|
|||||||
import androidx.compose.foundation.TooltipArea
|
import androidx.compose.foundation.TooltipArea
|
||||||
import androidx.compose.foundation.TooltipPlacement
|
import androidx.compose.foundation.TooltipPlacement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
@@ -26,8 +25,6 @@ import androidx.compose.material.Surface
|
|||||||
import androidx.compose.material.Text
|
import androidx.compose.material.Text
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.rounded.Home
|
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.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.collectAsState
|
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.DpOffset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import ca.gosyer.build.BuildConfig
|
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.KamelImage
|
||||||
import ca.gosyer.ui.base.components.Toolbar
|
|
||||||
import ca.gosyer.ui.base.components.combinedMouseClickable
|
import ca.gosyer.ui.base.components.combinedMouseClickable
|
||||||
import ca.gosyer.ui.base.resources.stringResource
|
import ca.gosyer.ui.base.resources.stringResource
|
||||||
import ca.gosyer.ui.base.vm.viewModel
|
import ca.gosyer.ui.base.vm.viewModel
|
||||||
import ca.gosyer.ui.extensions.LanguageDialog
|
|
||||||
import ca.gosyer.ui.manga.openMangaMenu
|
import ca.gosyer.ui.manga.openMangaMenu
|
||||||
import ca.gosyer.ui.sources.components.SourceHomeScreen
|
import ca.gosyer.ui.sources.components.SourceHomeScreen
|
||||||
import ca.gosyer.ui.sources.components.SourceScreen
|
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 com.github.zsoltk.compose.savedinstancestate.LocalSavedInstanceState
|
||||||
import io.kamel.image.lazyPainterResource
|
import io.kamel.image.lazyPainterResource
|
||||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
|
|
||||||
@OptIn(DelicateCoroutinesApi::class)
|
@OptIn(DelicateCoroutinesApi::class)
|
||||||
fun openSourcesMenu() {
|
fun openSourcesMenu() {
|
||||||
@@ -87,110 +81,108 @@ fun SourcesMenu(bundle: Bundle, onSourceSettingsClick: (Long) -> Unit, onMangaCl
|
|||||||
val vm = viewModel<SourcesMenuViewModel> {
|
val vm = viewModel<SourcesMenuViewModel> {
|
||||||
bundle
|
bundle
|
||||||
}
|
}
|
||||||
val isLoading by vm.isLoading.collectAsState()
|
|
||||||
val sources by vm.sources.collectAsState()
|
|
||||||
val sourceTabs by vm.sourceTabs.collectAsState()
|
val sourceTabs by vm.sourceTabs.collectAsState()
|
||||||
val selectedSourceTab by vm.selectedSourceTab.collectAsState()
|
val selectedSourceTab by vm.selectedSourceTab.collectAsState()
|
||||||
val sourceSearchEnabled by vm.sourceSearchEnabled.collectAsState()
|
Row {
|
||||||
val sourceSearchQuery by vm.sourceSearchQuery.collectAsState()
|
SourcesSideMenu(
|
||||||
Column {
|
sourceTabs = sourceTabs,
|
||||||
Toolbar(
|
onSourceTabClick = vm::selectTab,
|
||||||
selectedSourceTab?.name ?: stringResource("location_sources"),
|
onCloseSourceTabClick = vm::closeTab
|
||||||
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 {
|
|
||||||
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 ->
|
SourceTab(
|
||||||
BundleScope(selectedSource?.id.toString(), autoDispose = false) {
|
onLoadSources = vm::setLoadedSources,
|
||||||
if (selectedSource != null) {
|
onSourceClicked = vm::addTab,
|
||||||
SourceScreen(it, selectedSource, onMangaClick, vm::enableSearch, vm::setSearch)
|
selectedSourceTab = selectedSourceTab,
|
||||||
} else {
|
onMangaClick = onMangaClick,
|
||||||
SourceHomeScreen(isLoading, sources, vm::addTab)
|
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
|
package ca.gosyer.ui.sources
|
||||||
|
|
||||||
import ca.gosyer.data.catalog.CatalogPreferences
|
|
||||||
import ca.gosyer.data.models.Source
|
import ca.gosyer.data.models.Source
|
||||||
import ca.gosyer.data.server.interactions.SourceInteractionHandler
|
|
||||||
import ca.gosyer.ui.base.vm.ViewModel
|
import ca.gosyer.ui.base.vm.ViewModel
|
||||||
import ca.gosyer.util.lang.throwIfCancellation
|
|
||||||
import ca.gosyer.util.system.CKLogger
|
import ca.gosyer.util.system.CKLogger
|
||||||
import com.github.zsoltk.compose.savedinstancestate.Bundle
|
import com.github.zsoltk.compose.savedinstancestate.Bundle
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -18,39 +15,17 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||||||
import kotlinx.coroutines.flow.drop
|
import kotlinx.coroutines.flow.drop
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class SourcesMenuViewModel @Inject constructor(
|
class SourcesMenuViewModel @Inject constructor(
|
||||||
private val bundle: Bundle,
|
private val bundle: Bundle
|
||||||
private val sourceHandler: SourceInteractionHandler,
|
|
||||||
catalogPreferences: CatalogPreferences
|
|
||||||
) : ViewModel() {
|
) : 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))
|
private val _sourceTabs = MutableStateFlow<List<Source?>>(listOf(null))
|
||||||
val sourceTabs = _sourceTabs.asStateFlow()
|
val sourceTabs = _sourceTabs.asStateFlow()
|
||||||
|
|
||||||
private val _selectedSourceTab = MutableStateFlow<Source?>(null)
|
private val _selectedSourceTab = MutableStateFlow<Source?>(null)
|
||||||
val selectedSourceTab = _selectedSourceTab.asStateFlow()
|
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 {
|
init {
|
||||||
_sourceTabs.drop(1)
|
_sourceTabs.drop(1)
|
||||||
.onEach { sources ->
|
.onEach { sources ->
|
||||||
@@ -67,38 +42,6 @@ class SourcesMenuViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.launchIn(scope)
|
.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?) {
|
fun selectTab(source: Source?) {
|
||||||
@@ -120,30 +63,19 @@ class SourcesMenuViewModel @Inject constructor(
|
|||||||
bundle.remove(source.id.toString())
|
bundle.remove(source.id.toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSearch(block: (String?) -> Unit) {
|
fun setLoadedSources(sources: List<Source>) {
|
||||||
searchSource = block
|
val sourceTabs = bundle.getLongArray(SOURCE_TABS_KEY)
|
||||||
}
|
if (sourceTabs != null) {
|
||||||
|
_sourceTabs.value = listOf(null) + sourceTabs.toList()
|
||||||
fun enableSearch(enabled: Boolean) {
|
.mapNotNull { sourceId ->
|
||||||
_sourceSearchEnabled.value = enabled
|
sources.find { it.id == sourceId }
|
||||||
}
|
}
|
||||||
|
_selectedSourceTab.value = bundle.getLong(SELECTED_SOURCE_TAB, -1).let { id ->
|
||||||
fun search(query: String) {
|
if (id != -1L) {
|
||||||
_sourceSearchQuery.value = query.takeUnless { it.isBlank() }
|
sources.find { it.id == id }
|
||||||
}
|
} else null
|
||||||
|
}
|
||||||
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({}) {
|
private companion object : CKLogger({}) {
|
||||||
|
|||||||
@@ -29,7 +29,12 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.material.MaterialTheme
|
import androidx.compose.material.MaterialTheme
|
||||||
import androidx.compose.material.Surface
|
import androidx.compose.material.Surface
|
||||||
import androidx.compose.material.Text
|
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.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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.shadow
|
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.data.models.Source
|
||||||
import ca.gosyer.ui.base.components.KamelImage
|
import ca.gosyer.ui.base.components.KamelImage
|
||||||
import ca.gosyer.ui.base.components.LoadingScreen
|
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 io.kamel.image.lazyPainterResource
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SourceHomeScreen(
|
fun SourceHomeScreen(
|
||||||
isLoading: Boolean,
|
bundle: Bundle,
|
||||||
sources: List<Source>,
|
onAddSource: (Source) -> Unit,
|
||||||
onSourceClicked: (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()) {
|
if (sources.isEmpty()) {
|
||||||
LoadingScreen(isLoading)
|
LoadingScreen(isLoading)
|
||||||
} else {
|
} else {
|
||||||
Box(Modifier.fillMaxSize(), Alignment.TopCenter) {
|
Column {
|
||||||
val state = rememberLazyListState()
|
SourceHomeScreenToolbar(
|
||||||
SourceCategory(sources, onSourceClicked, state)
|
vm.languages,
|
||||||
/*val sourcesByLang = sources.groupBy { it.lang.toLowerCase() }.toList()
|
vm::getSourceLanguages,
|
||||||
LazyColumn(state = state) {
|
vm::setEnabledLanguages
|
||||||
items(sourcesByLang) { (lang, sources) ->
|
|
||||||
SourceCategory(
|
|
||||||
lang,
|
|
||||||
sources,
|
|
||||||
onSourceClicked = sourceClicked
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
}
|
|
||||||
}*/
|
|
||||||
|
|
||||||
VerticalScrollbar(
|
|
||||||
modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
|
|
||||||
adapter = rememberScrollbarAdapter(state)
|
|
||||||
)
|
)
|
||||||
|
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
|
@Composable
|
||||||
fun SourceCategory(
|
fun SourceCategory(
|
||||||
sources: List<Source>,
|
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.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.lazy.GridCells
|
import androidx.compose.foundation.lazy.GridCells
|
||||||
import androidx.compose.foundation.lazy.LazyVerticalGrid
|
import androidx.compose.foundation.lazy.LazyVerticalGrid
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.material.Button
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.Text
|
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.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -27,8 +28,11 @@ import ca.gosyer.data.models.Manga
|
|||||||
import ca.gosyer.data.models.Source
|
import ca.gosyer.data.models.Source
|
||||||
import ca.gosyer.ui.base.components.LoadingScreen
|
import ca.gosyer.ui.base.components.LoadingScreen
|
||||||
import ca.gosyer.ui.base.components.MangaGridItem
|
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.resources.stringResource
|
||||||
import ca.gosyer.ui.base.vm.viewModel
|
import ca.gosyer.ui.base.vm.viewModel
|
||||||
|
import ca.gosyer.ui.sources.components.filter.SourceFiltersMenu
|
||||||
import ca.gosyer.util.compose.persistentLazyListState
|
import ca.gosyer.util.compose.persistentLazyListState
|
||||||
import com.github.zsoltk.compose.savedinstancestate.Bundle
|
import com.github.zsoltk.compose.savedinstancestate.Bundle
|
||||||
import io.kamel.image.lazyPainterResource
|
import io.kamel.image.lazyPainterResource
|
||||||
@@ -38,8 +42,8 @@ fun SourceScreen(
|
|||||||
bundle: Bundle,
|
bundle: Bundle,
|
||||||
source: Source,
|
source: Source,
|
||||||
onMangaClick: (Long) -> Unit,
|
onMangaClick: (Long) -> Unit,
|
||||||
enableSearch: (Boolean) -> Unit,
|
onCloseSourceTabClick: (Source) -> Unit,
|
||||||
setSearch: ((String?) -> Unit) -> Unit
|
onSourceSettingsClick: (Long) -> Unit
|
||||||
) {
|
) {
|
||||||
val vm = viewModel<SourceScreenViewModel>(source.id) {
|
val vm = viewModel<SourceScreenViewModel>(source.id) {
|
||||||
SourceScreenViewModel.Params(source, bundle)
|
SourceScreenViewModel.Params(source, bundle)
|
||||||
@@ -48,29 +52,124 @@ fun SourceScreen(
|
|||||||
val hasNextPage by vm.hasNextPage.collectAsState()
|
val hasNextPage by vm.hasNextPage.collectAsState()
|
||||||
val loading by vm.loading.collectAsState()
|
val loading by vm.loading.collectAsState()
|
||||||
val isLatest by vm.isLatest.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) {
|
LaunchedEffect(vm to source) {
|
||||||
setSearch(vm::search)
|
vm.enableLatest(source.supportsLatest)
|
||||||
}
|
}
|
||||||
|
|
||||||
DisposableEffect(isLatest) {
|
Column {
|
||||||
enableSearch(!isLatest)
|
SourceToolbar(
|
||||||
|
source = source,
|
||||||
onDispose {
|
onCloseSourceTabClick = onCloseSourceTabClick,
|
||||||
enableSearch(false)
|
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(
|
@Composable
|
||||||
bundle,
|
fun SourceToolbar(
|
||||||
mangas,
|
source: Source,
|
||||||
loading,
|
onCloseSourceTabClick: (Source) -> Unit,
|
||||||
hasNextPage,
|
sourceSearchQuery: String?,
|
||||||
source.supportsLatest,
|
onSearch: (String) -> Unit,
|
||||||
isLatest,
|
onSubmitSearch: () -> Unit,
|
||||||
onLoadNextPage = vm::loadNextPage,
|
onSourceSettingsClick: (Long) -> Unit,
|
||||||
onMangaClick = onMangaClick,
|
showFilterButton: Boolean,
|
||||||
onClickMode = vm::setMode
|
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>,
|
mangas: List<Manga>,
|
||||||
isLoading: Boolean = false,
|
isLoading: Boolean = false,
|
||||||
hasNextPage: Boolean = false,
|
hasNextPage: Boolean = false,
|
||||||
supportsLatest: Boolean,
|
|
||||||
isLatest: Boolean,
|
|
||||||
onLoadNextPage: () -> Unit,
|
onLoadNextPage: () -> Unit,
|
||||||
onMangaClick: (Long) -> Unit,
|
onMangaClick: (Long) -> Unit,
|
||||||
onClickMode: (Boolean) -> Unit
|
|
||||||
) {
|
) {
|
||||||
if (isLoading || mangas.isEmpty()) {
|
if (isLoading || mangas.isEmpty()) {
|
||||||
LoadingScreen(isLoading)
|
LoadingScreen(isLoading)
|
||||||
} else {
|
} else {
|
||||||
Column {
|
val persistentState = persistentLazyListState(bundle)
|
||||||
Box(modifier = Modifier.fillMaxWidth()) {
|
LazyVerticalGrid(GridCells.Adaptive(160.dp), state = persistentState) {
|
||||||
if (supportsLatest) {
|
itemsIndexed(mangas) { index, manga ->
|
||||||
Button(
|
if (hasNextPage && index == mangas.lastIndex) {
|
||||||
onClick = { onClickMode(!isLatest) },
|
LaunchedEffect(Unit) { onLoadNextPage() }
|
||||||
enabled = !isLoading,
|
|
||||||
modifier = Modifier.align(Alignment.TopEnd)
|
|
||||||
) {
|
|
||||||
Text(text = stringResource(if (isLatest) "move_to_browse" else "move_to_latest"))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
MangaGridItem(
|
||||||
|
title = manga.title,
|
||||||
val persistentState = persistentLazyListState(bundle)
|
cover = lazyPainterResource(manga, filterQuality = FilterQuality.Medium),
|
||||||
LazyVerticalGrid(GridCells.Adaptive(160.dp), state = persistentState) {
|
onClick = {
|
||||||
itemsIndexed(mangas) { index, manga ->
|
onMangaClick(manga.id)
|
||||||
if (hasNextPage && index == mangas.lastIndex) {
|
|
||||||
LaunchedEffect(Unit) { onLoadNextPage() }
|
|
||||||
}
|
}
|
||||||
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)
|
private val _isLatest = saveBooleanInBundle(scope, bundle, IS_LATEST_KEY, false)
|
||||||
val isLatest = _isLatest.asStateFlow()
|
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 _query = saveStringInBundle(scope, bundle, QUERY_KEY) { null }
|
||||||
|
|
||||||
private val _pageNum = saveIntInBundle(scope, bundle, PAGE_NUM_KEY, 1)
|
private val _pageNum = saveIntInBundle(scope, bundle, PAGE_NUM_KEY, 1)
|
||||||
@@ -115,12 +129,12 @@ class SourceScreenViewModel(
|
|||||||
private suspend fun getPage(): MangaPage {
|
private suspend fun getPage(): MangaPage {
|
||||||
return when {
|
return when {
|
||||||
isLatest.value -> sourceHandler.getLatestManga(source, pageNum.value)
|
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)
|
else -> sourceHandler.getPopularManga(source, pageNum.value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun search(query: String?) {
|
fun startSearch(query: String?) {
|
||||||
cleanBundle(false)
|
cleanBundle(false)
|
||||||
_pageNum.value = 0
|
_pageNum.value = 0
|
||||||
_hasNextPage.value = true
|
_hasNextPage.value = true
|
||||||
@@ -130,6 +144,26 @@ class SourceScreenViewModel(
|
|||||||
loadNextPage()
|
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)
|
data class Params(val source: Source, val bundle: Bundle)
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
@@ -137,6 +171,8 @@ class SourceScreenViewModel(
|
|||||||
const val NEXT_PAGE_KEY = "next_page"
|
const val NEXT_PAGE_KEY = "next_page"
|
||||||
const val PAGE_NUM_KEY = "page_num"
|
const val PAGE_NUM_KEY = "page_num"
|
||||||
const val IS_LATEST_KEY = "is_latest"
|
const val IS_LATEST_KEY = "is_latest"
|
||||||
|
const val SHOW_FILTERS = "show_filters"
|
||||||
|
const val SHOW_LATEST = "show_latest"
|
||||||
const val QUERY_KEY = "query"
|
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 -->
|
<!-- Sources Menu -->
|
||||||
<string name="sources_home">Home</string>
|
<string name="sources_home">Home</string>
|
||||||
<string name="move_to_browse">To browse</string>
|
<string name="move_to_browse">Browse</string>
|
||||||
<string name="move_to_latest">To latest</string>
|
<string name="move_to_latest">Latest</string>
|
||||||
|
<string name="reset_filters">Reset</string>
|
||||||
|
<string name="filter_source">Filter</string>
|
||||||
|
|
||||||
<!-- Reader Menu -->
|
<!-- Reader Menu -->
|
||||||
<string name="no_pages_found">No pages found</string>
|
<string name="no_pages_found">No pages found</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user