From 4e08f4f9626a73379f69a80d321ab1892073df3a Mon Sep 17 00:00:00 2001 From: nullArrayList <99822064+nullArrayList@users.noreply.github.com> Date: Sat, 11 Nov 2023 21:03:11 +0800 Subject: [PATCH] Compose Searchbar (#237) * Migrate to Compose SearchBar * Fix searchbar padding * Fix content padding * Implement scroll away behaviour * Reset searchBarOffsetY when expand * Fix multiple listener * Fix cursor position * Fix spotless --------- Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com> Co-authored-by: Lokesh Gidra --- .../androidx/compose/material3/SearchBar2.kt | 379 ++++++++++++ .../hippo/ehviewer/ui/scene/DownloadsScene.kt | 165 +++--- .../hippo/ehviewer/ui/scene/FavoritesScene.kt | 34 +- .../ehviewer/ui/scene/GalleryListScene.kt | 92 ++- .../hippo/ehviewer/ui/scene/SceneCompose.kt | 2 + .../hippo/ehviewer/ui/scene/SearchBarScene.kt | 557 ++++++++++-------- .../hippo/ehviewer/ui/scene/ToolBarScene.kt | 23 - .../com/hippo/ehviewer/ui/tools/Dialog.kt | 20 + .../main/res/drawable/ic_baseline_menu_24.xml | 28 - .../res/drawable/ic_outline_bookmarks_24.xml | 11 - .../drawable/ic_outline_folder_special_24.xml | 11 - .../main/res/drawable/ic_outline_label_24.xml | 12 - app/src/main/res/drawable/v_magnify_x24.xml | 27 - app/src/main/res/layout/scene_download.xml | 2 - .../main/res/layout/scene_gallery_list.xml | 21 +- app/src/main/res/layout/scene_searchbar.xml | 50 -- .../main/res/layout/widget_content_layout.xml | 2 - app/src/main/res/menu/scene_download.xml | 50 -- app/src/main/res/menu/scene_favorites.xml | 11 - .../scene_gallery_list_searchbar_menu.xml | 17 - app/src/main/res/values/dimens.xml | 2 - app/src/main/res/values/pathdata.xml | 1 - 22 files changed, 856 insertions(+), 661 deletions(-) create mode 100644 app/src/main/kotlin/androidx/compose/material3/SearchBar2.kt delete mode 100644 app/src/main/kotlin/com/hippo/ehviewer/ui/scene/ToolBarScene.kt delete mode 100644 app/src/main/res/drawable/ic_baseline_menu_24.xml delete mode 100644 app/src/main/res/drawable/ic_outline_bookmarks_24.xml delete mode 100644 app/src/main/res/drawable/ic_outline_folder_special_24.xml delete mode 100644 app/src/main/res/drawable/ic_outline_label_24.xml delete mode 100644 app/src/main/res/drawable/v_magnify_x24.xml delete mode 100644 app/src/main/res/layout/scene_searchbar.xml delete mode 100644 app/src/main/res/menu/scene_download.xml delete mode 100644 app/src/main/res/menu/scene_favorites.xml delete mode 100644 app/src/main/res/menu/scene_gallery_list_searchbar_menu.xml diff --git a/app/src/main/kotlin/androidx/compose/material3/SearchBar2.kt b/app/src/main/kotlin/androidx/compose/material3/SearchBar2.kt new file mode 100644 index 000000000..806a59c2c --- /dev/null +++ b/app/src/main/kotlin/androidx/compose/material3/SearchBar2.kt @@ -0,0 +1,379 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:Suppress("ktlint:standard:property-naming") + +package androidx.compose.material3 + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.interaction.Interaction +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.MutableWindowInsets +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.exclude +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.onConsumedWindowInsetsChanged +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.GenericShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text2.BasicTextField2 +import androidx.compose.foundation.text2.input.TextFieldLineLimits +import androidx.compose.foundation.text2.input.TextFieldState +import androidx.compose.material3.SearchBarDefaults.InputFieldHeight +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.structuralEqualityPolicy +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.geometry.CornerRadius +import androidx.compose.ui.geometry.RoundRect +import androidx.compose.ui.geometry.toRect +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.takeOrElse +import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.lerp +import androidx.compose.ui.unit.offset +import androidx.compose.ui.util.lerp +import androidx.compose.ui.zIndex +import kotlin.math.max +import kotlin.math.min +import kotlinx.coroutines.delay + +/** + * Material Design search. + * + * A search bar represents a floating search field that allows users to enter a keyword or phrase + * and get relevant information. It can be used as a way to navigate through an app via search + * queries. + * + * An active search bar expands into a search "view" and can be used to display dynamic suggestions. + * + * ![Search bar image](https://developer.android.com/images/reference/androidx/compose/material3/search-bar.png) + * + * A [SearchBar] expands to occupy the entirety of its allowed size when active. For full-screen + * behavior as specified by Material guidelines, parent layouts of the [SearchBar] must not pass + * any [Constraints] that limit its size, and the host activity should set + * `WindowCompat.setDecorFitsSystemWindows(window, false)`. + * + * If this expansion behavior is undesirable, for example on large tablet screens, [DockedSearchBar] + * can be used instead. + * + * @param state the query text to be shown in the search bar's input field + * @param onSearch the callback to be invoked when the input service triggers the [ImeAction.Search] + * action. + * @param active whether this search bar is active + * @param onActiveChange the callback to be invoked when this search bar's active state is changed + * @param modifier the [Modifier] to be applied to this search bar + * @param enabled controls the enabled state of this search bar. When `false`, this component will + * not respond to user input, and it will appear visually disabled and disabled to accessibility + * services. + * @param placeholder the placeholder to be displayed when the search bar's query is empty. + * @param leadingIcon the leading icon to be displayed at the beginning of the search bar container + * @param trailingIcon the trailing icon to be displayed at the end of the search bar container + * @param shape the shape of this search bar when it is not [active]. When [active], the shape will + * always be [SearchBarDefaults.fullScreenShape]. + * @param colors [SearchBarColors] that will be used to resolve the colors used for this search bar + * in different states. See [SearchBarDefaults.colors]. + * @param tonalElevation when [SearchBarColors.containerColor] is [ColorScheme.surface], a + * translucent primary color overlay is applied on top of the container. A higher tonal elevation + * value will result in a darker color in light theme and lighter color in dark theme. See also: + * [Surface]. + * @param shadowElevation the elevation for the shadow below the search bar + * @param windowInsets the window insets that the search bar will respect + * @param interactionSource the [MutableInteractionSource] representing the stream of [Interaction]s + * for this search bar. You can create and pass in your own `remember`ed instance to observe + * [Interaction]s and customize the appearance / behavior of this search bar in different states. + * @param content the content of this search bar that will be displayed below the input field + */ +@ExperimentalMaterial3Api +@Composable +fun SearchBar( + state: TextFieldState, + onSearch: () -> Unit, + active: Boolean, + onActiveChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + placeholder: @Composable (() -> Unit)? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + shape: Shape = SearchBarDefaults.inputFieldShape, + colors: SearchBarColors = SearchBarDefaults.colors(), + tonalElevation: Dp = SearchBarDefaults.TonalElevation, + shadowElevation: Dp = SearchBarDefaults.ShadowElevation, + windowInsets: WindowInsets = SearchBarDefaults.windowInsets, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + content: @Composable ColumnScope.() -> Unit, +) { + val animationProgress: State = animateFloatAsState( + targetValue = if (active) 1f else 0f, + animationSpec = if (active) AnimationEnterFloatSpec else AnimationExitFloatSpec, + label = "SearchBarAnimation", + ) + + val focusManager = LocalFocusManager.current + val density = LocalDensity.current + + val defaultInputFieldShape = SearchBarDefaults.inputFieldShape + val defaultFullScreenShape = SearchBarDefaults.fullScreenShape + val useFullScreenShape by remember { + derivedStateOf(structuralEqualityPolicy()) { animationProgress.value == 1f } + } + val animatedShape = remember(useFullScreenShape, shape) { + when { + shape == defaultInputFieldShape -> + // The shape can only be animated if it's the default spec value + GenericShape { size, _ -> + val radius = with(density) { + (SearchBarCornerRadius * (1 - animationProgress.value)).toPx() + } + addRoundRect(RoundRect(size.toRect(), CornerRadius(radius))) + } + useFullScreenShape -> defaultFullScreenShape + else -> shape + } + } + + // The main animation complexity is allowing the component to smoothly expand while keeping the + // input field at the same relative location on screen. `Modifier.windowInsetsPadding` does not + // support animation and thus is not suitable. Instead, we convert the insets to a padding + // applied to the Surface, which gradually becomes padding applied to the input field as the + // animation proceeds. + val unconsumedInsets = remember { MutableWindowInsets() } + val topPadding = remember(density) { + derivedStateOf { + SearchBarVerticalPadding + + unconsumedInsets.asPaddingValues(density).calculateTopPadding() + } + } + + Surface( + shape = animatedShape, + color = colors.containerColor, + contentColor = contentColorFor(colors.containerColor), + tonalElevation = tonalElevation, + shadowElevation = shadowElevation, + modifier = modifier + .zIndex(1f) + .onConsumedWindowInsetsChanged { consumedInsets -> + unconsumedInsets.insets = windowInsets.exclude(consumedInsets) + } + .consumeWindowInsets(unconsumedInsets) + .layout { measurable, constraints -> + val animatedTopPadding = + lerp(topPadding.value, 0.dp, animationProgress.value).roundToPx() + + val startWidth = max(constraints.minWidth, SearchBarMinWidth.roundToPx()) + .coerceAtMost(min(constraints.maxWidth, SearchBarMaxWidth.roundToPx())) + val startHeight = max(constraints.minHeight, InputFieldHeight.roundToPx()) + .coerceAtMost(constraints.maxHeight) + val endWidth = constraints.maxWidth + val endHeight = constraints.maxHeight + + val width = lerp(startWidth, endWidth, animationProgress.value) + val height = + lerp(startHeight, endHeight, animationProgress.value) + animatedTopPadding + + val placeable = measurable.measure( + Constraints.fixed(width, height).offset(vertical = -animatedTopPadding), + ) + layout(width, height) { + placeable.placeRelative(0, animatedTopPadding) + } + }, + ) { + Column { + val animatedInputFieldPadding = remember { + AnimatedPaddingValues2(animationProgress, topPadding) + } + SearchBarInputField( + state = state, + onSearch = onSearch, + onActiveChange = onActiveChange, + modifier = Modifier.padding(paddingValues = animatedInputFieldPadding), + enabled = enabled, + placeholder = placeholder, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + colors = colors.inputFieldColors, + interactionSource = interactionSource, + ) + + val showResults by remember { + derivedStateOf(structuralEqualityPolicy()) { animationProgress.value > 0 } + } + if (showResults) { + Column(Modifier.graphicsLayer { alpha = animationProgress.value }) { + HorizontalDivider(color = colors.dividerColor) + content() + } + } + } + } + + val isFocused = interactionSource.collectIsFocusedAsState().value + val shouldClearFocus = !active && isFocused + LaunchedEffect(active) { + if (shouldClearFocus) { + // Not strictly needed according to the motion spec, but since the animation already has + // a delay, this works around b/261632544. + delay(AnimationDelayMillis.toLong()) + focusManager.clearFocus() + } + } + + BackHandler(enabled = active) { + onActiveChange(false) + } +} + +@Composable +private fun SearchBarInputField( + state: TextFieldState, + onSearch: () -> Unit, + onActiveChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + placeholder: @Composable (() -> Unit)? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + colors: TextFieldColors = SearchBarDefaults.inputFieldColors(), + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + val focusRequester = remember { FocusRequester() } + val focused by interactionSource.collectIsFocusedAsState() + val textColor = LocalTextStyle.current.color.takeOrElse { + if (focused) colors.focusedTextColor else colors.unfocusedTextColor + } + + BasicTextField2( + state = state, + modifier = modifier + .height(InputFieldHeight) + .fillMaxWidth() + .focusRequester(focusRequester) + .onFocusChanged { if (it.isFocused) onActiveChange(true) } + .semantics { + onClick { + focusRequester.requestFocus() + true + } + }, + enabled = enabled, + lineLimits = TextFieldLineLimits.SingleLine, + textStyle = LocalTextStyle.current.merge(TextStyle(color = textColor)), + cursorBrush = SolidColor(colors.cursorColor), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { onSearch() }), + interactionSource = interactionSource, + decorationBox = @Composable { innerTextField -> + TextFieldDefaults.DecorationBox( + value = state.text.toString(), + innerTextField = innerTextField, + enabled = enabled, + singleLine = true, + visualTransformation = VisualTransformation.None, + interactionSource = interactionSource, + placeholder = placeholder, + leadingIcon = leadingIcon?.let { leading -> + { + Box(Modifier.offset(x = SearchBarIconOffsetX)) { leading() } + } + }, + trailingIcon = trailingIcon?.let { trailing -> + { + Box(Modifier.offset(x = -SearchBarIconOffsetX)) { trailing() } + } + }, + shape = SearchBarDefaults.inputFieldShape, + colors = colors, + contentPadding = TextFieldDefaults.contentPaddingWithoutLabel(), + container = {}, + ) + }, + ) +} + +@Stable +private class AnimatedPaddingValues2( + val animationProgress: State, + val topPadding: State, +) : PaddingValues { + override fun calculateTopPadding(): Dp = topPadding.value * animationProgress.value + override fun calculateBottomPadding(): Dp = SearchBarVerticalPadding * animationProgress.value + + override fun calculateLeftPadding(layoutDirection: LayoutDirection): Dp = 0.dp + override fun calculateRightPadding(layoutDirection: LayoutDirection): Dp = 0.dp +} + +// Measurement specs +private val SearchBarCornerRadius: Dp = InputFieldHeight / 2 +internal val SearchBarMinWidth: Dp = 360.dp +private val SearchBarMaxWidth: Dp = 720.dp +internal val SearchBarVerticalPadding: Dp = 8.dp + +// Search bar has 16dp padding between icons and start/end, while by default text field has 12dp. +private val SearchBarIconOffsetX: Dp = 4.dp + +// Animation specs +private const val AnimationEnterDurationMillis: Int = 600 +private const val AnimationExitDurationMillis: Int = 350 +private const val AnimationDelayMillis: Int = 100 +private val AnimationEnterEasing = CubicBezierEasing(0.05f, 0.7f, 0.1f, 1.0f) +private val AnimationExitEasing = CubicBezierEasing(0.0f, 1.0f, 0.0f, 1.0f) +private val AnimationEnterFloatSpec: FiniteAnimationSpec = tween( + durationMillis = AnimationEnterDurationMillis, + delayMillis = AnimationDelayMillis, + easing = AnimationEnterEasing, +) +private val AnimationExitFloatSpec: FiniteAnimationSpec = tween( + durationMillis = AnimationExitDurationMillis, + delayMillis = AnimationDelayMillis, + easing = AnimationExitEasing, +) diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/DownloadsScene.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/DownloadsScene.kt index 2877f83aa..fa3be9d9a 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/DownloadsScene.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/DownloadsScene.kt @@ -34,7 +34,14 @@ import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.Label +import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material3.Card +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ShapeDefaults import androidx.compose.material3.Text @@ -48,10 +55,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringArrayResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEachReversed import androidx.core.content.ContextCompat import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.DiffUtil @@ -60,6 +69,7 @@ import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.StaggeredGridLayoutManager +import androidx.viewbinding.ViewBinding import arrow.core.partially1 import coil.compose.AsyncImage import coil.compose.AsyncImagePainter @@ -105,8 +115,8 @@ import com.hippo.ehviewer.util.containsIgnoreCase import com.hippo.ehviewer.util.sendTo import com.hippo.unifile.UniFile import eu.kanade.tachiyomi.util.lang.launchIO -import eu.kanade.tachiyomi.util.lang.launchNonCancellable import eu.kanade.tachiyomi.util.lang.launchUI +import eu.kanade.tachiyomi.util.lang.withNonCancellableContext import eu.kanade.tachiyomi.util.lang.withUIContext import eu.kanade.tachiyomi.util.system.pxToDp import kotlinx.coroutines.Dispatchers @@ -144,7 +154,6 @@ class DownloadsScene : override val fabLayout get() = binding.fabLayout override val fastScroller get() = binding.fastScroller override val recyclerView get() = binding.recyclerView - override val contentView get() = binding.content private fun initLabels() { context ?: return @@ -247,13 +256,11 @@ class DownloadsScene : override fun onCreateViewWithToolbar( inflater: LayoutInflater, - container: ViewGroup?, + container: ViewGroup, savedInstanceState: Bundle?, - ): View { - _binding = SceneDownloadBinding.inflate(inflater, container!!) - container.addView(ComposeView(inflater.context).apply { setMD3Content { dialogState.Intercept() } }) + ): ViewBinding { + _binding = SceneDownloadBinding.inflate(inflater, container) binding.run { - setLiftOnScrollTargetView(recyclerView) mViewTransition = ViewTransition(content, tip) val drawable = ContextCompat.getDrawable(requireContext(), R.drawable.big_download) drawable!!.setBounds(0, 0, drawable.intrinsicWidth, drawable.intrinsicHeight) @@ -357,7 +364,7 @@ class DownloadsScene : mKeyword = it.takeUnless { it.isEmpty() } updateInfoList() } - return binding.root + return binding } override fun onSearchViewExpanded() { @@ -375,78 +382,84 @@ class DownloadsScene : _binding = null } - override fun onNavigationClick() { - openDrawer() - } - - override fun getMenuResId(): Int { - return R.menu.scene_download - } - - override fun onMenuItemClick(item: MenuItem): Boolean { - // Skip when in choice mode - val activity: Activity? = mainActivity - if (null == activity || tracker.isInCustomChoice) { - return false + @Composable + override fun TrailingIcon() { + dialogState.Intercept() + var expanded by remember { mutableStateOf(false) } + IconButton(onClick = { openSideSheet() }) { + Icon(imageVector = Icons.AutoMirrored.Outlined.Label, contentDescription = stringResource(id = R.string.download_labels)) } - when (item.itemId) { - R.id.action_filter -> { - BaseDialogBuilder(requireActivity()) - .setSingleChoiceItems( - R.array.download_state, - mType + 1, - ) { dialog: DialogInterface, which: Int -> - mType = which - 1 - updateInfoList() - dialog.dismiss() + IconButton(onClick = { expanded = !expanded }) { + Icon(imageVector = Icons.Default.MoreVert, contentDescription = null) + } + val states = stringArrayResource(id = R.array.download_state) + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + DropdownMenuItem( + text = { Text(text = stringResource(id = R.string.download_filter)) }, + onClick = { + expanded = false + lifecycleScope.launch { + val type = dialogState.showSingleChoice(states, mType) + if (type != mType) { + updateInfoList() + } } - .show() - return true - } - - R.id.action_start_all -> { - val intent = Intent(activity, DownloadService::class.java) - intent.action = DownloadService.ACTION_START_ALL - ContextCompat.startForegroundService(activity, intent) - return true - } - - R.id.action_stop_all -> { - lifecycleScope.launchIO { - DownloadManager.stopAllDownload() - } - return true - } - - R.id.action_reset_reading_progress -> { - BaseDialogBuilder(requireContext()) - .setMessage(R.string.reset_reading_progress_message) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> - lifecycleScope.launchNonCancellable { + }, + ) + DropdownMenuItem( + text = { Text(text = stringResource(id = R.string.download_start_all)) }, + onClick = { + expanded = false + val activity = requireActivity() + val intent = Intent(activity, DownloadService::class.java) + intent.action = DownloadService.ACTION_START_ALL + ContextCompat.startForegroundService(activity, intent) + }, + ) + DropdownMenuItem( + text = { Text(text = stringResource(id = R.string.download_stop_all)) }, + onClick = { + expanded = false + lifecycleScope.launchIO { + DownloadManager.stopAllDownload() + } + }, + ) + DropdownMenuItem( + text = { Text(text = stringResource(id = R.string.download_reset_reading_progress)) }, + onClick = { + expanded = false + lifecycleScope.launchIO { + dialogState.awaitPermissionOrCancel( + confirmText = android.R.string.ok, + dismissText = android.R.string.cancel, + ) { + Text(text = stringResource(id = R.string.reset_reading_progress_message)) + } + withNonCancellableContext { DownloadManager.resetAllReadingProgress() } - }.show() - return true - } - - R.id.action_start_all_reversed -> { - val list = mList ?: return true - val gidList = LongList() - for (i in list.size - 1 downTo 0) { - val info = list[i] - if (info.state != DownloadInfo.STATE_FINISH) { - gidList.add(info.gid) } - } - val intent = Intent(activity, DownloadService::class.java) - intent.action = DownloadService.ACTION_START_RANGE - intent.putExtra(DownloadService.KEY_GID_LIST, gidList) - ContextCompat.startForegroundService(activity, intent) - return true - } - - else -> return super.onMenuItemClick(item) + }, + ) + DropdownMenuItem( + text = { Text(text = stringResource(id = R.string.download_start_all_reversed)) }, + onClick = { + expanded = false + val list = mList ?: return@DropdownMenuItem + val activity = requireActivity() + val gidList = LongList() + list.fastForEachReversed { info -> + if (info.state != DownloadInfo.STATE_FINISH) { + gidList.add(info.gid) + } + } + val intent = Intent(activity, DownloadService::class.java) + intent.action = DownloadService.ACTION_START_RANGE + intent.putExtra(DownloadService.KEY_GID_LIST, gidList) + ContextCompat.startForegroundService(activity, intent) + }, + ) } } diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/FavoritesScene.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/FavoritesScene.kt index 25b5f8008..498247dae 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/FavoritesScene.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/FavoritesScene.kt @@ -18,16 +18,20 @@ package com.hippo.ehviewer.ui.scene import android.content.DialogInterface import android.os.Bundle import android.view.LayoutInflater -import android.view.View import android.view.ViewGroup import androidx.compose.foundation.clickable import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.FolderSpecial import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.currentRecomposeScope @@ -50,6 +54,7 @@ import androidx.paging.PagingState import androidx.paging.cachedIn import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.RecyclerView +import androidx.viewbinding.ViewBinding import com.google.android.material.datepicker.CalendarConstraints import com.google.android.material.datepicker.CalendarConstraints.DateValidator import com.google.android.material.datepicker.CompositeDateValidator @@ -73,7 +78,6 @@ import com.hippo.ehviewer.ui.legacy.AddDeleteDrawable import com.hippo.ehviewer.ui.legacy.BaseDialogBuilder import com.hippo.ehviewer.ui.legacy.FabLayout import com.hippo.ehviewer.ui.legacy.FabLayout.OnClickFabListener -import com.hippo.ehviewer.ui.legacy.FastScroller.OnDragHandlerListener import com.hippo.ehviewer.ui.legacy.HandlerDrawable import com.hippo.ehviewer.ui.legacy.ViewTransition import com.hippo.ehviewer.ui.legacy.WindowInsetsAnimationHelper @@ -160,7 +164,6 @@ class FavoritesScene : SearchBarScene() { override val fabLayout get() = binding.fabLayout override val fastScroller get() = binding.fastScroller override val recyclerView get() = binding.recyclerView - override val contentView get() = binding.contentLayout.contentView private fun onItemClick(position: Int) { // Skip if in search mode @@ -201,7 +204,13 @@ class FavoritesScene : SearchBarScene() { Settings.recentFavCat = urlBuilder.favCat } - override fun getMenuResId() = R.menu.scene_favorites + @Composable + override fun TrailingIcon() { + dialogState.Intercept() + IconButton(onClick = { openSideSheet() }) { + Icon(imageVector = Icons.Outlined.FolderSpecial, contentDescription = stringResource(id = R.string.collections)) + } + } override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) @@ -221,12 +230,10 @@ class FavoritesScene : SearchBarScene() { override fun onCreateViewWithToolbar( inflater: LayoutInflater, - container: ViewGroup?, + container: ViewGroup, savedInstanceState: Bundle?, - ): View { - _binding = SceneFavoritesBinding.inflate(inflater, container!!) - val stub = ComposeWithMD3 { dialogState.Intercept() } - container.addView(stub) + ): ViewBinding { + _binding = SceneFavoritesBinding.inflate(inflater, container) setOnApplySearch { if (!tracker.isInCustomChoice) { switchFav(urlBuilder.favCat, it) @@ -303,12 +310,6 @@ class FavoritesScene : SearchBarScene() { } } } - binding.fastScroller.setOnDragHandlerListener(object : OnDragHandlerListener { - override fun onStartDragHandler() {} - override fun onEndDragHandler() { - showSearchBar() - } - }) binding.recyclerView.run { mAdapter = GalleryAdapter( this@run, @@ -336,7 +337,6 @@ class FavoritesScene : SearchBarScene() { adapter.loadStateFlow.collectLatest { when (val state = it.refresh) { is LoadState.Loading -> { - showSearchBar() if (!binding.refreshLayout.isRefreshing) { transition.showView(1) } @@ -416,7 +416,7 @@ class FavoritesScene : SearchBarScene() { } }) itemTouchHelper.attachToRecyclerView(binding.recyclerView) - return binding.root + return binding } // Hide jump fab on local fav cat diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/GalleryListScene.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/GalleryListScene.kt index 538e06316..f9e2db361 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/GalleryListScene.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/GalleryListScene.kt @@ -27,12 +27,16 @@ import android.view.ViewGroup import android.widget.TextView import androidx.activity.OnBackPressedCallback import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.outlined.Bookmarks +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.Text +import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import androidx.core.content.ContextCompat import androidx.core.os.bundleOf -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsAnimationCompat import androidx.fragment.app.viewModels import androidx.lifecycle.ViewModel import androidx.lifecycle.lifecycleScope @@ -47,6 +51,7 @@ import androidx.paging.cachedIn import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView +import androidx.viewbinding.ViewBinding import com.google.android.material.datepicker.CalendarConstraints import com.google.android.material.datepicker.CalendarConstraints.DateValidator import com.google.android.material.datepicker.CompositeDateValidator @@ -85,19 +90,15 @@ import com.hippo.ehviewer.ui.legacy.BringOutTransition import com.hippo.ehviewer.ui.legacy.EditTextDialogBuilder import com.hippo.ehviewer.ui.legacy.FabLayout import com.hippo.ehviewer.ui.legacy.FabLayout.OnClickFabListener -import com.hippo.ehviewer.ui.legacy.FastScroller.OnDragHandlerListener import com.hippo.ehviewer.ui.legacy.HandlerDrawable import com.hippo.ehviewer.ui.legacy.LayoutManagerUtils.firstVisibleItemPosition import com.hippo.ehviewer.ui.legacy.ViewTransition -import com.hippo.ehviewer.ui.legacy.WindowInsetsAnimationHelper import com.hippo.ehviewer.ui.tools.DialogState import com.hippo.ehviewer.util.ExceptionUtils -import com.hippo.ehviewer.util.applyNavigationBarsPadding import com.hippo.ehviewer.util.getParcelableCompat import com.hippo.ehviewer.util.getValue import com.hippo.ehviewer.util.lazyMut import com.hippo.ehviewer.util.setValue -import dev.chrisbanes.insetter.applyInsetter import eu.kanade.tachiyomi.util.lang.launchIO import eu.kanade.tachiyomi.util.lang.withIOContext import eu.kanade.tachiyomi.util.lang.withUIContext @@ -197,11 +198,6 @@ class GalleryListScene : SearchBarScene() { override val fabLayout get() = binding.fabLayout override val fastScroller get() = binding.fastScroller override val recyclerView get() = binding.recyclerView - override val contentView get() = binding.contentLayout.contentView - - override fun getMenuResId(): Int { - return R.menu.scene_gallery_list_searchbar_menu - } private fun handleArgs(args: Bundle?) { val action = args?.getString(KEY_ACTION) ?: ACTION_HOMEPAGE @@ -293,30 +289,15 @@ class GalleryListScene : SearchBarScene() { override fun onCreateViewWithToolbar( inflater: LayoutInflater, - container: ViewGroup?, + container: ViewGroup, savedInstanceState: Bundle?, - ): View { - _binding = SceneGalleryListBinding.inflate(inflater, container!!) - container.addView(ComposeWithMD3 { dialogState.Intercept() }) + ): ViewBinding { + _binding = SceneGalleryListBinding.inflate(inflater, container) requireActivity().onBackPressedDispatcher.addCallback(stateBackPressedCallback) mHideActionFabSlop = ViewConfiguration.get(requireContext()).scaledTouchSlop - ViewCompat.setWindowInsetsAnimationCallback( - binding.root, - WindowInsetsAnimationHelper( - WindowInsetsAnimationCompat.Callback.DISPATCH_MODE_STOP, - binding.fabLayout, - binding.searchFabLayout, - ), - ) mViewTransition = BringOutTransition(binding.contentLayout.contentView, binding.searchLayout) binding.searchLayout.consumeWindowInsets = false binding.searchLayout.setViewTreeViewModelStoreOwner(this) - binding.fastScroller.setOnDragHandlerListener(object : OnDragHandlerListener { - override fun onStartDragHandler() {} - override fun onEndDragHandler() { - showSearchBar() - } - }) mAdapter = GalleryAdapter( binding.recyclerView, true, @@ -352,7 +333,6 @@ class GalleryListScene : SearchBarScene() { adapter.loadStateFlow.collectLatest { when (val state = it.refresh) { is LoadState.Loading -> { - showSearchBar() if (!binding.refreshLayout.isRefreshing) { transition.showView(1) } @@ -456,16 +436,6 @@ class GalleryListScene : SearchBarScene() { } } binding.fabLayout.primaryFab.setImageDrawable(actionFabDrawable) - binding.searchFab.setOnClickListener { onApplySearch() } - binding.searchFabLayout.apply { - bringToFront() - applyNavigationBarsPadding() - } - binding.searchLayout.applyInsetter { - type(statusBars = true) { - padding() - } - } // Update list url builder onUpdateUrlBuilder() @@ -474,7 +444,7 @@ class GalleryListScene : SearchBarScene() { val newState = mState mState = State.NORMAL setState(newState, false) - return binding.root + return binding } override fun onDestroyView() { @@ -680,21 +650,29 @@ class GalleryListScene : SearchBarScene() { } } + private fun showSearchFab(delay: Long) { + viewLifecycleOwner.lifecycleScope.launch { + delay(delay) + showSearchFab = true + } + } + private fun selectSearchFab(animation: Boolean) { val delay = binding.fabLayout.hide(animation) - binding.searchFabLayout.show(animation, delay) + showSearchFab(delay) } private fun selectActionFab(animation: Boolean) { - val delay = binding.searchFabLayout.hide(animation) - binding.fabLayout.show(animation, delay) + showSearchFab = false + binding.fabLayout.show(animation, FabLayout.ANIMATE_TIME) } override fun onSearchViewExpanded() { if (mState == State.NORMAL) { binding.fabLayout.hide() } else { - binding.searchFabLayout.hide() + showSearchFab = false + stateBackPressedCallback.isEnabled = false } super.onSearchViewExpanded() } @@ -704,7 +682,8 @@ class GalleryListScene : SearchBarScene() { if (mState == State.NORMAL) { binding.fabLayout.show(delay = SEARCH_VIEW_ANIMATE_TIME) } else { - binding.searchFabLayout.show(delay = SEARCH_VIEW_ANIMATE_TIME) + showSearchFab(SEARCH_VIEW_ANIMATE_TIME) + stateBackPressedCallback.isEnabled = true } } @@ -722,11 +701,7 @@ class GalleryListScene : SearchBarScene() { } } - private fun setState(state: State) { - setState(state, true) - } - - private fun setState(state: State, animation: Boolean) { + private fun setState(state: State, animation: Boolean = true) { _binding ?: return if (null == mViewTransition) { return @@ -734,7 +709,6 @@ class GalleryListScene : SearchBarScene() { if (mState != state) { val oldState = mState mState = state - showSearchBar() onStateChange(state) when (oldState) { State.NORMAL -> when (state) { @@ -767,16 +741,20 @@ class GalleryListScene : SearchBarScene() { } } - override fun onMenuItemClick(item: MenuItem): Boolean { - return if (item.itemId == R.id.action_search_more) { + @Composable + override fun TrailingIcon() { + dialogState.Intercept() + IconButton(onClick = { openSideSheet() }) { + Icon(imageVector = Icons.Outlined.Bookmarks, contentDescription = stringResource(id = R.string.quick_search)) + } + IconButton(onClick = { if (mState == State.NORMAL) { setState(State.SEARCH) } else { setState(State.NORMAL) } - true - } else { - super.onMenuItemClick(item) + }) { + Icon(imageVector = Icons.Default.Add, contentDescription = null) } } diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/SceneCompose.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/SceneCompose.kt index d53309c33..12dec4ac4 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/SceneCompose.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/SceneCompose.kt @@ -1,5 +1,6 @@ package com.hippo.ehviewer.ui.scene +import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.runtime.Composable import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy @@ -8,6 +9,7 @@ import com.hippo.ehviewer.ui.setMD3Content @Suppress("FunctionName") inline fun Fragment.ComposeWithMD3(crossinline content: @Composable () -> Unit) = ComposeView(requireContext()).apply { + consumeWindowInsets = false setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnLifecycleDestroyed(viewLifecycleOwner)) setMD3Content(content) } diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/SearchBarScene.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/SearchBarScene.kt index 2ddf163a4..7f9df83bd 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/SearchBarScene.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/SearchBarScene.kt @@ -2,86 +2,272 @@ package com.hippo.ehviewer.ui.scene import android.os.Bundle import android.view.LayoutInflater -import android.view.MenuItem import android.view.View import android.view.ViewGroup -import androidx.activity.OnBackPressedCallback -import androidx.annotation.CallSuper -import androidx.annotation.Keep +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text2.input.clearText +import androidx.compose.foundation.text2.input.forEachTextValue +import androidx.compose.foundation.text2.input.rememberTextFieldState +import androidx.compose.foundation.text2.input.setTextAndPlaceCursorAtEnd import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.MenuBook import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SearchBar import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.core.widget.addTextChangedListener -import androidx.lifecycle.lifecycleScope -import com.google.android.material.search.SearchView -import com.google.android.material.shape.MaterialShapeDrawable +import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidViewBinding +import androidx.core.view.updatePadding +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.OnScrollListener +import androidx.viewbinding.ViewBinding import com.hippo.ehviewer.EhApplication.Companion.searchDatabase import com.hippo.ehviewer.R import com.hippo.ehviewer.Settings import com.hippo.ehviewer.client.EhTagDatabase import com.hippo.ehviewer.dao.Search import com.hippo.ehviewer.dao.SearchDao -import com.hippo.ehviewer.databinding.SceneSearchbarBinding -import com.hippo.ehviewer.ui.legacy.BaseDialogBuilder -import com.hippo.ehviewer.ui.legacy.FabLayout -import com.hippo.ehviewer.ui.setMD3Content -import com.hippo.ehviewer.util.applyNavigationBarsPadding -import com.hippo.ehviewer.util.isAtLeastT +import com.hippo.ehviewer.ui.MainActivity +import com.hippo.ehviewer.ui.tools.LocalDialogState +import com.hippo.ehviewer.util.findActivity import com.jamal.composeprefs3.ui.ifNotNullThen import com.jamal.composeprefs3.ui.ifTrueThen -import dev.chrisbanes.insetter.applyInsetter import eu.kanade.tachiyomi.util.lang.launchIO +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch -abstract class SearchBarScene : BaseScene(), ToolBarScene { - private var _binding: SceneSearchbarBinding? = null +fun interface SuggestionProvider { + fun providerSuggestions(text: String): Suggestion? +} - @get:Keep - private val binding get() = _binding!! - private var mSuggestionList by mutableStateOf(emptyList()) - private var mSuggestionProvider: SuggestionProvider? = null - private var allowEmptySearch = true - private val mSearchDatabase = searchDatabase.searchDao() - private var onApplySearch: (String) -> Unit = {} +abstract class Suggestion { + abstract val keyword: String + open val hint: String? = null + abstract fun onClick() + open val canDelete: Boolean = false + open val canOpenDirectly: Boolean = false +} - protected abstract val fabLayout: FabLayout - protected abstract val fastScroller: View - protected abstract val recyclerView: View - protected abstract val contentView: View +suspend fun SearchDao.suggestions(prefix: String, limit: Int) = + (if (prefix.isBlank()) list(limit) else rawSuggestions(prefix, limit)).map { it.query } - override val enableDrawerGestures = true +@Composable +fun SearchBarScreen( + initialQuery: String = "", + hint: String = "", + showSearchFab: Boolean = false, + onApplySearch: (String) -> Unit, + onSearchExpanded: () -> Unit, + onSearchHidden: () -> Unit, + suggestionProvider: SuggestionProvider? = null, + searchBarOffsetY: MutableState, + trailingIcon: @Composable () -> Unit, + content: @Composable (PaddingValues) -> Unit, +) { + var mSuggestionList by remember { mutableStateOf(emptyList()) } + val mSearchDatabase = searchDatabase.searchDao() + val searchFieldState = rememberTextFieldState(initialQuery) + var active by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope { Dispatchers.IO } + val context = LocalContext.current + val activity = context.findActivity() + val dialogState = LocalDialogState.current - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - _binding = SceneSearchbarBinding.inflate(inflater, container, false) - binding.appbar.statusBarForeground = MaterialShapeDrawable.createWithElevationOverlay(context) - binding.searchview.editText.addTextChangedListener { updateSuggestions() } - binding.searchview.editText.setOnEditorActionListener { _, _, _ -> - onApplySearch() - true + class TagSuggestion( + override val hint: String?, + override val keyword: String, + ) : Suggestion() { + override fun onClick() { + val query = searchFieldState.text.toString() + var keywords = query.substringBeforeLast(' ', "") + if (keywords.isNotEmpty()) keywords += ' ' + keywords += wrapTagKeyword(keyword) + if (!keywords.endsWith(':')) keywords += ' ' + searchFieldState.setTextAndPlaceCursorAtEnd(keywords) } - binding.searchBarList.consumeWindowInsets = false - binding.searchBarList.setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnLifecycleDestroyed(viewLifecycleOwner)) - binding.searchBarList.setMD3Content { - LazyColumn(modifier = Modifier.navigationBarsPadding().imePadding()) { + } + + class KeywordSuggestion( + override val keyword: String, + ) : Suggestion() { + override val canDelete = true + override fun onClick() { + searchFieldState.setTextAndPlaceCursorAtEnd(keyword) + } + } + + fun mergedSuggestionFlow(): Flow = flow { + val query = searchFieldState.text.toString() + suggestionProvider?.run { providerSuggestions(query)?.let { emit(it) } } + mSearchDatabase.suggestions(query, 128).forEach { emit(KeywordSuggestion(it)) } + EhTagDatabase.takeIf { it.initialized }?.run { + if (query.isNotEmpty() && !query.endsWith(' ')) { + val keyword = query.substringAfterLast(' ') + val translate = Settings.showTagTranslations && isTranslatable(context) + suggestFlow(keyword, translate, true).collect { + emit(TagSuggestion(it.first, it.second)) + } + suggestFlow(keyword, translate).collect { + emit(TagSuggestion(it.first, it.second)) + } + } + } + } + + suspend fun updateSuggestions() { + mSuggestionList = mergedSuggestionFlow().toList() + } + + var drawerLocked by remember { mutableStateOf(false) } + + fun onSearchViewExpanded() { + onSearchExpanded() + if (!activity.drawerLocked) { + drawerLocked = true + activity.drawerLocked = true + } + } + + fun onSearchViewHidden() { + if (drawerLocked) { + activity.drawerLocked = false + drawerLocked = false + } + onSearchHidden() + } + + if (active) { + LaunchedEffect(Unit) { + searchFieldState.forEachTextValue { + updateSuggestions() + } + } + } + + fun hideSearchView() { + onSearchViewHidden() + active = false + } + + fun onApplySearch() { + val query = searchFieldState.text.trim().toString() + if (query.isNotEmpty()) { + scope.launchIO { + mSearchDatabase.deleteQuery(query) + val search = Search(System.currentTimeMillis(), query) + mSearchDatabase.insert(search) + } + } + onApplySearch(query) + } + + fun deleteKeyword(keyword: String) { + scope.launch { + dialogState.awaitPermissionOrCancel( + confirmText = R.string.delete, + ) { + Text(text = stringResource(id = R.string.delete_search_history, keyword)) + } + mSearchDatabase.deleteQuery(keyword) + updateSuggestions() + } + } + + var searchbarOfs by searchBarOffsetY + Box(modifier = Modifier.fillMaxSize()) { + SearchBar( + modifier = Modifier.layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + layout(placeable.width, placeable.height) { + placeable.placeRelative(0, searchbarOfs) + } + }.align(Alignment.TopCenter), + state = searchFieldState, + onSearch = { + hideSearchView() + onApplySearch() + }, + active = active, + onActiveChange = { + if (it) { + searchbarOfs = 0 + onSearchViewExpanded() + } else { + onSearchViewHidden() + } + active = it + }, + placeholder = { Text(hint) }, + leadingIcon = { + if (active) { + IconButton(onClick = { hideSearchView() }) { + Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = null) + } + } else { + IconButton(onClick = { activity.openDrawer() }) { + Icon(Icons.Default.Menu, contentDescription = null) + } + } + }, + trailingIcon = { + if (active) { + if (searchFieldState.text.isNotEmpty()) { + IconButton(onClick = { searchFieldState.clearText() }) { + Icon(Icons.Default.Close, contentDescription = null) + } + } + } else { + Row { + trailingIcon() + } + } + }, + ) { + LazyColumn( + modifier = Modifier.imePadding(), + contentPadding = WindowInsets.navigationBars.asPaddingValues(), + ) { items(mSuggestionList) { ListItem( headlineContent = { Text(text = it.keyword) }, @@ -106,233 +292,116 @@ abstract class SearchBarScene : BaseScene(), ToolBarScene { } } } - binding.searchview.addTransitionListener { _, _, newState -> - if (newState == SearchView.TransitionState.SHOWING) { - onSearchViewExpanded() - } else if (newState == SearchView.TransitionState.HIDING) { - onSearchViewHidden() + Scaffold( + floatingActionButton = { + AnimatedVisibility( + visible = showSearchFab, + enter = scaleIn(), + exit = scaleOut(), + ) { + FloatingActionButton(onClick = ::onApplySearch) { + Icon(imageVector = Icons.Default.Search, contentDescription = null) + } + } + }, + ) { + content( + PaddingValues( + top = it.calculateTopPadding() + 64.dp, + bottom = it.calculateBottomPadding(), + ), + ) + } + } +} + +abstract class SearchBarScene : BaseScene() { + private var mSuggestionProvider: SuggestionProvider? = null + private var onApplySearch: (String) -> Unit = {} + private var query by mutableStateOf("") + private var hint by mutableStateOf("") + var showSearchFab by mutableStateOf(false) + + protected abstract val fabLayout: View + protected abstract val fastScroller: View + protected abstract val recyclerView: RecyclerView + + override val enableDrawerGestures = true + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + val ofs = mutableIntStateOf(0) + val onScrollListener = object : OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + var toChange by ofs + toChange = (toChange - dy).coerceIn(-300, 0) } } - onCreateViewWithToolbar(inflater, binding.root, savedInstanceState) - if (!isAtLeastT) { - // This has to be placed after onCreateViewWithToolbar() since - // callbacks are invoked in the reverse order in which they are added - binding.searchview.addTransitionListener(mSearchViewOnBackPressedCallback) - requireActivity().onBackPressedDispatcher.addCallback(mSearchViewOnBackPressedCallback) - } - binding.appbar.bringToFront() - fabLayout.applyNavigationBarsPadding() - fastScroller.applyNavigationBarsPadding() - recyclerView.applyNavigationBarsPadding() - contentView.applyInsetter { - type(statusBars = true) { - padding() + return ComposeWithMD3 { + val density = LocalDensity.current + val fabPadding = with(density) { 16.dp.roundToPx() } + SearchBarScreen( + initialQuery = query, + hint = hint, + showSearchFab = showSearchFab, + onApplySearch = onApplySearch, + onSearchExpanded = ::onSearchViewExpanded, + onSearchHidden = ::onSearchViewHidden, + suggestionProvider = mSuggestionProvider, + searchBarOffsetY = ofs, + trailingIcon = { + TrailingIcon() + }, + ) { contentPadding -> + val bottomPadding = with(density) { contentPadding.calculateBottomPadding().roundToPx() } + AndroidViewBinding( + modifier = Modifier.padding(top = contentPadding.calculateTopPadding()).fillMaxSize(), + factory = { inflater, parent, _ -> + parent.clipChildren = false + onCreateViewWithToolbar(inflater, parent, savedInstanceState).apply { + recyclerView.addOnScrollListener(onScrollListener) + } + }, + ) { + fabLayout.updatePadding(bottom = fabPadding + bottomPadding) + fastScroller.updatePadding(bottom = bottomPadding) + recyclerView.updatePadding(bottom = bottomPadding) + } } } - return binding.root } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - binding.toolbar.apply { - val menuResId = getMenuResId() - if (menuResId != 0) { - inflateMenu(menuResId) - setOnMenuItemClickListener { item: MenuItem -> onMenuItemClick(item) } - } - setNavigationOnClickListener { onNavigationClick() } - } - } + abstract fun onCreateViewWithToolbar( + inflater: LayoutInflater, + container: ViewGroup, + savedInstanceState: Bundle?, + ): ViewBinding - override fun onDestroyView() { - super.onDestroyView() - if (!isAtLeastT) mSearchViewOnBackPressedCallback.remove() - binding.root.removeAllViews() - _binding = null - } + @Composable + abstract fun TrailingIcon() - private var drawerLocked = false + open fun onSearchViewExpanded() {} - @CallSuper - open fun onSearchViewExpanded() { - if (!isDrawerLocked) { - drawerLocked = true - lockDrawer() - } - updateSuggestions() - } - - @CallSuper - open fun onSearchViewHidden() { - binding.toolbar.setText(binding.searchview.text) - if (drawerLocked) { - unlockDrawer() - drawerLocked = false - } - } + open fun onSearchViewHidden() {} fun setSearchBarHint(hint: String?) { - binding.toolbar.hint = hint + this.hint = hint.orEmpty() } fun setSearchBarText(text: String?) { - binding.toolbar.setText(text) - binding.searchview.setText(text) + query = text.orEmpty() } fun setEditTextHint(hint: String?) { - binding.searchview.editText.hint = hint - } - - override fun onNavigationClick() { - openDrawer() - } - - override fun getMenuResId(): Int { - return 0 - } - - override fun onMenuItemClick(item: MenuItem): Boolean { - return if (item.itemId == R.id.action_open_side_sheet) { - openSideSheet() - true - } else { - false - } - } - - override fun setLiftOnScrollTargetView(view: View?) { - binding.appbar.setLiftOnScrollTargetView(view) + this.hint = hint.orEmpty() } fun setOnApplySearch(lambda: (String) -> Unit) { onApplySearch = lambda } - private suspend fun addQuery(query: String) { - mSearchDatabase.deleteQuery(query) - if (query.isBlank()) return - val search = Search(System.currentTimeMillis(), query) - mSearchDatabase.insert(search) - } - - fun onApplySearch() { - binding.toolbar.setText(binding.searchview.text) - binding.searchview.hide() - val query = binding.toolbar.text.toString().trim() - if (!allowEmptySearch && query.isEmpty()) return - lifecycleScope.launchIO { addQuery(query) } - onApplySearch(query) - } - - fun interface SuggestionProvider { - fun providerSuggestions(text: String): Suggestion? - } - - private fun deleteKeyword(keyword: String) { - BaseDialogBuilder(requireContext()) - .setMessage(requireContext().getString(R.string.delete_search_history, keyword)) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(R.string.delete) { _, _ -> - lifecycleScope.launchIO { - mSearchDatabase.deleteQuery(keyword) - updateSuggestions() - } - } - .show() - } - - abstract class Suggestion { - abstract val keyword: String - open val hint: String? = null - abstract fun onClick() - open val canDelete: Boolean = false - open val canOpenDirectly: Boolean = false - } - - inner class TagSuggestion( - override val hint: String?, - override val keyword: String, - ) : Suggestion() { - override fun onClick() { - binding.searchview.editText.let { - var keywords = it.text.toString().substringBeforeLast(' ', "") - if (keywords.isNotEmpty()) keywords += ' ' - keywords += wrapTagKeyword(keyword) - if (!keywords.endsWith(':')) keywords += ' ' - it.setText(keywords) - it.setSelection(keywords.length) - } - } - } - - inner class KeywordSuggestion( - override val keyword: String, - ) : Suggestion() { - override val canDelete = true - override fun onClick() { - binding.searchview.editText.run { - setText(keyword) - setSelection(length()) - } - } - } - - private fun updateSuggestions() { - _binding ?: return - viewLifecycleOwner.lifecycleScope.launchIO { - mSuggestionList = mergedSuggestionFlow().toList() - } - } - - private suspend fun SearchDao.suggestions(prefix: String, limit: Int) = (if (prefix.isBlank()) list(limit) else rawSuggestions(prefix, limit)).map { it.query } - - private fun mergedSuggestionFlow(): Flow = flow { - binding.searchview.editText.text?.toString()?.let { text -> - mSuggestionProvider?.run { providerSuggestions(text)?.let { emit(it) } } - mSearchDatabase.suggestions(text, 128).forEach { emit(KeywordSuggestion(it)) } - EhTagDatabase.takeIf { it.initialized }?.run { - if (text.isNotEmpty() && !text.endsWith(' ')) { - val keyword = text.substringAfterLast(' ') - val translate = Settings.showTagTranslations && isTranslatable(requireContext()) - suggestFlow(keyword, translate, true).collect { - emit(TagSuggestion(it.first, it.second)) - } - suggestFlow(keyword, translate).collect { - emit(TagSuggestion(it.first, it.second)) - } - } - } - } - } - fun setSuggestionProvider(suggestionProvider: SuggestionProvider) { mSuggestionProvider = suggestionProvider } - - fun showSearchBar() { - _binding ?: return - binding.appbar.setExpanded(true) - } - - private val mSearchViewOnBackPressedCallback = - object : OnBackPressedCallback(false), SearchView.TransitionListener { - override fun handleOnBackPressed() { - binding.searchview.hide() - } - - override fun onStateChanged( - searchView: SearchView, - previousState: SearchView.TransitionState, - newState: SearchView.TransitionState, - ) { - if (newState == SearchView.TransitionState.SHOWING) { - isEnabled = true - } else if (newState == SearchView.TransitionState.HIDING) { - isEnabled = false - } - } - } } fun wrapTagKeyword(keyword: String, translate: Boolean = false): String { diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/ToolBarScene.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/ToolBarScene.kt deleted file mode 100644 index b74b1c2f2..000000000 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/ToolBarScene.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.hippo.ehviewer.ui.scene - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.MenuItem -import android.view.View -import android.view.ViewGroup - -interface ToolBarScene { - fun onCreateViewWithToolbar( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View - - fun onNavigationClick() - - fun getMenuResId(): Int - - fun onMenuItemClick(item: MenuItem): Boolean - - fun setLiftOnScrollTargetView(view: View?) -} diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/tools/Dialog.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/tools/Dialog.kt index fe990e7c2..a4cc59d6c 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/tools/Dialog.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/tools/Dialog.kt @@ -3,6 +3,7 @@ package com.hippo.ehviewer.ui.tools import androidx.annotation.StringRes import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxWidth @@ -24,6 +25,7 @@ import androidx.compose.material3.ListItem import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton import androidx.compose.material3.ShapeDefaults import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -214,6 +216,24 @@ class DialogState { } } + suspend fun showSingleChoice( + items: Array, + selected: Int, + ): Int = showNoButton { + Column(modifier = Modifier.padding(vertical = 8.dp)) { + items.forEachIndexed { index, text -> + Row( + modifier = Modifier.clickable { dismissWith(index) }.fillMaxWidth() + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton(selected = index == selected, onClick = { dismissWith(index) }) + Text(text = text) + } + } + } + } + suspend fun showSelectItem( vararg items: String?, @StringRes title: Int, diff --git a/app/src/main/res/drawable/ic_baseline_menu_24.xml b/app/src/main/res/drawable/ic_baseline_menu_24.xml deleted file mode 100644 index 15bd2968b..000000000 --- a/app/src/main/res/drawable/ic_baseline_menu_24.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_outline_bookmarks_24.xml b/app/src/main/res/drawable/ic_outline_bookmarks_24.xml deleted file mode 100644 index 72f804253..000000000 --- a/app/src/main/res/drawable/ic_outline_bookmarks_24.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_outline_folder_special_24.xml b/app/src/main/res/drawable/ic_outline_folder_special_24.xml deleted file mode 100644 index f98730fc7..000000000 --- a/app/src/main/res/drawable/ic_outline_folder_special_24.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_outline_label_24.xml b/app/src/main/res/drawable/ic_outline_label_24.xml deleted file mode 100644 index a91f2ebb7..000000000 --- a/app/src/main/res/drawable/ic_outline_label_24.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/v_magnify_x24.xml b/app/src/main/res/drawable/v_magnify_x24.xml deleted file mode 100644 index 7b0ca1b3a..000000000 --- a/app/src/main/res/drawable/v_magnify_x24.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - diff --git a/app/src/main/res/layout/scene_download.xml b/app/src/main/res/layout/scene_download.xml index cdac59992..01484d63e 100644 --- a/app/src/main/res/layout/scene_download.xml +++ b/app/src/main/res/layout/scene_download.xml @@ -28,7 +28,6 @@ android:id="@+id/recycler_view" android:layout_width="match_parent" android:layout_height="match_parent" - android:layout_marginTop="@dimen/gallery_padding_top_search_bar" android:paddingHorizontal="@dimen/gallery_list_margin_h" android:paddingVertical="@dimen/gallery_list_margin_v" /> @@ -37,7 +36,6 @@ android:layout_width="30dp" android:layout_height="match_parent" android:layout_gravity="end" - android:layout_marginTop="@dimen/gallery_padding_top_search_bar" android:paddingStart="20dp" android:paddingEnd="4dp" android:paddingBottom="8dp" /> diff --git a/app/src/main/res/layout/scene_gallery_list.xml b/app/src/main/res/layout/scene_gallery_list.xml index cdcd8b7af..b21f6a366 100644 --- a/app/src/main/res/layout/scene_gallery_list.xml +++ b/app/src/main/res/layout/scene_gallery_list.xml @@ -26,8 +26,7 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:layout_marginStart="@dimen/gallery_search_bar_margin_v" - android:layout_marginEnd="@dimen/gallery_search_bar_margin_v" - android:paddingTop="@dimen/gallery_padding_top_search_bar" /> + android:layout_marginEnd="@dimen/gallery_search_bar_margin_v" /> - - - - - - diff --git a/app/src/main/res/layout/scene_searchbar.xml b/app/src/main/res/layout/scene_searchbar.xml deleted file mode 100644 index cd354c6db..000000000 --- a/app/src/main/res/layout/scene_searchbar.xml +++ /dev/null @@ -1,50 +0,0 @@ - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/widget_content_layout.xml b/app/src/main/res/layout/widget_content_layout.xml index eebbf454b..afe053012 100644 --- a/app/src/main/res/layout/widget_content_layout.xml +++ b/app/src/main/res/layout/widget_content_layout.xml @@ -42,7 +42,6 @@ android:id="@+id/refresh_layout" android:layout_width="match_parent" android:layout_height="match_parent" - android:layout_marginTop="@dimen/gallery_padding_top_search_bar" android:clipChildren="false"> diff --git a/app/src/main/res/menu/scene_download.xml b/app/src/main/res/menu/scene_download.xml deleted file mode 100644 index 54034a95a..000000000 --- a/app/src/main/res/menu/scene_download.xml +++ /dev/null @@ -1,50 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/menu/scene_favorites.xml b/app/src/main/res/menu/scene_favorites.xml deleted file mode 100644 index d1242f539..000000000 --- a/app/src/main/res/menu/scene_favorites.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/menu/scene_gallery_list_searchbar_menu.xml b/app/src/main/res/menu/scene_gallery_list_searchbar_menu.xml deleted file mode 100644 index 4dddf8ce0..000000000 --- a/app/src/main/res/menu/scene_gallery_list_searchbar_menu.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 2e39d4ea7..3f487ba67 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -41,8 +41,6 @@ 10dp - 80dp - 480dp 320dp diff --git a/app/src/main/res/values/pathdata.xml b/app/src/main/res/values/pathdata.xml index 0e5941b07..bcf1e003b 100644 --- a/app/src/main/res/values/pathdata.xml +++ b/app/src/main/res/values/pathdata.xml @@ -25,7 +25,6 @@ M5,20H19V18H5M19,9H15V3H9V9H5L12,16L19,9Z M19,4H15.5L14.5,3H9.5L8.5,4H5V6H19M6,19A2,2 0 0,0 8,21H16A2,2 0 0,0 18,19V7H6V19Z M13,13L17,17L21,13H18V10C18,6.1,14.9,3,11,3S4,6.1,4,10V17H6V10C6,7.2,8.2,5,11,5S16,7.2,16,10V13H13M,16,19H18V21H16V19Z - M9.5,3A6.5,6.5 0 0,1 16,9.5C16,11.11 15.41,12.59 14.44,13.73L14.71,14H15.5L20.5,19L19,20.5L14,15.5V14.71L13.73,14.44C12.59,15.41 11.11,16 9.5,16A6.5,6.5 0 0,1 3,9.5A6.5,6.5 0 0,1 9.5,3M9.5,5C7,5 5,7 5,9.5C5,12 7,14 9.5,14C12,14 14,12 14,9.5C14,7 12,5 9.5,5Z M15.07,11.25L14.17,12.17C13.45,12.89 13,13.5 13,15H11V14.5C11,13.39 11.45,12.39 12.17,11.67L13.41,10.41C13.78,10.05 14,9.55 14,9C14,7.89 13.1,7 12,7A2,2 0 0,0 10,9H8A4,4 0 0,1 12,5A4,4 0 0,1 16,9C16,9.88 15.64,10.67 15.07,11.25M13,19H11V17H13M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12C22,6.47 17.5,2 12,2Z M19,3H5C3.9,3,3,3.9,3,5V19C3,20.1,3.9,21,5,21H19C20.1,21,21,20.1,21,19V5C21,3.9,20.1,3,19,3ZM12,17L7,12H10V8H14V12H17L12,17Z M17.65,6.35C16.2,4.9 14.21,4 12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20C15.73,20 18.84,17.45 19.73,14H17.65C16.83,16.33 14.61,18 12,18A6,6 0 0,1 6,12A6,6 0 0,1 12,6C13.66,6 15.14,6.69 16.22,7.78L13,11H20V4L17.65,6.35Z