Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71a4e84d7c | ||
|
|
d6c36146ba | ||
|
|
9013778f58 | ||
|
|
1af03dd145 | ||
|
|
b83e0fd092 | ||
|
|
1f8d786d05 | ||
|
|
5d87a4ada0 | ||
|
|
4d4fb1e398 | ||
|
|
05eea54f06 | ||
|
|
a00d9ef765 | ||
|
|
aa6ddac9c5 | ||
|
|
9a6f17c745 | ||
|
|
980977aaf3 | ||
|
|
3ed0afaf91 | ||
|
|
250adb4b33 | ||
|
|
0baf09cb53 | ||
|
|
d04a914b9b | ||
|
|
59775cb27c | ||
|
|
fb76cd7b1b | ||
|
|
52fb5306b6 | ||
|
|
74f7d95718 | ||
|
|
bcc1b78c7c | ||
|
|
3978abea87 | ||
|
|
556b726349 | ||
|
|
e0f16f31e6 | ||
|
|
b39e24467c | ||
|
|
65613f07ba | ||
|
|
666a7e4d77 | ||
|
|
cf08bfdd9b | ||
|
|
1f47d56c55 | ||
|
|
1a7dde848f | ||
|
|
c8207308a8 | ||
|
|
f23020cc6f | ||
|
|
555e487f1e | ||
|
|
107bb0b15d | ||
|
|
87a2de393e | ||
|
|
a7e4412454 | ||
|
|
7dce4cfa8e | ||
|
|
2b30aca47c | ||
|
|
0073d2862b | ||
|
|
b182c97185 | ||
|
|
a32119bada | ||
|
|
a928cfd066 | ||
|
|
8c7a371336 | ||
|
|
6f61eb4946 | ||
|
|
1096f05344 | ||
|
|
26f96d3e38 | ||
|
|
4a02189a59 | ||
|
|
7a5bab41f6 | ||
|
|
84c32fb28d | ||
|
|
a3cb6d2782 | ||
|
|
45be957df5 | ||
|
|
8e45014e3f | ||
|
|
d84e948352 | ||
|
|
68bd835038 | ||
|
|
c297bebea1 | ||
|
|
6c0a823216 | ||
|
|
067a701bdc | ||
|
|
c3fb008c92 | ||
|
|
bc4321da2e | ||
|
|
8dec040803 | ||
|
|
29e4e77c44 | ||
|
|
11429ef7fe | ||
|
|
f5fcd96a1e | ||
|
|
19120038c5 | ||
|
|
9021f28ead | ||
|
|
62934f72e3 | ||
|
|
6c534e1049 | ||
|
|
f80cd90792 | ||
|
|
3999c0c114 | ||
|
|
59399453c4 | ||
|
|
694bb19e2a | ||
|
|
9928ecd4d9 | ||
|
|
2c5293af6e | ||
|
|
9569ca774d | ||
|
|
3a04460926 | ||
|
|
acb4fb6166 | ||
|
|
7d137bb016 | ||
|
|
8c2ed37b72 | ||
|
|
749b230250 | ||
|
|
23531cb6e0 | ||
|
|
0ac11dbe0b | ||
|
|
9b0e017542 | ||
|
|
f2f5fcacf9 | ||
|
|
52ded265f3 | ||
|
|
e77f905c4a | ||
|
|
2d14d34d53 | ||
|
|
78facda18a | ||
|
|
1fe1b43015 | ||
|
|
63164dc70a | ||
|
|
0d39092a7f | ||
|
|
c50db8a327 | ||
|
|
7e107cc5e2 | ||
|
|
260949e3a7 | ||
|
|
d9ec108721 | ||
|
|
1e97407936 | ||
|
|
73afe05a18 | ||
|
|
23475de932 | ||
|
|
6fb2172c2c | ||
|
|
9226b71119 | ||
|
|
58e38b58bf | ||
|
|
db0d110627 | ||
|
|
08812bf7ff | ||
|
|
daf7f8ffca | ||
|
|
74564fa07f | ||
|
|
fc51d3fc81 | ||
|
|
c5d5b8abd2 | ||
|
|
193badccb8 | ||
|
|
2653d0568a | ||
|
|
adf5a65371 | ||
|
|
5b59dabc66 | ||
|
|
f7259c5b40 | ||
|
|
6f9fceaef8 |
No files matched your search
@@ -1,5 +1,45 @@
|
||||
# 变更日志
|
||||
|
||||
## [1.11.4] - 2024-04-21
|
||||
|
||||
### 新功能
|
||||
|
||||
* 更新前自动备份数据
|
||||
|
||||
### 改进
|
||||
|
||||
* 在 设置-高级 中加入了禁用列表项目动画的选项,该功能在部分设备上导致崩溃/卡顿 [#996](https://github.com/FooIbar/EhViewer/issues/996) [#1023](https://github.com/FooIbar/EhViewer/issues/1023)
|
||||
* 下载列表多选模式下避免误触 [#1016](https://github.com/FooIbar/EhViewer/issues/1016)
|
||||
* 杂项 UI 优化
|
||||
|
||||
### Bug 修复
|
||||
|
||||
* 重命名下载标签后删除该标签时崩溃 [#1008](https://github.com/FooIbar/EhViewer/issues/1008)
|
||||
* 搜索栏中某些输入法长按退格无法连续删除 [#606](https://github.com/FooIbar/EhViewer/issues/606)
|
||||
* 评论/屏蔽/搜索建议/下载标签/快速搜索列表卡顿 [#1041](https://github.com/FooIbar/EhViewer/issues/1041)
|
||||
|
||||
|
||||
## [1.11.3] - 2024-04-06
|
||||
|
||||
### 改进
|
||||
|
||||
* 优化预加载逻辑和图片加载顺序
|
||||
* 优化画廊详情和评论页面 UI
|
||||
|
||||
### Bug 修复
|
||||
|
||||
* 某些模式下搜索结果不显示画廊语言 [#923](https://github.com/FooIbar/EhViewer/issues/923)
|
||||
* 某些情况下在搜索结果中直接阅读画廊后崩溃 [#927](https://github.com/FooIbar/EhViewer/issues/927)
|
||||
* 更新至最新版本后仍提示有新版本 [#927](https://github.com/FooIbar/EhViewer/issues/927)
|
||||
* 从旧版本升级时包含不兼容数据导致崩溃/无法导出数据 [#940](https://github.com/FooIbar/EhViewer/issues/940) [#974](https://github.com/FooIbar/EhViewer/issues/974)
|
||||
* Android 10 以下的设备打开 设置-隐私 时崩溃 [#953](https://github.com/FooIbar/EhViewer/issues/953)
|
||||
* 下载标签无法拖动
|
||||
* 刷新 和 刷新(原图) 无法在已下载的图片上使用
|
||||
* 临时 IP 封禁错误显示为解析失败
|
||||
* 某些情况下下载时闪退 [#980](https://github.com/FooIbar/EhViewer/issues/980)
|
||||
* 某些设备上下载列表中的缩略图无法显示 [#977](https://github.com/FooIbar/EhViewer/issues/977)
|
||||
|
||||
|
||||
## [1.11.2] - 2024-03-17
|
||||
|
||||
### 重大变更
|
||||
|
||||
+26
-24
@@ -10,6 +10,7 @@ val isRelease: Boolean
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.kotlin.parcelize)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.ksp)
|
||||
@@ -62,8 +63,9 @@ android {
|
||||
applicationId = "moe.tarsin.ehviewer"
|
||||
minSdk = 26
|
||||
targetSdk = 34
|
||||
versionCode = 180053
|
||||
versionName = "1.11.2"
|
||||
versionCode = 180055
|
||||
versionName = "1.11.4"
|
||||
// versionNameSuffix = "-SNAPSHOT"
|
||||
resourceConfigurations.addAll(
|
||||
listOf(
|
||||
"zh",
|
||||
@@ -80,7 +82,7 @@ android {
|
||||
"nb-rNO",
|
||||
),
|
||||
)
|
||||
buildConfigField("String", "RAW_VERSION_NAME", "\"$versionName$versionNameSuffix\"")
|
||||
buildConfigField("String", "RAW_VERSION_NAME", "\"$versionName${versionNameSuffix.orEmpty()}\"")
|
||||
buildConfigField("String", "COMMIT_SHA", "\"$commitSha\"")
|
||||
buildConfigField("String", "REPO_NAME", "\"$repoName\"")
|
||||
ndk {
|
||||
@@ -131,27 +133,18 @@ android {
|
||||
"-opt-in=kotlinx.serialization.ExperimentalSerializationApi",
|
||||
"-opt-in=splitties.experimental.ExperimentalSplittiesApi",
|
||||
"-opt-in=splitties.preferences.DataStorePreferencesPreview",
|
||||
|
||||
"-P", "plugin:androidx.compose.compiler.plugins.kotlin:experimentalStrongSkipping=true",
|
||||
"-P", "plugin:androidx.compose.compiler.plugins.kotlin:nonSkippingGroupOptimization=true",
|
||||
)
|
||||
}
|
||||
|
||||
lint {
|
||||
disable += setOf("MissingTranslation", "MissingQuantity")
|
||||
fatal += setOf("NewApi", "InlineApi")
|
||||
fatal += setOf("NewApi", "InlinedApi")
|
||||
}
|
||||
|
||||
packaging {
|
||||
dex {
|
||||
useLegacyPackaging = false
|
||||
}
|
||||
resources {
|
||||
excludes += "/META-INF/**"
|
||||
excludes += "/kotlin/**"
|
||||
excludes += "**.txt"
|
||||
excludes += "**.bin"
|
||||
}
|
||||
}
|
||||
|
||||
dependenciesInfo.includeInApk = false
|
||||
@@ -176,13 +169,28 @@ android {
|
||||
viewBinding = true
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get()
|
||||
}
|
||||
|
||||
namespace = "com.hippo.ehviewer"
|
||||
}
|
||||
|
||||
composeCompiler {
|
||||
// https://youtrack.jetbrains.com/issue/KT-67216
|
||||
suppressKotlinVersionCompatibilityCheck = libs.versions.kotlin.get()
|
||||
enableIntrinsicRemember = true
|
||||
enableNonSkippingGroupOptimization = true
|
||||
enableExperimentalStrongSkippingMode = true
|
||||
}
|
||||
|
||||
androidComponents {
|
||||
onVariants(selector().withBuildType("release")) {
|
||||
it.packaging.resources.excludes.addAll(
|
||||
"/META-INF/**",
|
||||
"/kotlin/**",
|
||||
"**.txt",
|
||||
"**.bin",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// https://developer.android.com/jetpack/androidx/releases/activity
|
||||
implementation(libs.androidx.activity.compose)
|
||||
@@ -263,13 +271,6 @@ dependencies {
|
||||
implementation(libs.compose.ui.tooling.preview)
|
||||
}
|
||||
|
||||
configurations.all {
|
||||
resolutionStrategy {
|
||||
// Workaround for https://issuetracker.google.com/329489167
|
||||
force("androidx.compose.foundation:foundation-android:1.7.0-alpha03")
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
}
|
||||
@@ -277,6 +278,7 @@ kotlin {
|
||||
ksp {
|
||||
arg("room.schemaLocation", "$projectDir/schemas")
|
||||
arg("room.generateKotlin", "true")
|
||||
arg("compose-destinations.codeGenPackageName", "com.hippo.ehviewer.ui")
|
||||
}
|
||||
|
||||
aboutLibraries {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
From d1db03ebd9989d10a5a905b792b712c6487a0057 Mon Sep 17 00:00:00 2001
|
||||
From: FooIbar <118464521+FooIbar@users.noreply.github.com>
|
||||
Date: Sat, 24 Feb 2024 12:03:15 +0000
|
||||
Subject: [PATCH] Don't build other binaries
|
||||
|
||||
---
|
||||
CMakeLists.txt | 8 ++++----
|
||||
1 file changed, 4 insertions(+), 4 deletions(-)
|
||||
|
||||
diff --git a/CMakeLists.txt b/CMakeLists.txt
|
||||
index a346dd02..52b5d0d8 100644
|
||||
--- a/CMakeLists.txt
|
||||
+++ b/CMakeLists.txt
|
||||
@@ -1567,7 +1567,7 @@ endif()
|
||||
# xzdec and lzmadec
|
||||
#############################################################################
|
||||
|
||||
-if(HAVE_DECODERS AND (NOT MSVC OR MSVC_VERSION GREATER_EQUAL 1900))
|
||||
+if(0)
|
||||
foreach(XZDEC xzdec lzmadec)
|
||||
add_executable("${XZDEC}"
|
||||
src/common/sysdefs.h
|
||||
@@ -1624,7 +1624,7 @@ endif()
|
||||
# lzmainfo
|
||||
#############################################################################
|
||||
|
||||
-if(HAVE_DECODERS AND (NOT MSVC OR MSVC_VERSION GREATER_EQUAL 1900))
|
||||
+if(0)
|
||||
add_executable(lzmainfo
|
||||
src/common/sysdefs.h
|
||||
src/common/tuklib_common.h
|
||||
@@ -1680,7 +1680,7 @@ endif()
|
||||
# xz
|
||||
#############################################################################
|
||||
|
||||
-if(NOT MSVC OR MSVC_VERSION GREATER_EQUAL 1900)
|
||||
+if(0)
|
||||
add_executable(xz
|
||||
src/common/mythread.h
|
||||
src/common/sysdefs.h
|
||||
@@ -1914,7 +1914,7 @@ endif()
|
||||
# Scripts
|
||||
#############################################################################
|
||||
|
||||
-if(UNIX)
|
||||
+if(0)
|
||||
# NOTE: This isn't as sophisticated as in the Autotools build which
|
||||
# uses posix-shell.m4 but hopefully this doesn't need to be either.
|
||||
# CMake likely won't be used on as many (old) obscure systems as the
|
||||
--
|
||||
2.34.1
|
||||
|
||||
@@ -20,8 +20,9 @@ option(BUILD_TESTING OFF)
|
||||
|
||||
FetchContent_Declare(
|
||||
liblzma
|
||||
GIT_REPOSITORY https://github.com/FooIbar/xz.git
|
||||
GIT_REPOSITORY https://git.tukaani.org/xz.git
|
||||
GIT_TAG v5.6.1
|
||||
PATCH_COMMAND git apply ${CMAKE_CURRENT_LIST_DIR}/0001-Don-t-build-other-binaries.patch || cd .
|
||||
)
|
||||
|
||||
FetchContent_MakeAvailable(liblzma)
|
||||
@@ -62,6 +63,7 @@ option(ENABLE_CPIO OFF)
|
||||
option(ENABLE_CAT OFF)
|
||||
option(ENABLE_UNZIP OFF)
|
||||
option(ENABLE_TEST OFF)
|
||||
option(ENABLE_WERROR OFF)
|
||||
|
||||
# Configure libarchive link's static lib
|
||||
SET(LIBARCHIVE_CUSTOM_LIBS "nettle" "liblzma")
|
||||
@@ -69,7 +71,7 @@ SET(LIBARCHIVE_CUSTOM_LIBS "nettle" "liblzma")
|
||||
FetchContent_Declare(
|
||||
libarchive
|
||||
GIT_REPOSITORY https://github.com/FooIbar/libarchive.git
|
||||
GIT_TAG v3.7.2
|
||||
GIT_TAG v3.7.3
|
||||
)
|
||||
|
||||
FetchContent_MakeAvailable(libarchive)
|
||||
|
||||
@@ -127,8 +127,6 @@ static long archive_map_entries_index(archive_ctx *ctx, bool sort) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
if (!count)
|
||||
LOGE("%s", archive_error_string(ctx->arc));
|
||||
if (sort)
|
||||
qsort(entries, entryCount, sizeof(entry), compare_entries);
|
||||
return count;
|
||||
@@ -171,8 +169,6 @@ static long archive_list_all_entries(archive_ctx *ctx) {
|
||||
while (archive_read_next_header(ctx->arc, &ctx->entry) == ARCHIVE_OK)
|
||||
if (archive_entry_is_playable(ctx->entry))
|
||||
count++;
|
||||
if (!count)
|
||||
LOGE("%s", archive_error_string(ctx->arc));
|
||||
return count;
|
||||
}
|
||||
|
||||
@@ -306,6 +302,11 @@ Java_com_hippo_ehviewer_jni_ArchiveKt_openArchive(JNIEnv *env, jclass thiz, jint
|
||||
} else {
|
||||
entryCount = archive_list_all_entries(ctx);
|
||||
LOGI("%s%zu%s", "Found ", entryCount, " image entries in archive");
|
||||
if (!entryCount) {
|
||||
LOGE("%s%s", "Archive open failed:", archive_error_string(ctx->arc));
|
||||
archive_release_ctx(ctx);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// We must read through the file|vm then we can know whether it is encrypted
|
||||
int encryptRet = archive_read_has_encrypted_entries(ctx->arc);
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
@file:Suppress("ktlint:standard:property-naming")
|
||||
|
||||
package androidx.compose.material3
|
||||
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.sizeIn
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.text.input.TextFieldLineLimits
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.material3.SearchBarDefaults.InputFieldHeight
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.takeOrElse
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
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.dp
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@Composable
|
||||
fun SearchBarInputField(
|
||||
state: TextFieldState,
|
||||
onSearch: () -> Unit,
|
||||
expanded: Boolean,
|
||||
onExpandedChange: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
label: @Composable (() -> Unit)? = null,
|
||||
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 focusManager = LocalFocusManager.current
|
||||
val focused by interactionSource.collectIsFocusedAsState()
|
||||
val textColor = LocalTextStyle.current.color.takeOrElse {
|
||||
if (focused) colors.focusedTextColor else colors.unfocusedTextColor
|
||||
}
|
||||
|
||||
BasicTextField(
|
||||
state = state,
|
||||
modifier = modifier.sizeIn(
|
||||
minWidth = SearchBarMinWidth,
|
||||
maxWidth = SearchBarMaxWidth,
|
||||
minHeight = InputFieldHeight,
|
||||
).focusRequester(focusRequester).onFocusChanged {
|
||||
if (it.isFocused) onExpandedChange(true)
|
||||
}.fillMaxWidth(),
|
||||
enabled = enabled,
|
||||
lineLimits = TextFieldLineLimits.SingleLine,
|
||||
textStyle = LocalTextStyle.current.merge(TextStyle(color = textColor)),
|
||||
cursorBrush = SolidColor(colors.cursorColor),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
onKeyboardAction = { onSearch() },
|
||||
interactionSource = interactionSource,
|
||||
decorator = @Composable { innerTextField ->
|
||||
TextFieldDefaults.DecorationBox(
|
||||
value = state.text.toString(),
|
||||
innerTextField = innerTextField,
|
||||
enabled = enabled,
|
||||
singleLine = true,
|
||||
visualTransformation = VisualTransformation.None,
|
||||
interactionSource = interactionSource,
|
||||
label = label,
|
||||
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 = {},
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
val shouldClearFocus = !expanded && focused
|
||||
LaunchedEffect(expanded) {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val SearchBarIconOffsetX = 4.dp
|
||||
val SearchBarMinWidth = 360.dp
|
||||
val SearchBarMaxWidth = 720.dp
|
||||
val SearchBarHorizontalPadding = 16.dp
|
||||
const val AnimationDelayMillis = 100
|
||||
@@ -1,591 +0,0 @@
|
||||
/*
|
||||
* 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.BackEventCompat
|
||||
import androidx.activity.compose.PredictiveBackHandler
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.AnimationVector1D
|
||||
import androidx.compose.animation.core.CubicBezierEasing
|
||||
import androidx.compose.animation.core.EaseOut
|
||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.MutatorMutex
|
||||
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.BasicTextField2
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.text.input.TextFieldLineLimits
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.material3.SearchBarDefaults.InputFieldHeight
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableFloatState
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
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.coroutines.cancellation.CancellationException
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.sign
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
* <a href="https://m3.material.io/components/search/overview" class="external" target="_blank">Material Design search</a>.
|
||||
*
|
||||
* 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 an optional hoisted [MutableInteractionSource] for observing and
|
||||
* emitting [Interaction]s for this search bar. You can use this to change the search bar's
|
||||
* appearance or preview the search bar in different states. Note that if `null` is provided,
|
||||
* interactions will still happen internally.
|
||||
* @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,
|
||||
title: @Composable (() -> Unit)? = null,
|
||||
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? = null,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
val animationProgress = remember { Animatable(initialValue = if (active) 1f else 0f) }
|
||||
val finalBackProgress = remember { mutableFloatStateOf(Float.NaN) }
|
||||
val firstBackEvent = remember { mutableStateOf<BackEventCompat?>(null) }
|
||||
val currentBackEvent = remember { mutableStateOf<BackEventCompat?>(null) }
|
||||
|
||||
LaunchedEffect(active) {
|
||||
val animationInProgress = animationProgress.value > 0 && animationProgress.value < 1
|
||||
val animationSpec =
|
||||
if (animationInProgress) {
|
||||
AnimationPredictiveBackExitFloatSpec
|
||||
} else if (active) {
|
||||
AnimationEnterFloatSpec
|
||||
} else {
|
||||
AnimationExitFloatSpec
|
||||
}
|
||||
val targetValue = if (active) 1f else 0f
|
||||
if (animationProgress.value != targetValue) {
|
||||
animationProgress.animateTo(targetValue, animationSpec)
|
||||
}
|
||||
if (!active) {
|
||||
finalBackProgress.floatValue = Float.NaN
|
||||
firstBackEvent.value = null
|
||||
currentBackEvent.value = null
|
||||
}
|
||||
}
|
||||
|
||||
val mutatorMutex = remember { MutatorMutex() }
|
||||
PredictiveBackHandler(enabled = active) { progress ->
|
||||
mutatorMutex.mutate {
|
||||
try {
|
||||
finalBackProgress.floatValue = Float.NaN
|
||||
progress.collect { backEvent ->
|
||||
if (firstBackEvent.value == null) {
|
||||
firstBackEvent.value = backEvent
|
||||
}
|
||||
currentBackEvent.value = backEvent
|
||||
val interpolatedProgress = EaseOut.transform(backEvent.progress)
|
||||
animationProgress.snapTo(targetValue = 1 - interpolatedProgress)
|
||||
}
|
||||
finalBackProgress.floatValue = animationProgress.value
|
||||
onActiveChange(false)
|
||||
} catch (e: CancellationException) {
|
||||
animationProgress.animateTo(
|
||||
targetValue = 1f,
|
||||
animationSpec = AnimationPredictiveBackExitFloatSpec,
|
||||
)
|
||||
finalBackProgress.floatValue = Float.NaN
|
||||
firstBackEvent.value = null
|
||||
currentBackEvent.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SearchBarInternal(
|
||||
animationProgress = animationProgress,
|
||||
finalBackProgress = finalBackProgress,
|
||||
firstBackEvent = firstBackEvent,
|
||||
currentBackEvent = currentBackEvent,
|
||||
state = state,
|
||||
onSearch = onSearch,
|
||||
active = active,
|
||||
onActiveChange = onActiveChange,
|
||||
modifier = modifier,
|
||||
enabled = enabled,
|
||||
title = title,
|
||||
placeholder = placeholder,
|
||||
leadingIcon = leadingIcon,
|
||||
trailingIcon = trailingIcon,
|
||||
shape = shape,
|
||||
colors = colors,
|
||||
tonalElevation = tonalElevation,
|
||||
shadowElevation = shadowElevation,
|
||||
windowInsets = windowInsets,
|
||||
interactionSource = interactionSource,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
@ExperimentalMaterial3Api
|
||||
@Composable
|
||||
internal fun SearchBarInternal(
|
||||
animationProgress: Animatable<Float, AnimationVector1D>,
|
||||
finalBackProgress: MutableFloatState,
|
||||
firstBackEvent: MutableState<BackEventCompat?>,
|
||||
currentBackEvent: MutableState<BackEventCompat?>,
|
||||
state: TextFieldState,
|
||||
onSearch: () -> Unit,
|
||||
active: Boolean,
|
||||
onActiveChange: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
title: @Composable (() -> Unit)? = null,
|
||||
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? = null,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
@Suppress("NAME_SHADOWING")
|
||||
val interactionSource = interactionSource ?: remember { MutableInteractionSource() }
|
||||
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
|
||||
.padding(horizontal = lerp(SearchBarHorizontalPadding, 0.dp, animationProgress.value))
|
||||
.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 defaultStartWidth = max(constraints.minWidth, SearchBarMinWidth.roundToPx())
|
||||
.coerceAtMost(min(constraints.maxWidth, SearchBarMaxWidth.roundToPx()))
|
||||
val defaultStartHeight = max(constraints.minHeight, InputFieldHeight.roundToPx())
|
||||
.coerceAtMost(constraints.maxHeight)
|
||||
val predictiveBackStartWidth =
|
||||
(constraints.maxWidth * SearchBarPredictiveBackMinScale).roundToInt()
|
||||
val predictiveBackStartHeight =
|
||||
(constraints.maxHeight * SearchBarPredictiveBackMinScale).roundToInt()
|
||||
val predictiveBackMultiplier = calculatePredictiveBackMultiplier(
|
||||
currentBackEvent.value,
|
||||
animationProgress.value,
|
||||
finalBackProgress.floatValue,
|
||||
)
|
||||
val startWidth =
|
||||
lerp(defaultStartWidth, predictiveBackStartWidth, predictiveBackMultiplier)
|
||||
val startHeight =
|
||||
lerp(defaultStartHeight, predictiveBackStartHeight, predictiveBackMultiplier)
|
||||
|
||||
val endWidth = constraints.maxWidth
|
||||
val endHeight = constraints.maxHeight
|
||||
|
||||
val width = lerp(startWidth, endWidth, animationProgress.value)
|
||||
val height =
|
||||
lerp(startHeight, endHeight, animationProgress.value) + animatedTopPadding
|
||||
|
||||
val minOffsetMargin = SearchBarPredictiveBackMinMargin.roundToPx()
|
||||
val predictiveBackOffsetX = calculatePredictiveBackOffsetX(
|
||||
constraints,
|
||||
minOffsetMargin,
|
||||
currentBackEvent.value,
|
||||
animationProgress.value,
|
||||
predictiveBackMultiplier,
|
||||
)
|
||||
val predictiveBackOffsetY = calculatePredictiveBackOffsetY(
|
||||
constraints,
|
||||
minOffsetMargin,
|
||||
currentBackEvent.value,
|
||||
firstBackEvent.value,
|
||||
height,
|
||||
SearchBarPredictiveBackMaxOffsetY.roundToPx(),
|
||||
predictiveBackMultiplier,
|
||||
)
|
||||
|
||||
val placeable = measurable.measure(
|
||||
Constraints
|
||||
.fixed(width, height)
|
||||
.offset(
|
||||
vertical = -animatedTopPadding,
|
||||
),
|
||||
)
|
||||
layout(width, height) {
|
||||
placeable.placeRelative(
|
||||
predictiveBackOffsetX,
|
||||
animatedTopPadding + predictiveBackOffsetY,
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
Column {
|
||||
val animatedInputFieldPadding = remember {
|
||||
AnimatedPaddingValues2(animationProgress.asState(), topPadding)
|
||||
}
|
||||
SearchBarInputField(
|
||||
state = state,
|
||||
onSearch = onSearch,
|
||||
onActiveChange = onActiveChange,
|
||||
modifier = Modifier.padding(paddingValues = animatedInputFieldPadding),
|
||||
enabled = enabled,
|
||||
label = title.takeUnless { active || animationProgress.value > 0 || state.text.isNotEmpty() },
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun calculatePredictiveBackMultiplier(
|
||||
currentBackEvent: BackEventCompat?,
|
||||
progress: Float,
|
||||
finalBackProgress: Float,
|
||||
) = when {
|
||||
currentBackEvent == null -> 0f // Not in predictive back at all.
|
||||
finalBackProgress.isNaN() -> 1f // User is currently swiping predictive back.
|
||||
finalBackProgress <= 0 -> 0f // Safety check for divide by zero.
|
||||
else -> progress / finalBackProgress // User has released predictive back swipe.
|
||||
}
|
||||
|
||||
private fun calculatePredictiveBackOffsetX(
|
||||
constraints: Constraints,
|
||||
minMargin: Int,
|
||||
currentBackEvent: BackEventCompat?,
|
||||
progress: Float,
|
||||
predictiveBackMultiplier: Float,
|
||||
): Int {
|
||||
if (currentBackEvent == null || predictiveBackMultiplier == 0f) {
|
||||
return 0
|
||||
}
|
||||
val directionMultiplier = if (currentBackEvent.swipeEdge == BackEventCompat.EDGE_LEFT) 1 else -1
|
||||
val maxOffsetX =
|
||||
(constraints.maxWidth * SearchBarPredictiveBackMaxOffsetXRatio) - minMargin
|
||||
val interpolatedOffsetX = maxOffsetX * (1 - progress)
|
||||
return (interpolatedOffsetX * predictiveBackMultiplier * directionMultiplier).roundToInt()
|
||||
}
|
||||
|
||||
private fun calculatePredictiveBackOffsetY(
|
||||
constraints: Constraints,
|
||||
minMargin: Int,
|
||||
currentBackEvent: BackEventCompat?,
|
||||
firstBackEvent: BackEventCompat?,
|
||||
height: Int,
|
||||
maxOffsetY: Int,
|
||||
predictiveBackMultiplier: Float,
|
||||
): Int {
|
||||
if (firstBackEvent == null || currentBackEvent == null || predictiveBackMultiplier == 0f) {
|
||||
return 0
|
||||
}
|
||||
val availableVerticalSpace = max(0, (constraints.maxHeight - height) / 2 - minMargin)
|
||||
val adjustedMaxOffsetY = min(availableVerticalSpace, maxOffsetY)
|
||||
val yDelta = currentBackEvent.touchY - firstBackEvent.touchY
|
||||
val yProgress = abs(yDelta) / constraints.maxHeight
|
||||
val directionMultiplier = sign(yDelta)
|
||||
val interpolatedOffsetY = lerp(0, adjustedMaxOffsetY, yProgress)
|
||||
return (interpolatedOffsetY * predictiveBackMultiplier * directionMultiplier).roundToInt()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchBarInputField(
|
||||
state: TextFieldState,
|
||||
onSearch: () -> Unit,
|
||||
onActiveChange: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
label: @Composable (() -> Unit)? = null,
|
||||
placeholder: @Composable (() -> Unit)? = null,
|
||||
leadingIcon: @Composable (() -> Unit)? = null,
|
||||
trailingIcon: @Composable (() -> Unit)? = null,
|
||||
colors: TextFieldColors = SearchBarDefaults.inputFieldColors(),
|
||||
interactionSource: MutableInteractionSource? = null,
|
||||
) {
|
||||
@Suppress("NAME_SHADOWING")
|
||||
val interactionSource = interactionSource ?: 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,
|
||||
decorator = @Composable { innerTextField ->
|
||||
TextFieldDefaults.DecorationBox(
|
||||
value = state.text.toString(),
|
||||
innerTextField = innerTextField,
|
||||
enabled = enabled,
|
||||
singleLine = true,
|
||||
visualTransformation = VisualTransformation.None,
|
||||
interactionSource = interactionSource,
|
||||
label = label,
|
||||
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<Float>,
|
||||
val topPadding: State<Dp>,
|
||||
) : 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
|
||||
internal val SearchBarHorizontalPadding: Dp = 16.dp
|
||||
|
||||
// Search bar has 16dp padding between icons and start/end, while by default text field has 12dp.
|
||||
private val SearchBarIconOffsetX: Dp = 4.dp
|
||||
private const val SearchBarPredictiveBackMinScale: Float = 9f / 10f
|
||||
private val SearchBarPredictiveBackMinMargin: Dp = 8.dp
|
||||
private const val SearchBarPredictiveBackMaxOffsetXRatio: Float = 1f / 20f
|
||||
private val SearchBarPredictiveBackMaxOffsetY: Dp = 24.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<Float> = tween(
|
||||
durationMillis = AnimationEnterDurationMillis,
|
||||
delayMillis = AnimationDelayMillis,
|
||||
easing = AnimationEnterEasing,
|
||||
)
|
||||
private val AnimationExitFloatSpec: FiniteAnimationSpec<Float> = tween(
|
||||
durationMillis = AnimationExitDurationMillis,
|
||||
delayMillis = AnimationDelayMillis,
|
||||
easing = AnimationExitEasing,
|
||||
)
|
||||
private val AnimationPredictiveBackExitFloatSpec: FiniteAnimationSpec<Float> = tween(
|
||||
durationMillis = AnimationExitDurationMillis,
|
||||
easing = AnimationExitEasing,
|
||||
)
|
||||
+3
-140
@@ -236,7 +236,7 @@ class DrawerState2(
|
||||
* @param confirmStateChange Optional callback invoked to confirm or veto a pending state change.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberDrawerState(
|
||||
fun rememberDrawerState2(
|
||||
initialValue: DrawerValue,
|
||||
confirmStateChange: (DrawerValue) -> Boolean = { true },
|
||||
): DrawerState2 {
|
||||
@@ -246,147 +246,10 @@ fun rememberDrawerState(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ModalNavigationDrawer(
|
||||
fun ModalSideDrawer(
|
||||
drawerContent: @Composable () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
drawerState: DrawerState2 = rememberDrawerState(DrawerValue.Closed),
|
||||
gesturesEnabled: Boolean = true,
|
||||
scrimColor: Color = DrawerDefaults.scrimColor,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val density = LocalDensity.current
|
||||
var minValue by remember { mutableFloatStateOf(-with(density) { ContainerWidth.toPx() }) }
|
||||
val maxValue = 0f
|
||||
|
||||
SideEffect {
|
||||
drawerState.density = density
|
||||
drawerState.anchoredDraggableState.updateAnchors(
|
||||
DraggableAnchors {
|
||||
DrawerValue.Closed at minValue
|
||||
DrawerValue.Open at maxValue
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
|
||||
val velocityTracker = remember { VelocityTracker() }
|
||||
val dragModifier = if (gesturesEnabled) {
|
||||
Modifier.pointerInput(isRtl) {
|
||||
val multiplier = if (isRtl) -1 else 1
|
||||
awaitEachGesture {
|
||||
fun canConsume(slop: Float) = (slop > 0 && drawerState.isClosed) || (slop < 0 && drawerState.isOpen)
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
var ignore = false
|
||||
val drag = awaitHorizontalTouchSlopOrCancellation(down.id) { change, over ->
|
||||
val overSlop = over * multiplier
|
||||
val canConsume = canConsume(overSlop)
|
||||
if (canConsume && !ignore) {
|
||||
velocityTracker.addPointerInputChange(change)
|
||||
change.consume()
|
||||
drawerState.anchoredDraggableState.dispatchRawDelta(overSlop)
|
||||
} else {
|
||||
ignore = true
|
||||
}
|
||||
}
|
||||
if (drag != null) {
|
||||
horizontalDrag(drag.id) {
|
||||
velocityTracker.addPointerInputChange(it)
|
||||
drawerState.anchoredDraggableState.dispatchRawDelta(it.positionChange().x * multiplier)
|
||||
}
|
||||
val velocity = velocityTracker.calculateVelocity()
|
||||
velocityTracker.resetTracking()
|
||||
scope.launch {
|
||||
drawerState.anchoredDraggableState.settle(velocity.x * multiplier)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
Box(modifier.fillMaxSize().then(dragModifier)) {
|
||||
val radius by remember {
|
||||
snapshotFlow {
|
||||
val step = calculateFraction(minValue, maxValue, drawerState.currentOffset)
|
||||
lerp(0, 10, step).dp
|
||||
}
|
||||
}.collectAsState(0.dp)
|
||||
val blurModifier = Modifier.graphicsLayer {
|
||||
if (radius != 0.dp) {
|
||||
renderEffect = BlurEffect(radius.toPx(), radius.toPx(), TileMode.Clamp)
|
||||
shape = RectangleShape
|
||||
clip = true
|
||||
}
|
||||
}
|
||||
Box(modifier = blurModifier) {
|
||||
content()
|
||||
}
|
||||
Scrim(
|
||||
open = drawerState.isOpen,
|
||||
onClose = {
|
||||
if (gesturesEnabled && drawerState.confirmStateChange(DrawerValue.Closed)) {
|
||||
scope.launch { drawerState.close() }
|
||||
}
|
||||
},
|
||||
fraction = {
|
||||
calculateFraction(minValue, maxValue, drawerState.requireOffset())
|
||||
},
|
||||
color = scrimColor,
|
||||
)
|
||||
val predictiveState by animateFloatMergeOneWayPredictiveBackAsState(drawerState.isOpen) {
|
||||
drawerState.close()
|
||||
}
|
||||
val absoluteElevation = LocalAbsoluteTonalElevation.current + DrawerDefaults.ModalDrawerElevation
|
||||
val predictiveModifier = if (drawerState.isOpen) {
|
||||
if (predictiveState > 0) {
|
||||
Modifier.layout { measurable, constraints ->
|
||||
val placeable = measurable.measure(constraints)
|
||||
val multiplierX = lerp(1f, 1.05f, predictiveState)
|
||||
val multiplierY = lerp(1f, 0.95f, predictiveState)
|
||||
val scaledWidth = (multiplierX * placeable.width).roundToInt()
|
||||
val scaledHeight = (multiplierY * placeable.height).roundToInt()
|
||||
val reMeasured = measurable.measure(Constraints.fixed(scaledWidth, scaledHeight))
|
||||
layout(scaledWidth, scaledHeight) {
|
||||
reMeasured.placeRelative(scaledWidth - placeable.width, 0)
|
||||
}
|
||||
}.background(
|
||||
color = MaterialTheme.colorScheme.surfaceColorAtElevation(absoluteElevation),
|
||||
shape = DrawerDefaults.shape,
|
||||
)
|
||||
} else {
|
||||
Modifier.graphicsLayer {
|
||||
val scale = lerp(1f, 0.95f, -predictiveState)
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
}.offset {
|
||||
IntOffset(lerp(0f, minValue * 0.05f, -predictiveState).roundToInt(), 0)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Modifier.onGloballyPositioned {
|
||||
val w = it.size.width
|
||||
if (w != 0) {
|
||||
minValue = -it.size.width.toFloat()
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier.offset {
|
||||
IntOffset(drawerState.requireOffset().roundToInt(), 0)
|
||||
} then predictiveModifier.align(Alignment.CenterStart),
|
||||
contentAlignment = Alignment.CenterEnd,
|
||||
) {
|
||||
drawerContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SideDrawer(
|
||||
drawerContent: @Composable () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
drawerState: DrawerState2 = rememberDrawerState(DrawerValue.Closed),
|
||||
drawerState: DrawerState2 = rememberDrawerState2(DrawerValue.Closed),
|
||||
gesturesEnabled: Boolean = true,
|
||||
scrimColor: Color = DrawerDefaults.scrimColor,
|
||||
content: @Composable () -> Unit,
|
||||
+2
-21
@@ -1336,8 +1336,7 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a listener allowing notification of load and error events. Extend [DefaultOnImageEventListener]
|
||||
* to simplify implementation.
|
||||
* Add a listener allowing notification of load and error events.
|
||||
*
|
||||
* @param onImageEventListener an [OnImageEventListener] instance.
|
||||
*/
|
||||
@@ -1391,7 +1390,7 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
|
||||
/**
|
||||
* An event listener, allowing subclasses and activities to be notified of significant events.
|
||||
*/
|
||||
interface OnImageEventListener {
|
||||
fun interface OnImageEventListener {
|
||||
/**
|
||||
* Called when the dimensions of the image and view are known, and either a preview image,
|
||||
* the full size image, or base layer tiles are loaded. This indicates the scale and translate
|
||||
@@ -1399,16 +1398,6 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
|
||||
* graphic, or inform a subclass that it is safe to draw overlays.
|
||||
*/
|
||||
fun onReady()
|
||||
|
||||
/**
|
||||
* Indicates an error initiliasing the decoder when using a tiling, or when loading the full
|
||||
* size bitmap when tiling is disabled. This method cannot be relied upon; certain encoding
|
||||
* types of supported image formats can result in corrupt or blank images being loaded and
|
||||
* displayed with no detectable error.
|
||||
*
|
||||
* @param e The exception thrown. This error is also logged by the view.
|
||||
*/
|
||||
fun onImageLoadError(e: Exception)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1467,14 +1456,6 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
|
||||
|
||||
private data class ScaleAndTranslate(var scale: Float, val vTranslate: PointF)
|
||||
|
||||
/**
|
||||
* Default implementation of [OnImageEventListener] for extension. This does nothing in any method.
|
||||
*/
|
||||
open class DefaultOnImageEventListener : OnImageEventListener {
|
||||
override fun onReady() {}
|
||||
override fun onImageLoadError(e: Exception) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builder class used to set additional options for a scale animation. Create an instance using [.animateScale],
|
||||
* then set your options and call [.start].
|
||||
|
||||
@@ -25,11 +25,14 @@ import androidx.collection.LruCache
|
||||
import androidx.compose.runtime.snapshots.Snapshot
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import androidx.lifecycle.coroutineScope
|
||||
import coil3.EventListener
|
||||
import coil3.SingletonImageLoader
|
||||
import coil3.asCoilImage
|
||||
import coil3.gif.AnimatedImageDecoder
|
||||
import coil3.gif.GifDecoder
|
||||
import coil3.network.ktor.KtorNetworkFetcherFactory
|
||||
import coil3.request.ErrorResult
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.crossfade
|
||||
import coil3.serviceLoaderEnabled
|
||||
import coil3.util.DebugLogger
|
||||
@@ -64,6 +67,7 @@ import kotlinx.coroutines.launch
|
||||
import logcat.AndroidLogcatLogger
|
||||
import logcat.LogPriority
|
||||
import logcat.LogcatLogger
|
||||
import logcat.asLog
|
||||
import okio.Path.Companion.toOkioPath
|
||||
import splitties.arch.room.roomDb
|
||||
import splitties.init.appCtx
|
||||
@@ -174,7 +178,17 @@ class EhApplication : Application(), SingletonImageLoader.Factory {
|
||||
crossfade(300)
|
||||
val drawable = AppCompatResources.getDrawable(appCtx, R.drawable.image_failed)
|
||||
if (drawable != null) error(drawable.asCoilImage(true))
|
||||
if (BuildConfig.DEBUG) logger(DebugLogger())
|
||||
if (BuildConfig.DEBUG) {
|
||||
logger(DebugLogger())
|
||||
} else {
|
||||
eventListener(object : EventListener() {
|
||||
override fun onError(request: ImageRequest, result: ErrorResult) {
|
||||
logcat("ImageLoader", LogPriority.ERROR) {
|
||||
"🚨 Failed - ${request.data}\n${result.throwable.asLog()}"
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
@@ -187,7 +201,7 @@ class EhApplication : Application(), SingletonImageLoader.Factory {
|
||||
}
|
||||
|
||||
val noRedirectKtorClient by lazy {
|
||||
HttpClient(ktorClient.engine) {
|
||||
HttpClient(CronetEngine) {
|
||||
followRedirects = false
|
||||
install(HttpCookies) {
|
||||
storage = EhCookieStore
|
||||
|
||||
@@ -29,14 +29,19 @@ import com.hippo.ehviewer.dao.HistoryInfo
|
||||
import com.hippo.ehviewer.dao.LocalFavoriteInfo
|
||||
import com.hippo.ehviewer.dao.ProgressInfo
|
||||
import com.hippo.ehviewer.dao.QuickSearch
|
||||
import com.hippo.ehviewer.dao.Schema17to18
|
||||
import com.hippo.ehviewer.download.DownloadManager
|
||||
import com.hippo.ehviewer.util.sendTo
|
||||
import com.hippo.unifile.UniFile
|
||||
import com.hippo.unifile.asUniFile
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import splitties.arch.room.roomDb
|
||||
|
||||
object EhDB {
|
||||
private val db = roomDb<EhDatabase>("eh.db")
|
||||
private const val DB_NAME = "eh.db"
|
||||
private val db = roomDb<EhDatabase>(DB_NAME) {
|
||||
addMigrations(Schema17to18())
|
||||
}
|
||||
|
||||
private suspend fun putGalleryInfo(galleryInfo: BaseGalleryInfo) {
|
||||
db.galleryDao().upsert(galleryInfo)
|
||||
@@ -256,35 +261,20 @@ object EhDB {
|
||||
db.filterDao().update(filter)
|
||||
}
|
||||
|
||||
suspend fun exportDB(context: Context, uri: Uri) {
|
||||
val ehExportName = "eh.export.db"
|
||||
resource {
|
||||
context.deleteDatabase(ehExportName)
|
||||
roomDb<EhDatabase>(ehExportName)
|
||||
} release {
|
||||
it.close()
|
||||
context.deleteDatabase(ehExportName)
|
||||
} use { newDb ->
|
||||
db.galleryDao().list().let { newDb.galleryDao().insertOrIgnore(it) }
|
||||
db.progressDao().list().let { newDb.progressDao().insertOrIgnore(it) }
|
||||
db.downloadLabelDao().list().let { newDb.downloadLabelDao().insert(it) }
|
||||
db.downloadDirnameDao().list().let { newDb.downloadDirnameDao().insertOrIgnore(it) }
|
||||
db.downloadsDao().list().let { newDb.downloadsDao().insert(it) }
|
||||
db.historyDao().list().let { newDb.historyDao().insertOrIgnore(it) }
|
||||
db.quickSearchDao().list().let { newDb.quickSearchDao().insert(it) }
|
||||
db.localFavoritesDao().list().let { newDb.localFavoritesDao().insertOrIgnore(it) }
|
||||
db.filterDao().list().let { newDb.filterDao().insert(it) }
|
||||
newDb.close()
|
||||
val dbFile = context.getDatabasePath(ehExportName)
|
||||
dbFile.asUniFile() sendTo uri.asUniFile()
|
||||
}
|
||||
fun exportDB(context: Context, file: UniFile) {
|
||||
db.query("PRAGMA wal_checkpoint(FULL)", null).use { it.moveToNext() }
|
||||
val dbFile = context.getDatabasePath(DB_NAME)
|
||||
dbFile.asUniFile() sendTo file
|
||||
}
|
||||
|
||||
suspend fun importDB(context: Context, uri: Uri) {
|
||||
val tempDBName = "tmp.db"
|
||||
resource {
|
||||
context.deleteDatabase(tempDBName)
|
||||
roomDb<EhDatabase>(tempDBName) { createFromInputStream { context.contentResolver.openInputStream(uri) } }
|
||||
roomDb<EhDatabase>(tempDBName) {
|
||||
createFromInputStream { context.contentResolver.openInputStream(uri) }
|
||||
addMigrations(Schema17to18())
|
||||
}
|
||||
} release {
|
||||
it.close()
|
||||
context.deleteDatabase(tempDBName)
|
||||
|
||||
@@ -116,6 +116,7 @@ object Settings : DataStorePreferences(null) {
|
||||
val showGalleryPages = boolPref("show_gallery_pages", true)
|
||||
val qSSaveProgress = boolPref("qs_save_progress", true)
|
||||
val security = boolPref("require_unlock", false)
|
||||
val animateItems = boolPref("animate_items", true)
|
||||
val recentDownloadLabel = stringOrNullPref("recent_download_label", null)
|
||||
|
||||
var downloadScheme by stringOrNullPref("image_scheme", null)
|
||||
@@ -142,6 +143,7 @@ object Settings : DataStorePreferences(null) {
|
||||
var showTagTranslations by boolPref(KEY_SHOW_TAG_TRANSLATIONS, false).observed { updateWhenTagTranslationChanges() }
|
||||
var appLinkVerifyTip by boolPref("app_link_verify_tip", false)
|
||||
var enabledSecurity by boolPref("enable_secure", false)
|
||||
var backupBeforeUpdate by boolPref("backup_before_update", true)
|
||||
var useCIUpdateChannel by boolPref("ci_update_channel", false)
|
||||
var mediaScan by boolPref("media_scan", false).observed { updateWhenKeepMediaStatusChanges() }
|
||||
var hasDefaultDownloadLabel by boolPref("has_default_download_label", false)
|
||||
|
||||
@@ -60,7 +60,6 @@ import io.ktor.client.statement.bodyAsChannel
|
||||
import io.ktor.utils.io.pool.DirectByteBufferPool
|
||||
import io.ktor.utils.io.pool.useInstance
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
import java.nio.ByteBuffer
|
||||
import kotlin.math.ceil
|
||||
import kotlinx.coroutines.CancellationException
|
||||
@@ -71,6 +70,8 @@ import kotlinx.serialization.json.addJsonArray
|
||||
import kotlinx.serialization.json.put
|
||||
import moe.tarsin.coroutines.removeAllSuspend
|
||||
import moe.tarsin.coroutines.runSuspendCatching
|
||||
import okio.buffer
|
||||
import okio.sink
|
||||
import org.jsoup.Jsoup
|
||||
import splitties.init.appCtx
|
||||
|
||||
@@ -84,11 +85,11 @@ private const val U_CONFIG_TEXT = "Selected Profile"
|
||||
fun Either<String, ByteBuffer>.saveParseError(e: Throwable) {
|
||||
val dir = AppConfig.externalParseErrorDir ?: return
|
||||
val file = File(dir, ReadableTime.getFilenamableTime(System.currentTimeMillis()) + ".txt")
|
||||
RandomAccessFile(file, "rw").use { randomAccessFile ->
|
||||
with(randomAccessFile) {
|
||||
writeUTF(e.message + "\n")
|
||||
onLeft { writeUTF(it) }
|
||||
onRight { channel.write(it) }
|
||||
file.sink().buffer().use { sink ->
|
||||
with(sink) {
|
||||
writeUtf8(e.message + "\n")
|
||||
onLeft { writeUtf8(it) }
|
||||
onRight { write(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -100,7 +101,7 @@ fun rethrowExactly(code: Int, body: Either<String, ByteBuffer>, e: Throwable): N
|
||||
// Check sad panda (without panda)
|
||||
val empty = body.fold(
|
||||
{ it.isEmpty() },
|
||||
{ it.limit() == 0 },
|
||||
{ !it.hasRemaining() },
|
||||
)
|
||||
if (empty) {
|
||||
if (EhUtils.isExHentai) {
|
||||
@@ -143,7 +144,7 @@ fun rethrowExactly(code: Int, body: Either<String, ByteBuffer>, e: Throwable): N
|
||||
|
||||
val httpContentPool = DirectByteBufferPool(8, 0x80000)
|
||||
|
||||
suspend inline fun <T> HttpStatement.fetchUsingAsText(crossinline block: suspend String.() -> T) = execute { response ->
|
||||
suspend inline fun <T> HttpStatement.fetchUsingAsText(crossinline block: suspend String.() -> T) = executeSafely { response ->
|
||||
val body = response.bodyAsUtf8Text()
|
||||
runSuspendCatching {
|
||||
block(body)
|
||||
@@ -152,20 +153,21 @@ suspend inline fun <T> HttpStatement.fetchUsingAsText(crossinline block: suspend
|
||||
}.getOrThrow()
|
||||
}
|
||||
|
||||
suspend inline fun <T> HttpStatement.fetchUsingAsByteBuffer(crossinline block: suspend ByteBuffer.() -> T) = execute { response ->
|
||||
suspend inline fun <T> HttpStatement.fetchUsingAsByteBuffer(crossinline block: suspend ByteBuffer.() -> T) = executeSafely { response ->
|
||||
httpContentPool.useInstance { buffer ->
|
||||
with(response.bodyAsChannel()) { while (!isClosedForRead) readAvailable(buffer) }
|
||||
buffer.flip()
|
||||
runSuspendCatching {
|
||||
block(buffer)
|
||||
}.onFailure {
|
||||
buffer.rewind()
|
||||
rethrowExactly(response.status.value, buffer.right(), it)
|
||||
}.getOrThrow()
|
||||
}
|
||||
}
|
||||
|
||||
object EhEngine {
|
||||
suspend fun getOriginalImageUrl(url: String, referer: String?) = noRedirectEhRequest(url, referer).execute { response ->
|
||||
suspend fun getOriginalImageUrl(url: String, referer: String?) = noRedirectEhRequest(url, referer).executeSafely { response ->
|
||||
val location = response.headers["Location"] ?: throw InsufficientFundsException()
|
||||
location.takeIf { "bounce_login" !in it } ?: throw NotLoggedInException()
|
||||
}
|
||||
@@ -241,7 +243,7 @@ object EhEngine {
|
||||
append("edit_comment", id.toString())
|
||||
}
|
||||
}
|
||||
}.execute { response ->
|
||||
}.executeSafely { response ->
|
||||
// Ktor does not handle POST redirect, we need to do it manually
|
||||
// https://youtrack.jetbrains.com/issue/KTOR-478
|
||||
val location = response.headers["Location"] ?: url
|
||||
@@ -270,7 +272,7 @@ object EhEngine {
|
||||
append("apply", "Apply Changes")
|
||||
append("update", "1")
|
||||
}
|
||||
}.execute { }
|
||||
}.executeSafely { }
|
||||
}
|
||||
|
||||
suspend fun downloadArchive(gid: Long, token: String, or: String, res: String, isHAtH: Boolean): String? {
|
||||
|
||||
@@ -25,6 +25,7 @@ import io.ktor.client.request.forms.FormDataContent
|
||||
import io.ktor.client.request.header
|
||||
import io.ktor.client.request.prepareRequest
|
||||
import io.ktor.client.request.setBody
|
||||
import io.ktor.client.statement.HttpResponse
|
||||
import io.ktor.client.statement.HttpStatement
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.HttpHeaders
|
||||
@@ -32,6 +33,7 @@ import io.ktor.http.HttpMethod
|
||||
import io.ktor.http.ParametersBuilder
|
||||
import io.ktor.http.content.TextContent
|
||||
import io.ktor.http.userAgent
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArrayBuilder
|
||||
import kotlinx.serialization.json.JsonObjectBuilder
|
||||
@@ -42,7 +44,7 @@ import okio.BufferedSource
|
||||
|
||||
inline fun JsonObjectBuilder.array(name: String, builder: JsonArrayBuilder.() -> Unit) = put(name, buildJsonArray(builder))
|
||||
|
||||
suspend inline fun <reified T> HttpStatement.executeAndParseAs() = execute { it.bodyAsUtf8Text().parseAs<T>() }
|
||||
suspend inline fun <reified T> HttpStatement.executeAndParseAs() = executeSafely { it.bodyAsUtf8Text().parseAs<T>() }
|
||||
inline fun <reified T> BufferedSource.parseAs(): T = json.decodeFromBufferedSource(this)
|
||||
inline fun <reified T> String.parseAs(): T = json.decodeFromString(this)
|
||||
|
||||
@@ -82,6 +84,16 @@ suspend inline fun noRedirectEhRequest(
|
||||
apply(builder)
|
||||
}.also { logcat("EhRequest") { url } }
|
||||
|
||||
suspend inline fun <T> HttpStatement.executeSafely(
|
||||
crossinline block: suspend (response: HttpResponse) -> T,
|
||||
) = execute { resp ->
|
||||
try {
|
||||
block(resp)
|
||||
} finally {
|
||||
resp.coroutineContext.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
inline fun HttpRequestBuilder.formBody(builder: ParametersBuilder.() -> Unit) {
|
||||
method = HttpMethod.Post
|
||||
val parameters = ParametersBuilder().apply(builder).build()
|
||||
|
||||
@@ -152,7 +152,7 @@ object EhTagDatabase : CoroutineScope {
|
||||
|
||||
private suspend fun save(client: HttpClient, url: String, file: File) {
|
||||
runCatching {
|
||||
client.prepareGet(url).execute {
|
||||
client.prepareGet(url).executeSafely {
|
||||
if (it.status.isSuccess()) {
|
||||
it.bodyAsChannel().copyTo(file)
|
||||
} else {
|
||||
|
||||
@@ -122,7 +122,7 @@ interface GalleryInfo {
|
||||
|
||||
private fun generateSLangFromTitle(title: String): String? {
|
||||
for (i in S_LANGS.indices) {
|
||||
if (S_LANG_PATTERNS[i].matches(title)) {
|
||||
if (S_LANG_PATTERNS[i].containsMatchIn(title)) {
|
||||
return S_LANGS[i]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import com.hippo.ehviewer.image.detectBorder
|
||||
import com.hippo.ehviewer.util.isAtLeastO
|
||||
|
||||
private const val CROP_THRESHOLD = 0.75f
|
||||
private const val WEBTOON_THRESHOLD = 3
|
||||
private const val RATIO_THRESHOLD = 2
|
||||
|
||||
private val maybeCropBorderKey = Extras.Key(default = false)
|
||||
|
||||
@@ -48,7 +48,12 @@ object CropBorderInterceptor : Interceptor {
|
||||
val image = result.image
|
||||
if (image is BitmapImage) {
|
||||
val bitmap = image.bitmap
|
||||
val rect = if (image.height / image.width < WEBTOON_THRESHOLD) {
|
||||
val ratio = if (image.height > image.width) {
|
||||
image.height / image.width
|
||||
} else {
|
||||
image.width / image.height
|
||||
}
|
||||
val rect = if (ratio < RATIO_THRESHOLD) {
|
||||
val array = detectBorder(bitmap)
|
||||
val minWidth = image.width * CROP_THRESHOLD
|
||||
val minHeight = image.height * CROP_THRESHOLD
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.hippo.ehviewer.coil
|
||||
|
||||
import coil3.intercept.Interceptor
|
||||
import coil3.request.ErrorResult
|
||||
import coil3.request.ImageResult
|
||||
import coil3.request.SuccessResult
|
||||
import com.hippo.ehviewer.EhApplication.Companion.imageCache
|
||||
@@ -30,24 +29,24 @@ object DownloadThumbInterceptor : Interceptor {
|
||||
}
|
||||
if (withIOContext { thumb.isFile }) {
|
||||
val new = chain.request.newBuilder().data(thumb.uri.toString()).build()
|
||||
when (val result = chain.withRequest(new).proceed()) {
|
||||
is SuccessResult -> return result
|
||||
is ErrorResult -> withIOContext { thumb.delete() }
|
||||
}
|
||||
val result = chain.withRequest(new).proceed()
|
||||
if (result is SuccessResult) return result
|
||||
}
|
||||
val new = chain.request.newBuilder().data(url).build()
|
||||
return chain.withRequest(new).proceed().also {
|
||||
if (it is SuccessResult) {
|
||||
withIOContext {
|
||||
if (thumb.ensureFile()) {
|
||||
val key = requireNotNull(chain.request.memoryCacheKey)
|
||||
imageCache.read(key) {
|
||||
data.asUniFile() sendTo thumb
|
||||
}
|
||||
val result = chain.withRequest(new).proceed()
|
||||
if (result is SuccessResult) {
|
||||
withIOContext {
|
||||
// Accessing the recreated file immediately after deleting it throws
|
||||
// FileNotFoundException, so we just overwrite the existing file.
|
||||
if (thumb.ensureFile()) {
|
||||
val key = requireNotNull(chain.request.memoryCacheKey)
|
||||
imageCache.read(key) {
|
||||
data.asUniFile() sendTo thumb
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
return chain.proceed()
|
||||
|
||||
@@ -28,7 +28,6 @@ import com.hippo.ehviewer.client.data.BaseGalleryInfo
|
||||
AutoMigration(from = 14, to = 15),
|
||||
AutoMigration(from = 15, to = 16),
|
||||
AutoMigration(from = 16, to = 17),
|
||||
AutoMigration(from = 17, to = 18),
|
||||
AutoMigration(from = 18, to = 19, spec = Schema18to19::class),
|
||||
AutoMigration(from = 19, to = 20),
|
||||
AutoMigration(from = 20, to = 21, spec = Schema20to21::class),
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.room.DeleteTable
|
||||
import androidx.room.RenameColumn
|
||||
import androidx.room.RenameTable
|
||||
import androidx.room.migration.AutoMigrationSpec
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo.Companion.NOT_FAVORITED
|
||||
|
||||
@@ -103,6 +104,15 @@ class Schema12to13 : AutoMigrationSpec {
|
||||
@DeleteColumn(tableName = "DOWNLOADS", columnName = "SIMPLE_LANGUAGE")
|
||||
class Schema13to14 : AutoMigrationSpec
|
||||
|
||||
class Schema17to18 : Migration(17, 18) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("CREATE TABLE IF NOT EXISTS `_new_GALLERIES` (`GID` INTEGER NOT NULL, `TOKEN` TEXT NOT NULL, `TITLE` TEXT, `TITLE_JPN` TEXT, `THUMB` TEXT, `CATEGORY` INTEGER NOT NULL, `POSTED` TEXT, `UPLOADER` TEXT, `RATING` REAL NOT NULL, `SIMPLE_LANGUAGE` TEXT, `FAVORITE_SLOT` INTEGER NOT NULL, PRIMARY KEY(`GID`))")
|
||||
db.execSQL("INSERT INTO `_new_GALLERIES` (`GID`,`TOKEN`,`TITLE`,`TITLE_JPN`,`THUMB`,`CATEGORY`,`POSTED`,`UPLOADER`,`RATING`,`SIMPLE_LANGUAGE`,`FAVORITE_SLOT`) SELECT `GID`,`TOKEN`,`TITLE`,`TITLE_JPN`,`THUMB`,`CATEGORY`,`POSTED`,`UPLOADER`,`RATING`,`SIMPLE_LANGUAGE`,`FAVORITE_SLOT` FROM `GALLERIES` WHERE `TOKEN` IS NOT NULL")
|
||||
db.execSQL("DROP TABLE `GALLERIES`")
|
||||
db.execSQL("ALTER TABLE `_new_GALLERIES` RENAME TO `GALLERIES`")
|
||||
}
|
||||
}
|
||||
|
||||
@RenameColumn(tableName = "DOWNLOADS", fromColumnName = "POSITION", toColumnName = "TIME")
|
||||
class Schema18to19 : AutoMigrationSpec
|
||||
|
||||
|
||||
@@ -148,9 +148,9 @@ class DownloadService : Service(), DownloadManager.DownloadListener, CoroutineSc
|
||||
|
||||
ACTION_CLEAR -> {
|
||||
clear()
|
||||
checkStopSelf()
|
||||
}
|
||||
}
|
||||
checkStopSelf()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent) = error("No bindService")
|
||||
|
||||
@@ -30,8 +30,8 @@ import coil3.imageLoader
|
||||
import coil3.request.CachePolicy
|
||||
import coil3.request.ErrorResult
|
||||
import coil3.request.SuccessResult
|
||||
import coil3.size.Dimension
|
||||
import coil3.size.Precision
|
||||
import coil3.size.Scale
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.coil.BitmapImageWithRect
|
||||
import com.hippo.ehviewer.coil.maybeCropBorder
|
||||
@@ -73,15 +73,13 @@ class Image private constructor(image: CoilImage, private val src: AutoCloseable
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val targetWidth = appCtx.resources.displayMetrics.widthPixels * 2
|
||||
private val targetHeight = appCtx.resources.displayMetrics.heightPixels * 2
|
||||
private val targetWidth = appCtx.resources.displayMetrics.widthPixels * 3
|
||||
|
||||
private suspend fun Either<ByteBufferSource, UniFileSource>.decodeCoil(): CoilImage {
|
||||
val req = appCtx.imageRequest {
|
||||
onLeft { data(it.source) }
|
||||
onRight { data(it.source.uri) }
|
||||
size(targetWidth, targetHeight)
|
||||
scale(Scale.FILL)
|
||||
size(Dimension(targetWidth), Dimension.Undefined)
|
||||
precision(Precision.INEXACT)
|
||||
maybeCropBorder(Settings.cropBorder.value)
|
||||
memoryCachePolicy(CachePolicy.DISABLED)
|
||||
@@ -94,7 +92,7 @@ class Image private constructor(image: CoilImage, private val src: AutoCloseable
|
||||
|
||||
suspend fun decode(src: ImageSource): Image? {
|
||||
return runCatching {
|
||||
when (src) {
|
||||
val image = when (src) {
|
||||
is UniFileSource -> {
|
||||
if (isAtLeastP && !isAtLeastU) {
|
||||
src.source.openFileDescriptor("rw").use {
|
||||
@@ -112,20 +110,19 @@ class Image private constructor(image: CoilImage, private val src: AutoCloseable
|
||||
}
|
||||
}
|
||||
}
|
||||
val image = src.right().decodeCoil()
|
||||
if (image is BitmapImage) src.close()
|
||||
Image(image, src)
|
||||
src.right().decodeCoil()
|
||||
}
|
||||
|
||||
is ByteBufferSource -> {
|
||||
if (isAtLeastP && !isAtLeastU) {
|
||||
rewriteGifSource(src.source)
|
||||
}
|
||||
val image = src.left().decodeCoil()
|
||||
if (image is BitmapImage) src.close()
|
||||
Image(image, src)
|
||||
src.left().decodeCoil()
|
||||
}
|
||||
}
|
||||
Image(image, src).apply {
|
||||
if (innerImage is BitmapImage) src.close()
|
||||
}
|
||||
}.onFailure {
|
||||
src.close()
|
||||
logcat(it)
|
||||
|
||||
@@ -24,8 +24,6 @@ import java.nio.ByteBuffer
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.channels.consumeEach
|
||||
import kotlinx.coroutines.job
|
||||
@@ -42,7 +40,6 @@ object CronetEngine : HttpClientEngineBase("Cronet") {
|
||||
@InternalAPI
|
||||
override suspend fun execute(data: HttpRequestData) = executeHttpRequest(callContext(), data)
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
private suspend fun executeHttpRequest(
|
||||
callContext: CoroutineContext,
|
||||
data: HttpRequestData,
|
||||
@@ -50,7 +47,7 @@ object CronetEngine : HttpClientEngineBase("Cronet") {
|
||||
val requestTime = GMTDate()
|
||||
|
||||
val callback = object : UrlRequest.Callback() {
|
||||
lateinit var chunkChan: Channel<ByteBuffer>
|
||||
val chunkChan = Channel<ByteBuffer>()
|
||||
override fun onRedirectReceived(request: UrlRequest, info: UrlResponseInfo, newLocationUrl: String) {
|
||||
continuation.resume(
|
||||
info.toHttpResponseData(
|
||||
@@ -65,9 +62,8 @@ object CronetEngine : HttpClientEngineBase("Cronet") {
|
||||
info.toHttpResponseData(
|
||||
requestTime = requestTime,
|
||||
callContext = callContext,
|
||||
responseBody = GlobalScope.writer(callContext) {
|
||||
responseBody = writer(callContext.job) {
|
||||
pool.useInstance {
|
||||
chunkChan = Channel()
|
||||
request.read(it)
|
||||
chunkChan.consumeEach { buffer ->
|
||||
buffer.flip()
|
||||
|
||||
@@ -99,7 +99,6 @@ fun ComicInfo.toSimpleTags() = listOfNotNull(
|
||||
|
||||
fun ComicInfo.write(file: UniFile) {
|
||||
file.openOutputStream().use {
|
||||
it.channel.truncate(0)
|
||||
OutputStreamWriter(it, Charsets.UTF_8).use { writer ->
|
||||
xmlStreaming.newGenericWriter(writer).use { xmlWriter ->
|
||||
xml.encodeToWriter(xmlWriter, ComicInfo.serializer(), this)
|
||||
|
||||
@@ -28,6 +28,7 @@ import com.hippo.ehviewer.client.EhUtils.getSuitableTitle
|
||||
import com.hippo.ehviewer.client.data.GalleryDetail
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo
|
||||
import com.hippo.ehviewer.client.ehRequest
|
||||
import com.hippo.ehviewer.client.executeSafely
|
||||
import com.hippo.ehviewer.client.getImageKey
|
||||
import com.hippo.ehviewer.coil.read
|
||||
import com.hippo.ehviewer.coil.suspendEdit
|
||||
@@ -157,7 +158,7 @@ class SpiderDen(val info: GalleryInfo) {
|
||||
notifyProgress(total!!, done, (done - prev).toInt())
|
||||
prev = done
|
||||
}
|
||||
}.execute {
|
||||
}.executeSafely {
|
||||
if (it.status.isSuccess()) {
|
||||
saveFromHttpResponse(index, it)
|
||||
} else {
|
||||
@@ -191,8 +192,7 @@ class SpiderDen(val info: GalleryInfo) {
|
||||
val extension = MimeTypeMap.getFileExtensionFromUrl(url).ifEmpty { "jpg" }
|
||||
return saveResponseMeta(index, extension) { outFile ->
|
||||
outFile.openOutputStream().use {
|
||||
val chan = it.channel.apply { truncate(0) }
|
||||
response.bodyAsChannel().copyTo(chan)
|
||||
response.bodyAsChannel().copyTo(it.channel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -547,13 +547,20 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
|
||||
private fun doLaunchDownloadJob(index: Int, force: Boolean, orgImg: Boolean = false) {
|
||||
val state = mPageStateArray[index]
|
||||
if (!force && state == STATE_FINISHED) return
|
||||
if (!force) {
|
||||
if (state == STATE_FINISHED) return
|
||||
if (index in mSpiderDen) return updatePageState(index, STATE_FINISHED)
|
||||
}
|
||||
val currentJob = mFetcherJobMap[index]
|
||||
val skipHath = force && !orgImg && currentJob?.isActive == true
|
||||
if (force) currentJob?.cancel(CancellationException(FORCE_RETRY))
|
||||
if (currentJob?.isActive != true) {
|
||||
mFetcherJobMap[index] = launch {
|
||||
runCatching {
|
||||
delayLock.withLock {
|
||||
delay(mDownloadDelay - lastRequestTime.elapsedNow())
|
||||
lastRequestTime = TimeSource.Monotonic.markNow()
|
||||
}
|
||||
mSemaphore.withPermit {
|
||||
doInJob(index, force, orgImg, skipHath)
|
||||
}
|
||||
@@ -591,19 +598,14 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
?: getPTokenFromMultiPageViewer(index)
|
||||
}
|
||||
updatePageState(index, STATE_DOWNLOADING)
|
||||
if (!force && index in mSpiderDen) {
|
||||
return updatePageState(index, STATE_FINISHED)
|
||||
}
|
||||
if (force) {
|
||||
pTokenLock.withLock {
|
||||
val pToken = mSpiderInfo.pTokenMap[index]
|
||||
if (pToken == TOKEN_FAILED) mSpiderInfo.pTokenMap.remove(index)
|
||||
}
|
||||
}
|
||||
val previousPToken: String?
|
||||
val pToken: String
|
||||
|
||||
pTokenLock.withLock {
|
||||
if (force) {
|
||||
if (mSpiderInfo.pTokenMap[index] == TOKEN_FAILED) {
|
||||
mSpiderInfo.pTokenMap.remove(index)
|
||||
}
|
||||
}
|
||||
pToken = getPToken(index)
|
||||
?: return updatePageState(index, STATE_FAILED, PTOKEN_FAILED_MESSAGE).also {
|
||||
mSpiderInfo.pTokenMap[index] = TOKEN_FAILED
|
||||
@@ -611,11 +613,6 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
previousPToken = getPToken(index - 1)
|
||||
}
|
||||
|
||||
delayLock.withLock {
|
||||
delay(mDownloadDelay - lastRequestTime.elapsedNow())
|
||||
lastRequestTime = TimeSource.Monotonic.markNow()
|
||||
}
|
||||
|
||||
var skipHathKey: String? = null
|
||||
var originImageUrl: String? = null
|
||||
var error: String? = null
|
||||
@@ -738,31 +735,37 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
val currentJob = mDecodeJobMap[index]
|
||||
if (currentJob?.isActive != true) {
|
||||
mDecodeJobMap[index] = launch {
|
||||
doInJob(index)
|
||||
runCatching {
|
||||
mSemaphore.withPermit {
|
||||
mFetcherJobMap[index]?.join()
|
||||
if (mPageStateArray[index] == STATE_FINISHED) {
|
||||
doInJob(index)
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
if (mPageStateArray[index] == STATE_FINISHED) {
|
||||
notifyGetImageFailure(index, DECODE_ERROR)
|
||||
}
|
||||
if (it is CancellationException) {
|
||||
throw it
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun doInJob(index: Int) {
|
||||
mFetcherJobMap[index]?.takeIf { it.isActive }?.join()
|
||||
val src = mSpiderDen.getImageSource(index) ?: return
|
||||
val image = Image.decode(src)
|
||||
checkNotNull(image)
|
||||
runCatching {
|
||||
val src = mSpiderDen.getImageSource(index) ?: return
|
||||
val image = mSemaphore.withPermit { Image.decode(src) }
|
||||
checkNotNull(image)
|
||||
runCatching {
|
||||
currentCoroutineContext().ensureActive()
|
||||
}.onFailure {
|
||||
image.recycle()
|
||||
throw it
|
||||
}
|
||||
notifyGetImageSuccess(index, image)
|
||||
currentCoroutineContext().ensureActive()
|
||||
}.onFailure {
|
||||
notifyGetImageFailure(index, DECODE_ERROR)
|
||||
if (it is CancellationException) {
|
||||
throw it
|
||||
}
|
||||
image.recycle()
|
||||
throw it
|
||||
}
|
||||
notifyGetImageSuccess(index, image)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,7 +314,11 @@ suspend fun doGalleryInfoAction(info: BaseGalleryInfo) {
|
||||
val selected = showSelectItemWithIcon(items, EhUtils.getSuitableTitle(info))
|
||||
with(findActivity<MainActivity>()) {
|
||||
when (selected) {
|
||||
0 -> navToReader(info)
|
||||
0 -> {
|
||||
EhDB.putHistoryInfo(info)
|
||||
navToReader(info)
|
||||
}
|
||||
|
||||
1 -> withUIContext {
|
||||
if (downloaded) {
|
||||
confirmRemoveDownload(info)
|
||||
|
||||
@@ -49,27 +49,30 @@ import androidx.compose.material.icons.filled.History
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Whatshot
|
||||
import androidx.compose.material3.DrawerState
|
||||
import androidx.compose.material3.DrawerState2
|
||||
import androidx.compose.material3.DrawerValue
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalDrawerSheet
|
||||
import androidx.compose.material3.ModalNavigationDrawer
|
||||
import androidx.compose.material3.ModalSideDrawer
|
||||
import androidx.compose.material3.NavigationDrawerItem
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ShapeDefaults
|
||||
import androidx.compose.material3.SideDrawer
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.SnackbarResult
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.material3.rememberDrawerState2
|
||||
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.currentCompositeKeyHash
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -79,8 +82,14 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.BlurEffect
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.graphics.TileMode
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
@@ -90,6 +99,7 @@ import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.util.lerp
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
@@ -117,7 +127,7 @@ import com.hippo.ehviewer.ui.destinations.WhatshotScreenDestination
|
||||
import com.hippo.ehviewer.ui.screen.asDst
|
||||
import com.hippo.ehviewer.ui.screen.asDstWith
|
||||
import com.hippo.ehviewer.ui.screen.navWithUrl
|
||||
import com.hippo.ehviewer.ui.screen.navigate
|
||||
import com.hippo.ehviewer.ui.screen.navigateTo
|
||||
import com.hippo.ehviewer.ui.settings.showNewVersion
|
||||
import com.hippo.ehviewer.ui.tools.DialogState
|
||||
import com.hippo.ehviewer.ui.tools.LabeledCheckbox
|
||||
@@ -126,6 +136,7 @@ import com.hippo.ehviewer.ui.tools.LocalTouchSlopProvider
|
||||
import com.hippo.ehviewer.ui.tools.LocalWindowSizeClass
|
||||
import com.hippo.ehviewer.updater.AppUpdater
|
||||
import com.hippo.ehviewer.util.addTextToClipboard
|
||||
import com.hippo.ehviewer.util.calculateFraction
|
||||
import com.hippo.ehviewer.util.displayString
|
||||
import com.hippo.ehviewer.util.getParcelableExtraCompat
|
||||
import com.hippo.ehviewer.util.getUrlFromClipboard
|
||||
@@ -134,7 +145,7 @@ import com.hippo.ehviewer.util.isAtLeastS
|
||||
import com.hippo.unifile.asUniFile
|
||||
import com.hippo.unifile.sha1
|
||||
import com.ramcosta.composedestinations.DestinationsNavHost
|
||||
import com.ramcosta.composedestinations.rememberNavHostEngine
|
||||
import com.ramcosta.composedestinations.spec.DirectionDestinationSpec
|
||||
import com.ramcosta.composedestinations.utils.currentDestinationAsState
|
||||
import eu.kanade.tachiyomi.util.lang.withIOContext
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
@@ -148,7 +159,7 @@ import moe.tarsin.coroutines.runSuspendCatching
|
||||
import splitties.systemservices.clipboardManager
|
||||
import splitties.systemservices.connectivityManager
|
||||
|
||||
private val navItems = arrayOf(
|
||||
private val navItems = arrayOf<Triple<DirectionDestinationSpec, Int, ImageVector>>(
|
||||
Triple(HomePageScreenDestination, R.string.homepage, Icons.Default.Home),
|
||||
Triple(SubscriptionScreenDestination, R.string.subscription, EhIcons.Default.Subscriptions),
|
||||
Triple(WhatshotScreenDestination, R.string.whats_hot, Icons.Default.Whatshot),
|
||||
@@ -203,7 +214,7 @@ class MainActivity : EhActivity() {
|
||||
val configuration = LocalConfiguration.current
|
||||
val dialogState = LocalDialogState.current
|
||||
val navDrawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
val sideSheetState = rememberDrawerState(DrawerValue.Closed)
|
||||
val sideSheetState = rememberDrawerState2(DrawerValue.Closed)
|
||||
val snackbarState = remember { SnackbarHostState() }
|
||||
val scope = rememberCoroutineScope()
|
||||
val navController = rememberNavController()
|
||||
@@ -225,7 +236,7 @@ class MainActivity : EhActivity() {
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
navController.navigate(DownloadScreenDestination)
|
||||
navController.navigateTo(DownloadScreenDestination)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,25 +270,24 @@ class MainActivity : EhActivity() {
|
||||
if ("text/plain" == type) {
|
||||
val keyword = intent.getStringExtra(Intent.EXTRA_TEXT)
|
||||
if (keyword != null && !navController.navWithUrl(keyword)) {
|
||||
navController.navigate(ListUrlBuilder(mKeyword = keyword).asDst())
|
||||
navController.navigateTo(ListUrlBuilder(mKeyword = keyword).asDst())
|
||||
}
|
||||
} else if (type != null && type.startsWith("image/")) {
|
||||
val uri = intent.getParcelableExtraCompat<Uri>(Intent.EXTRA_STREAM)
|
||||
if (null != uri) {
|
||||
withIOContext { uri.asUniFile().sha1() }?.let {
|
||||
navController.navigate(
|
||||
ListUrlBuilder(
|
||||
mode = ListUrlBuilder.MODE_IMAGE_SEARCH,
|
||||
hash = it,
|
||||
).asDst(),
|
||||
)
|
||||
}
|
||||
val hash = withIOContext { uri.asUniFile().sha1() }
|
||||
navController.navigateTo(
|
||||
ListUrlBuilder(
|
||||
mode = ListUrlBuilder.MODE_IMAGE_SEARCH,
|
||||
hash = hash,
|
||||
).asDst(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
DownloadService.ACTION_START_DOWNLOADSCENE -> {
|
||||
val args = intent.getBundleExtra(DownloadService.ACTION_START_DOWNLOADSCENE_ARGS)
|
||||
navController.navigate(DownloadsScreenDestination)
|
||||
navController.navigateTo(DownloadsScreenDestination)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -308,7 +318,7 @@ class MainActivity : EhActivity() {
|
||||
}
|
||||
val snackMessage = stringResource(R.string.clipboard_gallery_url_snack_message)
|
||||
val snackAction = stringResource(R.string.clipboard_gallery_url_snack_action)
|
||||
LifecycleResumeEffect {
|
||||
LifecycleResumeEffect(scope) {
|
||||
val job = scope.launch {
|
||||
delay(300)
|
||||
val text = clipboardManager.getUrlFromClipboard(applicationContext)
|
||||
@@ -317,11 +327,11 @@ class MainActivity : EhActivity() {
|
||||
val result1 = GalleryDetailUrlParser.parse(text, false)
|
||||
var launch: (() -> Unit)? = null
|
||||
if (result1 != null) {
|
||||
launch = { navController.navigate(result1.gid asDstWith result1.token) }
|
||||
launch = { navController.navigateTo(result1.gid asDstWith result1.token) }
|
||||
}
|
||||
val result2 = GalleryPageUrlParser.parse(text, false)
|
||||
if (result2 != null) {
|
||||
launch = { navController.navigate(ProgressScreenDestination(result2.gid, result2.pToken, result2.page)) }
|
||||
launch = { navController.navigateTo(ProgressScreenDestination(result2.gid, result2.pToken, result2.page)) }
|
||||
}
|
||||
launch?.let {
|
||||
val ret = snackbarState.showSnackbar(snackMessage, snackAction, true)
|
||||
@@ -358,9 +368,11 @@ class MainActivity : EhActivity() {
|
||||
},
|
||||
) {
|
||||
LocalTouchSlopProvider(Settings.touchSlopFactor.toFloat()) {
|
||||
val minOffset = -with(density) { 360.dp.toPx() }
|
||||
ModalNavigationDrawer(
|
||||
drawerContent = {
|
||||
ModalDrawerSheet(
|
||||
drawerState = navDrawerState,
|
||||
modifier = Modifier.widthIn(max = (configuration.screenWidthDp - 56).dp),
|
||||
windowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Start),
|
||||
) {
|
||||
@@ -398,13 +410,19 @@ class MainActivity : EhActivity() {
|
||||
gesturesEnabled = !drawerLocked || navDrawerState.isOpen,
|
||||
) {
|
||||
val sheet = sideSheet.firstOrNull()
|
||||
SideDrawer(
|
||||
val radius by remember {
|
||||
snapshotFlow {
|
||||
val step = calculateFraction(minOffset, 0f, navDrawerState.currentOffset)
|
||||
with(density) { lerp(0, 10, step).dp.toPx() }
|
||||
}
|
||||
}.collectAsState(0f)
|
||||
ModalSideDrawer(
|
||||
drawerContent = {
|
||||
if (sheet != null) {
|
||||
ModalDrawerSheet(
|
||||
modifier = Modifier.widthIn(max = (configuration.screenWidthDp - 112).dp),
|
||||
drawerShape = ShapeDefaults.Large.copy(topEnd = CornerSize(0), bottomEnd = CornerSize(0)),
|
||||
windowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Top + WindowInsetsSides.End),
|
||||
windowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Vertical + WindowInsetsSides.End),
|
||||
) {
|
||||
CompositionLocalProvider(LocalViewConfiguration provides viewConfiguration) {
|
||||
sheet(sideSheetState)
|
||||
@@ -412,6 +430,13 @@ class MainActivity : EhActivity() {
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.graphicsLayer {
|
||||
if (radius != 0f) {
|
||||
renderEffect = BlurEffect(radius, radius, TileMode.Clamp)
|
||||
shape = RectangleShape
|
||||
clip = true
|
||||
}
|
||||
},
|
||||
drawerState = sideSheetState,
|
||||
gesturesEnabled = sheet != null && !drawerLocked,
|
||||
) {
|
||||
@@ -422,12 +447,8 @@ class MainActivity : EhActivity() {
|
||||
) {
|
||||
DestinationsNavHost(
|
||||
navGraph = NavGraphs.root,
|
||||
startRoute = if (Settings.needSignIn) {
|
||||
SignInScreenDestination
|
||||
} else {
|
||||
StartDestination
|
||||
},
|
||||
engine = rememberNavHostEngine(rootDefaultAnimations = rememberEhNavAnim()),
|
||||
startRoute = if (Settings.needSignIn) SignInScreenDestination else StartDestination,
|
||||
defaultTransitions = rememberEhNavAnim(),
|
||||
navController = navController,
|
||||
)
|
||||
}
|
||||
@@ -508,7 +529,7 @@ class MainActivity : EhActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
val LocalNavDrawerState = compositionLocalOf<DrawerState2> { error("CompositionLocal LocalNavDrawerState not present!") }
|
||||
val LocalNavDrawerState = compositionLocalOf<DrawerState> { error("CompositionLocal LocalNavDrawerState not present!") }
|
||||
val LocalSideSheetState = compositionLocalOf<DrawerState2> { error("CompositionLocal LocalSideSheetState not present!") }
|
||||
val LocalDrawerLockHandle = compositionLocalOf<SnapshotStateList<Int>> { error("CompositionLocal LocalDrawerLockHandle not present!") }
|
||||
val LocalSnackBarHostState = compositionLocalOf<SnackbarHostState> { error("CompositionLocal LocalSnackBarHostState not present!") }
|
||||
|
||||
@@ -2,17 +2,21 @@ package com.hippo.ehviewer.ui
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.animation.AnimatedContentTransitionScope
|
||||
import androidx.compose.animation.EnterTransition
|
||||
import androidx.compose.animation.ExitTransition
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import com.hippo.ehviewer.ui.theme.EhTheme
|
||||
import com.hippo.ehviewer.ui.tools.DialogState
|
||||
import com.hippo.ehviewer.ui.tools.LocalDialogState
|
||||
import com.hippo.ehviewer.ui.tools.ProvideVectorPainterCache
|
||||
import com.ramcosta.composedestinations.animations.defaults.RootNavGraphDefaultAnimations
|
||||
import com.ramcosta.composedestinations.animations.NavHostAnimatedDestinationStyle
|
||||
import soup.compose.material.motion.animation.materialSharedAxisXIn
|
||||
import soup.compose.material.motion.animation.materialSharedAxisXOut
|
||||
import soup.compose.material.motion.animation.rememberSlideDistance
|
||||
@@ -46,14 +50,14 @@ inline fun ComponentActivity.setMD3Content(crossinline content: @Composable () -
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberEhNavAnim(): RootNavGraphDefaultAnimations {
|
||||
fun rememberEhNavAnim(): NavHostAnimatedDestinationStyle {
|
||||
val slideDistance = rememberSlideDistance()
|
||||
return remember {
|
||||
RootNavGraphDefaultAnimations(
|
||||
enterTransition = { materialSharedAxisXIn(true, slideDistance) },
|
||||
exitTransition = { materialSharedAxisXOut(true, slideDistance) },
|
||||
popEnterTransition = { materialSharedAxisXIn(false, slideDistance) },
|
||||
popExitTransition = { materialSharedAxisXOut(false, slideDistance) },
|
||||
)
|
||||
return remember(slideDistance) {
|
||||
object : NavHostAnimatedDestinationStyle() {
|
||||
override val enterTransition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition = { materialSharedAxisXIn(true, slideDistance) }
|
||||
override val exitTransition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition = { materialSharedAxisXOut(true, slideDistance) }
|
||||
override val popEnterTransition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition = { materialSharedAxisXIn(false, slideDistance) }
|
||||
override val popExitTransition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition = { materialSharedAxisXOut(false, slideDistance) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,11 +43,10 @@ import com.hippo.ehviewer.ui.openBrowser
|
||||
import com.hippo.ehviewer.ui.screen.popNavigate
|
||||
import com.hippo.ehviewer.ui.tools.LocalWindowSizeClass
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.annotation.RootNavGraph
|
||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
|
||||
@RootNavGraph(start = true)
|
||||
@Destination
|
||||
@Destination<RootGraph>(start = true)
|
||||
@Composable
|
||||
fun SignInScreen(navigator: DestinationsNavigator) {
|
||||
LockDrawer(true)
|
||||
|
||||
@@ -18,13 +18,14 @@ import com.hippo.ehviewer.ui.StartDestination
|
||||
import com.hippo.ehviewer.ui.screen.popNavigate
|
||||
import com.hippo.ehviewer.util.setDefaultSettings
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import eu.kanade.tachiyomi.util.lang.launchIO
|
||||
import eu.kanade.tachiyomi.util.lang.withNonCancellableContext
|
||||
import eu.kanade.tachiyomi.util.lang.withUIContext
|
||||
import io.ktor.http.Url
|
||||
|
||||
@Destination
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun WebViewSignInScreen(navigator: DestinationsNavigator) {
|
||||
LockDrawer(true)
|
||||
|
||||
@@ -2,10 +2,10 @@ package com.hippo.ehviewer.ui.main
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
@@ -18,19 +18,24 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ProvideTextStyle
|
||||
import androidx.compose.material3.ShapeDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.minimumInteractiveComponentSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.movableContentOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.layoutId
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.constraintlayout.compose.ConstraintLayout
|
||||
import androidx.constraintlayout.compose.ConstraintSet
|
||||
import androidx.constraintlayout.compose.Dimension
|
||||
import arrow.core.Tuple9
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.EhUtils
|
||||
@@ -40,6 +45,51 @@ import com.hippo.ehviewer.ui.tools.CrystalCard
|
||||
import com.hippo.ehviewer.ui.tools.GalleryListCardRating
|
||||
import com.hippo.ehviewer.util.FileUtils
|
||||
|
||||
private val ids = Tuple9(1, 2, 3, 4, 5, 6, 7, 8, 9)
|
||||
|
||||
private val constraintSet = ConstraintSet {
|
||||
val (
|
||||
titleRef, uploaderRef, ratingRef, categoryRef, actionsRef, stateTextRef,
|
||||
progressBarRef, progressTextRef, speedRef,
|
||||
) = createRefsFor(1, 2, 3, 4, 5, 6, 7, 8, 9)
|
||||
constrain(titleRef) {
|
||||
top.linkTo(parent.top)
|
||||
width = Dimension.matchParent
|
||||
}
|
||||
constrain(uploaderRef) {
|
||||
start.linkTo(parent.start)
|
||||
bottom.linkTo(actionsRef.top)
|
||||
}
|
||||
constrain(ratingRef) {
|
||||
start.linkTo(parent.start)
|
||||
top.linkTo(actionsRef.top, 1.dp)
|
||||
}
|
||||
constrain(categoryRef) {
|
||||
start.linkTo(parent.start)
|
||||
bottom.linkTo(parent.bottom)
|
||||
}
|
||||
constrain(progressBarRef) {
|
||||
bottom.linkTo(actionsRef.top)
|
||||
width = Dimension.matchParent
|
||||
}
|
||||
constrain(progressTextRef) {
|
||||
bottom.linkTo(progressBarRef.top)
|
||||
start.linkTo(progressBarRef.start)
|
||||
}
|
||||
constrain(speedRef) {
|
||||
bottom.linkTo(progressBarRef.top)
|
||||
end.linkTo(progressBarRef.end)
|
||||
}
|
||||
constrain(actionsRef) {
|
||||
end.linkTo(parent.end, (-4).dp)
|
||||
bottom.linkTo(parent.bottom, (-4).dp)
|
||||
}
|
||||
constrain(stateTextRef) {
|
||||
end.linkTo(parent.end)
|
||||
bottom.linkTo(actionsRef.top)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DownloadCard(
|
||||
onClick: () -> Unit,
|
||||
@@ -48,143 +98,122 @@ fun DownloadCard(
|
||||
onStart: () -> Unit,
|
||||
onStop: () -> Unit,
|
||||
info: DownloadInfo,
|
||||
selectMode: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
) {
|
||||
CrystalCard(
|
||||
modifier = modifier,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
interactionSource = interactionSource,
|
||||
) {
|
||||
Row {
|
||||
Card(onClick = onThumbClick) {
|
||||
) = CrystalCard(modifier = modifier, onClick = onClick, onLongClick = onLongClick, interactionSource = interactionSource) {
|
||||
Row {
|
||||
val thumb = remember {
|
||||
movableContentOf<DownloadInfo> {
|
||||
EhAsyncCropThumb(
|
||||
key = info,
|
||||
key = it,
|
||||
modifier = Modifier.aspectRatio(DEFAULT_ASPECT).fillMaxSize(),
|
||||
)
|
||||
}
|
||||
|
||||
val stateFailed = stringResource(R.string.download_state_failed)
|
||||
val stateFailed2 = stringResource(R.string.download_state_failed_2, info.legacy)
|
||||
val downloadState by DownloadManager.collectDownloadState(info.gid)
|
||||
val stateText = when (downloadState) {
|
||||
DownloadInfo.STATE_NONE -> stringResource(R.string.download_state_none)
|
||||
DownloadInfo.STATE_WAIT -> stringResource(R.string.download_state_wait)
|
||||
DownloadInfo.STATE_DOWNLOAD -> null
|
||||
DownloadInfo.STATE_FAILED -> if (info.legacy <= 0) stateFailed else stateFailed2
|
||||
DownloadInfo.STATE_FINISH -> stringResource(R.string.download_state_finish)
|
||||
else -> null // Chill, will be removed soon
|
||||
}
|
||||
if (selectMode) {
|
||||
Card {
|
||||
thumb(info)
|
||||
}
|
||||
ConstraintLayout(modifier = Modifier.padding(8.dp, 4.dp).fillMaxSize()) {
|
||||
val (
|
||||
titleRef, uploaderRef, ratingRef, categoryRef, actionsRef, stateTextRef,
|
||||
progressBarRef, progressTextRef, speedRef,
|
||||
) = createRefs()
|
||||
Text(
|
||||
text = EhUtils.getSuitableTitle(info),
|
||||
maxLines = 2,
|
||||
modifier = Modifier.constrainAs(titleRef) {
|
||||
top.linkTo(parent.top)
|
||||
start.linkTo(parent.start)
|
||||
}.fillMaxWidth(),
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
if (downloadState != DownloadInfo.STATE_DOWNLOAD) {
|
||||
ProvideTextStyle(MaterialTheme.typography.labelLarge) {
|
||||
info.uploader?.let {
|
||||
Text(
|
||||
text = it,
|
||||
modifier = Modifier.constrainAs(uploaderRef) {
|
||||
start.linkTo(parent.start)
|
||||
bottom.linkTo(ratingRef.top)
|
||||
}.alpha(if (info.disowned) 0.5f else 1f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
GalleryListCardRating(
|
||||
rating = info.rating,
|
||||
modifier = Modifier.constrainAs(ratingRef) {
|
||||
start.linkTo(parent.start)
|
||||
bottom.linkTo(categoryRef.top)
|
||||
},
|
||||
)
|
||||
val categoryColor = EhUtils.getCategoryColor(info.category)
|
||||
val categoryText = EhUtils.getCategory(info.category).uppercase()
|
||||
} else {
|
||||
Card(onClick = onThumbClick) {
|
||||
thumb(info)
|
||||
}
|
||||
}
|
||||
val stateFailed = stringResource(R.string.download_state_failed)
|
||||
val stateFailed2 = stringResource(R.string.download_state_failed_2, info.legacy)
|
||||
val downloadState by DownloadManager.collectDownloadState(info.gid)
|
||||
val stateText = when (downloadState) {
|
||||
DownloadInfo.STATE_NONE -> stringResource(R.string.download_state_none)
|
||||
DownloadInfo.STATE_WAIT -> stringResource(R.string.download_state_wait)
|
||||
DownloadInfo.STATE_DOWNLOAD -> null
|
||||
DownloadInfo.STATE_FAILED -> if (info.legacy <= 0) stateFailed else stateFailed2
|
||||
DownloadInfo.STATE_FINISH -> stringResource(R.string.download_state_finish)
|
||||
else -> null // Chill, will be removed soon
|
||||
}
|
||||
ConstraintLayout(
|
||||
modifier = Modifier.padding(start = 8.dp, top = 2.dp, end = 4.dp, bottom = 4.dp).fillMaxSize(),
|
||||
constraintSet = constraintSet,
|
||||
) {
|
||||
val (
|
||||
titleRef, uploaderRef, ratingRef, categoryRef, actionsRef, stateTextRef,
|
||||
progressBarRef, progressTextRef, speedRef,
|
||||
) = ids
|
||||
Text(
|
||||
text = EhUtils.getSuitableTitle(info),
|
||||
maxLines = 2,
|
||||
modifier = Modifier.layoutId(titleRef),
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
if (downloadState != DownloadInfo.STATE_DOWNLOAD) {
|
||||
ProvideTextStyle(MaterialTheme.typography.labelLarge) {
|
||||
info.uploader?.let {
|
||||
Text(
|
||||
text = categoryText,
|
||||
modifier = Modifier.constrainAs(categoryRef) {
|
||||
start.linkTo(parent.start)
|
||||
bottom.linkTo(parent.bottom)
|
||||
}.clip(ShapeDefaults.Small).background(categoryColor).padding(vertical = 2.dp, horizontal = 8.dp),
|
||||
color = if (Settings.harmonizeCategoryColor) Color.Unspecified else EhUtils.categoryTextColor,
|
||||
textAlign = TextAlign.Center,
|
||||
text = it,
|
||||
modifier = Modifier.layoutId(uploaderRef).alpha(if (info.disowned) 0.5f else 1f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val (total, finished, speed) = DownloadManager.updatedDownloadInfo(info) {
|
||||
Triple(total, finished, speed)
|
||||
}
|
||||
ProvideTextStyle(MaterialTheme.typography.labelMedium) {
|
||||
if (total <= 0 || finished < 0) {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier.constrainAs(progressBarRef) {
|
||||
bottom.linkTo(actionsRef.top)
|
||||
}.fillMaxWidth(),
|
||||
)
|
||||
} else {
|
||||
LinearProgressIndicator(
|
||||
progress = { finished.toFloat() / total.toFloat() },
|
||||
modifier = Modifier.constrainAs(progressBarRef) {
|
||||
bottom.linkTo(actionsRef.top)
|
||||
}.fillMaxWidth(),
|
||||
)
|
||||
Text(
|
||||
text = "$finished/$total",
|
||||
modifier = Modifier.constrainAs(progressTextRef) {
|
||||
bottom.linkTo(progressBarRef.top)
|
||||
start.linkTo(progressBarRef.start)
|
||||
},
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = FileUtils.humanReadableByteCount(speed.coerceAtLeast(0), false) + "/S",
|
||||
modifier = Modifier.constrainAs(speedRef) {
|
||||
bottom.linkTo(progressBarRef.top)
|
||||
end.linkTo(progressBarRef.end)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.constrainAs(actionsRef) {
|
||||
end.linkTo(parent.end)
|
||||
bottom.linkTo(parent.bottom)
|
||||
},
|
||||
) {
|
||||
if (downloadState == DownloadInfo.STATE_WAIT || downloadState == DownloadInfo.STATE_DOWNLOAD) {
|
||||
IconButton(onClick = onStop) {
|
||||
Icon(imageVector = Icons.Default.Pause, contentDescription = null)
|
||||
}
|
||||
} else {
|
||||
IconButton(onClick = onStart) {
|
||||
Icon(imageVector = Icons.Default.PlayArrow, contentDescription = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (stateText != null) {
|
||||
GalleryListCardRating(
|
||||
rating = info.rating,
|
||||
modifier = Modifier.layoutId(ratingRef),
|
||||
)
|
||||
val categoryColor = EhUtils.getCategoryColor(info.category)
|
||||
val categoryText = EhUtils.getCategory(info.category).uppercase()
|
||||
Text(
|
||||
text = stateText,
|
||||
modifier = Modifier.constrainAs(stateTextRef) {
|
||||
end.linkTo(parent.end, 16.dp)
|
||||
bottom.linkTo(actionsRef.top)
|
||||
},
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
text = categoryText,
|
||||
modifier = Modifier.layoutId(categoryRef).clip(ShapeDefaults.Small)
|
||||
.background(categoryColor).padding(vertical = 2.dp, horizontal = 8.dp),
|
||||
color = if (Settings.harmonizeCategoryColor) Color.Unspecified else EhUtils.categoryTextColor,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val (total, finished, speed) = DownloadManager.updatedDownloadInfo(info) {
|
||||
Triple(total, finished, speed)
|
||||
}
|
||||
ProvideTextStyle(MaterialTheme.typography.labelMedium) {
|
||||
if (total <= 0 || finished < 0) {
|
||||
LinearProgressIndicator(modifier = Modifier.layoutId(progressBarRef))
|
||||
} else {
|
||||
LinearProgressIndicator(
|
||||
progress = { finished.toFloat() / total.toFloat() },
|
||||
modifier = Modifier.layoutId(progressBarRef),
|
||||
)
|
||||
Text(
|
||||
text = "$finished/$total",
|
||||
modifier = Modifier.layoutId(progressTextRef),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = FileUtils.humanReadableByteCount(speed.coerceAtLeast(0), false) + "/S",
|
||||
modifier = Modifier.layoutId(speedRef),
|
||||
)
|
||||
}
|
||||
}
|
||||
val running = downloadState == DownloadInfo.STATE_WAIT || downloadState == DownloadInfo.STATE_DOWNLOAD
|
||||
val icon = remember {
|
||||
movableContentOf<Boolean> {
|
||||
Icon(imageVector = if (it) Icons.Default.Pause else Icons.Default.PlayArrow, contentDescription = null)
|
||||
}
|
||||
}
|
||||
if (selectMode) {
|
||||
Box(modifier = Modifier.layoutId(actionsRef).minimumInteractiveComponentSize()) {
|
||||
icon(running)
|
||||
}
|
||||
} else {
|
||||
IconButton(onClick = if (running) onStop else onStart, modifier = Modifier.layoutId(actionsRef)) {
|
||||
icon(running)
|
||||
}
|
||||
}
|
||||
if (stateText != null) {
|
||||
Text(
|
||||
text = stateText,
|
||||
modifier = Modifier.layoutId(stateTextRef),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package com.hippo.ehviewer.ui.main
|
||||
import androidx.activity.compose.PredictiveBackHandler
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.AnimationSpec
|
||||
import androidx.compose.animation.core.EaseOut
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.MutatePriority
|
||||
import androidx.compose.foundation.MutatorMutex
|
||||
@@ -12,6 +11,7 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
@@ -31,9 +31,10 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.util.lerp
|
||||
import com.hippo.ehviewer.ui.tools.PredictiveBackEasing
|
||||
import com.hippo.ehviewer.ui.tools.delegateSnapshotUpdate
|
||||
import com.hippo.ehviewer.ui.tools.snackBarPadding
|
||||
import eu.kanade.tachiyomi.util.lang.launchIO
|
||||
@@ -140,69 +141,69 @@ fun FabLayout(
|
||||
},
|
||||
)
|
||||
}
|
||||
val appearState by state.appearProgress.asState()
|
||||
val animatedProgress by state.expandProgress.asState()
|
||||
PredictiveBackHandler(updatedExpanded) { flow ->
|
||||
try {
|
||||
state.mutatorMutex.mutate(MutatePriority.UserInput) {
|
||||
flow.collect {
|
||||
val eased = PredictiveBackEasing.transform(it.progress)
|
||||
state.expandProgress.snapTo(1 - eased)
|
||||
}
|
||||
}
|
||||
onExpandChanged(false)
|
||||
} catch (e: CancellationException) {
|
||||
coroutineScope.launch {
|
||||
state.expand()
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().navigationBarsPadding().padding(16.dp).snackBarPadding(),
|
||||
modifier = Modifier.fillMaxSize().navigationBarsPadding().snackBarPadding(),
|
||||
contentAlignment = Alignment.BottomEnd,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.graphicsLayer {
|
||||
if (!state.appearProgress.isRunning && !updatedHidden) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().graphicsLayer { alpha = animatedProgress },
|
||||
contentAlignment = Alignment.BottomEnd,
|
||||
) {
|
||||
with(secondaryFab) {
|
||||
forEachIndexed { index, (imageVector, onClick) ->
|
||||
SmallFloatingActionButton(
|
||||
onClick = {
|
||||
coroutineScope.launchIO {
|
||||
runSuspendCatching {
|
||||
onClick()
|
||||
}
|
||||
onExpandChanged(false)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.padding(20.dp).offset {
|
||||
val distance = lerp(0, 150 * (size - index) + 50, animatedProgress)
|
||||
IntOffset(0, -distance)
|
||||
},
|
||||
) {
|
||||
Icon(imageVector = imageVector, contentDescription = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val appearState by state.appearProgress.asState()
|
||||
FloatingActionButton(
|
||||
onClick = { onExpandChanged(!updatedExpanded) },
|
||||
modifier = Modifier.padding(16.dp).graphicsLayer {
|
||||
rotationZ = lerp(-90f, 0f, appearState)
|
||||
scaleX = appearState
|
||||
scaleY = appearState
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
val animatedProgress by state.expandProgress.asState()
|
||||
PredictiveBackHandler(updatedExpanded) { flow ->
|
||||
try {
|
||||
state.mutatorMutex.mutate(MutatePriority.UserInput) {
|
||||
flow.collect {
|
||||
val eased = EaseOut.transform(it.progress)
|
||||
state.expandProgress.snapTo(1 - eased)
|
||||
}
|
||||
}
|
||||
onExpandChanged(false)
|
||||
} catch (e: CancellationException) {
|
||||
coroutineScope.launch {
|
||||
state.expand()
|
||||
}
|
||||
}
|
||||
}
|
||||
FloatingActionButton(onClick = { onExpandChanged(!updatedExpanded) }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.graphicsLayer {
|
||||
rotationZ = lerp(-135f, 0f, animatedProgress)
|
||||
},
|
||||
)
|
||||
}
|
||||
secondaryFab.run {
|
||||
forEachIndexed { index, (imageVector, onClick) ->
|
||||
SmallFloatingActionButton(
|
||||
onClick = {
|
||||
coroutineScope.launchIO {
|
||||
runSuspendCatching {
|
||||
onClick()
|
||||
}
|
||||
onExpandChanged(false)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.layout { measurable, constraints ->
|
||||
val placeable = measurable.measure(constraints)
|
||||
layout(placeable.width, placeable.height) {
|
||||
val distance = lerp(0, 150 * (size - index) + 50, animatedProgress)
|
||||
placeable.placeRelative(0, -distance, -(size - index).toFloat())
|
||||
}
|
||||
}.graphicsLayer {
|
||||
alpha = animatedProgress
|
||||
},
|
||||
) {
|
||||
Icon(imageVector = imageVector, contentDescription = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.graphicsLayer {
|
||||
rotationZ = lerp(-135f, 0f, animatedProgress)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.constraintlayout.compose.ConstraintLayout
|
||||
import androidx.constraintlayout.compose.ConstraintSet
|
||||
import androidx.constraintlayout.compose.Dimension
|
||||
import arrow.core.Tuple7
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.EhUtils
|
||||
@@ -60,15 +61,15 @@ private val constraintSet = ConstraintSet {
|
||||
createRefsFor(1, 2, 3, 4, 5, 6, 7)
|
||||
constrain(titleRef) {
|
||||
top.linkTo(parent.top)
|
||||
start.linkTo(parent.start)
|
||||
width = Dimension.matchParent
|
||||
}
|
||||
constrain(uploaderRef) {
|
||||
start.linkTo(parent.start)
|
||||
bottom.linkTo(ratingRef.top)
|
||||
bottom.linkTo(iconsRef.top)
|
||||
}
|
||||
constrain(ratingRef) {
|
||||
start.linkTo(parent.start)
|
||||
bottom.linkTo(categoryRef.top)
|
||||
top.linkTo(iconsRef.top, 1.dp)
|
||||
}
|
||||
constrain(categoryRef) {
|
||||
start.linkTo(parent.start)
|
||||
@@ -76,7 +77,7 @@ private val constraintSet = ConstraintSet {
|
||||
}
|
||||
constrain(iconsRef) {
|
||||
end.linkTo(parent.end)
|
||||
bottom.linkTo(postedRef.top)
|
||||
bottom.linkTo(categoryRef.top)
|
||||
}
|
||||
constrain(favRef) {
|
||||
end.linkTo(parent.end)
|
||||
@@ -84,7 +85,7 @@ private val constraintSet = ConstraintSet {
|
||||
}
|
||||
constrain(postedRef) {
|
||||
end.linkTo(parent.end)
|
||||
bottom.linkTo(parent.bottom)
|
||||
linkTo(categoryRef.top, parent.bottom)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,12 +111,15 @@ fun GalleryInfoListItem(
|
||||
modifier = Modifier.aspectRatio(DEFAULT_ASPECT).fillMaxSize(),
|
||||
)
|
||||
}
|
||||
ConstraintLayout(modifier = Modifier.padding(8.dp, 4.dp).fillMaxSize(), constraintSet = constraintSet) {
|
||||
ConstraintLayout(
|
||||
modifier = Modifier.padding(start = 8.dp, top = 2.dp, end = 4.dp, bottom = 4.dp).fillMaxSize(),
|
||||
constraintSet = constraintSet,
|
||||
) {
|
||||
val (titleRef, uploaderRef, ratingRef, categoryRef, postedRef, favRef, iconsRef) = ids
|
||||
Text(
|
||||
text = EhUtils.getSuitableTitle(info),
|
||||
maxLines = 2,
|
||||
modifier = Modifier.layoutId(titleRef).fillMaxWidth(),
|
||||
modifier = Modifier.layoutId(titleRef),
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
@@ -145,6 +149,8 @@ fun GalleryInfoListItem(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.layoutId(iconsRef),
|
||||
) {
|
||||
// Placeholder to reserve minimum height
|
||||
Text(text = "")
|
||||
val download by DownloadManager.collectContainDownloadInfo(info.gid)
|
||||
if (download) {
|
||||
Icon(
|
||||
@@ -153,7 +159,9 @@ fun GalleryInfoListItem(
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
Text(text = info.simpleLanguage.orEmpty())
|
||||
info.simpleLanguage?.let {
|
||||
Text(text = it)
|
||||
}
|
||||
if (info.pages != 0 && showPages) {
|
||||
Text(text = "${info.pages}P")
|
||||
}
|
||||
@@ -163,11 +171,12 @@ fun GalleryInfoListItem(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.layoutId(favRef),
|
||||
) {
|
||||
// Placeholder to reserve minimum height
|
||||
Text(text = "")
|
||||
if (isInFavScene) {
|
||||
Text(
|
||||
text = info.favoriteNote.orEmpty(),
|
||||
fontStyle = FontStyle.Italic,
|
||||
)
|
||||
info.favoriteNote?.let {
|
||||
Text(text = it, fontStyle = FontStyle.Italic)
|
||||
}
|
||||
} else {
|
||||
val showFav by FavouriteStatusRouter.collectAsState(info) { it != NOT_FAVORITED }
|
||||
if (showFav) {
|
||||
@@ -176,7 +185,9 @@ fun GalleryInfoListItem(
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Text(text = info.favoriteName.orEmpty())
|
||||
info.favoriteName?.let {
|
||||
Text(text = it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ package com.hippo.ehviewer.ui.main
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
@@ -40,7 +40,9 @@ import com.hippo.ehviewer.asMutableState
|
||||
import com.hippo.ehviewer.client.EhTagDatabase
|
||||
import com.hippo.ehviewer.client.EhUtils
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo
|
||||
import com.hippo.ehviewer.collectAsState
|
||||
import com.hippo.ehviewer.ui.tools.LocalDialogState
|
||||
import com.hippo.ehviewer.ui.tools.thenIf
|
||||
import com.hippo.ehviewer.util.toIntOrDefault
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -68,23 +70,31 @@ fun SearchFilter(
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
val dialogState = LocalDialogState.current
|
||||
val animateItems by Settings.animateItems.collectAsState()
|
||||
fun isCategoryChecked(bit: Int) = category and bit != 0
|
||||
val categories = remember(category) { categoryTable.sortedBy { !isCategoryChecked(it.first) } }
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
|
||||
// Workaround for https://issuetracker.google.com/332939169
|
||||
// contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
// Workaround for the first item's animation
|
||||
// https://github.com/Calvin-LL/Reorderable/issues/4#issuecomment-1853131769
|
||||
item {}
|
||||
item {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
items(categories, { it.first }) {
|
||||
FilterChip(
|
||||
selected = isCategoryChecked(it.first),
|
||||
onClick = { onCategoryChanged(category xor it.first) },
|
||||
label = { Text(text = stringResource(id = it.second)) },
|
||||
modifier = Modifier.animateItemPlacement(),
|
||||
modifier = Modifier.thenIf(animateItems) { animateItem() },
|
||||
)
|
||||
}
|
||||
item {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
}
|
||||
var languageFilter by Settings.languageFilter.asMutableState()
|
||||
val languages = remember {
|
||||
|
||||
@@ -43,7 +43,6 @@ fun GalleryPager(
|
||||
val items = pageLoader.pages
|
||||
when (type) {
|
||||
DEFAULT, LEFT_TO_RIGHT, RIGHT_TO_LEFT -> {
|
||||
val zoomableState = rememberZoomableState(zoomSpec = zoomSpec)
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = modifier,
|
||||
@@ -51,6 +50,7 @@ fun GalleryPager(
|
||||
key = { it },
|
||||
) { index ->
|
||||
val page = items[index]
|
||||
val zoomableState = rememberZoomableState(zoomSpec = zoomSpec)
|
||||
val status by page.status.collectAsState()
|
||||
val boxModifier = if (status == Page.State.READY) {
|
||||
Modifier.zoomable(
|
||||
@@ -79,13 +79,13 @@ fun GalleryPager(
|
||||
}
|
||||
}
|
||||
VERTICAL -> {
|
||||
val zoomableState = rememberZoomableState(zoomSpec = zoomSpec)
|
||||
VerticalPager(
|
||||
state = pagerState,
|
||||
modifier = modifier,
|
||||
key = { it },
|
||||
) { index ->
|
||||
val page = items[index]
|
||||
val zoomableState = rememberZoomableState(zoomSpec = zoomSpec)
|
||||
val status by page.status.collectAsState()
|
||||
val boxModifier = if (status == Page.State.READY) {
|
||||
Modifier.zoomable(
|
||||
|
||||
@@ -28,7 +28,6 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.util.VelocityTrackerAddPointsFix
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.Settings
|
||||
@@ -42,6 +41,7 @@ import com.hippo.ehviewer.ui.LockDrawer
|
||||
import com.hippo.ehviewer.ui.composing
|
||||
import com.hippo.ehviewer.ui.tools.Deferred
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import eu.kanade.tachiyomi.ui.reader.PageIndicatorText
|
||||
import eu.kanade.tachiyomi.ui.reader.ReaderAppBars
|
||||
@@ -53,18 +53,9 @@ import kotlin.coroutines.resume
|
||||
import kotlinx.coroutines.launch
|
||||
import moe.tarsin.kt.unreachable
|
||||
|
||||
@Destination
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun ReaderScreen(info: BaseGalleryInfo, page: Int = -1, navigator: DestinationsNavigator) = composing(navigator) {
|
||||
// Workaround for incorrect fling velocity on zoom gesture end
|
||||
// https://github.com/saket/telephoto/issues/71
|
||||
DisposableEffect(Unit) {
|
||||
VelocityTrackerAddPointsFix = false
|
||||
onDispose {
|
||||
VelocityTrackerAddPointsFix = true
|
||||
}
|
||||
}
|
||||
|
||||
LockDrawer(true)
|
||||
ConfigureKeepScreenOn()
|
||||
val pageLoader = remember {
|
||||
|
||||
@@ -8,7 +8,6 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
@@ -16,7 +15,7 @@ import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.gallery.PageLoader2
|
||||
import kotlin.math.absoluteValue
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.mapNotNull
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
|
||||
@Stable
|
||||
class SliderPagerDoubleSync(
|
||||
@@ -24,8 +23,10 @@ class SliderPagerDoubleSync(
|
||||
private val pagerState: PagerState,
|
||||
private val pageLoader: PageLoader2,
|
||||
) {
|
||||
private var sliderFollowPager by mutableStateOf(true)
|
||||
var sliderValue by mutableIntStateOf(1)
|
||||
private var requestScroll = true
|
||||
private var sliderFollowPager = true
|
||||
var sliderValue by mutableIntStateOf(pageLoader.startPage + 1)
|
||||
private set
|
||||
|
||||
fun sliderScrollTo(index: Int) {
|
||||
sliderFollowPager = false
|
||||
@@ -37,52 +38,55 @@ class SliderPagerDoubleSync(
|
||||
val fling by lazyListState.interactionSource.collectIsDraggedAsState()
|
||||
val pagerFling by pagerState.interactionSource.collectIsDraggedAsState()
|
||||
if (fling || pagerFling) sliderFollowPager = true
|
||||
val startPage = remember { pageLoader.startPage }
|
||||
val currentIndexFlow = remember(webtoon) {
|
||||
requestScroll = true
|
||||
if (webtoon) {
|
||||
snapshotFlow { lazyListState.layoutInfo }.mapNotNull { info ->
|
||||
info.visibleItemsInfo.lastOrNull {
|
||||
it.offset <= maxOf(info.viewportStartOffset, info.viewportEndOffset - it.size)
|
||||
}?.index
|
||||
}
|
||||
snapshotFlow {
|
||||
with(lazyListState.layoutInfo) {
|
||||
visibleItemsInfo.lastOrNull {
|
||||
it.offset <= maxOf(viewportStartOffset, viewportEndOffset - it.size)
|
||||
}?.index
|
||||
}
|
||||
}.filterNotNull()
|
||||
} else {
|
||||
snapshotFlow { pagerState.currentPage }
|
||||
}
|
||||
}
|
||||
if (sliderFollowPager) {
|
||||
LaunchedEffect(currentIndexFlow) {
|
||||
currentIndexFlow.collect { index ->
|
||||
LaunchedEffect(currentIndexFlow) {
|
||||
currentIndexFlow.collect { index ->
|
||||
if (sliderFollowPager && !requestScroll) {
|
||||
sliderValue = index + 1
|
||||
pageLoader.startPage = index
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LaunchedEffect(webtoon) {
|
||||
snapshotFlow { sliderValue - 1 }.collectLatest { index ->
|
||||
if (webtoon) {
|
||||
val noAnim = (index - lazyListState.firstVisibleItemIndex).absoluteValue > SMOOTH_SCROLL_THRESHOLD || !Settings.pageTransitions.value
|
||||
if (noAnim) {
|
||||
lazyListState.scrollToItem(index)
|
||||
} else {
|
||||
lazyListState.animateScrollToItem(index)
|
||||
}
|
||||
}
|
||||
LaunchedEffect(webtoon) {
|
||||
snapshotFlow { sliderValue - 1 }.collectLatest { index ->
|
||||
if (sliderFollowPager && !requestScroll) return@collectLatest
|
||||
if (webtoon) {
|
||||
if (requestScroll) {
|
||||
lazyListState.requestScrollToItem(index)
|
||||
requestScroll = false
|
||||
} else {
|
||||
val noAnim = (index - pagerState.currentPage).absoluteValue > SMOOTH_SCROLL_THRESHOLD || !Settings.pageTransitions.value
|
||||
if (noAnim) {
|
||||
pagerState.scrollToPage(index)
|
||||
if (smoothScroll((index - lazyListState.firstVisibleItemIndex))) {
|
||||
lazyListState.animateScrollToItem(index)
|
||||
} else {
|
||||
lazyListState.scrollToItem(index)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (requestScroll) {
|
||||
pagerState.requestScrollToPage(index)
|
||||
requestScroll = false
|
||||
} else {
|
||||
if (smoothScroll((index - pagerState.currentPage))) {
|
||||
pagerState.animateScrollToPage(index)
|
||||
} else {
|
||||
pagerState.scrollToPage(index)
|
||||
}
|
||||
}
|
||||
pageLoader.startPage = index
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
if (webtoon) {
|
||||
lazyListState.scrollToItem(startPage)
|
||||
} else {
|
||||
pagerState.scrollToPage(startPage)
|
||||
pageLoader.startPage = index
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -100,4 +104,7 @@ fun rememberSliderPagerDoubleSyncState(
|
||||
}
|
||||
}
|
||||
|
||||
private fun smoothScroll(distance: Int) =
|
||||
distance.absoluteValue < SMOOTH_SCROLL_THRESHOLD && Settings.pageTransitions.value
|
||||
|
||||
private const val SMOOTH_SCROLL_THRESHOLD = 50
|
||||
@@ -29,6 +29,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
fun SpinnerChoice(title: String, entries: Array<String>, values: Array<String>, field: MutableState<Int>) {
|
||||
@@ -97,9 +98,7 @@ fun SliderChoice(
|
||||
) {
|
||||
startSlot()
|
||||
val configuration = LocalConfiguration.current
|
||||
// https://m3.material.io/components/sliders/specs
|
||||
// Tick width is 2 dp
|
||||
val maxTickCount = configuration.screenWidthDp / 6
|
||||
val maxTickCount = configuration.screenWidthDp / (SliderDefaults.TickSize.value * 2.5f).roundToInt()
|
||||
val steps = range.last - range.first - 1
|
||||
val showTicks = steps < maxTickCount
|
||||
Slider(
|
||||
|
||||
@@ -10,15 +10,11 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.calculateEndPadding
|
||||
import androidx.compose.foundation.layout.calculateStartPadding
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
@@ -111,9 +107,11 @@ import com.hippo.ehviewer.ui.tools.SwipeToDismissBox2
|
||||
import com.hippo.ehviewer.ui.tools.delegateSnapshotUpdate
|
||||
import com.hippo.ehviewer.ui.tools.draggingHapticFeedback
|
||||
import com.hippo.ehviewer.ui.tools.rememberInVM
|
||||
import com.hippo.ehviewer.ui.tools.thenIf
|
||||
import com.hippo.ehviewer.util.findActivity
|
||||
import com.hippo.ehviewer.util.mapToLongArray
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import eu.kanade.tachiyomi.util.lang.launchIO
|
||||
import eu.kanade.tachiyomi.util.lang.withNonCancellableContext
|
||||
@@ -126,7 +124,7 @@ import moe.tarsin.coroutines.onEachLatest
|
||||
import sh.calvin.reorderable.ReorderableItem
|
||||
import sh.calvin.reorderable.rememberReorderableLazyColumnState
|
||||
|
||||
@Destination
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
var gridView by Settings.gridView.asMutableState()
|
||||
@@ -134,6 +132,7 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
var filterState by rememberSaveable { mutableStateOf(DownloadsFilterState(Settings.recentDownloadLabel.value)) }
|
||||
var invalidateKey by rememberSaveable { mutableStateOf(false) }
|
||||
var searchBarOffsetY by remember(filterState.label) { mutableIntStateOf(0) }
|
||||
val animateItems by Settings.animateItems.collectAsState()
|
||||
|
||||
var fabExpanded by remember { mutableStateOf(false) }
|
||||
var fabHidden by remember { mutableStateOf(false) }
|
||||
@@ -203,7 +202,12 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
launch {
|
||||
showSelectActions(R.string.default_download_label) {
|
||||
val selected = if (!Settings.hasDefaultDownloadLabel) {
|
||||
0
|
||||
} else {
|
||||
labelList.indexOfFirst { it.label == Settings.defaultDownloadLabel } + 2
|
||||
}
|
||||
showSelectActions(R.string.default_download_label, selected) {
|
||||
onSelect(letMeSelect) {
|
||||
Settings.hasDefaultDownloadLabel = false
|
||||
}
|
||||
@@ -237,9 +241,10 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
state = labelsListState,
|
||||
contentPadding = WindowInsets.systemBars.only(WindowInsetsSides.Bottom).asPaddingValues(),
|
||||
// Workaround for https://issuetracker.google.com/332939169
|
||||
// contentPadding = WindowInsets.systemBars.only(WindowInsetsSides.Bottom).asPaddingValues(),
|
||||
) {
|
||||
stickyHeader {
|
||||
item {
|
||||
ListItem(
|
||||
modifier = Modifier.clickable {
|
||||
switchLabel("")
|
||||
@@ -252,7 +257,7 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
},
|
||||
)
|
||||
}
|
||||
stickyHeader {
|
||||
item {
|
||||
ListItem(
|
||||
modifier = Modifier.clickable {
|
||||
switchLabel(null)
|
||||
@@ -265,7 +270,8 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
},
|
||||
)
|
||||
}
|
||||
itemsIndexed(labelList, key = { _, item -> item.id!! }) { index, (item) ->
|
||||
itemsIndexed(labelList, key = { _, item -> item.id!! }) { index, (label, _, id) ->
|
||||
val item by rememberUpdatedState(label)
|
||||
// Not using rememberSwipeToDismissBoxState to prevent LazyColumn from reusing it
|
||||
val dismissState = remember { SwipeToDismissBoxState(SwipeToDismissBoxValue.Settled, density, positionalThreshold = positionalThreshold) }
|
||||
LaunchedEffect(dismissState) {
|
||||
@@ -287,7 +293,7 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
}
|
||||
}
|
||||
}
|
||||
ReorderableItem(reorderableLabelState, key = item) { isDragging ->
|
||||
ReorderableItem(reorderableLabelState, key = id!!) { isDragging ->
|
||||
SwipeToDismissBox2(
|
||||
state = dismissState,
|
||||
backgroundContent = {},
|
||||
@@ -480,7 +486,7 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
onClick = ::onItemClick.partially1(info),
|
||||
onLongClick = { navigator.navigate(info.galleryInfo.asDst()) },
|
||||
info = info,
|
||||
modifier = Modifier.animateItemPlacement(),
|
||||
modifier = Modifier.thenIf(animateItems) { animateItem() },
|
||||
badgeText = info.pages.takeIf { it > 0 }?.toString(),
|
||||
)
|
||||
}
|
||||
@@ -493,7 +499,10 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
) {
|
||||
items(list, key = { it.gid }) { info ->
|
||||
val checked = info.gid in checkedInfoMap
|
||||
CheckableItem(checked = checked, modifier = Modifier.animateItemPlacement()) { interactionSource ->
|
||||
CheckableItem(
|
||||
checked = checked,
|
||||
modifier = Modifier.thenIf(animateItems) { animateItem() },
|
||||
) { interactionSource ->
|
||||
DownloadCard(
|
||||
onClick = {
|
||||
if (selectMode) {
|
||||
@@ -520,6 +529,7 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
},
|
||||
onStop = { launchIO { DownloadManager.stopDownload(info.gid) } },
|
||||
info = info,
|
||||
selectMode = selectMode,
|
||||
modifier = Modifier.height(height),
|
||||
interactionSource = interactionSource,
|
||||
)
|
||||
|
||||
@@ -77,9 +77,11 @@ import com.hippo.ehviewer.ui.startDownload
|
||||
import com.hippo.ehviewer.ui.tools.delegateSnapshotUpdate
|
||||
import com.hippo.ehviewer.ui.tools.foldToLoadResult
|
||||
import com.hippo.ehviewer.ui.tools.rememberInVM
|
||||
import com.hippo.ehviewer.ui.tools.thenIf
|
||||
import com.hippo.ehviewer.util.displayString
|
||||
import com.hippo.ehviewer.util.mapToLongArray
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import eu.kanade.tachiyomi.util.lang.withIOContext
|
||||
import eu.kanade.tachiyomi.util.lang.withUIContext
|
||||
@@ -90,12 +92,13 @@ import kotlinx.coroutines.launch
|
||||
import moe.tarsin.coroutines.onEachLatest
|
||||
import moe.tarsin.coroutines.runSuspendCatching
|
||||
|
||||
@Destination
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun FavouritesScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
// Immutables
|
||||
val localFavName = stringResource(R.string.local_favorites)
|
||||
val cloudFavName = stringResource(R.string.cloud_favorites)
|
||||
val animateItems by Settings.animateItems.collectAsState()
|
||||
|
||||
// Meta State
|
||||
var urlBuilder by rememberSaveable { mutableStateOf(FavListUrlBuilder(favCat = Settings.recentFavCat)) }
|
||||
@@ -265,7 +268,10 @@ fun FavouritesScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
listMode = listMode,
|
||||
detailItemContent = { info ->
|
||||
val checked = info.gid in checkedInfoMap
|
||||
CheckableItem(checked = checked, modifier = Modifier.animateItemPlacement()) { interactionSource ->
|
||||
CheckableItem(
|
||||
checked = checked,
|
||||
modifier = Modifier.thenIf(animateItems) { animateItem() },
|
||||
) { interactionSource ->
|
||||
GalleryInfoListItem(
|
||||
onClick = {
|
||||
if (selectMode) {
|
||||
@@ -291,7 +297,10 @@ fun FavouritesScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
},
|
||||
thumbItemContent = { info ->
|
||||
val checked = info.gid in checkedInfoMap
|
||||
CheckableItem(checked = checked, modifier = Modifier.animateItemPlacement()) { interactionSource ->
|
||||
CheckableItem(
|
||||
checked = checked,
|
||||
modifier = Modifier.thenIf(animateItems) { animateItem() },
|
||||
) { interactionSource ->
|
||||
GalleryInfoGridItem(
|
||||
onClick = {
|
||||
if (selectMode) {
|
||||
|
||||
@@ -7,13 +7,14 @@ import android.text.style.ForegroundColorSpan
|
||||
import android.text.style.RelativeSizeSpan
|
||||
import android.text.style.StyleSpan
|
||||
import android.widget.TextView
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
@@ -63,6 +64,8 @@ import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalTextToolbar
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.fromHtml
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -72,12 +75,14 @@ import androidx.core.text.inSpans
|
||||
import androidx.core.text.parseAsHtml
|
||||
import com.hippo.ehviewer.EhApplication
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.EhCookieStore
|
||||
import com.hippo.ehviewer.client.EhEngine
|
||||
import com.hippo.ehviewer.client.EhFilter.remember
|
||||
import com.hippo.ehviewer.client.EhUrl
|
||||
import com.hippo.ehviewer.client.data.GalleryComment
|
||||
import com.hippo.ehviewer.client.data.ListUrlBuilder
|
||||
import com.hippo.ehviewer.collectAsState
|
||||
import com.hippo.ehviewer.dao.Filter
|
||||
import com.hippo.ehviewer.dao.FilterMode
|
||||
import com.hippo.ehviewer.ui.LockDrawer
|
||||
@@ -90,7 +95,7 @@ import com.hippo.ehviewer.ui.tools.animateFloatMergePredictiveBackAsState
|
||||
import com.hippo.ehviewer.ui.tools.normalizeSpan
|
||||
import com.hippo.ehviewer.ui.tools.rememberBBCodeTextToolbar
|
||||
import com.hippo.ehviewer.ui.tools.snackBarPadding
|
||||
import com.hippo.ehviewer.ui.tools.toAnnotatedString
|
||||
import com.hippo.ehviewer.ui.tools.thenIf
|
||||
import com.hippo.ehviewer.ui.tools.toBBCode
|
||||
import com.hippo.ehviewer.ui.tools.updateSpan
|
||||
import com.hippo.ehviewer.util.ReadableTime
|
||||
@@ -98,6 +103,7 @@ import com.hippo.ehviewer.util.TextUrl
|
||||
import com.hippo.ehviewer.util.addTextToClipboard
|
||||
import com.hippo.ehviewer.util.displayString
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import eu.kanade.tachiyomi.util.lang.launchIO
|
||||
import eu.kanade.tachiyomi.util.lang.withIOContext
|
||||
@@ -143,13 +149,14 @@ private fun Context.generateComment(
|
||||
|
||||
private val MinimumContentPaddingEditText = 88.dp
|
||||
|
||||
@Destination
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun GalleryCommentsScreen(gid: Long, navigator: DestinationsNavigator) = composing(navigator) {
|
||||
LockDrawer(true)
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
var commenting by rememberSaveable { mutableStateOf(false) }
|
||||
val animationProgress by animateFloatMergePredictiveBackAsState(enable = commenting) { commenting = false }
|
||||
val animateItems by Settings.animateItems.collectAsState()
|
||||
|
||||
val galleryDetail = remember { EhApplication.galleryDetailCache[gid]!! }
|
||||
val userCommentBackField = remember { mutableStateOf(TextFieldValue()) }
|
||||
@@ -285,11 +292,7 @@ fun GalleryCommentsScreen(gid: Long, navigator: DestinationsNavigator) = composi
|
||||
val additionalPadding = if (commenting) {
|
||||
editTextMeasured
|
||||
} else {
|
||||
if (!comments.hasMore) {
|
||||
MinimumContentPaddingEditText
|
||||
} else {
|
||||
0.dp
|
||||
}
|
||||
0.dp
|
||||
}
|
||||
val voteUpSucceed = stringResource(R.string.vote_up_successfully)
|
||||
val cancelVoteUpSucceed = stringResource(R.string.cancel_vote_up_successfully)
|
||||
@@ -300,10 +303,13 @@ fun GalleryCommentsScreen(gid: Long, navigator: DestinationsNavigator) = composi
|
||||
modifier = Modifier.padding(horizontal = keylineMargin),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
contentPadding = PaddingValues(
|
||||
top = paddingValues.calculateTopPadding(),
|
||||
// top = paddingValues.calculateTopPadding(),
|
||||
bottom = paddingValues.calculateBottomPadding() + additionalPadding,
|
||||
),
|
||||
) {
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(paddingValues.calculateTopPadding()))
|
||||
}
|
||||
items(
|
||||
items = comments.comments,
|
||||
key = { it.id },
|
||||
@@ -335,7 +341,7 @@ fun GalleryCommentsScreen(gid: Long, navigator: DestinationsNavigator) = composi
|
||||
}
|
||||
if (comment.editable) {
|
||||
onSelect(editComment) {
|
||||
userComment = TextFieldValue(comment.comment.parseAsHtml().toAnnotatedString())
|
||||
userComment = TextFieldValue(AnnotatedString.fromHtml(comment.comment))
|
||||
commentId = comment.id
|
||||
commenting = true
|
||||
}
|
||||
@@ -358,7 +364,7 @@ fun GalleryCommentsScreen(gid: Long, navigator: DestinationsNavigator) = composi
|
||||
}
|
||||
|
||||
GalleryCommentCard(
|
||||
modifier = Modifier.animateItemPlacement(),
|
||||
modifier = Modifier.thenIf(animateItems) { animateItem() },
|
||||
comment = item,
|
||||
onUserClick = {
|
||||
navigate(
|
||||
@@ -376,28 +382,36 @@ fun GalleryCommentsScreen(gid: Long, navigator: DestinationsNavigator) = composi
|
||||
}
|
||||
if (comments.hasMore) {
|
||||
item {
|
||||
AnimatedVisibility(refreshing) {
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.align(Alignment.Center).padding(keylineMargin),
|
||||
)
|
||||
}
|
||||
}
|
||||
AnimatedVisibility(!refreshing) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
launchIO {
|
||||
refreshing = true
|
||||
runSuspendCatching { refreshComment(true) }
|
||||
refreshing = false
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().padding(keylineMargin),
|
||||
Crossfade(targetState = refreshing, modifier = Modifier.padding(keylineMargin), label = "refreshing") {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().height(40.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.click_more_comments))
|
||||
if (it) {
|
||||
CircularProgressIndicator()
|
||||
} else {
|
||||
TextButton(
|
||||
onClick = {
|
||||
launchIO {
|
||||
refreshing = true
|
||||
runSuspendCatching { refreshComment(true) }
|
||||
refreshing = false
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.click_more_comments))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Workaround for comment list lagging when reaching the bottom
|
||||
// https://github.com/FooIbar/EhViewer/issues/994
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(MinimumContentPaddingEditText))
|
||||
}
|
||||
}
|
||||
}
|
||||
PullToRefreshContainer(
|
||||
|
||||
@@ -156,6 +156,7 @@ import com.hippo.ehviewer.util.isAtLeastQ
|
||||
import com.hippo.ehviewer.util.requestPermission
|
||||
import com.hippo.unifile.asUniFile
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import eu.kanade.tachiyomi.util.lang.launchIO
|
||||
import eu.kanade.tachiyomi.util.lang.launchUI
|
||||
@@ -210,7 +211,7 @@ private fun List<GalleryTagGroup>.getArtistTag(): String? {
|
||||
return null
|
||||
}
|
||||
|
||||
@Destination
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNavigator) = composing(navigator) {
|
||||
LockDrawer(true)
|
||||
@@ -483,7 +484,7 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
|
||||
navigator.navigate(info.gid asDstWith info.token)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.size(dimensionResource(id = R.dimen.keyline_margin)))
|
||||
Spacer(modifier = Modifier.size(keylineMargin))
|
||||
if (galleryDetail.newerVersions.isNotEmpty()) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
CrystalCard(
|
||||
@@ -493,7 +494,7 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
|
||||
}
|
||||
Text(text = stringResource(id = R.string.newer_version_available))
|
||||
}
|
||||
Spacer(modifier = Modifier.size(dimensionResource(id = R.dimen.keyline_margin)))
|
||||
Spacer(modifier = Modifier.size(keylineMargin))
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
@@ -601,7 +602,7 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
|
||||
showSnackbar(noCurrentTorrents)
|
||||
} else {
|
||||
val items = mTorrentList!!.map { it.format() }
|
||||
val selected = showSelectItem(items, R.string.torrents, false)
|
||||
val selected = showSelectItem(items, R.string.torrents, respectDefaultWidth = false)
|
||||
val url = mTorrentList!![selected].url
|
||||
val name = "${mTorrentList!![selected].name}.torrent"
|
||||
val r = DownloadManager.Request(Uri.parse(url))
|
||||
@@ -639,7 +640,7 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
|
||||
},
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.size(dimensionResource(id = R.dimen.keyline_margin)))
|
||||
Spacer(modifier = Modifier.size(keylineMargin))
|
||||
fun getAllRatingText(rating: Float, ratingCount: Int): String {
|
||||
return getString(
|
||||
R.string.rating_text,
|
||||
@@ -664,7 +665,7 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
|
||||
var rating by remember { mutableFloatStateOf(galleryDetail.rating.coerceAtLeast(.5f)) }
|
||||
var text by remember { mutableIntStateOf(getRatingText(rating)) }
|
||||
Text(text = stringResource(id = text), style = MaterialTheme.typography.bodyLarge)
|
||||
Spacer(modifier = Modifier.size(dimensionResource(id = R.dimen.keyline_margin)))
|
||||
Spacer(modifier = Modifier.size(keylineMargin))
|
||||
GalleryRatingBar(
|
||||
rating = rating,
|
||||
onRatingChange = {
|
||||
@@ -695,11 +696,11 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
GalleryDetailRating(rating = galleryDetail.rating)
|
||||
Spacer(modifier = Modifier.size(dimensionResource(id = R.dimen.keyline_margin)))
|
||||
Spacer(modifier = Modifier.size(keylineMargin))
|
||||
Text(text = ratingText)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.size(dimensionResource(id = R.dimen.keyline_margin)))
|
||||
Spacer(modifier = Modifier.size(keylineMargin))
|
||||
val tags = galleryDetail.tags
|
||||
if (tags.isEmpty()) {
|
||||
Box(
|
||||
@@ -760,7 +761,7 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
|
||||
},
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.size(dimensionResource(id = R.dimen.keyline_margin)))
|
||||
Spacer(modifier = Modifier.size(keylineMargin))
|
||||
if (Settings.showComments) {
|
||||
GalleryDetailComment(galleryDetail.comments.comments)
|
||||
Spacer(modifier = Modifier.size(dimensionResource(id = R.dimen.strip_item_padding_v)))
|
||||
@@ -894,7 +895,7 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
|
||||
BelowHeader(galleryDetail)
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
modifier = Modifier.fillMaxSize().padding(keylineMargin),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
@@ -954,7 +955,7 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
|
||||
BelowHeader(galleryDetail)
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
modifier = Modifier.fillMaxSize().padding(keylineMargin),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
|
||||
@@ -18,6 +18,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.collectAsState
|
||||
@@ -63,7 +64,7 @@ fun collectListThumbSizeAsState(): State<Dp> {
|
||||
val density = LocalDensity.current
|
||||
return Settings.listThumbSize.collectAsState {
|
||||
with(density) {
|
||||
it.toDp() * 9
|
||||
(it.toDp() * 9).coerceAtLeast(120.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,27 +10,19 @@ import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.calculateEndPadding
|
||||
import androidx.compose.foundation.layout.calculateStartPadding
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Help
|
||||
import androidx.compose.material.icons.automirrored.filled.LastPage
|
||||
@@ -41,7 +33,6 @@ import androidx.compose.material.icons.filled.Shuffle
|
||||
import androidx.compose.material.icons.outlined.Bookmarks
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
@@ -140,6 +131,7 @@ import com.hippo.ehviewer.util.pickVisualMedia
|
||||
import com.hippo.unifile.asUniFile
|
||||
import com.hippo.unifile.sha1
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import com.ramcosta.composedestinations.spec.Direction
|
||||
import eu.kanade.tachiyomi.util.lang.launchIO
|
||||
@@ -154,23 +146,23 @@ import moe.tarsin.coroutines.runSuspendCatching
|
||||
import sh.calvin.reorderable.ReorderableItem
|
||||
import sh.calvin.reorderable.rememberReorderableLazyColumnState
|
||||
|
||||
@Destination
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun HomePageScreen(navigator: DestinationsNavigator) = GalleryListScreen(ListUrlBuilder(), navigator)
|
||||
|
||||
@Destination
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun SubscriptionScreen(navigator: DestinationsNavigator) = GalleryListScreen(ListUrlBuilder(MODE_SUBSCRIPTION), navigator)
|
||||
|
||||
@Destination
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun WhatshotScreen(navigator: DestinationsNavigator) = GalleryListScreen(ListUrlBuilder(MODE_WHATS_HOT), navigator)
|
||||
|
||||
@Destination
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun ToplistScreen(navigator: DestinationsNavigator) = GalleryListScreen(ListUrlBuilder(MODE_TOPLIST, mKeyword = Settings.recentToplist), navigator)
|
||||
|
||||
@Destination
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = composing(navigator) {
|
||||
val searchFieldState = rememberTextFieldState()
|
||||
@@ -380,12 +372,11 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = c
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
state = quickSearchListState,
|
||||
contentPadding = WindowInsets.systemBars.only(WindowInsetsSides.Bottom).asPaddingValues(),
|
||||
// Workaround for https://issuetracker.google.com/332939169
|
||||
// contentPadding = WindowInsets.systemBars.only(WindowInsetsSides.Bottom).asPaddingValues(),
|
||||
) {
|
||||
// Fix the first item's reorder animation
|
||||
stickyHeader {
|
||||
HorizontalDivider()
|
||||
}
|
||||
item {}
|
||||
itemsIndexed(quickSearchList, key = { _, item -> item.id!! }) { index, item ->
|
||||
ReorderableItem(reorderableLazyListState, key = item.id!!) { isDragging ->
|
||||
// Not using rememberSwipeToDismissBoxState to prevent LazyColumn from reusing it
|
||||
@@ -605,42 +596,37 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = c
|
||||
val layoutDirection = LocalLayoutDirection.current
|
||||
val marginH = dimensionResource(id = R.dimen.gallery_list_margin_h)
|
||||
val marginV = dimensionResource(id = R.dimen.gallery_list_margin_v)
|
||||
Column(
|
||||
modifier = Modifier.imePadding().verticalScroll(rememberScrollState())
|
||||
.padding(
|
||||
top = contentPadding.calculateTopPadding() + marginV,
|
||||
start = contentPadding.calculateStartPadding(layoutDirection) + marginH,
|
||||
end = contentPadding.calculateEndPadding(layoutDirection) + marginH,
|
||||
bottom = 8.dp,
|
||||
)
|
||||
.windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Bottom))
|
||||
.graphicsLayer {
|
||||
scaleX = 1 - animatedSearchLayout
|
||||
scaleY = 1 - animatedSearchLayout
|
||||
alpha = 1 - animatedSearchLayout
|
||||
},
|
||||
ElevatedCard(
|
||||
modifier = Modifier.graphicsLayer {
|
||||
scaleX = 1 - animatedSearchLayout
|
||||
scaleY = 1 - animatedSearchLayout
|
||||
alpha = 1 - animatedSearchLayout
|
||||
}.padding(
|
||||
top = contentPadding.calculateTopPadding() + marginV,
|
||||
start = contentPadding.calculateStartPadding(layoutDirection) + marginH,
|
||||
end = contentPadding.calculateEndPadding(layoutDirection) + marginH,
|
||||
bottom = 8.dp,
|
||||
).padding(vertical = dimensionResource(id = R.dimen.search_layout_margin_v)),
|
||||
) {
|
||||
ElevatedCard(modifier = Modifier.padding(vertical = dimensionResource(id = R.dimen.search_layout_margin_v))) {
|
||||
Column(
|
||||
modifier = Modifier.padding(
|
||||
horizontal = dimensionResource(id = R.dimen.search_category_padding_h),
|
||||
vertical = dimensionResource(id = R.dimen.search_category_padding_v),
|
||||
).fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.search_image),
|
||||
modifier = Modifier.height(dimensionResource(id = R.dimen.search_category_title_height)),
|
||||
fontWeight = FontWeight.Bold,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
ImageSearch(
|
||||
image = imageUri,
|
||||
onSelectImage = {
|
||||
launch { imageUri = pickVisualMedia(ActivityResultContracts.PickVisualMedia.ImageOnly) }
|
||||
},
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.padding(
|
||||
horizontal = dimensionResource(id = R.dimen.search_category_padding_h),
|
||||
vertical = dimensionResource(id = R.dimen.search_category_padding_v),
|
||||
).fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.search_image),
|
||||
modifier = Modifier.height(dimensionResource(id = R.dimen.search_category_title_height)),
|
||||
fontWeight = FontWeight.Bold,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
ImageSearch(
|
||||
image = imageUri,
|
||||
onSelectImage = {
|
||||
launch { imageUri = pickVisualMedia(ActivityResultContracts.PickVisualMedia.ImageOnly) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -51,11 +51,12 @@ import com.hippo.ehviewer.ui.tools.getOffset
|
||||
import com.hippo.ehviewer.ui.tools.rememberInVM
|
||||
import com.hippo.ehviewer.util.flattenForEach
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import eu.kanade.tachiyomi.util.lang.withIOContext
|
||||
import moe.tarsin.coroutines.runSuspendCatching
|
||||
|
||||
@Destination
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun GalleryPreviewScreen(detail: GalleryDetail, toNextPage: Boolean, navigator: DestinationsNavigator) = composing(navigator) {
|
||||
LockDrawer(true)
|
||||
|
||||
@@ -54,18 +54,21 @@ import com.hippo.ehviewer.ui.tools.Deferred
|
||||
import com.hippo.ehviewer.ui.tools.FastScrollLazyColumn
|
||||
import com.hippo.ehviewer.ui.tools.SwipeToDismissBox2
|
||||
import com.hippo.ehviewer.ui.tools.rememberInVM
|
||||
import com.hippo.ehviewer.ui.tools.thenIf
|
||||
import com.hippo.ehviewer.util.FavouriteStatusRouter
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import kotlin.math.roundToInt
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Destination
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun HistoryScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
val title = stringResource(id = R.string.history)
|
||||
val hint = stringResource(R.string.search_bar_hint, title)
|
||||
val animateItems by Settings.animateItems.collectAsState()
|
||||
|
||||
var searchBarOffsetY by remember { mutableIntStateOf(0) }
|
||||
var keyword by rememberSaveable { mutableStateOf("") }
|
||||
@@ -141,7 +144,7 @@ fun HistoryScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
SwipeToDismissBox2(
|
||||
state = dismissState,
|
||||
backgroundContent = {},
|
||||
modifier = Modifier.animateItemPlacement(),
|
||||
modifier = Modifier.thenIf(animateItems) { animateItem() },
|
||||
) {
|
||||
GalleryInfoListItem(
|
||||
onClick = { navigate(info.asDst()) },
|
||||
|
||||
@@ -40,12 +40,12 @@ fun DestinationsNavigator.popNavigate(direction: Direction) = navigate(direction
|
||||
@MainThread
|
||||
fun NavController.navWithUrl(url: String): Boolean {
|
||||
val dest = urlToDestination(url) ?: return false
|
||||
navigate(dest)
|
||||
navigateTo(dest)
|
||||
return true
|
||||
}
|
||||
|
||||
@MainThread
|
||||
fun NavController.navigate(direction: Direction) = navigate(direction.route)
|
||||
fun NavController.navigateTo(direction: Direction) = navigate(direction.route)
|
||||
|
||||
fun BaseGalleryInfo.asDst() = GalleryDetailScreenDestination(GalleryInfoArgs(this))
|
||||
|
||||
|
||||
@@ -27,11 +27,12 @@ import com.hippo.ehviewer.icons.big.SadAndroid
|
||||
import com.hippo.ehviewer.ui.LockDrawer
|
||||
import com.hippo.ehviewer.util.displayString
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import eu.kanade.tachiyomi.util.lang.withUIContext
|
||||
import moe.tarsin.coroutines.runSuspendCatching
|
||||
|
||||
@Destination
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun ProgressScreen(gid: Long, token: String, page: Int, navigator: DestinationsNavigator) {
|
||||
LockDrawer(true)
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
package com.hippo.ehviewer.ui.screen
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.displayCutoutPadding
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
@@ -20,15 +18,14 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawingPadding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.union
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.foundation.text.input.clearText
|
||||
import androidx.compose.foundation.text.input.forEachTextValue
|
||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
|
||||
import androidx.compose.foundation.text.input.textAsFlow
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.MenuBook
|
||||
@@ -41,18 +38,20 @@ import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SearchBar
|
||||
import androidx.compose.material3.SearchBarDefaults
|
||||
import androidx.compose.material3.SearchBarHorizontalPadding
|
||||
import androidx.compose.material3.SearchBarInputField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshContainer
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -61,24 +60,26 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import arrow.fx.coroutines.mapIndexed
|
||||
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.client.data.TagNamespace
|
||||
import com.hippo.ehviewer.collectAsState
|
||||
import com.hippo.ehviewer.dao.Search
|
||||
import com.hippo.ehviewer.dao.SearchDao
|
||||
import com.hippo.ehviewer.ui.LocalNavDrawerState
|
||||
import com.hippo.ehviewer.ui.LockDrawer
|
||||
import com.hippo.ehviewer.ui.tools.LocalDialogState
|
||||
import com.hippo.ehviewer.ui.tools.rememberCompositionActiveState
|
||||
import com.hippo.ehviewer.ui.tools.thenIf
|
||||
import com.jamal.composeprefs3.ui.ifNotNullThen
|
||||
import com.jamal.composeprefs3.ui.ifTrueThen
|
||||
import eu.kanade.tachiyomi.util.lang.launchIO
|
||||
import eu.kanade.tachiyomi.util.lang.launchUI
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -117,10 +118,11 @@ fun SearchBarScreen(
|
||||
) {
|
||||
var mSuggestionList by remember { mutableStateOf(emptyList<Suggestion>()) }
|
||||
val mSearchDatabase = searchDatabase.searchDao()
|
||||
var active by rememberSaveable { mutableStateOf(false) }
|
||||
var expanded by rememberSaveable { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope { Dispatchers.IO }
|
||||
val context = LocalContext.current
|
||||
val dialogState = LocalDialogState.current
|
||||
val animateItems by Settings.animateItems.collectAsState()
|
||||
|
||||
class TagSuggestion(
|
||||
override val hint: String?,
|
||||
@@ -180,9 +182,9 @@ fun SearchBarScreen(
|
||||
onSearchHidden()
|
||||
}
|
||||
|
||||
if (active) {
|
||||
if (expanded) {
|
||||
LaunchedEffect(Unit) {
|
||||
searchFieldState.forEachTextValue {
|
||||
snapshotFlow { searchFieldState.text }.collectLatest {
|
||||
updateSuggestions()
|
||||
}
|
||||
}
|
||||
@@ -190,7 +192,7 @@ fun SearchBarScreen(
|
||||
|
||||
fun hideSearchView() {
|
||||
onSearchViewHidden()
|
||||
active = false
|
||||
expanded = false
|
||||
}
|
||||
|
||||
fun onApplySearch() {
|
||||
@@ -217,13 +219,13 @@ fun SearchBarScreen(
|
||||
}
|
||||
}
|
||||
|
||||
val scrollAwayModifier = if (!active) {
|
||||
val scrollAwayModifier = if (!expanded) {
|
||||
Modifier.offset { IntOffset(0, searchBarOffsetY()) }
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
// Placeholder, fill immutable SearchBar padding
|
||||
@@ -242,61 +244,73 @@ fun SearchBarScreen(
|
||||
.padding(top = 48.dp) then scrollAwayModifier,
|
||||
)
|
||||
}
|
||||
val onExpandedChange = { v: Boolean ->
|
||||
if (v) {
|
||||
onSearchViewExpanded()
|
||||
} else {
|
||||
onSearchViewHidden()
|
||||
}
|
||||
expanded = v
|
||||
}
|
||||
val activeState = rememberCompositionActiveState()
|
||||
SearchBar(
|
||||
modifier = Modifier.fillMaxWidth().align(Alignment.TopCenter) then scrollAwayModifier
|
||||
modifier = Modifier.align(Alignment.TopCenter) then scrollAwayModifier
|
||||
.windowInsetsPadding(WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal)),
|
||||
state = searchFieldState,
|
||||
onSearch = {
|
||||
hideSearchView()
|
||||
onApplySearch()
|
||||
},
|
||||
active = active,
|
||||
onActiveChange = {
|
||||
if (it) {
|
||||
onSearchViewExpanded()
|
||||
} else {
|
||||
onSearchViewHidden()
|
||||
}
|
||||
active = it
|
||||
},
|
||||
title = title.ifNotNullThen { Text(title!!, overflow = TextOverflow.Ellipsis) },
|
||||
placeholder = searchFieldHint.ifNotNullThen { Text(searchFieldHint!!) },
|
||||
leadingIcon = {
|
||||
if (active) {
|
||||
IconButton(onClick = { hideSearchView() }) {
|
||||
Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = null)
|
||||
}
|
||||
} else {
|
||||
val drawerState = LocalNavDrawerState.current
|
||||
IconButton(onClick = { scope.launchUI { drawerState.open() } }) {
|
||||
Icon(Icons.Default.Menu, contentDescription = null)
|
||||
}
|
||||
}
|
||||
},
|
||||
trailingIcon = {
|
||||
if (active) {
|
||||
val hasText by searchFieldState.textAsFlow().mapIndexed { index, text ->
|
||||
// Workaround for https://github.com/FooIbar/EhViewer/issues/715
|
||||
if (index == 0) delay(700)
|
||||
text.isNotEmpty()
|
||||
}.collectAsState(false)
|
||||
if (hasText) {
|
||||
IconButton(onClick = { searchFieldState.clearText() }) {
|
||||
Icon(Icons.Default.Close, contentDescription = null)
|
||||
inputField = {
|
||||
SearchBarInputField(
|
||||
state = searchFieldState,
|
||||
onSearch = {
|
||||
hideSearchView()
|
||||
onApplySearch()
|
||||
},
|
||||
expanded = expanded,
|
||||
onExpandedChange = onExpandedChange,
|
||||
modifier = Modifier.widthIn(max = maxWidth - SearchBarHorizontalPadding * 2),
|
||||
label = title.ifNotNullThen {
|
||||
Text(title!!, overflow = TextOverflow.Ellipsis)
|
||||
}.takeUnless {
|
||||
val contentActive by activeState.state
|
||||
expanded || contentActive || searchFieldState.text.isNotEmpty()
|
||||
},
|
||||
placeholder = searchFieldHint.ifNotNullThen { Text(searchFieldHint!!) },
|
||||
leadingIcon = {
|
||||
if (expanded) {
|
||||
IconButton(onClick = { hideSearchView() }) {
|
||||
Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = null)
|
||||
}
|
||||
} else {
|
||||
val drawerState = LocalNavDrawerState.current
|
||||
IconButton(onClick = { scope.launchUI { drawerState.open() } }) {
|
||||
Icon(Icons.Default.Menu, contentDescription = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Row {
|
||||
trailingIcon()
|
||||
}
|
||||
}
|
||||
},
|
||||
trailingIcon = {
|
||||
if (expanded) {
|
||||
if (searchFieldState.text.isNotEmpty()) {
|
||||
IconButton(onClick = { searchFieldState.clearText() }) {
|
||||
Icon(Icons.Default.Close, contentDescription = null)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Row {
|
||||
trailingIcon()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
expanded = expanded,
|
||||
onExpandedChange = onExpandedChange,
|
||||
) {
|
||||
activeState.Anchor()
|
||||
filter?.invoke()
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = WindowInsets.navigationBars.union(WindowInsets.ime)
|
||||
.only(WindowInsetsSides.Bottom).asPaddingValues(),
|
||||
modifier = Modifier.fillMaxSize()
|
||||
.windowInsetsPadding(WindowInsets.navigationBars.union(WindowInsets.ime).only(WindowInsetsSides.Bottom)),
|
||||
// Workaround for https://issuetracker.google.com/332939169
|
||||
// contentPadding = WindowInsets.navigationBars.union(WindowInsets.ime)
|
||||
// .only(WindowInsetsSides.Bottom).asPaddingValues(),
|
||||
) {
|
||||
// Workaround for prepending before the first item
|
||||
item {}
|
||||
@@ -319,7 +333,7 @@ fun SearchBarScreen(
|
||||
}
|
||||
},
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
modifier = Modifier.clickable { it.onClick() }.animateItemPlacement(),
|
||||
modifier = Modifier.clickable { it.onClick() }.thenIf(animateItems) { animateItem() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,27 +26,32 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.fromHtml
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.text.parseAsHtml
|
||||
import com.hippo.ehviewer.BuildConfig
|
||||
import com.hippo.ehviewer.EhDB
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.download.downloadLocation
|
||||
import com.hippo.ehviewer.ui.destinations.LicenseScreenDestination
|
||||
import com.hippo.ehviewer.ui.tools.DialogState
|
||||
import com.hippo.ehviewer.ui.tools.LocalDialogState
|
||||
import com.hippo.ehviewer.ui.tools.observed
|
||||
import com.hippo.ehviewer.ui.tools.toAnnotatedString
|
||||
import com.hippo.ehviewer.updater.AppUpdater
|
||||
import com.hippo.ehviewer.updater.Release
|
||||
import com.hippo.ehviewer.util.AppConfig
|
||||
import com.hippo.ehviewer.util.ReadableTime
|
||||
import com.hippo.ehviewer.util.displayString
|
||||
import com.hippo.ehviewer.util.installPackage
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import eu.kanade.tachiyomi.util.lang.withUIContext
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.Clock
|
||||
import moe.tarsin.coroutines.runSuspendCatching
|
||||
|
||||
private const val REPO_URL = "https://github.com/${BuildConfig.REPO_NAME}"
|
||||
@@ -58,9 +63,9 @@ private fun versionCode() = "${BuildConfig.VERSION_NAME} (${BuildConfig.COMMIT_S
|
||||
|
||||
@Composable
|
||||
@Stable
|
||||
private fun author() = stringResource(R.string.settings_about_author_summary).replace('$', '@').parseAsHtml().toAnnotatedString()
|
||||
private fun author() = AnnotatedString.fromHtml(stringResource(R.string.settings_about_author_summary).replace('$', '@'))
|
||||
|
||||
@Destination
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun AboutScreen(navigator: DestinationsNavigator) {
|
||||
val context = LocalContext.current
|
||||
@@ -107,6 +112,10 @@ fun AboutScreen(navigator: DestinationsNavigator) {
|
||||
title = stringResource(id = R.string.settings_about_version),
|
||||
summary = versionCode(),
|
||||
)
|
||||
SwitchPreference(
|
||||
title = stringResource(id = R.string.backup_before_update),
|
||||
value = Settings::backupBeforeUpdate,
|
||||
)
|
||||
SwitchPreference(
|
||||
title = stringResource(id = R.string.use_ci_update_channel),
|
||||
value = Settings::useCIUpdateChannel,
|
||||
@@ -144,6 +153,12 @@ suspend fun DialogState.showNewVersion(context: Context, release: Release) {
|
||||
Text(text = release.changelog)
|
||||
}
|
||||
}
|
||||
if (Settings.backupBeforeUpdate) {
|
||||
val time = ReadableTime.getFilenamableTime(Clock.System.now().toEpochMilliseconds())
|
||||
downloadLocation.createFile("$time.db")?.let {
|
||||
EhDB.exportDB(context, it)
|
||||
}
|
||||
}
|
||||
// TODO: Download in the background and show progress in notification
|
||||
val file = File(AppConfig.tempDir, "update.apk").apply { delete() }
|
||||
AppUpdater.downloadUpdate(release.downloadLink, file)
|
||||
|
||||
@@ -33,6 +33,7 @@ import com.hippo.ehviewer.BuildConfig
|
||||
import com.hippo.ehviewer.EhDB
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.asMutableState
|
||||
import com.hippo.ehviewer.client.CHROME_USER_AGENT
|
||||
import com.hippo.ehviewer.client.EhCookieStore
|
||||
import com.hippo.ehviewer.client.EhEngine
|
||||
@@ -42,7 +43,10 @@ import com.hippo.ehviewer.ui.tools.observed
|
||||
import com.hippo.ehviewer.util.AppConfig
|
||||
import com.hippo.ehviewer.util.Crash
|
||||
import com.hippo.ehviewer.util.ReadableTime
|
||||
import com.hippo.unifile.asUniFile
|
||||
import com.jamal.composeprefs3.ui.prefs.SwitchPref
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import eu.kanade.tachiyomi.util.system.logcat
|
||||
import java.io.File
|
||||
@@ -53,7 +57,7 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import moe.tarsin.coroutines.runSuspendCatching
|
||||
|
||||
@Destination
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun AdvancedScreen(navigator: DestinationsNavigator) {
|
||||
val context = LocalContext.current
|
||||
@@ -141,6 +145,13 @@ fun AdvancedScreen(navigator: DestinationsNavigator) {
|
||||
title = stringResource(id = R.string.preload_thumb_aggressively),
|
||||
value = Settings::preloadThumbAggressively,
|
||||
)
|
||||
var animateItems by Settings.animateItems.asMutableState()
|
||||
SwitchPref(
|
||||
checked = animateItems,
|
||||
onMutate = { animateItems = !animateItems },
|
||||
title = stringResource(id = R.string.animate_items),
|
||||
summary = stringResource(id = R.string.animate_items_summary),
|
||||
)
|
||||
IntSliderPreference(
|
||||
maxValue = 5,
|
||||
minValue = 1,
|
||||
@@ -171,7 +182,7 @@ fun AdvancedScreen(navigator: DestinationsNavigator) {
|
||||
uri?.let {
|
||||
context.runCatching {
|
||||
grantUriPermission(BuildConfig.APPLICATION_ID, uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
|
||||
EhDB.exportDB(context, uri)
|
||||
EhDB.exportDB(context, uri.asUniFile())
|
||||
launchSnackBar(getString(R.string.settings_advanced_export_data_to, uri.toString()))
|
||||
}.onFailure {
|
||||
logcat(it)
|
||||
|
||||
@@ -50,6 +50,7 @@ import com.hippo.unifile.UniFile
|
||||
import com.hippo.unifile.asUniFile
|
||||
import com.hippo.unifile.displayPath
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import eu.kanade.tachiyomi.util.lang.launchNonCancellable
|
||||
import eu.kanade.tachiyomi.util.system.logcat
|
||||
@@ -58,7 +59,7 @@ import kotlinx.coroutines.launch
|
||||
import moe.tarsin.coroutines.runSuspendCatching
|
||||
import splitties.init.appCtx
|
||||
|
||||
@Destination
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun DownloadScreen(navigator: DestinationsNavigator) {
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
@@ -93,6 +94,7 @@ fun DownloadScreen(navigator: DestinationsNavigator) {
|
||||
keepNoMediaFileStatus(downloadLocationState)
|
||||
}
|
||||
}.onFailure {
|
||||
logcat(it)
|
||||
launchSnackBar(cannotGetDownloadLocation)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,8 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.stringArrayResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.core.text.parseAsHtml
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.fromHtml
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.asMutableState
|
||||
@@ -54,12 +55,12 @@ import com.hippo.ehviewer.ui.screen.popNavigate
|
||||
import com.hippo.ehviewer.ui.tools.LocalDialogState
|
||||
import com.hippo.ehviewer.ui.tools.observed
|
||||
import com.hippo.ehviewer.ui.tools.rememberedAccessor
|
||||
import com.hippo.ehviewer.ui.tools.toAnnotatedString
|
||||
import com.hippo.ehviewer.util.copyTextToClipboard
|
||||
import com.hippo.ehviewer.util.displayString
|
||||
import com.hippo.ehviewer.util.isAtLeastT
|
||||
import com.jamal.composeprefs3.ui.prefs.SwitchPref
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import eu.kanade.tachiyomi.util.lang.withIOContext
|
||||
import eu.kanade.tachiyomi.util.lang.withUIContext
|
||||
@@ -68,7 +69,7 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.LocalTime
|
||||
import moe.tarsin.coroutines.runSuspendCatching
|
||||
|
||||
@Destination
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun EhScreen(navigator: DestinationsNavigator) {
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
@@ -116,7 +117,7 @@ fun EhScreen(navigator: DestinationsNavigator) {
|
||||
Column {
|
||||
val warning = stringResource(id = R.string.settings_eh_identity_cookies_signed)
|
||||
val str = cookies.joinToString("\n") { (k, v) -> "$k: $v" }
|
||||
Text(text = warning.parseAsHtml().toAnnotatedString())
|
||||
Text(text = AnnotatedString.fromHtml(warning))
|
||||
Spacer(modifier = Modifier.size(dimensionResource(id = R.dimen.keyline_margin)))
|
||||
OutlinedTextField(
|
||||
value = str,
|
||||
@@ -241,7 +242,11 @@ fun EhScreen(navigator: DestinationsNavigator) {
|
||||
addAll(Settings.favCat)
|
||||
}
|
||||
}
|
||||
defaultFavSlot = dialogState.showSelectItem(items, R.string.default_favorites_collection) - 2
|
||||
defaultFavSlot = dialogState.showSelectItem(
|
||||
items = items,
|
||||
title = R.string.default_favorites_collection,
|
||||
selected = defaultFavSlot + 2,
|
||||
) - 2
|
||||
}
|
||||
}
|
||||
SimpleMenuPreferenceInt(
|
||||
|
||||
@@ -28,6 +28,7 @@ import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.MenuAnchorType
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
@@ -49,22 +50,26 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.EhFilter
|
||||
import com.hippo.ehviewer.client.EhFilter.forget
|
||||
import com.hippo.ehviewer.client.EhFilter.remember
|
||||
import com.hippo.ehviewer.client.EhFilter.trigger
|
||||
import com.hippo.ehviewer.collectAsState
|
||||
import com.hippo.ehviewer.dao.Filter
|
||||
import com.hippo.ehviewer.dao.FilterMode
|
||||
import com.hippo.ehviewer.ui.tools.Deferred
|
||||
import com.hippo.ehviewer.ui.tools.LocalDialogState
|
||||
import com.hippo.ehviewer.ui.tools.thenIf
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import kotlin.coroutines.resume
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.launch
|
||||
import moe.tarsin.coroutines.groupByToObserved
|
||||
|
||||
@Destination
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun FilterScreen(navigator: DestinationsNavigator) {
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -73,6 +78,7 @@ fun FilterScreen(navigator: DestinationsNavigator) {
|
||||
val dialogState = LocalDialogState.current
|
||||
val textIsEmpty = stringResource(R.string.text_is_empty)
|
||||
val labelExist = stringResource(R.string.label_text_exist)
|
||||
val animateItems by Settings.animateItems.collectAsState()
|
||||
|
||||
fun addFilter() {
|
||||
scope.launch {
|
||||
@@ -121,7 +127,7 @@ fun FilterScreen(navigator: DestinationsNavigator) {
|
||||
onExpandedChange = { expanded = !expanded },
|
||||
) {
|
||||
OutlinedTextField(
|
||||
modifier = Modifier.menuAnchor(),
|
||||
modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable),
|
||||
readOnly = true,
|
||||
value = type,
|
||||
onValueChange = {},
|
||||
@@ -209,8 +215,9 @@ fun FilterScreen(navigator: DestinationsNavigator) {
|
||||
) { paddingValues ->
|
||||
Deferred({ allFilterMap.await() }) { filters ->
|
||||
LazyColumn(
|
||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
contentPadding = paddingValues,
|
||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection).padding(paddingValues),
|
||||
// Workaround for https://issuetracker.google.com/332939169
|
||||
// contentPadding = paddingValues,
|
||||
) {
|
||||
var showTip = true
|
||||
filters.forEach { (filterMode, filters) ->
|
||||
@@ -223,10 +230,10 @@ fun FilterScreen(navigator: DestinationsNavigator) {
|
||||
FilterMode.COMMENT -> R.string.filter_comment
|
||||
}
|
||||
if (filters.isNotEmpty()) {
|
||||
stickyHeader(key = filterMode) {
|
||||
item(key = filterMode) {
|
||||
Text(
|
||||
text = stringResource(id = title),
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp).animateItemPlacement(),
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp).thenIf(animateItems) { animateItem() },
|
||||
color = MaterialTheme.colorScheme.tertiary,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
@@ -234,7 +241,7 @@ fun FilterScreen(navigator: DestinationsNavigator) {
|
||||
items(filters, key = { requireNotNull(it.id) }) { filter ->
|
||||
val filterCheckBoxRecomposeScope = currentRecomposeScope
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().animateItemPlacement().clickable { filter.trigger { filterCheckBoxRecomposeScope.invalidate() } },
|
||||
modifier = Modifier.fillMaxWidth().thenIf(animateItems) { animateItem() }.clickable { filter.trigger { filterCheckBoxRecomposeScope.invalidate() } },
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Checkbox(
|
||||
|
||||
@@ -21,9 +21,10 @@ import com.hippo.ehviewer.ui.openBrowser
|
||||
import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer
|
||||
import com.mikepenz.aboutlibraries.ui.compose.m3.LibraryDefaults
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
|
||||
@Destination
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun LicenseScreen(navigator: DestinationsNavigator) {
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
|
||||
@@ -19,9 +19,10 @@ import com.hippo.ehviewer.client.EhUrl
|
||||
import com.hippo.ehviewer.ui.LockDrawer
|
||||
import com.hippo.ehviewer.util.setDefaultSettings
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
|
||||
@Destination
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun MyTagsScreen(navigator: DestinationsNavigator) {
|
||||
val url = EhUrl.myTagsUrl
|
||||
|
||||
@@ -34,7 +34,6 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.unit.dp
|
||||
import arrow.atomic.Atomic
|
||||
import com.hippo.ehviewer.ui.destinations.DirectionDestination
|
||||
import com.hippo.ehviewer.ui.openBrowser
|
||||
import com.hippo.ehviewer.ui.settings.PreferenceTokens.PreferenceTextPadding
|
||||
import com.hippo.ehviewer.util.ProgressDialog
|
||||
@@ -45,6 +44,7 @@ import com.jamal.composeprefs3.ui.prefs.SpannedTextPref
|
||||
import com.jamal.composeprefs3.ui.prefs.SwitchPref
|
||||
import com.jamal.composeprefs3.ui.prefs.TextPref
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import com.ramcosta.composedestinations.spec.DirectionDestinationSpec
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
import kotlin.math.roundToInt
|
||||
@@ -64,7 +64,7 @@ object PreferenceTokens {
|
||||
fun PreferenceHeader(
|
||||
icon: Painter,
|
||||
@StringRes title: Int,
|
||||
childRoute: DirectionDestination,
|
||||
childRoute: DirectionDestinationSpec,
|
||||
navigator: DestinationsNavigator,
|
||||
) {
|
||||
Row(
|
||||
@@ -82,7 +82,7 @@ fun PreferenceHeader(
|
||||
fun PreferenceHeader(
|
||||
icon: ImageVector,
|
||||
@StringRes title: Int,
|
||||
childRoute: DirectionDestination,
|
||||
childRoute: DirectionDestinationSpec,
|
||||
navigator: DestinationsNavigator,
|
||||
) = PreferenceHeader(
|
||||
icon = rememberVectorPainter(image = icon),
|
||||
|
||||
@@ -29,11 +29,12 @@ import com.hippo.ehviewer.ui.tools.LocalDialogState
|
||||
import com.hippo.ehviewer.ui.tools.observed
|
||||
import com.hippo.ehviewer.ui.tools.rememberedAccessor
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Destination
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun PrivacyScreen(navigator: DestinationsNavigator) {
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
|
||||
@@ -27,9 +27,10 @@ import com.hippo.ehviewer.ui.destinations.DownloadScreenDestination
|
||||
import com.hippo.ehviewer.ui.destinations.EhScreenDestination
|
||||
import com.hippo.ehviewer.ui.destinations.PrivacyScreenDestination
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
|
||||
@Destination
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun SettingsScreen(navigator: DestinationsNavigator) {
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
|
||||
@@ -28,11 +28,12 @@ import com.hippo.ehviewer.client.EhUrl
|
||||
import com.hippo.ehviewer.ui.LockDrawer
|
||||
import com.hippo.ehviewer.util.setDefaultSettings
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
|
||||
private const val APPLY_JS = "javascript:(function(){var apply = document.getElementById(\"apply\").children[0];apply.click();})();"
|
||||
|
||||
@Destination
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun UConfigScreen(navigator: DestinationsNavigator) {
|
||||
LockDrawer(true)
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
package com.hippo.ehviewer.ui.tools
|
||||
|
||||
import android.graphics.Typeface
|
||||
import android.text.Spanned
|
||||
import android.text.style.StrikethroughSpan
|
||||
import android.text.style.StyleSpan
|
||||
import android.text.style.UnderlineSpan
|
||||
import android.view.ActionMode
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
@@ -146,25 +141,6 @@ fun AnnotatedString.toBBCode() = buildString {
|
||||
}
|
||||
}
|
||||
|
||||
fun Spanned.toAnnotatedString(): AnnotatedString = buildAnnotatedString {
|
||||
val spanned = this@toAnnotatedString
|
||||
append(spanned.toString())
|
||||
getSpans(0, spanned.length, Any::class.java).forEach { span ->
|
||||
val start = getSpanStart(span)
|
||||
val end = getSpanEnd(span)
|
||||
fun addStyle(spanStyle: SpanStyle) = addStyle(spanStyle, start, end)
|
||||
when (span) {
|
||||
is StrikethroughSpan -> addStyle(SpanStyle(textDecoration = TextDecoration.LineThrough))
|
||||
is UnderlineSpan -> addStyle(SpanStyle(textDecoration = TextDecoration.Underline))
|
||||
is StyleSpan -> {
|
||||
val s = span.style
|
||||
if (s and Typeface.BOLD != 0) addStyle(SpanStyle(fontWeight = FontWeight.Bold))
|
||||
if (s and Typeface.ITALIC != 0) addStyle(SpanStyle(fontStyle = FontStyle.Italic))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberBBCodeTextToolbar(textFieldValue: MutableState<TextFieldValue>): TextToolbar {
|
||||
var tfv by textFieldValue
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.hippo.ehviewer.ui.tools
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
|
||||
@JvmInline
|
||||
value class CompositionActiveState(val state: MutableState<Boolean>) {
|
||||
@Composable
|
||||
fun Anchor() = DisposableEffect(state) {
|
||||
state.value = true
|
||||
onDispose {
|
||||
state.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberCompositionActiveState() = remember {
|
||||
CompositionActiveState(mutableStateOf(false))
|
||||
}
|
||||
@@ -35,7 +35,7 @@ import androidx.compose.material3.DisplayMode
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.RadioButton
|
||||
@@ -50,7 +50,6 @@ import androidx.compose.material3.TimePicker
|
||||
import androidx.compose.material3.rememberDatePickerState
|
||||
import androidx.compose.material3.rememberTimePickerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -100,9 +99,7 @@ class DialogState {
|
||||
content = null
|
||||
}
|
||||
|
||||
// Move to crossinline on next compose compiler update
|
||||
// https://issuetracker.google.com/issues/325502738
|
||||
suspend inline fun <R> dialog(noinline block: @Composable (CancellableContinuation<R>) -> Unit) = suspendCancellableCoroutine { cont ->
|
||||
suspend inline fun <R> dialog(crossinline block: @Composable (CancellableContinuation<R>) -> Unit) = suspendCancellableCoroutine { cont ->
|
||||
cont.invokeOnCancellation { dismiss() }
|
||||
val realContinuation = object : CancellableContinuation<R> by cont {
|
||||
override fun resumeWith(result: Result<R>) {
|
||||
@@ -444,12 +441,14 @@ class DialogState {
|
||||
suspend fun showSelectItem(
|
||||
items: List<String>,
|
||||
@StringRes title: Int? = null,
|
||||
selected: Int = -1,
|
||||
respectDefaultWidth: Boolean = true,
|
||||
) = showSelectItem(items, title?.right(), respectDefaultWidth)
|
||||
) = showSelectItem(items, title?.right(), selected, respectDefaultWidth)
|
||||
|
||||
suspend fun showSelectItem(
|
||||
items: List<String>,
|
||||
title: Either<String, Int>?,
|
||||
selected: Int = -1,
|
||||
respectDefaultWidth: Boolean = true,
|
||||
): Int = showNoButton(respectDefaultWidth) {
|
||||
Column(modifier = Modifier.padding(vertical = 8.dp)) {
|
||||
@@ -462,14 +461,11 @@ class DialogState {
|
||||
}
|
||||
LazyColumn {
|
||||
itemsIndexed(items) { index, text ->
|
||||
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.tertiary) {
|
||||
Text(
|
||||
text = text,
|
||||
modifier = Modifier.clickable { dismissWith(index) }.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
CheckableItem(
|
||||
text = text,
|
||||
checked = index == selected,
|
||||
modifier = Modifier.fillMaxWidth().clickable { dismissWith(index) }.padding(horizontal = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -477,11 +473,12 @@ class DialogState {
|
||||
|
||||
suspend inline fun showSelectActions(
|
||||
@StringRes title: Int? = null,
|
||||
selected: Int = -1,
|
||||
builder: ActionScope.() -> Unit,
|
||||
) {
|
||||
val (items, actions) = buildList { builder(ActionScope { action, that -> add(action to that) }) }.unzip()
|
||||
val selected = showSelectItem(items, title)
|
||||
actions[selected].invoke()
|
||||
val index = showSelectItem(items, title, selected)
|
||||
actions[index].invoke()
|
||||
}
|
||||
|
||||
suspend fun showSelectItemWithCheckBox(
|
||||
@@ -492,8 +489,6 @@ class DialogState {
|
||||
initialChecked: Boolean = false,
|
||||
): Pair<Int, Boolean> = showNoButton {
|
||||
var checked by remember { mutableStateOf(initialChecked) }
|
||||
val textStyle = MaterialTheme.typography.titleMedium
|
||||
val selectedColor = MaterialTheme.colorScheme.tertiary
|
||||
Column(modifier = Modifier.padding(vertical = 8.dp)) {
|
||||
Text(
|
||||
text = stringResource(id = title),
|
||||
@@ -502,19 +497,10 @@ class DialogState {
|
||||
)
|
||||
LazyColumn {
|
||||
itemsIndexed(items) { index, text ->
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = text,
|
||||
style = if (index == selected) textStyle.copy(color = selectedColor) else textStyle,
|
||||
)
|
||||
},
|
||||
modifier = Modifier.clickable { dismissWith(index to checked) }.fillMaxWidth(),
|
||||
trailingContent = {
|
||||
if (index == selected) {
|
||||
Icon(imageVector = Icons.Default.Check, contentDescription = null, tint = selectedColor)
|
||||
}
|
||||
},
|
||||
CheckableItem(
|
||||
text = text,
|
||||
checked = index == selected,
|
||||
modifier = Modifier.fillMaxWidth().clickable { dismissWith(index to checked) }.padding(horizontal = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -540,10 +526,11 @@ class DialogState {
|
||||
headlineContent = {
|
||||
Text(text = stringResource(id = text), style = MaterialTheme.typography.titleMedium)
|
||||
},
|
||||
modifier = Modifier.clickable { dismissWith(index) },
|
||||
modifier = Modifier.clickable { dismissWith(index) }.padding(horizontal = 8.dp),
|
||||
leadingContent = {
|
||||
Icon(imageVector = icon, contentDescription = null, tint = AlertDialogDefaults.iconContentColor)
|
||||
},
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Unspecified),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -608,6 +595,25 @@ class DialogState {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CheckableItem(text: String, checked: Boolean, modifier: Modifier = Modifier) {
|
||||
val textStyle = MaterialTheme.typography.titleMedium
|
||||
val checkedColor = MaterialTheme.colorScheme.primary
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = text,
|
||||
style = if (checked) textStyle.copy(color = checkedColor) else textStyle,
|
||||
)
|
||||
},
|
||||
modifier = modifier,
|
||||
trailingContent = checked.ifTrueThen {
|
||||
Icon(imageVector = Icons.Default.Check, contentDescription = null, tint = checkedColor)
|
||||
},
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Unspecified),
|
||||
)
|
||||
}
|
||||
|
||||
private val IconWithTextCorner = RoundedCornerShape(8.dp)
|
||||
private val DatePickerTitlePadding = PaddingValues(start = 24.dp, end = 12.dp, top = 16.dp)
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ fun ReorderableItemScope.DragHandle(
|
||||
} else if (isAtLeastOMR1) {
|
||||
HapticFeedbackConstants.KEYBOARD_RELEASE
|
||||
} else {
|
||||
HapticFeedbackConstants.VIRTUAL_KEY_RELEASE
|
||||
HapticFeedbackConstants.VIRTUAL_KEY
|
||||
}
|
||||
view.performHapticFeedback(feedbackConstant)
|
||||
onDragStopped()
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.hippo.ehviewer.ui.tools
|
||||
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
inline fun Modifier.thenIf(condition: Boolean, crossinline block: Modifier.() -> Modifier) =
|
||||
if (condition) block() else this
|
||||
@@ -4,7 +4,7 @@ import androidx.activity.BackEventCompat
|
||||
import androidx.activity.compose.PredictiveBackHandler
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.AnimationSpec
|
||||
import androidx.compose.animation.core.EaseOut
|
||||
import androidx.compose.animation.core.CubicBezierEasing
|
||||
import androidx.compose.animation.core.Easing
|
||||
import androidx.compose.animation.core.VectorConverter
|
||||
import androidx.compose.animation.core.spring
|
||||
@@ -23,6 +23,8 @@ import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
val PredictiveBackEasing = CubicBezierEasing(0.1f, 0.1f, 0f, 1f)
|
||||
|
||||
/* enable 0 1
|
||||
* animationProgress 1F 0F
|
||||
*
|
||||
@@ -41,7 +43,7 @@ import kotlinx.coroutines.launch
|
||||
fun animateFloatMergePredictiveBackAsState(
|
||||
enable: Boolean,
|
||||
animationSpec: AnimationSpec<Float> = spring(),
|
||||
predictiveBackInterpolator: Easing = EaseOut,
|
||||
predictiveBackInterpolator: Easing = PredictiveBackEasing,
|
||||
finishedListener: ((Float) -> Unit)? = null,
|
||||
onBack: () -> Unit,
|
||||
): State<Float> {
|
||||
@@ -84,7 +86,7 @@ fun animateFloatMergePredictiveBackAsState(
|
||||
fun animateFloatMergeOneWayPredictiveBackAsState(
|
||||
enable: Boolean,
|
||||
animationSpec: AnimationSpec<Float> = spring(),
|
||||
predictiveBackInterpolator: Easing = EaseOut,
|
||||
predictiveBackInterpolator: Easing = PredictiveBackEasing,
|
||||
onBack: suspend () -> Unit,
|
||||
): State<Float> {
|
||||
val animatable = remember { Animatable(0f, Float.VectorConverter) }
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.hippo.ehviewer.BuildConfig
|
||||
import com.hippo.ehviewer.EhApplication.Companion.ktorClient
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.executeAndParseAs
|
||||
import com.hippo.ehviewer.client.executeSafely
|
||||
import com.hippo.ehviewer.util.copyTo
|
||||
import io.ktor.client.request.header
|
||||
import io.ktor.client.request.prepareGet
|
||||
@@ -36,7 +37,7 @@ object AppUpdater {
|
||||
Settings.lastUpdateTime = now.epochSeconds
|
||||
if (Settings.useCIUpdateChannel) {
|
||||
val curSha = BuildConfig.COMMIT_SHA
|
||||
val branch = ghStatement(API_URL).execute { JSONObject(it.bodyAsText()).getString("default_branch") }
|
||||
val branch = ghStatement(API_URL).executeSafely { JSONObject(it.bodyAsText()).getString("default_branch") }
|
||||
val workflowRunsUrl = "$API_URL/actions/workflows/ci.yml/runs?branch=$branch&event=push&status=success&per_page=1"
|
||||
val workflowRun = ghStatement(workflowRunsUrl).executeAndParseAs<GithubWorkflowRuns>().workflowRuns[0]
|
||||
val shortSha = workflowRun.headSha.take(7)
|
||||
@@ -68,7 +69,7 @@ object AppUpdater {
|
||||
}
|
||||
|
||||
suspend fun downloadUpdate(url: String, file: File) =
|
||||
ghStatement(url).execute { response ->
|
||||
ghStatement(url).executeSafely { response ->
|
||||
if (url.endsWith("zip")) {
|
||||
response.bodyAsChannel().toInputStream().use { stream ->
|
||||
ZipInputStream(stream).use { zip ->
|
||||
|
||||
@@ -4,18 +4,23 @@ import android.os.ParcelFileDescriptor
|
||||
import android.system.Int64Ref
|
||||
import android.system.Os
|
||||
import com.hippo.unifile.UniFile
|
||||
import eu.kanade.tachiyomi.util.system.logcat
|
||||
import java.io.FileDescriptor
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
|
||||
private fun sendFileTotally(from: FileDescriptor, to: FileDescriptor) {
|
||||
private fun sendFileTotally(from: FileDescriptor, to: FileDescriptor): Long {
|
||||
if (isAtLeastP) {
|
||||
Os.sendfile(to, from, Int64Ref(0), Long.MAX_VALUE)
|
||||
} else {
|
||||
FileInputStream(from).use { src ->
|
||||
FileOutputStream(to).use { dst ->
|
||||
src.channel.transferTo(0, Long.MAX_VALUE, dst.channel)
|
||||
}
|
||||
// sendfile may fail on some devices
|
||||
try {
|
||||
return Os.sendfile(to, from, Int64Ref(0), Long.MAX_VALUE)
|
||||
} catch (e: Exception) {
|
||||
logcat("sendfile", e)
|
||||
}
|
||||
}
|
||||
return FileInputStream(from).use { src ->
|
||||
FileOutputStream(to).use { dst ->
|
||||
src.channel.transferTo(0, Long.MAX_VALUE, dst.channel)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,5 +30,5 @@ infix fun ParcelFileDescriptor.sendTo(fd: ParcelFileDescriptor) {
|
||||
}
|
||||
|
||||
infix fun UniFile.sendTo(file: UniFile) = openFileDescriptor("r").use { src ->
|
||||
file.openFileDescriptor("w").use { dst -> src sendTo dst }
|
||||
file.openFileDescriptor("wt").use { dst -> src sendTo dst }
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.hippo.ehviewer.util
|
||||
|
||||
fun calculateFraction(a: Float, b: Float, pos: Float) = ((pos - a) / (b - a)).coerceIn(0f, 1f)
|
||||
@@ -15,45 +15,40 @@
|
||||
*/
|
||||
package com.hippo.unifile
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.provider.DocumentsContract
|
||||
import splitties.init.appCtx
|
||||
|
||||
object DocumentsContractApi21 {
|
||||
private const val PATH_DOCUMENT = "document"
|
||||
private const val PATH_TREE = "tree"
|
||||
private val resolver = appCtx.contentResolver
|
||||
private val projection = arrayOf(
|
||||
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
|
||||
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
|
||||
DocumentsContract.Document.COLUMN_MIME_TYPE,
|
||||
)
|
||||
|
||||
fun createFile(self: Uri, mimeType: String, displayName: String) = runCatching {
|
||||
DocumentsContract.createDocument(resolver, self, mimeType, displayName)
|
||||
}.getOrNull()
|
||||
|
||||
fun createDirectory(self: Uri, displayName: String) = createFile(self, DocumentsContract.Document.MIME_TYPE_DIR, displayName)
|
||||
|
||||
fun prepareTreeUri(treeUri: Uri?): Uri {
|
||||
fun prepareTreeUri(treeUri: Uri): Uri {
|
||||
val documentId = if (treeUri.pathSegments.size == 2) {
|
||||
DocumentsContract.getTreeDocumentId(treeUri)
|
||||
} else {
|
||||
DocumentsContract.getDocumentId(treeUri)
|
||||
}
|
||||
return DocumentsContract.buildDocumentUriUsingTree(
|
||||
treeUri,
|
||||
DocumentsContract.getTreeDocumentId(treeUri),
|
||||
documentId,
|
||||
)
|
||||
}
|
||||
|
||||
fun getTreeDocumentPath(documentUri: Uri): String {
|
||||
val paths = documentUri.pathSegments
|
||||
if (paths.size >= 4 && PATH_TREE == paths[0] && PATH_DOCUMENT == paths[2]) {
|
||||
return paths[3]
|
||||
}
|
||||
error("Invalid URI: $documentUri")
|
||||
}
|
||||
|
||||
fun buildChildUri(uri: Uri, displayName: String): Uri {
|
||||
return DocumentsContract.buildDocumentUriUsingTree(
|
||||
uri,
|
||||
getTreeDocumentPath(uri) + "/" + displayName,
|
||||
DocumentsContract.getDocumentId(uri) + "/" + displayName,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -70,7 +65,7 @@ object DocumentsContractApi21 {
|
||||
}
|
||||
}
|
||||
|
||||
fun renameTo(context: Context, self: Uri, displayName: String) = runCatching {
|
||||
DocumentsContract.renameDocument(context.contentResolver, self, displayName)
|
||||
fun renameTo(self: Uri, displayName: String) = runCatching {
|
||||
DocumentsContract.renameDocument(resolver, self, displayName)
|
||||
}.getOrNull()
|
||||
}
|
||||
@@ -18,7 +18,7 @@ package com.hippo.unifile
|
||||
import android.net.Uri
|
||||
import java.io.IOException
|
||||
|
||||
class MediaFile(override val uri: Uri) : UniFile(null) {
|
||||
class MediaFile(override val uri: Uri) : UniFile() {
|
||||
override fun createFile(displayName: String) = null
|
||||
|
||||
override fun createDirectory(displayName: String) = null
|
||||
|
||||
@@ -24,8 +24,7 @@ import eu.kanade.tachiyomi.util.system.logcat
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
|
||||
class RawFile(parent: UniFile?, private var file: File) : UniFile(parent) {
|
||||
private val parent = parent as? RawFile
|
||||
class RawFile(private val parent: RawFile?, private var file: File) : UniFile() {
|
||||
|
||||
private var cachePresent = false
|
||||
private val allChildren by lazy {
|
||||
|
||||
@@ -17,7 +17,7 @@ package com.hippo.unifile
|
||||
|
||||
import android.net.Uri
|
||||
|
||||
class SingleDocumentFile(parent: UniFile?, override val uri: Uri) : UniFile(parent) {
|
||||
class SingleDocumentFile(override val uri: Uri) : UniFile() {
|
||||
|
||||
override fun createFile(displayName: String) = null
|
||||
|
||||
|
||||
@@ -18,19 +18,16 @@ package com.hippo.unifile
|
||||
import android.net.Uri
|
||||
import android.provider.DocumentsContract.Document.MIME_TYPE_DIR
|
||||
import android.webkit.MimeTypeMap
|
||||
import eu.kanade.tachiyomi.util.system.logcat
|
||||
import splitties.init.appCtx
|
||||
|
||||
class TreeDocumentFile(
|
||||
parent: UniFile?,
|
||||
private val parent: UniFile?,
|
||||
override var uri: Uri,
|
||||
override var name: String = getFilenameForUri(uri),
|
||||
mimeType: String? = null,
|
||||
) : UniFile(parent) {
|
||||
) : UniFile() {
|
||||
private var cachePresent = false
|
||||
private val allChildren by lazy {
|
||||
cachePresent = true
|
||||
logcat { "Directory lookup cache created for $name" }
|
||||
DocumentsContractApi21.listFiles(uri).mapTo(mutableListOf()) { (uri, name, mimeType) ->
|
||||
TreeDocumentFile(this, uri, name, mimeType)
|
||||
}
|
||||
@@ -98,7 +95,6 @@ class TreeDocumentFile(
|
||||
override fun ensureDir(): Boolean {
|
||||
if (isDirectory) return true
|
||||
if (isFile) return false
|
||||
val parent = parentFile
|
||||
return if (parent != null && parent.ensureDir()) {
|
||||
parent.createDirectory(name) != null
|
||||
} else {
|
||||
@@ -109,7 +105,6 @@ class TreeDocumentFile(
|
||||
override fun ensureFile(): Boolean {
|
||||
if (isFile) return true
|
||||
if (isDirectory) return false
|
||||
val parent = parentFile
|
||||
return if (parent != null && parent.ensureDir()) {
|
||||
parent.createFile(name) != null
|
||||
} else {
|
||||
@@ -135,7 +130,7 @@ class TreeDocumentFile(
|
||||
}
|
||||
|
||||
override fun renameTo(displayName: String): Boolean {
|
||||
val result = DocumentsContractApi21.renameTo(appCtx, uri, displayName)
|
||||
val result = DocumentsContractApi21.renameTo(uri, displayName)
|
||||
if (result != null) {
|
||||
uri = result
|
||||
name = displayName
|
||||
|
||||
@@ -28,7 +28,7 @@ import splitties.init.appCtx
|
||||
* In Android files can be accessed via [java.io.File] and [android.net.Uri].
|
||||
* The UniFile is designed to emulate File interface for both File and Uri.
|
||||
*/
|
||||
abstract class UniFile internal constructor(private val parent: UniFile?) {
|
||||
sealed class UniFile {
|
||||
/**
|
||||
* Create a new file as a direct child of this directory.
|
||||
*
|
||||
@@ -79,21 +79,6 @@ abstract class UniFile internal constructor(private val parent: UniFile?) {
|
||||
*/
|
||||
abstract val type: String?
|
||||
|
||||
/**
|
||||
* Return the parent file of this file. Only defined inside of the
|
||||
* user-selected tree; you can never escape above the top of the tree.
|
||||
*
|
||||
*
|
||||
* The underlying [android.provider.DocumentsProvider] only defines a
|
||||
* forward mapping from parent to child, so the reverse mapping of child to
|
||||
* parent offered here is purely a convenience method, and it may be
|
||||
* incorrect if the underlying tree structure changes.
|
||||
*
|
||||
* @return parent of the file, or null if it is the top of the file tree
|
||||
*/
|
||||
val parentFile: UniFile?
|
||||
get() = parent
|
||||
|
||||
/**
|
||||
* Indicates if this file represents a *directory*.
|
||||
*
|
||||
@@ -235,7 +220,7 @@ abstract class UniFile internal constructor(private val parent: UniFile?) {
|
||||
companion object {
|
||||
fun fromFile(file: File) = RawFile(null, file)
|
||||
|
||||
private fun fromSingleUri(singleUri: Uri) = SingleDocumentFile(null, singleUri)
|
||||
private fun fromSingleUri(singleUri: Uri) = SingleDocumentFile(singleUri)
|
||||
|
||||
private fun fromTreeUri(treeUri: Uri) = TreeDocumentFile(null, DocumentsContractApi21.prepareTreeUri(treeUri))
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ fun UniFile.openInputStream(): FileInputStream {
|
||||
* Use Native IO/NIO directly if possible, unless you need process file content on JVM!
|
||||
*/
|
||||
fun UniFile.openOutputStream(): FileOutputStream {
|
||||
return AutoCloseOutputStream(openFileDescriptor("w"))
|
||||
return AutoCloseOutputStream(openFileDescriptor("wt"))
|
||||
}
|
||||
|
||||
fun UniFile.sha1() = openFileDescriptor("r").use { sha1(it.fd) }
|
||||
|
||||
@@ -43,9 +43,7 @@ fun ChapterNavigator(
|
||||
val layoutDirection = if (isRtl) LayoutDirection.Rtl else LayoutDirection.Ltr
|
||||
val view = LocalView.current
|
||||
val configuration = LocalConfiguration.current
|
||||
// https://m3.material.io/components/sliders/specs
|
||||
// Tick width is 2 dp
|
||||
val maxTickCount = configuration.screenWidthDp / 6
|
||||
val maxTickCount = configuration.screenWidthDp / (SliderDefaults.TickSize.value * 2.5f).roundToInt()
|
||||
val showTicks = totalPages < maxTickCount
|
||||
|
||||
// Match with toolbar background color set in ReaderActivity
|
||||
|
||||
@@ -222,7 +222,7 @@ class ReaderActivity : EhActivity() {
|
||||
mPage = savedInstanceState.getInt(KEY_PAGE, -1)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent?) {
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
handleIntent(intent)
|
||||
lifecycleScope.launchIO {
|
||||
|
||||
@@ -62,7 +62,10 @@ abstract class PageLoader {
|
||||
onRequest(index)
|
||||
}
|
||||
|
||||
val pagesAbsent = ((index - 5).coerceAtLeast(0) until (preloads + index).coerceAtMost(size)).mapNotNull { it.takeIf { cache[it] == null } }
|
||||
val pagesAbsent = ((index - 5).coerceAtLeast(0) until (preloads + index).coerceAtMost(size))
|
||||
.mapNotNullTo(mutableListOf()) { it.takeIf { it != index && cache[it] == null } }
|
||||
// Load forward first, then load backward from the nearest index
|
||||
pagesAbsent.sortBy { (index - it).coerceAtLeast(0) }
|
||||
preloadPages(pagesAbsent, (index - 10).coerceAtLeast(0) to (preloads + index + 10).coerceAtMost(size))
|
||||
}
|
||||
|
||||
|
||||
@@ -53,9 +53,7 @@ open class ReaderPageImageView @JvmOverloads constructor(
|
||||
private var config: Config? = null
|
||||
|
||||
var onImageLoaded: (() -> Unit)? = null
|
||||
var onImageLoadError: (() -> Unit)? = null
|
||||
var onScaleChanged: ((newScale: Float) -> Unit)? = null
|
||||
var onViewClicked: (() -> Unit)? = null
|
||||
|
||||
/**
|
||||
* For automatic background. Will be set as background color when [onImageLoaded] is called.
|
||||
@@ -68,40 +66,22 @@ open class ReaderPageImageView @JvmOverloads constructor(
|
||||
background = pageBackground
|
||||
}
|
||||
|
||||
@CallSuper
|
||||
open fun onImageLoadError() {
|
||||
onImageLoadError?.invoke()
|
||||
}
|
||||
|
||||
@CallSuper
|
||||
open fun onScaleChanged(newScale: Float) {
|
||||
onScaleChanged?.invoke(newScale)
|
||||
}
|
||||
|
||||
@CallSuper
|
||||
open fun onViewClicked() {
|
||||
onViewClicked?.invoke()
|
||||
}
|
||||
|
||||
open fun onPageSelected(forward: Boolean) {
|
||||
with(pageView as? SubsamplingScaleImageView) {
|
||||
if (this == null) return
|
||||
if (isReady) {
|
||||
landscapeZoom(forward)
|
||||
} else {
|
||||
setOnImageEventListener(
|
||||
object : SubsamplingScaleImageView.DefaultOnImageEventListener() {
|
||||
override fun onReady() {
|
||||
setupZoom(config)
|
||||
landscapeZoom(forward)
|
||||
this@ReaderPageImageView.onImageLoaded()
|
||||
}
|
||||
|
||||
override fun onImageLoadError(e: Exception) {
|
||||
onImageLoadError()
|
||||
}
|
||||
},
|
||||
)
|
||||
setOnImageEventListener {
|
||||
setupZoom(config)
|
||||
landscapeZoom(forward)
|
||||
this@ReaderPageImageView.onImageLoaded()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -221,7 +201,6 @@ open class ReaderPageImageView @JvmOverloads constructor(
|
||||
}
|
||||
},
|
||||
)
|
||||
setOnClickListener { this@ReaderPageImageView.onViewClicked() }
|
||||
}
|
||||
addView(pageView, MATCH_PARENT, MATCH_PARENT)
|
||||
}
|
||||
@@ -248,19 +227,11 @@ open class ReaderPageImageView @JvmOverloads constructor(
|
||||
setMinimumScaleType(config.minimumScaleType)
|
||||
setMinimumDpi(1) // Just so that very small image will be fit for initial load
|
||||
setCropBorders(config.cropBorders)
|
||||
setOnImageEventListener(
|
||||
object : SubsamplingScaleImageView.DefaultOnImageEventListener() {
|
||||
override fun onReady() {
|
||||
setupZoom(config)
|
||||
if (isVisibleOnScreen()) landscapeZoom(true)
|
||||
this@ReaderPageImageView.onImageLoaded()
|
||||
}
|
||||
|
||||
override fun onImageLoadError(e: Exception) {
|
||||
this@ReaderPageImageView.onImageLoadError()
|
||||
}
|
||||
},
|
||||
)
|
||||
setOnImageEventListener {
|
||||
setupZoom(config)
|
||||
if (isVisibleOnScreen()) landscapeZoom(true)
|
||||
this@ReaderPageImageView.onImageLoaded()
|
||||
}
|
||||
|
||||
setImage(image, bounds)
|
||||
isVisible = true
|
||||
@@ -292,11 +263,6 @@ open class ReaderPageImageView @JvmOverloads constructor(
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
|
||||
this@ReaderPageImageView.onViewClicked()
|
||||
return super.onSingleTapConfirmed(e)
|
||||
}
|
||||
},
|
||||
)
|
||||
setOnScaleChangeListener { _, _, _ ->
|
||||
|
||||
+13
-5
@@ -2,15 +2,16 @@ package eu.kanade.tachiyomi.ui.reader.viewer
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.Gravity
|
||||
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
import android.widget.FrameLayout
|
||||
import androidx.annotation.IntRange
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.AbstractComposeView
|
||||
import androidx.compose.ui.platform.ViewCompositionStrategy
|
||||
import androidx.core.view.isVisible
|
||||
@@ -23,7 +24,6 @@ class ReaderProgressIndicator @JvmOverloads constructor(
|
||||
) : AbstractComposeView(context, attrs, defStyleAttr) {
|
||||
|
||||
init {
|
||||
layoutParams = FrameLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT, Gravity.CENTER)
|
||||
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindowOrReleasedFromPool)
|
||||
}
|
||||
|
||||
@@ -33,7 +33,9 @@ class ReaderProgressIndicator @JvmOverloads constructor(
|
||||
@Composable
|
||||
override fun Content() {
|
||||
EhTheme {
|
||||
if (visible) CombinedCircularProgressIndicator(progress = progress)
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
if (visible) CombinedCircularProgressIndicator(progress = progress)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +49,12 @@ class ReaderProgressIndicator @JvmOverloads constructor(
|
||||
isVisible = false
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
progress = 0f
|
||||
visible = false
|
||||
isVisible = true
|
||||
}
|
||||
|
||||
fun setProgress(@IntRange(from = 0, to = 100) progress: Int) {
|
||||
this.progress = progress / 100f
|
||||
}
|
||||
|
||||
@@ -49,11 +49,6 @@ class PagerPageHolder(
|
||||
*/
|
||||
private var statusJob: Job? = null
|
||||
|
||||
/**
|
||||
* Job for progress changes of the page.
|
||||
*/
|
||||
private var progressJob: Job? = null
|
||||
|
||||
init {
|
||||
addView(progressIndicator)
|
||||
statusJob = scope.launch(Dispatchers.Main) {
|
||||
@@ -69,39 +64,25 @@ class PagerPageHolder(
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onDetachedFromWindow() {
|
||||
super.onDetachedFromWindow()
|
||||
cancelProgressJob()
|
||||
unsubscribeStatus()
|
||||
progressIndicator.hide()
|
||||
}
|
||||
|
||||
private fun launchProgressJob() {
|
||||
progressJob?.cancel()
|
||||
progressJob = scope.launch(Dispatchers.Main) {
|
||||
page.progressFlow.collectLatest { value -> progressIndicator.setProgress(value) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the status of the page changes.
|
||||
*
|
||||
* @param status the new status of the page.
|
||||
*/
|
||||
private fun processStatus(status: Page.State) {
|
||||
private suspend fun processStatus(status: Page.State) {
|
||||
when (status) {
|
||||
Page.State.QUEUE -> setQueued()
|
||||
Page.State.LOAD_PAGE -> setLoading()
|
||||
Page.State.DOWNLOAD_IMAGE -> {
|
||||
launchProgressJob()
|
||||
setDownloading()
|
||||
page.progressFlow.collectLatest { value -> progressIndicator.setProgress(value) }
|
||||
}
|
||||
Page.State.READY -> {
|
||||
page.image?.let { setImage(it) }
|
||||
cancelProgressJob()
|
||||
}
|
||||
Page.State.ERROR -> {
|
||||
setError()
|
||||
cancelProgressJob()
|
||||
}
|
||||
Page.State.READY -> page.image?.let { setImage(it) } ?: setError()
|
||||
Page.State.ERROR -> setError()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,11 +94,6 @@ class PagerPageHolder(
|
||||
statusJob = null
|
||||
}
|
||||
|
||||
private fun cancelProgressJob() {
|
||||
progressJob?.cancel()
|
||||
progressJob = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the page is queued.
|
||||
*/
|
||||
@@ -149,8 +125,6 @@ class PagerPageHolder(
|
||||
*/
|
||||
private fun setImage(image: Image) {
|
||||
progressIndicator.setProgress(0)
|
||||
progressIndicator.hide()
|
||||
removeErrorLayout()
|
||||
setImage(
|
||||
image,
|
||||
Config(
|
||||
@@ -177,15 +151,7 @@ class PagerPageHolder(
|
||||
override fun onImageLoaded() {
|
||||
super.onImageLoaded()
|
||||
progressIndicator.hide()
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when an image fails to decode.
|
||||
*/
|
||||
override fun onImageLoadError() {
|
||||
super.onImageLoadError()
|
||||
progressIndicator.hide()
|
||||
showErrorLayout()
|
||||
removeErrorLayout()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+15
-79
@@ -1,11 +1,9 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.webtoon
|
||||
|
||||
import android.content.res.Resources
|
||||
import android.view.ViewGroup
|
||||
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
|
||||
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
import android.widget.FrameLayout
|
||||
import androidx.core.view.isVisible
|
||||
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
|
||||
import com.hippo.ehviewer.image.Image
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
@@ -34,14 +32,10 @@ class WebtoonPageHolder(
|
||||
|
||||
/**
|
||||
* Loading progress bar to indicate the current progress.
|
||||
* Needed to keep a minimum height size of the holder, otherwise the adapter would create more
|
||||
* views to fill the screen, which is not wanted.
|
||||
*/
|
||||
private val progressIndicator = createProgressIndicator()
|
||||
|
||||
/**
|
||||
* Progress bar container. Needed to keep a minimum height size of the holder, otherwise the
|
||||
* adapter would create more views to fill the screen, which is not wanted.
|
||||
*/
|
||||
private lateinit var progressContainer: ViewGroup
|
||||
private val progressIndicator = ReaderProgressIndicator(context)
|
||||
|
||||
/**
|
||||
* Error layout to show when the image fails to load.
|
||||
@@ -57,7 +51,7 @@ class WebtoonPageHolder(
|
||||
/**
|
||||
* Page of a chapter.
|
||||
*/
|
||||
private var page: ReaderPage? = null
|
||||
private lateinit var page: ReaderPage
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.IO)
|
||||
|
||||
@@ -66,16 +60,11 @@ class WebtoonPageHolder(
|
||||
*/
|
||||
private var statusJob: Job? = null
|
||||
|
||||
/**
|
||||
* Job for progress changes of the page.
|
||||
*/
|
||||
private var progressJob: Job? = null
|
||||
|
||||
init {
|
||||
frame.addView(progressIndicator, MATCH_PARENT, defaultHeight)
|
||||
refreshLayoutParams()
|
||||
|
||||
frame.onImageLoaded = { onImageDecoded() }
|
||||
frame.onImageLoadError = { onImageDecodeError() }
|
||||
frame.onScaleChanged = { viewer.activity.hideMenu() }
|
||||
}
|
||||
|
||||
@@ -110,25 +99,11 @@ class WebtoonPageHolder(
|
||||
*/
|
||||
override fun recycle() {
|
||||
statusJob?.cancel()
|
||||
cancelProgressJob()
|
||||
progressIndicator.hide()
|
||||
statusJob = null
|
||||
|
||||
removeErrorLayout()
|
||||
frame.recycle()
|
||||
progressIndicator.setProgress(0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Observes the progress of the page and updates view.
|
||||
*/
|
||||
private fun launchProgressJob() {
|
||||
cancelProgressJob()
|
||||
|
||||
val page = page ?: return
|
||||
|
||||
progressJob = scope.launch(Dispatchers.Main) {
|
||||
page.progressFlow.collectLatest { value -> progressIndicator.setProgress(value) }
|
||||
}
|
||||
progressIndicator.reset()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -136,38 +111,23 @@ class WebtoonPageHolder(
|
||||
*
|
||||
* @param status the new status of the page.
|
||||
*/
|
||||
private fun processStatus(status: Page.State) {
|
||||
private suspend fun processStatus(status: Page.State) {
|
||||
when (status) {
|
||||
Page.State.QUEUE -> setQueued()
|
||||
Page.State.LOAD_PAGE -> setLoading()
|
||||
Page.State.DOWNLOAD_IMAGE -> {
|
||||
launchProgressJob()
|
||||
setDownloading()
|
||||
page.progressFlow.collectLatest { value -> progressIndicator.setProgress(value) }
|
||||
}
|
||||
Page.State.READY -> {
|
||||
page?.image?.let { setImage(it) }
|
||||
cancelProgressJob()
|
||||
}
|
||||
Page.State.ERROR -> {
|
||||
setError()
|
||||
cancelProgressJob()
|
||||
}
|
||||
Page.State.READY -> page.image?.let { setImage(it) } ?: setError()
|
||||
Page.State.ERROR -> setError()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribes from the progress subscription.
|
||||
*/
|
||||
private fun cancelProgressJob() {
|
||||
progressJob?.cancel()
|
||||
progressJob = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the page is queued.
|
||||
*/
|
||||
private fun setQueued() {
|
||||
progressContainer.isVisible = true
|
||||
progressIndicator.setProgress(0)
|
||||
progressIndicator.show()
|
||||
removeErrorLayout()
|
||||
@@ -178,7 +138,6 @@ class WebtoonPageHolder(
|
||||
* Called when the page is loading.
|
||||
*/
|
||||
private fun setLoading() {
|
||||
progressContainer.isVisible = true
|
||||
progressIndicator.show()
|
||||
removeErrorLayout()
|
||||
}
|
||||
@@ -187,7 +146,6 @@ class WebtoonPageHolder(
|
||||
* Called when the page is downloading
|
||||
*/
|
||||
private fun setDownloading() {
|
||||
progressContainer.isVisible = true
|
||||
progressIndicator.show()
|
||||
removeErrorLayout()
|
||||
}
|
||||
@@ -197,8 +155,6 @@ class WebtoonPageHolder(
|
||||
*/
|
||||
private fun setImage(drawable: Image) {
|
||||
progressIndicator.setProgress(0)
|
||||
progressIndicator.hide()
|
||||
removeErrorLayout()
|
||||
frame.setImage(
|
||||
drawable,
|
||||
ReaderPageImageView.Config(
|
||||
@@ -214,7 +170,6 @@ class WebtoonPageHolder(
|
||||
*/
|
||||
private fun setError() {
|
||||
progressIndicator.hide()
|
||||
progressContainer.isVisible = false
|
||||
initErrorLayout()
|
||||
}
|
||||
|
||||
@@ -222,27 +177,8 @@ class WebtoonPageHolder(
|
||||
* Called when the image is decoded and going to be displayed.
|
||||
*/
|
||||
private fun onImageDecoded() {
|
||||
progressContainer.isVisible = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the image fails to decode.
|
||||
*/
|
||||
private fun onImageDecodeError() {
|
||||
progressContainer.isVisible = false
|
||||
initErrorLayout()
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new progress bar.
|
||||
*/
|
||||
private fun createProgressIndicator(): ReaderProgressIndicator {
|
||||
progressContainer = FrameLayout(context)
|
||||
frame.addView(progressContainer, MATCH_PARENT, defaultHeight)
|
||||
|
||||
val progress = ReaderProgressIndicator(context)
|
||||
progressContainer.addView(progress)
|
||||
return progress
|
||||
progressIndicator.hide()
|
||||
removeErrorLayout()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -250,8 +186,8 @@ class WebtoonPageHolder(
|
||||
*/
|
||||
private fun initErrorLayout() {
|
||||
if (errorLayout == null) {
|
||||
errorLayout = ReaderErrorLayout(context, page!!.errorMsg) {
|
||||
viewer.activity.retryPage(page!!.index)
|
||||
errorLayout = ReaderErrorLayout(context, page.errorMsg) {
|
||||
viewer.activity.retryPage(page.index)
|
||||
}.also {
|
||||
frame.addView(it)
|
||||
}
|
||||
|
||||
@@ -97,10 +97,6 @@
|
||||
<!-- Gallery detail -->
|
||||
<string name="download">Descargar</string>
|
||||
<string name="read">Leer</string>
|
||||
<plurals name="page_count">
|
||||
<item quantity="one">%d páginas</item>
|
||||
<item quantity="other">%d páginas</item>
|
||||
</plurals>
|
||||
<string name="favored_times">\u2665 %d</string>
|
||||
<string name="rating_text">%1$s (%2$.2f, %3$d)</string>
|
||||
<string name="torrent_count">Torrent (%d)</string>
|
||||
@@ -336,36 +332,8 @@
|
||||
<!-- Readable Time -->
|
||||
<string name="from_the_future">Desde el futuro</string>
|
||||
<string name="just_now">Ahora</string>
|
||||
<plurals name="some_minutes_ago">
|
||||
<item quantity="one">Hace un minuto</item>
|
||||
<item quantity="other">Hace %d minutos</item>
|
||||
</plurals>
|
||||
<plurals name="some_hours_ago">
|
||||
<item quantity="one">Hace una hora</item>
|
||||
<item quantity="other">Hace %d horas</item>
|
||||
</plurals>
|
||||
<string name="yesterday">Ayer</string>
|
||||
<string name="some_days_ago">Hace %d días atrás</string>
|
||||
<plurals name="second">
|
||||
<item quantity="one">segundo</item>
|
||||
<item quantity="other">segundos</item>
|
||||
</plurals>
|
||||
<plurals name="minute">
|
||||
<item quantity="one">minuto</item>
|
||||
<item quantity="other">minutos</item>
|
||||
</plurals>
|
||||
<plurals name="hour">
|
||||
<item quantity="one">hora</item>
|
||||
<item quantity="other">horas</item>
|
||||
</plurals>
|
||||
<plurals name="day">
|
||||
<item quantity="one">día</item>
|
||||
<item quantity="other">días</item>
|
||||
</plurals>
|
||||
<plurals name="year">
|
||||
<item quantity="one">año</item>
|
||||
<item quantity="other">años</item>
|
||||
</plurals>
|
||||
<string name="no_archives">Sin archivos</string>
|
||||
<string name="download_archive_started">Descarga de archivos iniciada</string>
|
||||
<string name="download_archive_failure">Error al descargar archivo</string>
|
||||
@@ -399,4 +367,37 @@
|
||||
<string name="apply_tip">Toca la marca de verificación para guardar los cambios</string>
|
||||
<string name="dark_theme_follow_system">Sigue el sistema</string>
|
||||
|
||||
<plurals name="page_count">
|
||||
<item quantity="one">%d páginas</item>
|
||||
<item quantity="other">%d páginas</item>
|
||||
</plurals>
|
||||
<plurals name="some_minutes_ago">
|
||||
<item quantity="one">Hace un minuto</item>
|
||||
<item quantity="other">Hace %d minutos</item>
|
||||
</plurals>
|
||||
<plurals name="some_hours_ago">
|
||||
<item quantity="one">Hace una hora</item>
|
||||
<item quantity="other">Hace %d horas</item>
|
||||
</plurals>
|
||||
<plurals name="second">
|
||||
<item quantity="one">segundo</item>
|
||||
<item quantity="other">segundos</item>
|
||||
</plurals>
|
||||
<plurals name="minute">
|
||||
<item quantity="one">minuto</item>
|
||||
<item quantity="other">minutos</item>
|
||||
</plurals>
|
||||
<plurals name="hour">
|
||||
<item quantity="one">hora</item>
|
||||
<item quantity="other">horas</item>
|
||||
</plurals>
|
||||
<plurals name="day">
|
||||
<item quantity="one">día</item>
|
||||
<item quantity="other">días</item>
|
||||
</plurals>
|
||||
<plurals name="year">
|
||||
<item quantity="one">año</item>
|
||||
<item quantity="other">años</item>
|
||||
</plurals>
|
||||
|
||||
</resources>
|
||||
@@ -105,10 +105,6 @@
|
||||
<!-- Gallery detail -->
|
||||
<string name="download">Télécharger</string>
|
||||
<string name="read">Lire</string>
|
||||
<plurals name="page_count">
|
||||
<item quantity="one">%d page</item>
|
||||
<item quantity="other">%d pages</item>
|
||||
</plurals>
|
||||
<string name="favored_times">\u2665 %d</string>
|
||||
<string name="rating_text">%1$s (%2$.2f, %3$d)</string>
|
||||
<string name="torrent_count">Torrent (%d)</string>
|
||||
@@ -375,6 +371,30 @@
|
||||
<!-- Readable Time -->
|
||||
<string name="from_the_future">Du futur</string>
|
||||
<string name="just_now">En ce moment</string>
|
||||
<string name="yesterday">Hier</string>
|
||||
<string name="some_days_ago">Il y a %d jours</string>
|
||||
<string name="no_archives">Aucun fiche archives</string>
|
||||
<string name="download_archive_started">Téléchargement du fiche archives a commencé</string>
|
||||
<string name="download_archive_failure">Téléchargement du fiche archives a échoué</string>
|
||||
<string name="download_archive_failure_no_hath">Client H@H est nécessaire pour le téléchargement de fiches archives</string>
|
||||
<string name="settings_privacy">Confidentialité</string>
|
||||
<string name="settings_privacy_secure">Empêcher les captures d\'ecran</string>
|
||||
<string name="settings_privacy_secure_summary">Quand cette option est activée, vous ne pouvez prendre aucune capture d\'écran de l\'application, et le systéme n\'affiche pas le contenu d\'écran dans le sélecteur de tâches.\n\nRedémarrage de l\'application est nécessaire pour appliquer le changement</string>
|
||||
<string name="settings_advanced_app_language_title">Langue de l\'app (Language)</string>
|
||||
<string name="app_language_system">Langue du système (défaut)</string>
|
||||
<string name="settings_eh_identity_cookies_signed">Tout le monde peut utiliser les cookies de ce compte pour s\'y connecter.<br><b>GARDEZ-LES BIEN !</b></string>
|
||||
<string name="settings_eh_identity_cookies_guest">Vous ne vous êtes pas connecté.</string>
|
||||
<string name="settings_eh_show_gallery_pages">Montrer les nombres des pages des galeries</string>
|
||||
<string name="settings_eh_show_gallery_pages_summary">Montrer le nombre des pages des galerie dans la liste des galeries</string>
|
||||
<string name="dark_theme">Thème sombre</string>
|
||||
<string name="dark_theme_follow_system">Identique au système</string>
|
||||
<string name="dark_theme_off">Toujours actif</string>
|
||||
<string name="dark_theme_on">Toujours inactif</string>
|
||||
|
||||
<plurals name="page_count">
|
||||
<item quantity="one">%d page</item>
|
||||
<item quantity="other">%d pages</item>
|
||||
</plurals>
|
||||
<plurals name="some_minutes_ago">
|
||||
<item quantity="one">Il y a %d minute</item>
|
||||
<item quantity="other">Il y a %d minutes</item>
|
||||
@@ -383,8 +403,6 @@
|
||||
<item quantity="one">Il y a %d heure</item>
|
||||
<item quantity="other">Il y a %d heures</item>
|
||||
</plurals>
|
||||
<string name="yesterday">Hier</string>
|
||||
<string name="some_days_ago">Il y a %d jours</string>
|
||||
<plurals name="second">
|
||||
<item quantity="one">%d sec</item>
|
||||
<item quantity="other">secs</item>
|
||||
@@ -405,22 +423,5 @@
|
||||
<item quantity="one">%d an</item>
|
||||
<item quantity="other">ans</item>
|
||||
</plurals>
|
||||
<string name="no_archives">Aucun fiche archives</string>
|
||||
<string name="download_archive_started">Téléchargement du fiche archives a commencé</string>
|
||||
<string name="download_archive_failure">Téléchargement du fiche archives a échoué</string>
|
||||
<string name="download_archive_failure_no_hath">Client H@H est nécessaire pour le téléchargement de fiches archives</string>
|
||||
<string name="settings_privacy">Confidentialité</string>
|
||||
<string name="settings_privacy_secure">Empêcher les captures d\'ecran</string>
|
||||
<string name="settings_privacy_secure_summary">Quand cette option est activée, vous ne pouvez prendre aucune capture d\'écran de l\'application, et le systéme n\'affiche pas le contenu d\'écran dans le sélecteur de tâches.\n\nRedémarrage de l\'application est nécessaire pour appliquer le changement</string>
|
||||
<string name="settings_advanced_app_language_title">Langue de l\'app (Language)</string>
|
||||
<string name="app_language_system">Langue du système (défaut)</string>
|
||||
<string name="settings_eh_identity_cookies_signed">Tout le monde peut utiliser les cookies de ce compte pour s\'y connecter.<br><b>GARDEZ-LES BIEN !</b></string>
|
||||
<string name="settings_eh_identity_cookies_guest">Vous ne vous êtes pas connecté.</string>
|
||||
<string name="settings_eh_show_gallery_pages">Montrer les nombres des pages des galeries</string>
|
||||
<string name="settings_eh_show_gallery_pages_summary">Montrer le nombre des pages des galerie dans la liste des galeries</string>
|
||||
<string name="dark_theme">Thème sombre</string>
|
||||
<string name="dark_theme_follow_system">Identique au système</string>
|
||||
<string name="dark_theme_off">Toujours actif</string>
|
||||
<string name="dark_theme_on">Toujours inactif</string>
|
||||
|
||||
</resources>
|
||||
@@ -105,9 +105,6 @@
|
||||
<!-- Gallery detail -->
|
||||
<string name="download">ダウンロード</string>
|
||||
<string name="read">閲覧</string>
|
||||
<plurals name="page_count">
|
||||
<item quantity="other">%dページ</item>
|
||||
</plurals>
|
||||
<string name="favored_times">\u2665 %d</string>
|
||||
<string name="rating_text">%1$s (%2$.2f, %3$d)</string>
|
||||
<string name="torrent_count">トレント (%d)</string>
|
||||
@@ -372,30 +369,8 @@
|
||||
<!-- Readable Time -->
|
||||
<string name="from_the_future">未来から</string>
|
||||
<string name="just_now">ただ今</string>
|
||||
<plurals name="some_minutes_ago">
|
||||
<item quantity="other">%d分前</item>
|
||||
</plurals>
|
||||
<plurals name="some_hours_ago">
|
||||
<item quantity="other">%d時前</item>
|
||||
</plurals>
|
||||
<string name="yesterday">昨日</string>
|
||||
<string name="some_days_ago">%d日前</string>
|
||||
|
||||
<plurals name="second">
|
||||
<item quantity="other">秒</item>
|
||||
</plurals>
|
||||
<plurals name="minute">
|
||||
<item quantity="other">分</item>
|
||||
</plurals>
|
||||
<plurals name="hour">
|
||||
<item quantity="other">時</item>
|
||||
</plurals>
|
||||
<plurals name="day">
|
||||
<item quantity="other">日</item>
|
||||
</plurals>
|
||||
<plurals name="year">
|
||||
<item quantity="other">年</item>
|
||||
</plurals>
|
||||
<string name="no_archives">圧縮パッケージなし</string>
|
||||
<string name="download_archive_started">圧縮パッケージのダウンロードを開始しました</string>
|
||||
<string name="download_archive_failure">圧縮パッケージをダウンロードできませんでした</string>
|
||||
@@ -417,4 +392,29 @@
|
||||
<string name="settings_eh_show_gallery_pages">ギャラリーページ数を表示</string>
|
||||
<string name="settings_eh_show_gallery_pages_summary">リストにギャラリーのページの数量を表示します</string>
|
||||
<string name="dark_theme">暗いテーマ</string>
|
||||
|
||||
<plurals name="page_count">
|
||||
<item quantity="other">%dページ</item>
|
||||
</plurals>
|
||||
<plurals name="some_minutes_ago">
|
||||
<item quantity="other">%d分前</item>
|
||||
</plurals>
|
||||
<plurals name="some_hours_ago">
|
||||
<item quantity="other">%d時前</item>
|
||||
</plurals>
|
||||
<plurals name="second">
|
||||
<item quantity="other">秒</item>
|
||||
</plurals>
|
||||
<plurals name="minute">
|
||||
<item quantity="other">分</item>
|
||||
</plurals>
|
||||
<plurals name="hour">
|
||||
<item quantity="other">時</item>
|
||||
</plurals>
|
||||
<plurals name="day">
|
||||
<item quantity="other">日</item>
|
||||
</plurals>
|
||||
<plurals name="year">
|
||||
<item quantity="other">年</item>
|
||||
</plurals>
|
||||
</resources>
|
||||
@@ -100,9 +100,6 @@
|
||||
<!-- Gallery detail -->
|
||||
<string name="download">다운로드</string>
|
||||
<string name="read">읽기</string>
|
||||
<plurals name="page_count">
|
||||
<item quantity="other">%d 페이지</item>
|
||||
</plurals>
|
||||
<string name="torrent_count">토렌트 (%d)</string>
|
||||
<string name="share">공유</string>
|
||||
<string name="rate">평가</string>
|
||||
@@ -347,14 +344,32 @@
|
||||
<!-- Readable Time -->
|
||||
<string name="from_the_future">미래에</string>
|
||||
<string name="just_now">방금</string>
|
||||
<string name="yesterday">어제</string>
|
||||
<string name="some_days_ago">%d일 전</string>
|
||||
<string name="no_archives">아카이브 없음</string>
|
||||
<string name="download_archive_started">아카이브 다운로드가 시작되었습니다.</string>
|
||||
<string name="download_archive_failure">아카이브 다운로드에 실패했습니다.</string>
|
||||
<string name="download_archive_failure_no_hath">아카이브를 다운로드하려면 H@H 클라이언트가 필요합니다.</string>
|
||||
<string name="settings_privacy">개인 정보</string>
|
||||
<string name="settings_privacy_secure">화면 캡처 허용 안 함</string>
|
||||
<string name="settings_privacy_secure_summary">이 앱에서 스크린샷을 찍을 수 없게 하며, \"최근 앱\" 화면에서 앱의 내용을 보이지 않게 합니다.</string>
|
||||
|
||||
<string name="download_service">다운로드 서비스</string>
|
||||
<string name="favorite_name">즐겨찾기</string>
|
||||
<string name="dark_theme">어두운 테마</string>
|
||||
<string name="dark_theme_follow_system">시스템 설정</string>
|
||||
<string name="dark_theme_off">항상 꺼짐</string>
|
||||
<string name="dark_theme_on">항상 켜짐</string>
|
||||
|
||||
<plurals name="page_count">
|
||||
<item quantity="other">%d 페이지</item>
|
||||
</plurals>
|
||||
<plurals name="some_minutes_ago">
|
||||
<item quantity="other">%d분 전</item>
|
||||
</plurals>
|
||||
<plurals name="some_hours_ago">
|
||||
<item quantity="other">%d시간 전</item>
|
||||
</plurals>
|
||||
<string name="yesterday">어제</string>
|
||||
<string name="some_days_ago">%d일 전</string>
|
||||
<plurals name="second">
|
||||
<item quantity="other">초</item>
|
||||
</plurals>
|
||||
@@ -370,18 +385,4 @@
|
||||
<plurals name="year">
|
||||
<item quantity="other">년</item>
|
||||
</plurals>
|
||||
<string name="no_archives">아카이브 없음</string>
|
||||
<string name="download_archive_started">아카이브 다운로드가 시작되었습니다.</string>
|
||||
<string name="download_archive_failure">아카이브 다운로드에 실패했습니다.</string>
|
||||
<string name="download_archive_failure_no_hath">아카이브를 다운로드하려면 H@H 클라이언트가 필요합니다.</string>
|
||||
<string name="settings_privacy">개인 정보</string>
|
||||
<string name="settings_privacy_secure">화면 캡처 허용 안 함</string>
|
||||
<string name="settings_privacy_secure_summary">이 앱에서 스크린샷을 찍을 수 없게 하며, \"최근 앱\" 화면에서 앱의 내용을 보이지 않게 합니다.</string>
|
||||
|
||||
<string name="download_service">다운로드 서비스</string>
|
||||
<string name="favorite_name">즐겨찾기</string>
|
||||
<string name="dark_theme">어두운 테마</string>
|
||||
<string name="dark_theme_follow_system">시스템 설정</string>
|
||||
<string name="dark_theme_off">항상 꺼짐</string>
|
||||
<string name="dark_theme_on">항상 켜짐</string>
|
||||
</resources>
|
||||
@@ -53,10 +53,6 @@
|
||||
<string name="keyword_search">Nøkkelordssøk</string>
|
||||
<string name="search_sft">Etiketter</string>
|
||||
<string name="any">Ubegrenset</string>
|
||||
<plurals name="page_count">
|
||||
<item quantity="one">%d side</item>
|
||||
<item quantity="other">%d sider</item>
|
||||
</plurals>
|
||||
<string name="more_comment">Flere kommentarer</string>
|
||||
<string name="tag_vote_up">Stem opp</string>
|
||||
<string name="pref_grayscale">Gråskala</string>
|
||||
@@ -186,6 +182,18 @@
|
||||
<string name="delete_search_history">Slett «%s» fra søkehistorikk\?</string>
|
||||
<string name="collections">Samlinger</string>
|
||||
<string name="tag_vote_down">Stem ned</string>
|
||||
<string name="yesterday">I går</string>
|
||||
<string name="some_days_ago">%d dager siden</string>
|
||||
<string name="download_all">Alle</string>
|
||||
<string name="action_save">Lagre</string>
|
||||
<string name="settings_privacy">Personvern</string>
|
||||
<string name="no_archives">Ingen arkiver</string>
|
||||
<string name="action_save_to">Lagre i …</string>
|
||||
|
||||
<plurals name="page_count">
|
||||
<item quantity="one">%d side</item>
|
||||
<item quantity="other">%d sider</item>
|
||||
</plurals>
|
||||
<plurals name="some_minutes_ago">
|
||||
<item quantity="one">Et minutt siden</item>
|
||||
<item quantity="other">%d minutter siden</item>
|
||||
@@ -198,19 +206,14 @@
|
||||
<item quantity="one">Én time siden</item>
|
||||
<item quantity="other">%d timer siden</item>
|
||||
</plurals>
|
||||
<string name="yesterday">I går</string>
|
||||
<string name="some_days_ago">%d dager siden</string>
|
||||
<plurals name="hour">
|
||||
<item quantity="one">time</item>
|
||||
<item quantity="other">timer</item>
|
||||
</plurals>
|
||||
<string name="download_all">Alle</string>
|
||||
<string name="action_save">Lagre</string>
|
||||
<plurals name="second">
|
||||
<item quantity="one">sek</item>
|
||||
<item quantity="other">sek</item>
|
||||
</plurals>
|
||||
<string name="settings_privacy">Personvern</string>
|
||||
<plurals name="day">
|
||||
<item quantity="one">dag</item>
|
||||
<item quantity="other">dager</item>
|
||||
@@ -219,6 +222,4 @@
|
||||
<item quantity="one">år</item>
|
||||
<item quantity="other">år</item>
|
||||
</plurals>
|
||||
<string name="no_archives">Ingen arkiver</string>
|
||||
<string name="action_save_to">Lagre i …</string>
|
||||
</resources>
|
||||
@@ -100,9 +100,6 @@
|
||||
<!-- Gallery detail -->
|
||||
<string name="download">ดาวน์โหลด</string>
|
||||
<string name="read">อ่าน</string>
|
||||
<plurals name="page_count">
|
||||
<item quantity="other">%d หน้า</item>
|
||||
</plurals>
|
||||
<string name="favored_times">\u2665 %d</string>
|
||||
<string name="rating_text">%1$s (%2$.2f, %3$d)</string>
|
||||
<string name="torrent_count">Torrent (%d)</string>
|
||||
@@ -362,14 +359,28 @@
|
||||
<!-- Readable Time -->
|
||||
<string name="from_the_future">จากอนาคต</string>
|
||||
<string name="just_now">เดี๋ยวนี้</string>
|
||||
<string name="yesterday">เมื่อวาน</string>
|
||||
<string name="some_days_ago">%d วันที่ผ่านมา</string>
|
||||
<string name="no_archives">ยังไม่มีไฟล์บีบอัด</string>
|
||||
<string name="download_archive_started">เริ่มการดาวน์โหลดบีบอัด</string>
|
||||
<string name="download_archive_failure">การดาวน์โหลดไฟล์บีบอัดล้มเหลว</string>
|
||||
<string name="download_archive_failure_no_hath">ต้องการ H@H client เพื่อโหลดไฟล์บีบอัด</string>
|
||||
<string name="settings_privacy">ความเป็นส่วนตัว</string>
|
||||
<string name="settings_privacy_secure">ป้องกันการบันทึกภาพหน้าจอ</string>
|
||||
<string name="settings_privacy_secure_summary">ป้องกันเนื้อหาต่างๆของตัวแอปจากการถูกบันทึกหน้าจอ หรือถูกแสดงในหน้ารายการ \"แอพล่าสุด\" ตอนสลับหรือระหว่างสลับไปแอปอื่น.</string>
|
||||
|
||||
<string name="download_service">บริการดาวน์โหลด</string>
|
||||
<string name="favorite_name">รายการโปรด</string>
|
||||
|
||||
<plurals name="page_count">
|
||||
<item quantity="other">%d หน้า</item>
|
||||
</plurals>
|
||||
<plurals name="some_minutes_ago">
|
||||
<item quantity="other">%d นาทีที่ผ่านมา</item>
|
||||
</plurals>
|
||||
<plurals name="some_hours_ago">
|
||||
<item quantity="other">%d ชั่วโมงที่ผ่านมา</item>
|
||||
</plurals>
|
||||
<string name="yesterday">เมื่อวาน</string>
|
||||
<string name="some_days_ago">%d วันที่ผ่านมา</string>
|
||||
<plurals name="second">
|
||||
<item quantity="other">วิ</item>
|
||||
</plurals>
|
||||
@@ -385,14 +396,4 @@
|
||||
<plurals name="year">
|
||||
<item quantity="other">ปี</item>
|
||||
</plurals>
|
||||
<string name="no_archives">ยังไม่มีไฟล์บีบอัด</string>
|
||||
<string name="download_archive_started">เริ่มการดาวน์โหลดบีบอัด</string>
|
||||
<string name="download_archive_failure">การดาวน์โหลดไฟล์บีบอัดล้มเหลว</string>
|
||||
<string name="download_archive_failure_no_hath">ต้องการ H@H client เพื่อโหลดไฟล์บีบอัด</string>
|
||||
<string name="settings_privacy">ความเป็นส่วนตัว</string>
|
||||
<string name="settings_privacy_secure">ป้องกันการบันทึกภาพหน้าจอ</string>
|
||||
<string name="settings_privacy_secure_summary">ป้องกันเนื้อหาต่างๆของตัวแอปจากการถูกบันทึกหน้าจอ หรือถูกแสดงในหน้ารายการ \"แอพล่าสุด\" ตอนสลับหรือระหว่างสลับไปแอปอื่น.</string>
|
||||
|
||||
<string name="download_service">บริการดาวน์โหลด</string>
|
||||
<string name="favorite_name">รายการโปรด</string>
|
||||
</resources>
|
||||
@@ -1,5 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
~ Copyright 2016 Hippo Seven
|
||||
~
|
||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@@ -13,7 +12,8 @@
|
||||
~ 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.
|
||||
--><resources>
|
||||
-->
|
||||
<resources>
|
||||
<!-- Theme -->
|
||||
<string name="homepage">主页</string>
|
||||
<string name="subscription">订阅</string>
|
||||
@@ -96,9 +96,6 @@
|
||||
<!-- Gallery detail -->
|
||||
<string name="download">下载</string>
|
||||
<string name="read">阅读</string>
|
||||
<plurals name="page_count">
|
||||
<item quantity="other">%d 页</item>
|
||||
</plurals>
|
||||
<string name="favored_times">\u2665 %d</string>
|
||||
<string name="rating_text">%1$s (%2$.2f, %3$d)</string>
|
||||
<string name="torrent_count">种子 (%d)</string>
|
||||
@@ -400,29 +397,8 @@
|
||||
<!-- Readable Time -->
|
||||
<string name="from_the_future">来自未来</string>
|
||||
<string name="just_now">刚刚</string>
|
||||
<plurals name="some_minutes_ago">
|
||||
<item quantity="other">%d 分钟前</item>
|
||||
</plurals>
|
||||
<plurals name="some_hours_ago">
|
||||
<item quantity="other">%d 小时前</item>
|
||||
</plurals>
|
||||
<string name="yesterday">昨天</string>
|
||||
<string name="some_days_ago">%d 天前</string>
|
||||
<plurals name="second">
|
||||
<item quantity="other">秒</item>
|
||||
</plurals>
|
||||
<plurals name="minute">
|
||||
<item quantity="other">分钟</item>
|
||||
</plurals>
|
||||
<plurals name="hour">
|
||||
<item quantity="other">小时</item>
|
||||
</plurals>
|
||||
<plurals name="day">
|
||||
<item quantity="other">天</item>
|
||||
</plurals>
|
||||
<plurals name="year">
|
||||
<item quantity="other">年</item>
|
||||
</plurals>
|
||||
<string name="no_archives">没有压缩包</string>
|
||||
<string name="download_archive_started">开始下载压缩包</string>
|
||||
<string name="download_archive_failure">无法下载压缩包</string>
|
||||
@@ -448,6 +424,7 @@
|
||||
<string name="favorite_name">收藏画廊</string>
|
||||
<string name="black_dark_theme">纯黑深色主题</string>
|
||||
<string name="harmonize_category_color">类别颜色适配动态配色</string>
|
||||
<string name="backup_before_update">更新前备份数据</string>
|
||||
<string name="use_ci_update_channel">使用持续集成(CI)构建频道更新</string>
|
||||
<string name="sort_by">排序依据</string>
|
||||
<string name="added_time_desc">添加时间(降序)</string>
|
||||
@@ -501,6 +478,8 @@
|
||||
<string name="list_tile_thumb_size">详情模式下缩略图大小</string>
|
||||
<string name="account_name">账户</string>
|
||||
<string name="preload_thumb_aggressively">激进地预载缩略图</string>
|
||||
<string name="animate_items">列表项目动画</string>
|
||||
<string name="animate_items_summary">如遇崩溃/掉帧请尝试禁用此项</string>
|
||||
<string name="auto_updates">自动检查更新</string>
|
||||
<string name="update_frequency_never">从不</string>
|
||||
<string name="update_frequency_daily">每天</string>
|
||||
@@ -611,4 +590,29 @@
|
||||
<string name="dark_theme_on">总是开启</string>
|
||||
<string name="pref_crop_borders">裁剪边缘</string>
|
||||
<!-- Tachiyomi Reader End-->
|
||||
|
||||
<plurals name="page_count">
|
||||
<item quantity="other">%d 页</item>
|
||||
</plurals>
|
||||
<plurals name="some_minutes_ago">
|
||||
<item quantity="other">%d 分钟前</item>
|
||||
</plurals>
|
||||
<plurals name="some_hours_ago">
|
||||
<item quantity="other">%d 小时前</item>
|
||||
</plurals>
|
||||
<plurals name="second">
|
||||
<item quantity="other">秒</item>
|
||||
</plurals>
|
||||
<plurals name="minute">
|
||||
<item quantity="other">分钟</item>
|
||||
</plurals>
|
||||
<plurals name="hour">
|
||||
<item quantity="other">小时</item>
|
||||
</plurals>
|
||||
<plurals name="day">
|
||||
<item quantity="other">天</item>
|
||||
</plurals>
|
||||
<plurals name="year">
|
||||
<item quantity="other">年</item>
|
||||
</plurals>
|
||||
</resources>
|
||||
@@ -110,9 +110,6 @@
|
||||
<!-- Gallery detail -->
|
||||
<string name="download">下載</string>
|
||||
<string name="read">閲讀</string>
|
||||
<plurals name="page_count">
|
||||
<item quantity="other">%d 頁</item>
|
||||
</plurals>
|
||||
<string name="favored_times">\u2665 %d</string>
|
||||
<string name="rating_text">%1$s (%2$.2f, %3$d)</string>
|
||||
<string name="torrent_count">種子 (%d)</string>
|
||||
@@ -397,30 +394,8 @@
|
||||
<!-- Readable Time -->
|
||||
<string name="from_the_future">來自未來</string>
|
||||
<string name="just_now">剛剛</string>
|
||||
<plurals name="some_minutes_ago">
|
||||
<item quantity="other">%d 分鐘前</item>
|
||||
</plurals>
|
||||
<plurals name="some_hours_ago">
|
||||
<item quantity="other">%d 小時前</item>
|
||||
</plurals>
|
||||
<string name="yesterday">昨天</string>
|
||||
<string name="some_days_ago">%d 天前</string>
|
||||
|
||||
<plurals name="second">
|
||||
<item quantity="other">秒</item>
|
||||
</plurals>
|
||||
<plurals name="minute">
|
||||
<item quantity="other">分鐘</item>
|
||||
</plurals>
|
||||
<plurals name="hour">
|
||||
<item quantity="other">小時</item>
|
||||
</plurals>
|
||||
<plurals name="day">
|
||||
<item quantity="other">天</item>
|
||||
</plurals>
|
||||
<plurals name="year">
|
||||
<item quantity="other">年</item>
|
||||
</plurals>
|
||||
<string name="no_archives">沒有壓縮包</string>
|
||||
<string name="download_archive_started">開始下載壓縮包</string>
|
||||
<string name="download_archive_failure">無法下載壓縮包</string>
|
||||
@@ -517,4 +492,29 @@
|
||||
<string name="disabled_nav">停用</string>
|
||||
<!-- Tachiyomi Reader End-->
|
||||
|
||||
<plurals name="page_count">
|
||||
<item quantity="other">%d 頁</item>
|
||||
</plurals>
|
||||
<plurals name="some_minutes_ago">
|
||||
<item quantity="other">%d 分鐘前</item>
|
||||
</plurals>
|
||||
<plurals name="some_hours_ago">
|
||||
<item quantity="other">%d 小時前</item>
|
||||
</plurals>
|
||||
<plurals name="second">
|
||||
<item quantity="other">秒</item>
|
||||
</plurals>
|
||||
<plurals name="minute">
|
||||
<item quantity="other">分鐘</item>
|
||||
</plurals>
|
||||
<plurals name="hour">
|
||||
<item quantity="other">小時</item>
|
||||
</plurals>
|
||||
<plurals name="day">
|
||||
<item quantity="other">天</item>
|
||||
</plurals>
|
||||
<plurals name="year">
|
||||
<item quantity="other">年</item>
|
||||
</plurals>
|
||||
|
||||
</resources>
|
||||
@@ -1,5 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
~ Copyright 2016 Hippo Seven
|
||||
~
|
||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@@ -13,7 +12,8 @@
|
||||
~ 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.
|
||||
--><resources>
|
||||
-->
|
||||
<resources>
|
||||
<!-- Theme -->
|
||||
<string name="homepage">首頁</string>
|
||||
<string name="subscription">訂閱</string>
|
||||
@@ -96,9 +96,6 @@
|
||||
<!-- Gallery detail -->
|
||||
<string name="download">下載</string>
|
||||
<string name="read">閱讀</string>
|
||||
<plurals name="page_count">
|
||||
<item quantity="other">%d 頁</item>
|
||||
</plurals>
|
||||
<string name="favored_times">\u2665 %d</string>
|
||||
<string name="rating_text">%1$s (%2$.2f, %3$d)</string>
|
||||
<string name="torrent_count">種子 (%d)</string>
|
||||
@@ -303,7 +300,7 @@
|
||||
<string name="settings_eh_show_gallery_pages_summary">在圖庫列表中顯示頁數</string>
|
||||
<string name="settings_eh_show_gallery_comments">顯示畫廊評論</string>
|
||||
<string name="settings_eh_show_gallery_comments_summary">在畫廊詳情頁中顯示評論</string>
|
||||
<string name="settings_eh_show_gallery_comment_threshold">評論分數閥值</string>
|
||||
<string name="settings_eh_show_gallery_comment_threshold">評論分數閥值</string>
|
||||
<string name="settings_eh_show_gallery_comment_threshold_summary">隱藏小於或等於設定分數的評論(-101 為停用)</string>
|
||||
<string name="settings_eh_show_tag_translations">顯示標籤翻譯</string>
|
||||
<string name="settings_eh_show_tag_translations_summary">顯示翻譯後的標籤而非原始文字(需花費時間來下載資料檔案)</string>
|
||||
@@ -399,29 +396,8 @@
|
||||
<!-- Readable Time -->
|
||||
<string name="from_the_future">來自未來</string>
|
||||
<string name="just_now">剛剛</string>
|
||||
<plurals name="some_minutes_ago">
|
||||
<item quantity="other">%d 分鐘前</item>
|
||||
</plurals>
|
||||
<plurals name="some_hours_ago">
|
||||
<item quantity="other">%d 小時前</item>
|
||||
</plurals>
|
||||
<string name="yesterday">昨天</string>
|
||||
<string name="some_days_ago">%d 天前</string>
|
||||
<plurals name="second">
|
||||
<item quantity="other">秒</item>
|
||||
</plurals>
|
||||
<plurals name="minute">
|
||||
<item quantity="other">分鐘</item>
|
||||
</plurals>
|
||||
<plurals name="hour">
|
||||
<item quantity="other">小時</item>
|
||||
</plurals>
|
||||
<plurals name="day">
|
||||
<item quantity="other">天</item>
|
||||
</plurals>
|
||||
<plurals name="year">
|
||||
<item quantity="other">年</item>
|
||||
</plurals>
|
||||
<string name="no_archives">沒有壓縮檔</string>
|
||||
<string name="download_archive_started">開始下載壓縮檔</string>
|
||||
<string name="download_archive_failure">下載失敗</string>
|
||||
@@ -507,7 +483,7 @@
|
||||
<string name="update_frequency_weekly">每週</string>
|
||||
<string name="update_frequency_biweekly">每兩週</string>
|
||||
<string name="update_frequency_monthly">每月</string>
|
||||
<string name="update_failed">更新失败:%s</string>
|
||||
<string name="update_failed">更新失败:%s</string>
|
||||
<string name="new_version_available">有新版本可用!</string>
|
||||
<string name="already_latest_version">已是最新版本</string>
|
||||
<string name="permission_denied">沒有權限</string>
|
||||
@@ -610,4 +586,29 @@
|
||||
<string name="dark_theme_on">總是開啟</string>
|
||||
<string name="pref_crop_borders">邊緣裁剪</string>
|
||||
<!-- Tachiyomi Reader End-->
|
||||
|
||||
<plurals name="page_count">
|
||||
<item quantity="other">%d 頁</item>
|
||||
</plurals>
|
||||
<plurals name="some_minutes_ago">
|
||||
<item quantity="other">%d 分鐘前</item>
|
||||
</plurals>
|
||||
<plurals name="some_hours_ago">
|
||||
<item quantity="other">%d 小時前</item>
|
||||
</plurals>
|
||||
<plurals name="second">
|
||||
<item quantity="other">秒</item>
|
||||
</plurals>
|
||||
<plurals name="minute">
|
||||
<item quantity="other">分鐘</item>
|
||||
</plurals>
|
||||
<plurals name="hour">
|
||||
<item quantity="other">小時</item>
|
||||
</plurals>
|
||||
<plurals name="day">
|
||||
<item quantity="other">天</item>
|
||||
</plurals>
|
||||
<plurals name="year">
|
||||
<item quantity="other">年</item>
|
||||
</plurals>
|
||||
</resources>
|
||||
@@ -1,5 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
~ Copyright 2016 Hippo Seven
|
||||
~
|
||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@@ -13,7 +12,8 @@
|
||||
~ 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.
|
||||
--><resources>
|
||||
-->
|
||||
<resources>
|
||||
<string name="app_name" translatable="false">EhViewer</string>
|
||||
<string name="site_e" translatable="false">e-hentai</string>
|
||||
<string name="site_ex" translatable="false">exhentai</string>
|
||||
@@ -104,7 +104,7 @@
|
||||
<string name="search_sp_err1">The page range maximum cannot be below 10</string>
|
||||
<string name="search_sp_err2">The page range must be at least 20</string>
|
||||
<!-- A placeholder for other languages -->
|
||||
<string name="search_sp_suffix"/>
|
||||
<string name="search_sp_suffix" />
|
||||
<string name="search_sf">Disable default filters for:</string>
|
||||
<string name="search_sfl">Language</string>
|
||||
<string name="search_sfu">Uploader</string>
|
||||
@@ -136,10 +136,6 @@
|
||||
<!-- Gallery detail -->
|
||||
<string name="download">Download</string>
|
||||
<string name="read">Read</string>
|
||||
<plurals name="page_count">
|
||||
<item quantity="one">%d page</item>
|
||||
<item quantity="other">%d pages</item>
|
||||
</plurals>
|
||||
<string name="favored_times">\u2665 %d</string>
|
||||
<string name="rating_text">%1$s (%2$.2f, %3$d)</string>
|
||||
<string name="torrent_count">Torrent (%d)</string>
|
||||
@@ -458,36 +454,8 @@
|
||||
<!-- Readable Time -->
|
||||
<string name="from_the_future">From the future</string>
|
||||
<string name="just_now">Just now</string>
|
||||
<plurals name="some_minutes_ago">
|
||||
<item quantity="one">A minute ago</item>
|
||||
<item quantity="other">%d minutes ago</item>
|
||||
</plurals>
|
||||
<plurals name="some_hours_ago">
|
||||
<item quantity="one">An hour ago</item>
|
||||
<item quantity="other">%d hours ago</item>
|
||||
</plurals>
|
||||
<string name="yesterday">Yesterday</string>
|
||||
<string name="some_days_ago">%d days ago</string>
|
||||
<plurals name="second">
|
||||
<item quantity="one">sec</item>
|
||||
<item quantity="other">secs</item>
|
||||
</plurals>
|
||||
<plurals name="minute">
|
||||
<item quantity="one">min</item>
|
||||
<item quantity="other">mins</item>
|
||||
</plurals>
|
||||
<plurals name="hour">
|
||||
<item quantity="one">hour</item>
|
||||
<item quantity="other">hours</item>
|
||||
</plurals>
|
||||
<plurals name="day">
|
||||
<item quantity="one">day</item>
|
||||
<item quantity="other">days</item>
|
||||
</plurals>
|
||||
<plurals name="year">
|
||||
<item quantity="one">year</item>
|
||||
<item quantity="other">years</item>
|
||||
</plurals>
|
||||
<string name="no_archives">No Archives</string>
|
||||
<string name="download_archive_started">Archive download started</string>
|
||||
<string name="download_archive_failure">Failed to download archive</string>
|
||||
@@ -551,6 +519,7 @@
|
||||
<string name="app_link_not_verified_message">For Android 12 and newer, you need to manually add link to verified links in order to open E-Hentai links in EhViewer.</string>
|
||||
<string name="app_link_not_verified_title">App links not verified</string>
|
||||
<string name="open_by_default">Open by default</string>
|
||||
<string name="backup_before_update">Backup data before update</string>
|
||||
<string name="use_ci_update_channel">Use CI update channel</string>
|
||||
<string name="settings_privacy_require_unlock">Require Unlock</string>
|
||||
<string name="settings_privacy_require_unlock_delay">Lock Delay</string>
|
||||
@@ -564,6 +533,8 @@
|
||||
<string name="list_tile_thumb_size">Thumb size in detail mode</string>
|
||||
<string name="account_name">Account</string>
|
||||
<string name="preload_thumb_aggressively">Preload thumbs aggressively</string>
|
||||
<string name="animate_items">List item animations</string>
|
||||
<string name="animate_items_summary">Try disabling this if you are facing crashes / frame drops</string>
|
||||
<string name="user_agent">User Agent</string>
|
||||
<string name="auto_updates">Automatically check for updates</string>
|
||||
<string name="update_frequency_never">Never</string>
|
||||
@@ -674,4 +645,37 @@
|
||||
<string name="dark_theme_off">Always off</string>
|
||||
<string name="dark_theme_on">Always on</string>
|
||||
<!-- Tachiyomi Reader End-->
|
||||
|
||||
<plurals name="page_count">
|
||||
<item quantity="one">%d page</item>
|
||||
<item quantity="other">%d pages</item>
|
||||
</plurals>
|
||||
<plurals name="some_minutes_ago">
|
||||
<item quantity="one">A minute ago</item>
|
||||
<item quantity="other">%d minutes ago</item>
|
||||
</plurals>
|
||||
<plurals name="some_hours_ago">
|
||||
<item quantity="one">An hour ago</item>
|
||||
<item quantity="other">%d hours ago</item>
|
||||
</plurals>
|
||||
<plurals name="second">
|
||||
<item quantity="one">sec</item>
|
||||
<item quantity="other">secs</item>
|
||||
</plurals>
|
||||
<plurals name="minute">
|
||||
<item quantity="one">min</item>
|
||||
<item quantity="other">mins</item>
|
||||
</plurals>
|
||||
<plurals name="hour">
|
||||
<item quantity="one">hour</item>
|
||||
<item quantity="other">hours</item>
|
||||
</plurals>
|
||||
<plurals name="day">
|
||||
<item quantity="one">day</item>
|
||||
<item quantity="other">days</item>
|
||||
</plurals>
|
||||
<plurals name="year">
|
||||
<item quantity="one">year</item>
|
||||
<item quantity="other">years</item>
|
||||
</plurals>
|
||||
</resources>
|
||||
@@ -15,7 +15,7 @@ extern crate sha1;
|
||||
extern crate tl;
|
||||
|
||||
use android_logger::Config;
|
||||
use anyhow::{anyhow, Result};
|
||||
use anyhow::{anyhow, ensure, Result};
|
||||
use jni::objects::JByteBuffer;
|
||||
use jni::sys::{jint, jobject, JavaVM, JNI_VERSION_1_6};
|
||||
use jni::JNIEnv;
|
||||
@@ -114,7 +114,8 @@ where
|
||||
let buffer = deref_mut_direct_bytebuffer(env, str)?;
|
||||
let value = {
|
||||
let html = unsafe { from_utf8_unchecked(&buffer[..limit as usize]) };
|
||||
let dom = tl::parse(html, ParserOptions::default()).map_err(|_| anyhow!("{html}"))?;
|
||||
let dom = tl::parse(html, ParserOptions::default()).map_err(|e| anyhow!(e))?;
|
||||
ensure!(dom.version().is_some(), "{html}");
|
||||
f(&dom, html)?
|
||||
};
|
||||
let mut cursor = Cursor::new(buffer);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application) apply false
|
||||
alias(libs.plugins.kotlin.android) apply false
|
||||
alias(libs.plugins.kotlin.compose) apply false
|
||||
alias(libs.plugins.kotlin.parcelize) apply false
|
||||
alias(libs.plugins.kotlin.serialization) apply false
|
||||
alias(libs.plugins.ksp) apply false
|
||||
|
||||
+22
-24
@@ -1,34 +1,33 @@
|
||||
[versions]
|
||||
aboutlibraries = "11.1.0"
|
||||
aboutlibraries = "11.1.3"
|
||||
accompanist = "0.34.0"
|
||||
agp = "8.4.0-alpha13"
|
||||
androidx-activity = "1.8.2"
|
||||
androidx-appcompat = "1.6.1"
|
||||
agp = "8.5.0-alpha06"
|
||||
androidx-activity = "1.9.0"
|
||||
androidx-appcompat = "1.7.0-alpha03"
|
||||
androidx-biometric = "1.2.0-alpha05"
|
||||
androidx-browser = "1.8.0"
|
||||
androidx-constraintlayout-compose = "1.1.0-alpha13"
|
||||
androidx-core = "1.12.0"
|
||||
androidx-core-splashscreen = "1.1.0-alpha02"
|
||||
androidx-lifecycle = "2.7.0"
|
||||
androidx-navigation = "2.8.0-alpha04"
|
||||
androidx-paging = "3.3.0-alpha04"
|
||||
androidx-recyclerview = "1.3.2"
|
||||
androidx-core = "1.13.0"
|
||||
androidx-core-splashscreen = "1.2.0-alpha01"
|
||||
androidx-lifecycle = "2.8.0-beta01"
|
||||
androidx-navigation = "2.8.0-alpha07"
|
||||
androidx-paging = "3.3.0-beta01"
|
||||
androidx-recyclerview = "1.4.0-alpha01"
|
||||
androidx-room = "2.6.1"
|
||||
androidx-work = "2.9.0"
|
||||
arrow = "1.2.3"
|
||||
androidx-work = "2.10.0-alpha02"
|
||||
arrow = "1.2.4"
|
||||
coil = "3.0.0-alpha06"
|
||||
compose-bom = "2024.01.00-alpha03"
|
||||
compose-compiler = "1.5.11-dev-k2.0.0-Beta4-21f5e479a96"
|
||||
compose-destinations = "1.10.2"
|
||||
kotlin = "2.0.0-Beta4"
|
||||
compose-bom = "2024.04.00-alpha02"
|
||||
compose-destinations = "2.1.0-beta01"
|
||||
kotlin = "2.0.0-RC1"
|
||||
kotlinx-coroutines = "1.8.0"
|
||||
kotlinx-datetime = "0.6.0-RC.2"
|
||||
kotlinx-serialization = "1.6.3"
|
||||
ksp = "2.0.0-Beta4-1.0.19"
|
||||
ksp = "2.0.0-RC1-1.0.20"
|
||||
ktor = "3.0.0-beta-1"
|
||||
okio = "3.9.0"
|
||||
splitties = "3.0.0"
|
||||
telephoto = "0.8.0"
|
||||
telephoto = "0.10.0"
|
||||
xmlutil = "0.86.3"
|
||||
|
||||
[libraries]
|
||||
@@ -66,11 +65,9 @@ coil-gif = { module = "io.coil-kt.coil3:coil-gif" }
|
||||
coil-network = { module = "io.coil-kt.coil3:coil-network-ktor" }
|
||||
|
||||
compose-bom = { module = "dev.chrisbanes.compose:compose-bom", version.ref = "compose-bom" }
|
||||
# For renovate
|
||||
compose-compiler = { module = "androidx.compose.compiler:compiler", version.ref = "compose-compiler" }
|
||||
compose-foundation = { module = "androidx.compose.foundation:foundation-android", version = "1.7.0-SNAPSHOT" }
|
||||
compose-foundation = { module = "androidx.compose.foundation:foundation" }
|
||||
compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" }
|
||||
compose-material3 = { module = "androidx.compose.material3:material3", version = "1.3.0-SNAPSHOT" }
|
||||
compose-material3 = { module = "androidx.compose.material3:material3" }
|
||||
compose-material3-windowsizeclass = { module = "androidx.compose.material3:material3-window-size-class" }
|
||||
compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
|
||||
compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
|
||||
@@ -101,7 +98,7 @@ okio-jvm = { module = "com.squareup.okio:okio-jvm", version.ref = "okio" }
|
||||
|
||||
photoview = "com.github.chrisbanes:PhotoView:2.3.0"
|
||||
|
||||
reorderable = "sh.calvin.reorderable:reorderable:1.3.3"
|
||||
reorderable = "sh.calvin.reorderable:reorderable:1.5.1"
|
||||
|
||||
serialization-cbor = { module = "org.jetbrains.kotlinx:kotlinx-serialization-cbor", version.ref = "kotlinx-serialization" }
|
||||
serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
|
||||
@@ -126,8 +123,9 @@ splitties = ["splitties-appctx", "splitties-arch-room", "splitties-preferences",
|
||||
[plugins]
|
||||
aboutlibrariesPlugin = { id = "com.mikepenz.aboutlibraries.plugin", version.ref = "aboutlibraries" }
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
composeCompilerReportGenerator = { id = "dev.shreyaspatil.compose-compiler-report-generator", version = "1.1.0" }
|
||||
composeCompilerReportGenerator = { id = "dev.shreyaspatil.compose-compiler-report-generator", version = "1.2.0" }
|
||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" }
|
||||
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
||||
|
||||
Vendored
BIN
Binary file not shown.
+1
-1
@@ -1,6 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
||||
Vendored
+10
-10
@@ -43,11 +43,11 @@ set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
@@ -57,11 +57,11 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
|
||||
Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user