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.
+ *
+ * 
+ *
+ * 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