komikku/app/src/main/java/eu/kanade/presentation/browse/SourcesScreen.kt

522 lines
18 KiB
Kotlin
Raw Normal View History

package eu.kanade.presentation.browse
2024-01-29 17:14:38 +01:00
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.clickable
2024-01-29 17:14:38 +01:00
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
2024-01-29 17:14:38 +01:00
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
2024-01-29 17:14:38 +01:00
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
2024-01-29 17:14:38 +01:00
import androidx.compose.foundation.lazy.rememberLazyListState
2022-12-04 22:34:24 +01:00
import androidx.compose.foundation.rememberScrollState
2024-01-29 17:14:38 +01:00
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
2022-12-04 22:34:24 +01:00
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
2024-01-29 17:14:38 +01:00
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.PushPin
2024-01-29 17:14:38 +01:00
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
2024-01-29 17:14:38 +01:00
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
2024-01-29 17:14:38 +01:00
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
2024-01-29 17:14:38 +01:00
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
2024-01-29 17:14:38 +01:00
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
2024-01-29 17:14:38 +01:00
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
2024-01-29 17:14:38 +01:00
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
2024-01-28 17:11:39 +01:00
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
2023-12-24 04:40:54 +01:00
import kotlinx.collections.immutable.ImmutableList
import tachiyomi.domain.source.model.Pin
import tachiyomi.domain.source.model.Source
2023-12-24 04:40:54 +01:00
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
2023-12-24 04:40:54 +01:00
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.screens.LoadingScreen
import tachiyomi.presentation.core.theme.header
2024-01-29 17:14:38 +01:00
import tachiyomi.presentation.core.util.clearFocusOnSoftKeyboardHide
import tachiyomi.presentation.core.util.plus
2024-01-29 17:14:38 +01:00
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,
2024-01-29 17:14:38 +01:00
// KMK -->
@Suppress("UNUSED_PARAMETER") modifier: Modifier = Modifier,
onChangeSearchQuery: (String?) -> Unit,
// KMK <--
) {
2024-01-29 17:14:38 +01:00
// KMK -->
val lazyListState = rememberLazyListState()
// KMK <--
when {
state.isLoading -> LoadingScreen(Modifier.padding(contentPadding))
2024-01-29 17:14:38 +01:00
// 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(
2024-01-29 17:14:38 +01:00
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,
)
}
}
}
}
}
}
2024-01-29 17:14:38 +01:00
// 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)
2022-09-12 01:43:45 +02:00
} 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(
2023-12-24 04:40:54 +01:00
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,
)
}
2023-12-24 04:40:54 +01:00
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 -->
2022-12-04 22:34:24 +01:00
onClickSetCategories: (() -> Unit)?,
onClickToggleDataSaver: (() -> Unit)?,
// SY <--
onDismiss: () -> Unit,
) {
AlertDialog(
title = {
Text(text = source.visualName)
},
text = {
Column {
2023-12-24 04:40:54 +01:00
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(
2023-12-24 04:40:54 +01:00
text = stringResource(MR.strings.action_disable),
modifier = Modifier
.clickable(onClick = onClickDisable)
.fillMaxWidth()
.padding(vertical = 16.dp),
)
}
// SY -->
2022-12-04 22:34:24 +01:00
if (onClickSetCategories != null) {
Text(
2023-12-24 04:40:54 +01:00
text = stringResource(MR.strings.categories),
2022-12-04 22:34:24 +01:00
modifier = Modifier
.clickable(onClick = onClickSetCategories)
.fillMaxWidth()
.padding(vertical = 16.dp),
)
}
if (onClickToggleDataSaver != null) {
Text(
text = if (source.isExcludedFromDataSaver) {
2023-12-24 04:40:54 +01:00
stringResource(SYMR.strings.data_saver_stop_exclude)
2022-12-04 22:34:24 +01:00
} else {
2023-12-24 04:40:54 +01:00
stringResource(SYMR.strings.data_saver_exclude)
2022-12-04 22:34:24 +01:00
},
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(
2022-12-04 22:34:24 +01:00
source: Source,
2023-12-24 04:40:54 +01:00
categories: ImmutableList<String>,
onClickCategories: (List<String>) -> Unit,
2022-12-04 22:34:24 +01:00
onDismissRequest: () -> Unit,
) {
val newCategories = remember(source) {
mutableStateListOf<String>().also { it += source.categories }
}
AlertDialog(
title = {
2022-07-31 00:29:17 +02:00
Text(text = source.visualName)
},
text = {
2022-12-04 22:34:24 +01:00
Column(Modifier.verticalScroll(rememberScrollState())) {
categories.forEach { category ->
LabeledCheckbox(
label = category,
checked = category in newCategories,
onCheckedChange = {
if (it) {
newCategories += category
} else {
newCategories -= category
}
},
)
}
}
},
2022-12-04 22:34:24 +01:00
onDismissRequest = onDismissRequest,
confirmButton = {
TextButton(onClick = { onClickCategories(newCategories.toList()) }) {
2023-12-24 04:40:54 +01:00
Text(text = stringResource(MR.strings.action_ok))
}
},
)
}
// SY <--