521 lines
18 KiB
Kotlin
521 lines
18 KiB
Kotlin
package eu.kanade.presentation.browse
|
|
|
|
import androidx.compose.animation.AnimatedVisibility
|
|
import androidx.compose.animation.expandVertically
|
|
import androidx.compose.animation.shrinkVertically
|
|
import androidx.compose.foundation.clickable
|
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
import androidx.compose.foundation.layout.Column
|
|
import androidx.compose.foundation.layout.PaddingValues
|
|
import androidx.compose.foundation.layout.fillMaxWidth
|
|
import androidx.compose.foundation.layout.height
|
|
import androidx.compose.foundation.layout.padding
|
|
import androidx.compose.foundation.lazy.LazyListState
|
|
import androidx.compose.foundation.lazy.items
|
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
|
import androidx.compose.foundation.rememberScrollState
|
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
import androidx.compose.foundation.text.BasicTextField
|
|
import androidx.compose.foundation.text.KeyboardActions
|
|
import androidx.compose.foundation.text.KeyboardOptions
|
|
import androidx.compose.foundation.verticalScroll
|
|
import androidx.compose.material.icons.Icons
|
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
|
import androidx.compose.material.icons.filled.Close
|
|
import androidx.compose.material.icons.filled.PushPin
|
|
import androidx.compose.material.icons.filled.Search
|
|
import androidx.compose.material.icons.outlined.PushPin
|
|
import androidx.compose.material3.AlertDialog
|
|
import androidx.compose.material3.Icon
|
|
import androidx.compose.material3.IconButton
|
|
import androidx.compose.material3.LocalTextStyle
|
|
import androidx.compose.material3.MaterialTheme
|
|
import androidx.compose.material3.Text
|
|
import androidx.compose.material3.TextButton
|
|
import androidx.compose.material3.TextFieldDefaults
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.runtime.State
|
|
import androidx.compose.runtime.getValue
|
|
import androidx.compose.runtime.mutableStateListOf
|
|
import androidx.compose.runtime.mutableStateOf
|
|
import androidx.compose.runtime.produceState
|
|
import androidx.compose.runtime.remember
|
|
import androidx.compose.runtime.setValue
|
|
import androidx.compose.runtime.snapshotFlow
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.focus.FocusRequester
|
|
import androidx.compose.ui.focus.focusRequester
|
|
import androidx.compose.ui.focus.onFocusChanged
|
|
import androidx.compose.ui.graphics.Color
|
|
import androidx.compose.ui.graphics.SolidColor
|
|
import androidx.compose.ui.platform.LocalContext
|
|
import androidx.compose.ui.platform.LocalFocusManager
|
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
|
import androidx.compose.ui.text.input.ImeAction
|
|
import androidx.compose.ui.text.input.VisualTransformation
|
|
import androidx.compose.ui.text.style.TextOverflow
|
|
import androidx.compose.ui.unit.dp
|
|
import eu.kanade.presentation.browse.components.BaseSourceItem
|
|
import eu.kanade.tachiyomi.ui.browse.source.SourcesScreenModel
|
|
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceScreenModel.Listing
|
|
import eu.kanade.tachiyomi.util.system.LocaleHelper
|
|
import kotlinx.collections.immutable.ImmutableList
|
|
import tachiyomi.domain.source.model.Pin
|
|
import tachiyomi.domain.source.model.Source
|
|
import tachiyomi.i18n.MR
|
|
import tachiyomi.i18n.sy.SYMR
|
|
import tachiyomi.presentation.core.components.FastScrollLazyColumn
|
|
import tachiyomi.presentation.core.components.LabeledCheckbox
|
|
import tachiyomi.presentation.core.components.material.SecondaryItemAlpha
|
|
import tachiyomi.presentation.core.components.material.padding
|
|
import tachiyomi.presentation.core.components.material.topSmallPaddingValues
|
|
import tachiyomi.presentation.core.i18n.stringResource
|
|
import tachiyomi.presentation.core.screens.LoadingScreen
|
|
import tachiyomi.presentation.core.theme.header
|
|
import tachiyomi.presentation.core.util.clearFocusOnSoftKeyboardHide
|
|
import tachiyomi.presentation.core.util.plus
|
|
import tachiyomi.presentation.core.util.runOnEnterKeyPressed
|
|
import tachiyomi.presentation.core.util.secondaryItemAlpha
|
|
import tachiyomi.source.local.isLocal
|
|
|
|
@Composable
|
|
fun SourcesScreen(
|
|
state: SourcesScreenModel.State,
|
|
contentPadding: PaddingValues,
|
|
onClickItem: (Source, Listing) -> Unit,
|
|
onClickPin: (Source) -> Unit,
|
|
onLongClickItem: (Source) -> Unit,
|
|
// KMK -->
|
|
@Suppress("UNUSED_PARAMETER") modifier: Modifier = Modifier,
|
|
onChangeSearchQuery: (String?) -> Unit,
|
|
// KMK <--
|
|
) {
|
|
// KMK -->
|
|
val lazyListState = rememberLazyListState()
|
|
// KMK <--
|
|
|
|
when {
|
|
state.isLoading -> LoadingScreen(Modifier.padding(contentPadding))
|
|
// KMK -->
|
|
// Disable this since a query with empty result will cause empty screen and hide search box
|
|
// state.isEmpty -> EmptyScreen(
|
|
// MR.strings.source_empty_screen,
|
|
// modifier = Modifier.padding(contentPadding),
|
|
// )
|
|
else -> Column {
|
|
AnimatedFloatingSearchBox(
|
|
listState = lazyListState,
|
|
searchQuery = state.searchQuery,
|
|
onChangeSearchQuery = onChangeSearchQuery,
|
|
placeholderText = stringResource(MR.strings.action_source_search),
|
|
)
|
|
|
|
FastScrollLazyColumn(
|
|
state = lazyListState,
|
|
// KMK <--
|
|
contentPadding = contentPadding + topSmallPaddingValues,
|
|
) {
|
|
items(
|
|
items = state.items,
|
|
contentType = {
|
|
when (it) {
|
|
is SourceUiModel.Header -> "header"
|
|
is SourceUiModel.Item -> "item"
|
|
}
|
|
},
|
|
key = {
|
|
when (it) {
|
|
is SourceUiModel.Header -> it.hashCode()
|
|
is SourceUiModel.Item -> "source-${it.source.key()}"
|
|
}
|
|
},
|
|
) { model ->
|
|
when (model) {
|
|
is SourceUiModel.Header -> {
|
|
SourceHeader(
|
|
modifier = Modifier.animateItemPlacement(),
|
|
language = model.language,
|
|
// SY -->
|
|
isCategory = model.isCategory,
|
|
// SY <--
|
|
)
|
|
}
|
|
is SourceUiModel.Item -> SourceItem(
|
|
modifier = Modifier.animateItemPlacement(),
|
|
source = model.source,
|
|
// SY -->
|
|
showLatest = state.showLatest,
|
|
showPin = state.showPin,
|
|
// SY <--
|
|
onClickItem = onClickItem,
|
|
onLongClickItem = onLongClickItem,
|
|
onClickPin = onClickPin,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// KMK -->
|
|
@Composable
|
|
fun AnimatedFloatingSearchBox(
|
|
listState: LazyListState,
|
|
searchQuery: String?,
|
|
onChangeSearchQuery: (String?) -> Unit,
|
|
@Suppress("UNUSED_PARAMETER") modifier: Modifier = Modifier,
|
|
placeholderText: String? = null,
|
|
) {
|
|
AnimatedVisibility(
|
|
visible = listState.isScrollingUp().value,
|
|
enter = expandVertically(),
|
|
exit = shrinkVertically()
|
|
) {
|
|
SourcesSearch(
|
|
searchQuery = searchQuery,
|
|
onChangeSearchQuery = onChangeSearchQuery,
|
|
placeholderText = placeholderText,
|
|
)
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
fun LazyListState.isScrollingUp(): State<Boolean> {
|
|
return produceState(initialValue = true) {
|
|
var lastIndex = 0
|
|
var lastScroll = Int.MAX_VALUE
|
|
snapshotFlow {
|
|
firstVisibleItemIndex to firstVisibleItemScrollOffset
|
|
}.collect { (currentIndex, currentScroll) ->
|
|
if (currentIndex != lastIndex || currentScroll != lastScroll) {
|
|
value = currentIndex < lastIndex ||
|
|
(currentIndex == lastIndex && currentScroll < lastScroll)
|
|
lastIndex = currentIndex
|
|
lastScroll = currentScroll
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
fun SourcesSearch(
|
|
searchQuery: String?,
|
|
onChangeSearchQuery: (String?) -> Unit,
|
|
@Suppress("UNUSED_PARAMETER") modifier: Modifier = Modifier,
|
|
placeholderText: String? = null,
|
|
) {
|
|
val focusRequester = remember { FocusRequester() }
|
|
val keyboardController = LocalSoftwareKeyboardController.current
|
|
val focusManager = LocalFocusManager.current
|
|
|
|
val searchAndClearFocus: () -> Unit = f@{
|
|
if (searchQuery.isNullOrBlank()) return@f
|
|
focusManager.clearFocus()
|
|
keyboardController?.hide()
|
|
}
|
|
val onClickClearSearch: () -> Unit = {
|
|
onChangeSearchQuery(null)
|
|
focusRequester.requestFocus()
|
|
keyboardController?.show()
|
|
}
|
|
val onClickCloseSearch: () -> Unit = {
|
|
onClickClearSearch()
|
|
focusManager.clearFocus()
|
|
keyboardController?.hide()
|
|
}
|
|
|
|
var isFocused by remember { mutableStateOf(false) }
|
|
|
|
BasicTextField(
|
|
value = searchQuery ?: "",
|
|
onValueChange = onChangeSearchQuery,
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.height(66.dp)
|
|
.padding(12.dp)
|
|
.focusRequester(focusRequester)
|
|
.onFocusChanged { isFocused = it.isFocused }
|
|
.runOnEnterKeyPressed(action = searchAndClearFocus)
|
|
.clearFocusOnSoftKeyboardHide(),
|
|
enabled = true,
|
|
textStyle = MaterialTheme.typography.bodyMedium.copy(
|
|
color = MaterialTheme.colorScheme.onBackground,
|
|
),
|
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
|
keyboardActions = KeyboardActions(onSearch = { searchAndClearFocus() }),
|
|
singleLine = true,
|
|
cursorBrush = SolidColor(MaterialTheme.colorScheme.onBackground),
|
|
decorationBox = { innerTextField -> TextFieldDefaults.DecorationBox(
|
|
value = searchQuery ?: "",
|
|
innerTextField = innerTextField,
|
|
enabled = true,
|
|
singleLine = true,
|
|
visualTransformation = VisualTransformation.None,
|
|
interactionSource = remember { MutableInteractionSource() },
|
|
placeholder = { Text(
|
|
modifier = Modifier.secondaryItemAlpha(),
|
|
text = (placeholderText ?: stringResource(MR.strings.action_search_hint)),
|
|
maxLines = 1,
|
|
overflow = TextOverflow.Ellipsis,
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
) },
|
|
leadingIcon = { SearchBoxLeadingIcon(
|
|
isFocused || !searchQuery.isNullOrBlank(),
|
|
modifier = Modifier,
|
|
onClickCloseSearch,
|
|
) },
|
|
trailingIcon = { SearchBoxTrailingIcon(
|
|
searchQuery.isNullOrEmpty(),
|
|
modifier = Modifier,
|
|
onClickClearSearch,
|
|
) },
|
|
shape = RoundedCornerShape(24.dp),
|
|
colors = TextFieldDefaults.colors(
|
|
focusedIndicatorColor = Color.Transparent,
|
|
unfocusedIndicatorColor = Color.Transparent,
|
|
errorIndicatorColor = Color.Transparent,
|
|
cursorColor = MaterialTheme.colorScheme.onBackground,
|
|
),
|
|
contentPadding = PaddingValues(12.dp),
|
|
) }
|
|
)
|
|
}
|
|
|
|
@Composable
|
|
fun SearchBoxLeadingIcon(
|
|
isSearching: Boolean,
|
|
@Suppress("UNUSED_PARAMETER") modifier: Modifier = Modifier,
|
|
onClickCloseSearch: () -> Unit = {},
|
|
) {
|
|
if (isSearching)
|
|
IconButton(
|
|
onClick = onClickCloseSearch,
|
|
) {
|
|
Icon(
|
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
|
contentDescription = "Close",
|
|
)
|
|
}
|
|
else
|
|
Icon(
|
|
imageVector = Icons.Filled.Search,
|
|
contentDescription = "Search",
|
|
)
|
|
}
|
|
|
|
@Composable
|
|
fun SearchBoxTrailingIcon(
|
|
isEmpty: Boolean,
|
|
@Suppress("UNUSED_PARAMETER") modifier: Modifier = Modifier,
|
|
onClickClearSearch: () -> Unit = {},
|
|
) {
|
|
if (!isEmpty)
|
|
IconButton(
|
|
onClick = onClickClearSearch,
|
|
) {
|
|
Icon(
|
|
imageVector = Icons.Filled.Close,
|
|
contentDescription = "Clear",
|
|
)
|
|
}
|
|
}
|
|
// KMK <--
|
|
|
|
@Composable
|
|
private fun SourceHeader(
|
|
language: String,
|
|
// SY -->
|
|
isCategory: Boolean,
|
|
// SY <--
|
|
modifier: Modifier = Modifier,
|
|
) {
|
|
val context = LocalContext.current
|
|
Text(
|
|
// SY -->
|
|
text = if (!isCategory) {
|
|
LocaleHelper.getSourceDisplayName(language, context)
|
|
} else {
|
|
language
|
|
},
|
|
// SY <--
|
|
modifier = modifier
|
|
.padding(horizontal = MaterialTheme.padding.medium, vertical = MaterialTheme.padding.small),
|
|
style = MaterialTheme.typography.header,
|
|
)
|
|
}
|
|
|
|
@Composable
|
|
private fun SourceItem(
|
|
source: Source,
|
|
// SY -->
|
|
showLatest: Boolean,
|
|
showPin: Boolean,
|
|
// SY <--
|
|
onClickItem: (Source, Listing) -> Unit,
|
|
onLongClickItem: (Source) -> Unit,
|
|
onClickPin: (Source) -> Unit,
|
|
modifier: Modifier = Modifier,
|
|
) {
|
|
BaseSourceItem(
|
|
modifier = modifier,
|
|
source = source,
|
|
onClickItem = { onClickItem(source, Listing.Popular) },
|
|
onLongClickItem = { onLongClickItem(source) },
|
|
action = {
|
|
if (source.supportsLatest /* SY --> */ && showLatest /* SY <-- */) {
|
|
TextButton(onClick = { onClickItem(source, Listing.Latest) }) {
|
|
Text(
|
|
text = stringResource(MR.strings.latest),
|
|
style = LocalTextStyle.current.copy(
|
|
color = MaterialTheme.colorScheme.primary,
|
|
),
|
|
)
|
|
}
|
|
}
|
|
// SY -->
|
|
if (showPin) {
|
|
SourcePinButton(
|
|
isPinned = Pin.Pinned in source.pin,
|
|
onClick = { onClickPin(source) },
|
|
)
|
|
}
|
|
// SY <--
|
|
},
|
|
)
|
|
}
|
|
|
|
@Composable
|
|
private fun SourcePinButton(
|
|
isPinned: Boolean,
|
|
onClick: () -> Unit,
|
|
) {
|
|
val icon = if (isPinned) Icons.Filled.PushPin else Icons.Outlined.PushPin
|
|
val tint = if (isPinned) {
|
|
MaterialTheme.colorScheme.primary
|
|
} else {
|
|
MaterialTheme.colorScheme.onBackground.copy(
|
|
alpha = SecondaryItemAlpha,
|
|
)
|
|
}
|
|
val description = if (isPinned) MR.strings.action_unpin else MR.strings.action_pin
|
|
IconButton(onClick = onClick) {
|
|
Icon(
|
|
imageVector = icon,
|
|
tint = tint,
|
|
contentDescription = stringResource(description),
|
|
)
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
fun SourceOptionsDialog(
|
|
source: Source,
|
|
onClickPin: () -> Unit,
|
|
onClickDisable: () -> Unit,
|
|
// SY -->
|
|
onClickSetCategories: (() -> Unit)?,
|
|
onClickToggleDataSaver: (() -> Unit)?,
|
|
// SY <--
|
|
onDismiss: () -> Unit,
|
|
) {
|
|
AlertDialog(
|
|
title = {
|
|
Text(text = source.visualName)
|
|
},
|
|
text = {
|
|
Column {
|
|
val textId = if (Pin.Pinned in source.pin) MR.strings.action_unpin else MR.strings.action_pin
|
|
Text(
|
|
text = stringResource(textId),
|
|
modifier = Modifier
|
|
.clickable(onClick = onClickPin)
|
|
.fillMaxWidth()
|
|
.padding(vertical = 16.dp),
|
|
)
|
|
if (!source.isLocal()) {
|
|
Text(
|
|
text = stringResource(MR.strings.action_disable),
|
|
modifier = Modifier
|
|
.clickable(onClick = onClickDisable)
|
|
.fillMaxWidth()
|
|
.padding(vertical = 16.dp),
|
|
)
|
|
}
|
|
// SY -->
|
|
if (onClickSetCategories != null) {
|
|
Text(
|
|
text = stringResource(MR.strings.categories),
|
|
modifier = Modifier
|
|
.clickable(onClick = onClickSetCategories)
|
|
.fillMaxWidth()
|
|
.padding(vertical = 16.dp),
|
|
)
|
|
}
|
|
if (onClickToggleDataSaver != null) {
|
|
Text(
|
|
text = if (source.isExcludedFromDataSaver) {
|
|
stringResource(SYMR.strings.data_saver_stop_exclude)
|
|
} else {
|
|
stringResource(SYMR.strings.data_saver_exclude)
|
|
},
|
|
modifier = Modifier
|
|
.clickable(onClick = onClickToggleDataSaver)
|
|
.fillMaxWidth()
|
|
.padding(vertical = 16.dp),
|
|
)
|
|
}
|
|
// SY <--
|
|
}
|
|
},
|
|
onDismissRequest = onDismiss,
|
|
confirmButton = {},
|
|
)
|
|
}
|
|
|
|
sealed interface SourceUiModel {
|
|
data class Item(val source: Source) : SourceUiModel
|
|
data class Header(val language: String, val isCategory: Boolean) : SourceUiModel
|
|
}
|
|
|
|
// SY -->
|
|
@Composable
|
|
fun SourceCategoriesDialog(
|
|
source: Source,
|
|
categories: ImmutableList<String>,
|
|
onClickCategories: (List<String>) -> Unit,
|
|
onDismissRequest: () -> Unit,
|
|
) {
|
|
val newCategories = remember(source) {
|
|
mutableStateListOf<String>().also { it += source.categories }
|
|
}
|
|
AlertDialog(
|
|
title = {
|
|
Text(text = source.visualName)
|
|
},
|
|
text = {
|
|
Column(Modifier.verticalScroll(rememberScrollState())) {
|
|
categories.forEach { category ->
|
|
LabeledCheckbox(
|
|
label = category,
|
|
checked = category in newCategories,
|
|
onCheckedChange = {
|
|
if (it) {
|
|
newCategories += category
|
|
} else {
|
|
newCategories -= category
|
|
}
|
|
},
|
|
)
|
|
}
|
|
}
|
|
},
|
|
onDismissRequest = onDismissRequest,
|
|
confirmButton = {
|
|
TextButton(onClick = { onClickCategories(newCategories.toList()) }) {
|
|
Text(text = stringResource(MR.strings.action_ok))
|
|
}
|
|
},
|
|
)
|
|
}
|
|
// SY <--
|