为MIUIX主题添加了专属的关于界面

This commit is contained in:
HapeLee
2026-05-17 01:20:23 +08:00
parent ede560f3da
commit f0bbd4697f
35 changed files with 2439 additions and 662 deletions
+2 -1
View File
@@ -89,7 +89,8 @@
"WebFetch(domain:s01.oss.sonatype.org)",
"WebFetch(domain:api.github.com)",
"Bash(javap -public /tmp/classes.jar!top/yukonga/miuix/kmp/basic/NavigationBarKt.class)",
"Bash(javap -public -cp /tmp/classes.jar top.yukonga.miuix.kmp.basic.NavigationBarKt)"
"Bash(javap -public -cp /tmp/classes.jar top.yukonga.miuix.kmp.basic.NavigationBarKt)",
"PowerShell(Join-Path *)"
]
}
}
+2 -2
View File
@@ -229,6 +229,7 @@ dependencies {
implementation(libs.markwon.image.glide)
implementation(libs.markwon.ext.tables)
implementation(libs.markwon.html)
implementation(libs.markdown.renderer.m3)
implementation(libs.quick.chinese.transfer.core)
implementation(libs.hutool.crypto)
//noinspection GradleDependency
@@ -276,9 +277,8 @@ dependencies {
implementation(libs.miuix.ui.android)
implementation(libs.miuix.preference.android)
implementation(libs.miuix.icons.android)
//implementation(libs.miuix.blur.android)
implementation(libs.miuix.blur.android)
implementation(libs.miuix.core)
implementation(libs.miuix.shapes.android)
implementation(libs.capsule)
implementation(libs.backdrop)
implementation(libs.lyricViewx)
+2 -5
View File
@@ -2,6 +2,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-sdk tools:overrideLibrary="top.yukonga.miuix.kmp.blur" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
@@ -212,11 +214,6 @@
android:name=".ui.book.search.SearchActivity"
android:enableOnBackInvokedCallback="true"
android:launchMode="standard" />
<!-- 关于界面 -->
<activity
android:name=".ui.about.AboutActivity"
android:enableOnBackInvokedCallback="true"
android:launchMode="singleTask" />
<activity
android:name=".ui.about.CrashReportActivity"
android:enableOnBackInvokedCallback="true"
@@ -58,6 +58,7 @@ import io.legado.app.help.coil.CoverFetcher
import io.legado.app.help.coil.CoverInterceptor
import io.legado.app.help.http.okHttpClient
import io.legado.app.help.http.okHttpClientManga
import io.legado.app.ui.about.AboutViewModel
import io.legado.app.ui.book.bookmark.AllBookmarkViewModel
import io.legado.app.ui.book.cache.manage.BookCacheManageViewModel
import io.legado.app.ui.book.changecover.ChangeCoverViewModel
@@ -189,6 +190,7 @@ val appModule = module {
viewModelOf(::MyViewModel)
viewModelOf(::BookshelfViewModel)
viewModelOf(::MainViewModel)
viewModelOf(::AboutViewModel)
viewModelOf(::GroupViewModel)
viewModelOf(::ReplaceRuleViewModel)
viewModelOf(::AllBookmarkViewModel)
@@ -1,197 +0,0 @@
package io.legado.app.ui.about
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import androidx.activity.compose.setContent
import androidx.annotation.StringRes
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.core.net.toUri
import androidx.lifecycle.lifecycleScope
import io.legado.app.R
import io.legado.app.base.BaseComposeActivity
import io.legado.app.constant.AppLog
import io.legado.app.help.CrashHandler
import io.legado.app.help.config.AppConfig
import io.legado.app.help.coroutine.Coroutine
import io.legado.app.help.update.AppUpdate
import io.legado.app.ui.widget.dialog.TextDialog
import io.legado.app.ui.widget.dialog.WaitDialog
import io.legado.app.utils.FileDoc
import io.legado.app.utils.compress.ZipUtils
import io.legado.app.utils.createFileIfNotExist
import io.legado.app.utils.createFolderIfNotExist
import io.legado.app.utils.delete
import io.legado.app.utils.externalCache
import io.legado.app.utils.find
import io.legado.app.utils.list
import io.legado.app.utils.openInputStream
import io.legado.app.utils.openOutputStream
import io.legado.app.utils.openUrl
import io.legado.app.utils.share
import io.legado.app.utils.showDialogFragment
import io.legado.app.utils.toastOnUi
import kotlinx.coroutines.delay
import splitties.init.appCtx
import java.io.File
class AboutActivity : BaseComposeActivity() {
@Composable
override fun Content() {
AboutScreen(
onBack = { finish() },
onCheckUpdate = { checkUpdate() },
onOpenUrl = { openUrl(it) },
onShowMdFile = { title, file -> showMdFile(title, file) },
onSaveLog = { saveLog() },
onCreateHeapDump = { createHeapDump() },
onShowCrashLogs = { showDialogFragment<CrashLogsDialog>() },
)
}
private val waitDialog by lazy {
WaitDialog(this).setText(R.string.checking_update)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
}
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
menuInflater.inflate(R.menu.about, menu)
return true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.menu_share_it -> {
share(
getString(R.string.app_share_description),
getString(R.string.app_name)
)
true
}
else -> super.onOptionsItemSelected(item)
}
}
private fun showMdFile(title: String, fileName: String) {
val mdText = String(this.assets.open(fileName).readBytes())
showDialogFragment(TextDialog(title, mdText, TextDialog.Mode.MD))
}
private fun checkUpdate() {
waitDialog.show()
AppUpdate.gitHubUpdate?.run {
check(lifecycleScope)
.onSuccess {
showDialogFragment(UpdateDialog(it, UpdateDialog.Mode.UPDATE))
}.onError {
appCtx.toastOnUi("${getString(R.string.check_update)}\n${it.localizedMessage}")
}.onFinally {
waitDialog.dismiss()
}
}
}
@Suppress("SameParameterValue")
private fun openUrl(@StringRes addressID: Int) {
this.openUrl(getString(addressID))
}
private fun saveLog() {
Coroutine.async {
val backupPath = AppConfig.backupPath ?: let {
toastOnUi("未设置备份目录")
return@async
}
if (!AppConfig.recordLog) {
toastOnUi("未开启日志记录,请去其他设置里打开记录日志")
delay(3000)
}
val doc = FileDoc.fromUri(backupPath.toUri(), true)
copyLogs(doc)
copyHeapDump(doc)
toastOnUi("已保存至备份目录")
}.onError {
AppLog.put("保存日志出错\n${it.localizedMessage}", it, true)
}
}
private fun createHeapDump() {
Coroutine.async {
val backupPath = AppConfig.backupPath ?: let {
toastOnUi("未设置备份目录")
return@async
}
if (!AppConfig.recordHeapDump) {
toastOnUi("未开启堆转储记录,请去其他设置里打开记录堆转储")
delay(3000)
}
toastOnUi("开始创建堆转储")
System.gc()
CrashHandler.doHeapDump(true)
val doc = FileDoc.fromUri(backupPath.toUri(), true)
if (!copyHeapDump(doc)) {
toastOnUi("未找到堆转储文件")
} else {
toastOnUi("已保存至备份目录")
}
}.onError {
AppLog.put("保存堆转储失败\n${it.localizedMessage}", it)
}
}
private fun copyLogs(doc: FileDoc) {
val cacheDir = externalCache
val logFiles = File(cacheDir, "logs")
val crashFiles = File(cacheDir, "crash")
val logcatFile = File(cacheDir, "logcat.txt")
dumpLogcat(logcatFile)
val zipFile = File(cacheDir, "logs.zip")
ZipUtils.zipFiles(arrayListOf(logFiles, crashFiles, logcatFile), zipFile)
doc.find("logs.zip")?.delete()
zipFile.inputStream().use { input ->
doc.createFileIfNotExist("logs.zip").openOutputStream().getOrNull()
?.use {
input.copyTo(it)
}
}
zipFile.delete()
}
private fun copyHeapDump(doc: FileDoc): Boolean {
val heapFile = FileDoc.fromFile(File(externalCache, "heapDump")).list()
?.firstOrNull() ?: return false
doc.find("heapDump")?.delete()
val heapDumpDoc = doc.createFolderIfNotExist("heapDump")
heapFile.openInputStream().getOrNull()?.use { input ->
heapDumpDoc.createFileIfNotExist(heapFile.name).openOutputStream().getOrNull()
?.use {
input.copyTo(it)
}
}
return true
}
private fun dumpLogcat(file: File) {
try {
val process = Runtime.getRuntime().exec("logcat -d")
file.outputStream().use {
process.inputStream.copyTo(it)
}
} catch (e: Exception) {
AppLog.put("保存Logcat失败\n$e", e)
}
}
}
@@ -0,0 +1,48 @@
package io.legado.app.ui.about
import androidx.compose.runtime.Stable
import io.legado.app.help.update.AppUpdate
import io.legado.app.utils.FileDoc
@Stable
data class AboutUiState(
val sheet: AboutSheet = AboutSheet.None,
val dialog: AboutDialog? = null,
val crashLogFiles: List<FileDoc> = emptyList(),
)
sealed interface AboutSheet {
data object None : AboutSheet
data class Markdown(val title: String, val content: String) : AboutSheet
data object CrashLogs : AboutSheet
data class Update(
val updateInfo: AppUpdate.UpdateInfo,
val mode: UpdateMode = UpdateMode.UPDATE,
) : AboutSheet
}
enum class UpdateMode { UPDATE, VIEW_LOG }
sealed interface AboutDialog {
data object CheckingUpdate : AboutDialog
}
sealed interface AboutIntent {
data object DismissSheet : AboutIntent
data object DismissDialog : AboutIntent
data object CheckUpdate : AboutIntent
data class ShowMdFile(val title: String, val fileName: String) : AboutIntent
data object ShowCrashLogs : AboutIntent
data object SaveLog : AboutIntent
data object CreateHeapDump : AboutIntent
data class OpenUrl(val url: String) : AboutIntent
data object ClearCrashLogs : AboutIntent
data class ReadCrashFile(val fileDoc: FileDoc) : AboutIntent
data object StartDownload : AboutIntent
}
sealed interface AboutEffect {
data class OpenUrl(val url: String) : AboutEffect
data class ShowToast(val message: String) : AboutEffect
data class StartDownload(val url: String, val fileName: String) : AboutEffect
}
@@ -2,6 +2,7 @@ package io.legado.app.ui.about
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
@@ -16,11 +17,15 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
@@ -29,30 +34,56 @@ import androidx.compose.ui.unit.sp
import io.legado.app.R
import io.legado.app.constant.AppConst.appInfo
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.ThemeResolver
import io.legado.app.ui.widget.components.AppScaffold
import io.legado.app.ui.widget.components.SplicedColumnGroup
import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
import io.legado.app.ui.widget.components.card.TextCard
import io.legado.app.ui.widget.components.settingItem.SettingItem
import io.legado.app.ui.widget.components.SettingItemWithDivider
import io.legado.app.ui.widget.components.SplicedColumnGroup
import io.legado.app.ui.widget.components.alert.AppAlertDialog
import io.legado.app.ui.widget.components.card.TextCard
import io.legado.app.ui.widget.components.log.CrashLogSheet
import io.legado.app.ui.widget.components.progressIndicator.AppCircularProgressIndicator
import io.legado.app.ui.widget.components.settingItem.SettingItem
import io.legado.app.ui.widget.components.text.AppText
import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
import kotlinx.coroutines.delay
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun AboutScreen(
versionName: String = appInfo.versionName,
state: AboutUiState,
onIntent: (AboutIntent) -> Unit,
onBack: () -> Unit = {},
onCheckUpdate: () -> Unit = {},
onOpenUrl: (String) -> Unit = {},
onShowMdFile: (String, String) -> Unit = { _, _ -> },
onSaveLog: () -> Unit = {},
onCreateHeapDump: () -> Unit = {},
onShowCrashLogs: () -> Unit = {}
versionName: String = appInfo.versionName,
) {
LocalContext.current
if (ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)) {
MiuixAboutScreen(
state = state,
onIntent = onIntent,
onBack = onBack,
versionName = versionName,
)
} else {
MaterialAboutScreen(
state = state,
onIntent = onIntent,
onBack = onBack,
versionName = versionName,
)
}
AboutOverlays(state = state, onIntent = onIntent)
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun MaterialAboutScreen(
state: AboutUiState,
onIntent: (AboutIntent) -> Unit,
onBack: () -> Unit,
versionName: String,
) {
val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
AppScaffold(
@@ -88,14 +119,16 @@ fun AboutScreen(
fontWeight = FontWeight.Bold,
modifier = Modifier
.fillMaxWidth()
.wrapContentWidth(Alignment.CenterHorizontally))
.wrapContentWidth(Alignment.CenterHorizontally)
)
TextCard(
text = versionName,
cornerRadius = 8.dp,
modifier = Modifier
.fillMaxWidth()
.wrapContentWidth(Alignment.CenterHorizontally)
.padding(vertical = 4.dp))
.padding(vertical = 4.dp)
)
AppText(
text = stringResource(R.string.about_description),
style = LegadoTheme.typography.bodyLarge,
@@ -103,25 +136,25 @@ fun AboutScreen(
modifier = Modifier
.fillMaxWidth()
.wrapContentWidth(Alignment.CenterHorizontally)
.padding(bottom = 4.dp))
Row (modifier = Modifier
.fillMaxWidth(),
horizontalArrangement = Arrangement.Center){
FilledTonalIconButton ( onClick = { onOpenUrl("https://example.com") } ) {
.padding(bottom = 4.dp)
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
FilledTonalIconButton(onClick = { onIntent(AboutIntent.OpenUrl("https://github.com/HapeLee/legado-with-MD3")) }) {
Icon(
painter = painterResource(R.drawable.ic_web_outline),
contentDescription = stringResource(R.string.back)
)
}
FilledTonalIconButton (onClick = { onOpenUrl("https://github.com/HapeLee/legado-with-MD3") }) {
FilledTonalIconButton(onClick = { onIntent(AboutIntent.OpenUrl("https://github.com/HapeLee/legado-with-MD3")) }) {
Icon(
painter = painterResource(R.drawable.ic_github),
contentDescription = stringResource(R.string.back)
)
}
FilledTonalIconButton(onClick = onCheckUpdate) {
FilledTonalIconButton(onClick = { onIntent(AboutIntent.CheckUpdate) }) {
Icon(
painter = painterResource(R.drawable.ic_import),
contentDescription = stringResource(R.string.back)
@@ -132,58 +165,113 @@ fun AboutScreen(
SplicedColumnGroup(
modifier = Modifier.padding(horizontal = 16.dp),
title = ""
){
) {
SettingItemWithDivider {
SettingItem(
title = stringResource(R.string.contributors),
onClick = {
onOpenUrl("https://github.com/gedoor/legado/graphs/contributors")
}
onClick = { onIntent(AboutIntent.OpenUrl("https://github.com/HapeLee/legado-with-MD3")) }
)
}
SettingItemWithDivider {
SettingItem(
title = stringResource(R.string.privacy_policy),
onClick = {
onShowMdFile("隐私政策", "privacyPolicy.md")
onIntent(
AboutIntent.ShowMdFile(
"隐私政策",
"privacyPolicy.md"
)
)
}
)
}
SettingItemWithDivider {
SettingItem(
title = stringResource(R.string.license),
onClick = {
onShowMdFile("许可证", "LICENSE.md")
}
onClick = { onIntent(AboutIntent.ShowMdFile("许可证", "LICENSE.md")) }
)
}
SettingItemWithDivider {
SettingItem(
title = stringResource(R.string.disclaimer),
onClick = {
onShowMdFile("免责声明", "disclaimer.md")
}
onClick = { onIntent(AboutIntent.ShowMdFile("免责声明", "disclaimer.md")) }
)
}
SettingItemWithDivider {
SettingItem(
title = stringResource(R.string.crash_log),
onClick = onShowCrashLogs
onClick = { onIntent(AboutIntent.ShowCrashLogs) }
)
}
SettingItemWithDivider {
SettingItem(
title = stringResource(R.string.save_log),
onClick = onSaveLog
onClick = { onIntent(AboutIntent.SaveLog) }
)
}
SettingItemWithDivider {
SettingItem(
title = stringResource(R.string.create_heap_dump),
onClick = onCreateHeapDump
onClick = { onIntent(AboutIntent.CreateHeapDump) }
)
}
}
}
}
}
@Composable
private fun AboutOverlays(
state: AboutUiState,
onIntent: (AboutIntent) -> Unit,
) {
val currentSheet = state.sheet
var renderedSheet by remember { mutableStateOf<AboutSheet>(AboutSheet.None) }
LaunchedEffect(currentSheet) {
if (currentSheet is AboutSheet.None) {
delay(300)
renderedSheet = AboutSheet.None
} else {
renderedSheet = currentSheet
}
}
when (val sheet = renderedSheet) {
is AboutSheet.None -> Unit
is AboutSheet.Markdown -> MarkdownSheet(
show = currentSheet is AboutSheet.Markdown,
title = sheet.title,
content = sheet.content,
onDismissRequest = { onIntent(AboutIntent.DismissSheet) },
)
is AboutSheet.CrashLogs -> CrashLogSheet(
show = currentSheet is AboutSheet.CrashLogs,
logFiles = state.crashLogFiles,
onDismissRequest = { onIntent(AboutIntent.DismissSheet) },
onReadFile = { onIntent(AboutIntent.ReadCrashFile(it)) },
onClear = { onIntent(AboutIntent.ClearCrashLogs) },
)
is AboutSheet.Update -> UpdateSheet(
show = currentSheet is AboutSheet.Update,
updateInfo = sheet.updateInfo,
mode = sheet.mode,
onDismissRequest = { onIntent(AboutIntent.DismissSheet) },
onStartDownload = { onIntent(AboutIntent.StartDownload) },
)
}
AppAlertDialog(
show = state.dialog is AboutDialog.CheckingUpdate,
onDismissRequest = {},
content = {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
AppCircularProgressIndicator()
}
}
)
}
@@ -0,0 +1,193 @@
package io.legado.app.ui.about
import android.os.Build
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.mikepenz.markdown.m3.Markdown
import com.mikepenz.markdown.m3.markdownColor
import com.mikepenz.markdown.m3.markdownTypography
import io.legado.app.BuildConfig
import io.legado.app.R
import io.legado.app.constant.AppConst.appInfo
import io.legado.app.help.config.AppConfig
import io.legado.app.help.update.AppUpdate
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.widget.components.button.PrimaryButton
import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
import io.legado.app.ui.widget.components.text.AppText
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MarkdownSheet(
show: Boolean,
title: String,
content: String,
onDismissRequest: () -> Unit,
) {
AppModalBottomSheet(
show = show,
onDismissRequest = onDismissRequest,
title = title,
) {
SelectionContainer {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
) {
Markdown(
content = content,
colors = markdownColor(
text = LegadoTheme.colorScheme.onBackground,
codeBackground = LegadoTheme.colorScheme.onBackground.copy(alpha = 0.1f),
inlineCodeBackground = LegadoTheme.colorScheme.onBackground.copy(alpha = 0.1f),
dividerColor = LegadoTheme.colorScheme.outlineVariant,
tableBackground = LegadoTheme.colorScheme.onBackground.copy(alpha = 0.02f),
),
typography = markdownTypography(),
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.heightIn(min = 16.dp))
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun UpdateSheet(
show: Boolean,
updateInfo: AppUpdate.UpdateInfo,
mode: UpdateMode,
onDismissRequest: () -> Unit,
onStartDownload: () -> Unit,
) {
val title = when (mode) {
UpdateMode.UPDATE -> stringResource(R.string.check_update)
UpdateMode.VIEW_LOG -> "已经更新至"
}
AppModalBottomSheet(
show = show,
onDismissRequest = onDismissRequest,
title = title,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
) {
if (mode == UpdateMode.UPDATE) {
Row(
modifier = Modifier.padding(bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
AppText(
text = "当前版本",
style = LegadoTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.width(8.dp))
AppText(
text = BuildConfig.VERSION_NAME,
style = LegadoTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline,
)
}
Row(
modifier = Modifier.padding(bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
AppText(
text = "新版本",
style = LegadoTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.width(8.dp))
AppText(
text = updateInfo.tagName,
style = LegadoTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
)
}
Row(
modifier = Modifier.padding(bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
AppText(
text = "ABI",
style = LegadoTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.width(8.dp))
AppText(
text = Build.SUPPORTED_ABIS.firstOrNull() ?: "unknown",
style = LegadoTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline,
)
}
Row(
modifier = Modifier.padding(bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
AppText(
text = "渠道",
style = LegadoTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.width(8.dp))
AppText(
text = AppConfig.updateToVariant ?: appInfo.appVariant.toString(),
style = LegadoTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline,
)
}
} else {
AppText(
text = BuildConfig.VERSION_NAME,
style = LegadoTheme.typography.headlineMedium,
modifier = Modifier.padding(bottom = 16.dp),
)
}
val updateLog = updateInfo.updateLog
if (updateLog.isNotBlank()) {
Markdown(
content = updateLog,
colors = markdownColor(
text = LegadoTheme.colorScheme.onBackground,
codeBackground = LegadoTheme.colorScheme.onBackground.copy(alpha = 0.1f),
inlineCodeBackground = LegadoTheme.colorScheme.onBackground.copy(alpha = 0.1f),
dividerColor = LegadoTheme.colorScheme.outlineVariant,
tableBackground = LegadoTheme.colorScheme.onBackground.copy(alpha = 0.02f),
),
typography = markdownTypography(),
modifier = Modifier.fillMaxWidth(),
)
}
if (mode == UpdateMode.UPDATE) {
Spacer(modifier = Modifier.height(16.dp))
PrimaryButton(
onClick = onStartDownload,
text = "更新",
modifier = Modifier.fillMaxWidth()
)
}
Spacer(modifier = Modifier.height(16.dp))
}
}
}
@@ -0,0 +1,243 @@
package io.legado.app.ui.about
import android.app.Application
import android.net.Uri
import androidx.core.net.toUri
import androidx.lifecycle.viewModelScope
import io.legado.app.R
import io.legado.app.base.BaseViewModel
import io.legado.app.constant.AppLog
import io.legado.app.help.CrashHandler
import io.legado.app.help.config.AppConfig
import io.legado.app.help.update.AppUpdate
import io.legado.app.utils.FileDoc
import io.legado.app.utils.FileUtils
import io.legado.app.utils.compress.ZipUtils
import io.legado.app.utils.createFileIfNotExist
import io.legado.app.utils.createFolderIfNotExist
import io.legado.app.utils.delete
import io.legado.app.utils.externalCache
import io.legado.app.utils.find
import io.legado.app.utils.getFile
import io.legado.app.utils.list
import io.legado.app.utils.openInputStream
import io.legado.app.utils.openOutputStream
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import java.io.File
import java.io.FileFilter
class AboutViewModel(application: Application) : BaseViewModel(application) {
private val _uiState = MutableStateFlow(AboutUiState())
val uiState: StateFlow<AboutUiState> = _uiState.asStateFlow()
private val _effects = MutableSharedFlow<AboutEffect>(extraBufferCapacity = 8)
val effects = _effects.asSharedFlow()
fun onIntent(intent: AboutIntent) {
when (intent) {
is AboutIntent.DismissSheet -> _uiState.update { it.copy(sheet = AboutSheet.None) }
is AboutIntent.DismissDialog -> _uiState.update { it.copy(dialog = null) }
is AboutIntent.CheckUpdate -> checkUpdate()
is AboutIntent.ShowMdFile -> showMdFile(intent.title, intent.fileName)
is AboutIntent.ShowCrashLogs -> showCrashLogs()
is AboutIntent.SaveLog -> saveLog()
is AboutIntent.CreateHeapDump -> createHeapDump()
is AboutIntent.OpenUrl -> _effects.tryEmit(AboutEffect.OpenUrl(intent.url))
is AboutIntent.ClearCrashLogs -> clearCrashLogs()
is AboutIntent.ReadCrashFile -> readCrashFile(intent.fileDoc)
is AboutIntent.StartDownload -> startDownload()
}
}
private fun checkUpdate() {
_uiState.update { it.copy(dialog = AboutDialog.CheckingUpdate) }
AppUpdate.gitHubUpdate?.run {
check(viewModelScope)
.onSuccess { updateInfo ->
_uiState.update {
it.copy(
dialog = null,
sheet = AboutSheet.Update(updateInfo)
)
}
}.onError { e ->
_uiState.update { it.copy(dialog = null) }
_effects.tryEmit(
AboutEffect.ShowToast("${context.getString(R.string.check_update)}\n${e.localizedMessage}")
)
}.onFinally {
_uiState.update { it.copy(dialog = null) }
}
}
}
private fun showMdFile(title: String, fileName: String) {
execute {
String(context.assets.open(fileName).readBytes())
}.onSuccess { content ->
_uiState.update { it.copy(sheet = AboutSheet.Markdown(title, content)) }
}
}
private fun showCrashLogs() {
execute {
loadCrashLogFiles()
}.onSuccess { files ->
_uiState.update {
it.copy(
crashLogFiles = files,
sheet = AboutSheet.CrashLogs
)
}
}
}
private fun readCrashFile(fileDoc: FileDoc) {
execute {
String(fileDoc.readBytes())
}.onSuccess { content ->
_uiState.update { it.copy(sheet = AboutSheet.Markdown(fileDoc.name, content)) }
}.onError {
_effects.tryEmit(AboutEffect.ShowToast(it.localizedMessage ?: ""))
}
}
private fun clearCrashLogs() {
execute {
context.externalCacheDir
?.getFile("crash")
?.let { FileUtils.delete(it, false) }
val backupPath = AppConfig.backupPath
if (!backupPath.isNullOrEmpty()) {
val uri = Uri.parse(backupPath)
FileDoc.fromUri(uri, true)
.find("crash")
?.delete()
}
}.onError {
_effects.tryEmit(AboutEffect.ShowToast(it.localizedMessage ?: ""))
}.onFinally {
execute {
loadCrashLogFiles()
}.onSuccess { files ->
_uiState.update { it.copy(crashLogFiles = files) }
}
}
}
private fun loadCrashLogFiles(): List<FileDoc> {
val list = arrayListOf<FileDoc>()
context.externalCacheDir
?.getFile("crash")
?.listFiles(FileFilter { it.isFile })
?.forEach { list.add(FileDoc.fromFile(it)) }
val backupPath = AppConfig.backupPath
if (!backupPath.isNullOrEmpty()) {
val uri = Uri.parse(backupPath)
FileDoc.fromUri(uri, true)
.find("crash")
?.list { !it.isDir }
?.let { list.addAll(it) }
}
return list.sortedByDescending { it.name }.distinctBy { it.name }
}
private fun saveLog() {
execute {
val backupPath = AppConfig.backupPath ?: run {
_effects.tryEmit(AboutEffect.ShowToast("未设置备份目录"))
return@execute
}
if (!AppConfig.recordLog) {
_effects.tryEmit(AboutEffect.ShowToast("未开启日志记录,请去其他设置里打开记录日志"))
delay(3000)
}
val doc = FileDoc.fromUri(backupPath.toUri(), true)
copyLogs(doc)
copyHeapDump(doc)
_effects.tryEmit(AboutEffect.ShowToast("已保存至备份目录"))
}.onError {
AppLog.put("保存日志出错\n${it.localizedMessage}", it, true)
}
}
private fun createHeapDump() {
execute {
val backupPath = AppConfig.backupPath ?: run {
_effects.tryEmit(AboutEffect.ShowToast("未设置备份目录"))
return@execute
}
if (!AppConfig.recordHeapDump) {
_effects.tryEmit(AboutEffect.ShowToast("未开启堆转储记录,请去其他设置里打开记录堆转储"))
delay(3000)
}
_effects.tryEmit(AboutEffect.ShowToast("开始创建堆转储"))
System.gc()
CrashHandler.doHeapDump(true)
val doc = FileDoc.fromUri(backupPath.toUri(), true)
if (!copyHeapDump(doc)) {
_effects.tryEmit(AboutEffect.ShowToast("未找到堆转储文件"))
} else {
_effects.tryEmit(AboutEffect.ShowToast("已保存至备份目录"))
}
}.onError {
AppLog.put("保存堆转储失败\n${it.localizedMessage}", it)
}
}
private fun startDownload() {
val sheet = _uiState.value.sheet
if (sheet is AboutSheet.Update) {
val info = sheet.updateInfo
if (info.downloadUrl.isBlank() || info.fileName.isBlank()) {
_effects.tryEmit(AboutEffect.ShowToast("下载信息不完整"))
} else {
_effects.tryEmit(AboutEffect.StartDownload(info.downloadUrl, info.fileName))
}
}
}
private fun copyLogs(doc: FileDoc) {
val cacheDir = context.externalCache
val logFiles = File(cacheDir, "logs")
val crashFiles = File(cacheDir, "crash")
val logcatFile = File(cacheDir, "logcat.txt")
dumpLogcat(logcatFile)
val zipFile = File(cacheDir, "logs.zip")
ZipUtils.zipFiles(arrayListOf(logFiles, crashFiles, logcatFile), zipFile)
doc.find("logs.zip")?.delete()
zipFile.inputStream().use { input ->
doc.createFileIfNotExist("logs.zip").openOutputStream().getOrNull()
?.use { input.copyTo(it) }
}
zipFile.delete()
}
private fun copyHeapDump(doc: FileDoc): Boolean {
val heapFile = FileDoc.fromFile(File(context.externalCache, "heapDump")).list()
?.firstOrNull() ?: return false
doc.find("heapDump")?.delete()
val heapDumpDoc = doc.createFolderIfNotExist("heapDump")
heapFile.openInputStream().getOrNull()?.use { input ->
heapDumpDoc.createFileIfNotExist(heapFile.name).openOutputStream().getOrNull()
?.use { input.copyTo(it) }
}
return true
}
private fun dumpLogcat(file: File) {
try {
val process = Runtime.getRuntime().exec("logcat -d")
file.outputStream().use { process.inputStream.copyTo(it) }
} catch (e: Exception) {
AppLog.put("保存Logcat失败\n$e", e)
}
}
}
@@ -0,0 +1,515 @@
@file:OptIn(ExperimentalScrollBarApi::class)
package io.legado.app.ui.about
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.displayCutout
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
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.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.legado.app.R
import io.legado.app.constant.AppConst.appInfo
import io.legado.app.ui.util.BlurredBar
import io.legado.app.ui.util.LocalAppState
import io.legado.app.ui.util.LocalIsWideScreen
import io.legado.app.ui.util.LocalNavigator
import io.legado.app.ui.util.MiuixNavigator
import io.legado.app.ui.util.pageContentPadding
import io.legado.app.ui.util.pageScrollModifiers
import io.legado.app.ui.util.rememberBlurBackdrop
import io.legado.app.ui.util.shouldShowSplitPane
import io.legado.app.ui.widget.components.effect.BgEffectBackground
import io.legado.app.ui.widget.components.effect.ColorBlendToken
import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
import top.yukonga.miuix.kmp.basic.Card
import top.yukonga.miuix.kmp.basic.CardDefaults
import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior
import top.yukonga.miuix.kmp.basic.Scaffold
import top.yukonga.miuix.kmp.basic.ScrollBehavior
import top.yukonga.miuix.kmp.basic.SmallTopAppBar
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.VerticalScrollBar
import top.yukonga.miuix.kmp.basic.rememberScrollBarAdapter
import top.yukonga.miuix.kmp.blur.BlendColorEntry
import top.yukonga.miuix.kmp.blur.BlurBlendMode
import top.yukonga.miuix.kmp.blur.BlurColors
import top.yukonga.miuix.kmp.blur.BlurDefaults
import top.yukonga.miuix.kmp.blur.isRuntimeShaderSupported
import top.yukonga.miuix.kmp.blur.layerBackdrop
import top.yukonga.miuix.kmp.blur.textureBlur
import top.yukonga.miuix.kmp.interfaces.ExperimentalScrollBarApi
import top.yukonga.miuix.kmp.preference.ArrowPreference
import top.yukonga.miuix.kmp.theme.MiuixTheme
import androidx.compose.ui.graphics.BlendMode as ComposeBlendMode
@Composable
fun MiuixAboutScreen(
state: AboutUiState,
onIntent: (AboutIntent) -> Unit,
onBack: () -> Unit = {},
versionName: String = appInfo.versionName,
) {
val topAppBarScrollBehavior = MiuixScrollBehavior()
val lazyListState = rememberLazyListState()
val isWideScreen = shouldShowSplitPane()
val scrollProgress by remember {
derivedStateOf {
when {
lazyListState.firstVisibleItemIndex > 0 -> 1f
else -> {
val spacer =
lazyListState.layoutInfo.visibleItemsInfo.firstOrNull { it.key == "logoSpacer" }
if (spacer != null && spacer.size > 0) {
(lazyListState.firstVisibleItemScrollOffset.toFloat() / spacer.size).coerceIn(
0f,
1f
)
} else {
0f
}
}
}
}
}
val backdrop = rememberBlurBackdrop()
val blurActive = backdrop != null && scrollProgress == 1f
val barColor = if (blurActive) {
Color.Transparent
} else {
if (scrollProgress == 1f) MiuixTheme.colorScheme.surface else Color.Transparent
}
val navigator = remember {
object : MiuixNavigator {
override fun pop() {
onBack()
}
override fun push(route: Any) {
if (route == "License") {
onIntent(AboutIntent.ShowMdFile("许可证", "LICENSE.md"))
}
}
}
}
CompositionLocalProvider(
LocalNavigator provides navigator,
LocalIsWideScreen provides isWideScreen
) {
Scaffold(
topBar = {
BlurredBar(backdrop, blurActive) {
SmallTopAppBar(
title = stringResource(R.string.about),
scrollBehavior = topAppBarScrollBehavior,
color = barColor,
titleColor = MiuixTheme.colorScheme.onSurface.copy(
alpha = ((scrollProgress - 0.35f) / 0.65f).coerceIn(0f, 1f),
),
defaultWindowInsetsPadding = false,
navigationIcon = {
TopBarNavigationButton(onClick = onBack)
},
)
}
},
) { innerPadding ->
Box(modifier = if (backdrop != null) Modifier.layerBackdrop(backdrop) else Modifier) {
AboutContent(
padding = innerPadding,
topAppBarScrollBehavior = topAppBarScrollBehavior,
lazyListState = lazyListState,
scrollProgress = scrollProgress,
versionName = versionName,
onIntent = onIntent,
)
}
}
}
}
@Composable
private fun AboutContent(
padding: PaddingValues,
topAppBarScrollBehavior: ScrollBehavior,
lazyListState: LazyListState,
scrollProgress: Float,
versionName: String,
onIntent: (AboutIntent) -> Unit,
) {
val appState = LocalAppState.current
val isWideScreen = LocalIsWideScreen.current
val backdrop = rememberBlurBackdrop()
var blurRadius by remember { mutableFloatStateOf(60f) }
var noiseCoefficient by remember { mutableFloatStateOf(BlurDefaults.NoiseCoefficient) }
var brightness by remember { mutableFloatStateOf(0f) }
var contrast by remember { mutableFloatStateOf(1f) }
var saturation by remember { mutableFloatStateOf(1f) }
val scrollPadding = pageContentPadding(
padding,
PaddingValues(0.dp), // outerPadding placeholder
isWideScreen,
extraStart = WindowInsets.displayCutout.asPaddingValues()
.calculateLeftPadding(LayoutDirection.Ltr) + 16.dp,
extraEnd = WindowInsets.displayCutout.asPaddingValues()
.calculateRightPadding(LayoutDirection.Ltr) + 16.dp,
)
val logoPadding = pageContentPadding(
padding,
PaddingValues(0.dp),
isWideScreen,
extraTop = 40.dp,
extraStart = WindowInsets.displayCutout.asPaddingValues()
.calculateLeftPadding(LayoutDirection.Ltr) + 16.dp,
extraEnd = WindowInsets.displayCutout.asPaddingValues()
.calculateRightPadding(LayoutDirection.Ltr) + 16.dp,
)
val isInDark = isSystemInDarkTheme()
val dynamicBackground = isRuntimeShaderSupported()
val cardBlend =
if (isInDark) ColorBlendToken.Overlay_Thin_Light else ColorBlendToken.Pured_Regular_Light
val logoBlend = remember(isInDark) {
if (isInDark) {
listOf(
BlendColorEntry(Color(0xe6a1a1a1), BlurBlendMode.ColorDodge),
BlendColorEntry(Color(0x4de6e6e6), BlurBlendMode.LinearLight),
BlendColorEntry(Color(0xff1af500), BlurBlendMode.Lab),
)
} else {
listOf(
BlendColorEntry(Color(0xcc4a4a4a), BlurBlendMode.ColorBurn),
BlendColorEntry(Color(0xff4f4f4f), BlurBlendMode.LinearLight),
BlendColorEntry(Color(0xff1af200), BlurBlendMode.Lab),
)
}
}
val density = LocalDensity.current
var logoHeightDp by remember { mutableStateOf(300.dp) }
val versionCodeProgress = ((scrollProgress - 0.05f) / 0.15f).coerceIn(0f, 1f)
val projectNameProgress = ((scrollProgress - 0.20f) / 0.15f).coerceIn(0f, 1f)
val iconProgress = ((scrollProgress - 0.35f) / 0.15f).coerceIn(0f, 1f)
BgEffectBackground(
dynamicBackground = dynamicBackground,
isOs3Effect = true,
isFullSize = true,
modifier = Modifier.fillMaxSize(),
bgModifier = if (backdrop != null) Modifier.layerBackdrop(backdrop) else Modifier,
alpha = { 1f - scrollProgress },
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(
top = logoPadding.calculateTopPadding() + 52.dp,
start = logoPadding.calculateLeftPadding(LayoutDirection.Ltr),
end = logoPadding.calculateRightPadding(LayoutDirection.Ltr),
)
.onSizeChanged { size ->
with(density) { logoHeightDp = size.height.toDp() }
},
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(88.dp)
.graphicsLayer {
clip = true
shape = RoundedCornerShape(24.dp)
alpha = 1 - iconProgress
scaleX = 1 - (iconProgress * 0.05f)
scaleY = 1 - (iconProgress * 0.05f)
}
.background(Color.White),
) {
Image(
painter = painterResource(R.drawable.ic_launcher_foreground),
contentDescription = null,
modifier = Modifier.scale(1.1f)
)
}
Text(
modifier = Modifier
.padding(top = 12.dp, bottom = 5.dp)
.graphicsLayer {
alpha = 1 - projectNameProgress
scaleX = 1 - (projectNameProgress * 0.05f)
scaleY = 1 - (projectNameProgress * 0.05f)
}
.then(
if (backdrop != null) {
Modifier
.textureBlur(
backdrop = backdrop,
shape = RoundedCornerShape(16.dp),
blurRadius = 150f,
noiseCoefficient = noiseCoefficient,
colors = BlurColors(
blendColors = logoBlend,
),
contentBlendMode = ComposeBlendMode.DstIn,
)
} else {
Modifier
},
),
text = stringResource(R.string.app_name),
color = MiuixTheme.colorScheme.onBackground,
fontWeight = FontWeight.Bold,
fontSize = 35.sp,
)
Text(
modifier = Modifier
.fillMaxWidth()
.graphicsLayer {
alpha = 1 - versionCodeProgress
scaleX = 1 - (versionCodeProgress * 0.05f)
scaleY = 1 - (versionCodeProgress * 0.05f)
},
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
text = versionName,
fontSize = 14.sp,
textAlign = TextAlign.Center,
)
}
// Scrollable content
LazyColumn(
state = lazyListState,
modifier = Modifier
.fillMaxSize()
.pageScrollModifiers(
appState.enableScrollEndHaptic,
appState.showTopAppBar,
topAppBarScrollBehavior,
),
contentPadding = PaddingValues(
top = scrollPadding.calculateTopPadding(),
start = scrollPadding.calculateLeftPadding(LayoutDirection.Ltr),
end = scrollPadding.calculateRightPadding(LayoutDirection.Ltr),
),
) {
// Transparent spacer matching logo height
item(key = "logoSpacer") {
Box(
Modifier
.fillMaxWidth()
.height(
logoHeightDp + 52.dp + logoPadding.calculateTopPadding() - scrollPadding.calculateTopPadding() + 126.dp,
),
contentAlignment = Alignment.TopCenter,
content = { },
)
}
item(key = "about") {
Column(
modifier = Modifier
.fillParentMaxHeight()
.padding(bottom = scrollPadding.calculateBottomPadding()),
) {
Card(
modifier = Modifier
.then(
if (backdrop != null) {
Modifier
.textureBlur(
backdrop = backdrop,
shape = RoundedCornerShape(16.dp),
blurRadius = blurRadius,
noiseCoefficient = noiseCoefficient,
colors = BlurColors(
blendColors = cardBlend,
brightness = brightness,
contrast = contrast,
saturation = saturation,
),
)
} else {
Modifier
},
),
colors = CardDefaults.defaultColors(
if (backdrop != null) Color.Transparent else MiuixTheme.colorScheme.surfaceContainer,
Color.Transparent,
),
) {
ArrowPreference(
title = stringResource(R.string.check_update),
onClick = { onIntent(AboutIntent.CheckUpdate) },
)
ArrowPreference(
title = stringResource(R.string.contributors),
endActions = {
ValueText("GitHub")
},
onClick = { onIntent(AboutIntent.OpenUrl("https://github.com/HapeLee/legado-with-MD3")) },
)
}
Card(
modifier = Modifier
.padding(top = 12.dp)
.then(
if (backdrop != null) {
Modifier
.textureBlur(
backdrop = backdrop,
shape = RoundedCornerShape(16.dp),
blurRadius = blurRadius,
noiseCoefficient = noiseCoefficient,
colors = BlurColors(
blendColors = cardBlend,
brightness = brightness,
contrast = contrast,
saturation = saturation,
),
)
} else {
Modifier
},
),
colors = CardDefaults.defaultColors(
if (backdrop != null) Color.Transparent else MiuixTheme.colorScheme.surfaceContainer,
Color.Transparent,
),
) {
ArrowPreference(
title = stringResource(R.string.privacy_policy),
onClick = {
onIntent(
AboutIntent.ShowMdFile(
"隐私政策",
"privacyPolicy.md"
)
)
},
)
ArrowPreference(
title = stringResource(R.string.license),
onClick = { onIntent(AboutIntent.ShowMdFile("许可证", "LICENSE.md")) },
)
ArrowPreference(
title = stringResource(R.string.disclaimer),
onClick = {
onIntent(
AboutIntent.ShowMdFile(
"免责声明",
"disclaimer.md"
)
)
},
)
}
Card(
modifier = Modifier
.padding(top = 12.dp)
.then(
if (backdrop != null) {
Modifier
.textureBlur(
backdrop = backdrop,
shape = RoundedCornerShape(16.dp),
blurRadius = blurRadius,
noiseCoefficient = noiseCoefficient,
colors = BlurColors(
blendColors = cardBlend,
brightness = brightness,
contrast = contrast,
saturation = saturation,
),
)
} else {
Modifier
},
),
colors = CardDefaults.defaultColors(
if (backdrop != null) Color.Transparent else MiuixTheme.colorScheme.surfaceContainer,
Color.Transparent,
),
) {
ArrowPreference(
title = stringResource(R.string.crash_log),
onClick = { onIntent(AboutIntent.ShowCrashLogs) },
)
ArrowPreference(
title = stringResource(R.string.save_log),
onClick = { onIntent(AboutIntent.SaveLog) },
)
ArrowPreference(
title = stringResource(R.string.create_heap_dump),
onClick = { onIntent(AboutIntent.CreateHeapDump) },
)
}
Spacer(modifier = Modifier.height(12.dp))
}
}
}
VerticalScrollBar(
adapter = rememberScrollBarAdapter(lazyListState),
modifier = Modifier
.align(Alignment.CenterEnd)
.fillMaxHeight(),
trackPadding = scrollPadding,
)
}
}
@Suppress("FunctionName")
@Composable
private fun ValueText(text: String) {
Text(
text = text,
fontSize = MiuixTheme.textStyles.body2.fontSize,
color = MiuixTheme.colorScheme.onSurfaceVariantActions,
)
}
@@ -75,7 +75,6 @@ import io.legado.app.data.entities.BookChapter
import io.legado.app.help.book.isLocal
import io.legado.app.help.config.AppConfig
import io.legado.app.model.BookCover
import io.legado.app.ui.about.AppLogSheet
import io.legado.app.ui.config.coverConfig.CoverConfig
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.LocalHazeState
@@ -95,6 +94,7 @@ import io.legado.app.ui.widget.components.card.TextCard
import io.legado.app.ui.widget.components.cover.CoilBookCover
import io.legado.app.ui.widget.components.cover.buildCoverImageRequest
import io.legado.app.ui.widget.components.icon.AppIcon
import io.legado.app.ui.widget.components.log.AppLogSheet
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
import io.legado.app.ui.widget.components.progressIndicator.AppCircularProgressIndicator
@@ -77,7 +77,6 @@ import io.legado.app.help.book.getExportFileName
import io.legado.app.help.book.isLocal
import io.legado.app.help.book.tryParesExportFileName
import io.legado.app.service.ExportBookService
import io.legado.app.ui.about.AppLogSheet
import io.legado.app.ui.book.changesource.ChangeSourceMigrationOptionsSheet
import io.legado.app.ui.book.info.ChangeSourceSheet
import io.legado.app.ui.book.info.GroupSelectSheet
@@ -99,6 +98,7 @@ import io.legado.app.ui.widget.components.filePicker.FilePickerSheet
import io.legado.app.ui.widget.components.icon.AppIcons
import io.legado.app.ui.widget.components.list.ListScaffold
import io.legado.app.ui.widget.components.list.ListUiState
import io.legado.app.ui.widget.components.log.AppLogSheet
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
@@ -7,11 +7,17 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.ui.LocalNavAnimatedContentScope
import androidx.navigation3.ui.NavDisplay
import io.legado.app.model.Download
import io.legado.app.ui.about.AboutEffect
import io.legado.app.ui.about.AboutScreen
import io.legado.app.ui.about.AboutViewModel
import io.legado.app.ui.book.cache.manage.BookCacheManageRouteScreen
import io.legado.app.ui.book.explore.ExploreShowScreen
import io.legado.app.ui.book.import.local.ImportBookScreen
@@ -40,9 +46,12 @@ import io.legado.app.ui.rss.favorites.RssFavoritesScreen
import io.legado.app.ui.rss.read.MainRouteRssRead
import io.legado.app.ui.rss.read.RssReadRouteScreen
import io.legado.app.ui.rss.subscription.RuleSubScreen
import io.legado.app.utils.openUrl
import io.legado.app.utils.startActivity
import io.legado.app.utils.startActivityForBook
import io.legado.app.utils.toastOnUi
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.androidx.compose.koinViewModel
@@ -127,6 +136,9 @@ fun MainActivity.mainEntryProvider(
onNavigateToReadRecord = {
onNavigateToRoute(MainRouteReadRecord)
},
onNavigateToAbout = {
onNavigateToRoute(MainRouteAbout)
},
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = LocalNavAnimatedContentScope.current,
)
@@ -420,4 +432,27 @@ fun MainActivity.mainEntryProvider(
animatedVisibilityScope = LocalNavAnimatedContentScope.current,
)
}
entry<MainRouteAbout> {
val viewModel = koinViewModel<AboutViewModel>()
val context = LocalContext.current
LaunchedEffect(viewModel) {
viewModel.effects.collectLatest { effect ->
when (effect) {
is AboutEffect.OpenUrl -> context.openUrl(effect.url)
is AboutEffect.ShowToast -> context.toastOnUi(effect.message)
is AboutEffect.StartDownload -> Download.start(
context,
effect.url,
effect.fileName
)
}
}
}
AboutScreen(
state = viewModel.uiState.collectAsStateWithLifecycle().value,
onIntent = viewModel::onIntent,
onBack = { onNavigateBack() },
)
}
}
@@ -80,6 +80,9 @@ data class MainRouteExploreShow(
val exploreUrl: String?,
) : MainRoute
@Serializable
data object MainRouteAbout : MainRoute
object MainRouteConst {
const val ROUTE_MAIN = "main"
const val ROUTE_SETTINGS = "settings"
@@ -103,4 +106,5 @@ object MainRouteConst {
const val ROUTE_RULE_SUB = "rss/rule_sub"
const val ROUTE_READ_RECORD = "read_record"
const val ROUTE_READ_RECORD_OVERVIEW = "read_record_overview"
const val ROUTE_ABOUT = "about"
}
@@ -149,6 +149,16 @@ object MainNavigator {
}
}
MainRouteAbout -> {
if (currentRoute == MainRouteHome) {
backStack.add(route)
} else {
backStack.clear()
backStack.add(MainRouteHome)
backStack.add(route)
}
}
MainRouteReadRecordOverview -> {
if (currentRoute == MainRouteHome || currentRoute == MainRouteReadRecord) {
backStack.add(route)
@@ -258,6 +268,8 @@ object MainNavigator {
)
} ?: MainRouteHome
MainRouteConst.ROUTE_ABOUT -> MainRouteAbout
else -> MainRouteHome
}
}
@@ -116,6 +116,7 @@ fun MainScreen(
onNavigateToRssFavorites: () -> Unit,
onNavigateToRuleSub: () -> Unit,
onNavigateToReadRecord: () -> Unit,
onNavigateToAbout: () -> Unit,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
) {
@@ -154,6 +155,7 @@ fun MainScreen(
MainEffect.ExitApp -> (context as? ComponentActivity)?.finish()
MainEffect.NavigateToReadRecord -> onNavigateToReadRecord()
MainEffect.NavigateToAbout -> onNavigateToAbout()
}
}
}
@@ -231,7 +233,7 @@ fun MainScreen(
modifier = Modifier.padding(start = 20.dp),
onClick = { onNavigateToSearch(null) },
expanded = expanded,
icon = { Icon(Icons.Default.Search, contentDescription = null) },
icon = { AppIcon(Icons.Default.Search, contentDescription = null) },
text = { AppText(stringResource(R.string.search)) }
)
}
@@ -115,6 +115,8 @@ class MainViewModel(
PrefClickEvent.OpenReadRecord -> _effects.tryEmit(MainEffect.NavigateToReadRecord)
PrefClickEvent.OpenAbout -> _effects.tryEmit(MainEffect.NavigateToAbout)
else -> Unit
}
}
@@ -132,6 +134,7 @@ sealed interface MainEffect {
data object ExitApp : MainEffect
data object NavigateToReadRecord : MainEffect
data object NavigateToAbout : MainEffect
}
@Stable
@@ -99,7 +99,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import io.legado.app.R
import io.legado.app.base.BaseRuleEvent
import io.legado.app.data.entities.BookGroup
import io.legado.app.ui.about.AppLogSheet
import io.legado.app.ui.book.group.GroupEditSheet
import io.legado.app.ui.book.info.GroupSelectSheet
import io.legado.app.ui.config.bookshelfConfig.BookshelfConfig
@@ -125,6 +124,7 @@ import io.legado.app.ui.widget.components.icon.AppIcons
import io.legado.app.ui.widget.components.importComponents.SourceInputDialog
import io.legado.app.ui.widget.components.lazylist.FastScrollLazyVerticalGrid
import io.legado.app.ui.widget.components.list.TopFloatingStickyItem
import io.legado.app.ui.widget.components.log.AppLogSheet
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
import io.legado.app.ui.widget.components.progressIndicator.AppCircularProgressIndicator
@@ -3,20 +3,20 @@ package io.legado.app.ui.main.my
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ExitToApp
import androidx.compose.material.icons.automirrored.filled.HelpOutline
@@ -45,7 +45,6 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import io.legado.app.R
import io.legado.app.ui.about.AboutActivity
import io.legado.app.ui.book.bookmark.AllBookmarkActivity
import io.legado.app.ui.book.source.manage.BookSourceActivity
import io.legado.app.ui.book.toc.rule.TxtTocRuleActivity
@@ -206,7 +205,7 @@ fun MyScreen(
title = stringResource(R.string.about),
imageVector = Icons.Default.Info,
onClick = {
onNavigate(PrefClickEvent.StartActivity(AboutActivity::class.java))
onNavigate(PrefClickEvent.OpenAbout)
}
)
ClickableSettingItem(
@@ -26,6 +26,7 @@ sealed class PrefClickEvent {
data class StartActivity(val destination: Class<*>, val configTag: String? = null) : PrefClickEvent()
object OpenReadRecord : PrefClickEvent()
object OpenBookCacheManage : PrefClickEvent()
object OpenAbout : PrefClickEvent()
object ToggleWebService : PrefClickEvent()
object ExitApp : PrefClickEvent()
}
@@ -0,0 +1,174 @@
package io.legado.app.ui.util
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import top.yukonga.miuix.kmp.basic.ScrollBehavior
import top.yukonga.miuix.kmp.basic.SmallTopAppBar
import top.yukonga.miuix.kmp.basic.TopAppBar
import top.yukonga.miuix.kmp.blur.BlendColorEntry
import top.yukonga.miuix.kmp.blur.BlurColors
import top.yukonga.miuix.kmp.blur.LayerBackdrop
import top.yukonga.miuix.kmp.blur.isRenderEffectSupported
import top.yukonga.miuix.kmp.blur.rememberLayerBackdrop
import top.yukonga.miuix.kmp.blur.textureBlur
import top.yukonga.miuix.kmp.theme.MiuixTheme
import top.yukonga.miuix.kmp.utils.overScrollVertical
import top.yukonga.miuix.kmp.utils.scrollEndHaptic
@Stable
data class AppState(
val enableBlur: Boolean = true,
val enableScrollEndHaptic: Boolean = true,
val showTopAppBar: Boolean = true,
)
val LocalAppState = compositionLocalOf { AppState() }
val LocalIsWideScreen = staticCompositionLocalOf { false }
interface MiuixNavigator {
fun pop()
fun push(route: Any)
}
val LocalNavigator = staticCompositionLocalOf<MiuixNavigator> { error("No navigator found!") }
@Composable
fun shouldShowSplitPane(): Boolean {
val windowInfo = LocalWindowInfo.current
val density = LocalDensity.current
return with(density) {
val widthDp = windowInfo.containerSize.width.toDp()
val heightDp = windowInfo.containerSize.height.toDp()
val ratio = heightDp / widthDp
widthDp >= 840.dp || (widthDp >= 600.dp && ratio < 1.2f)
}
}
fun Modifier.pageScrollModifiers(
enableScrollEndHaptic: Boolean,
showTopAppBar: Boolean,
topAppBarScrollBehavior: ScrollBehavior,
): Modifier = this
.then(if (enableScrollEndHaptic) Modifier.scrollEndHaptic() else Modifier)
.overScrollVertical()
.then(if (showTopAppBar) Modifier.nestedScroll(topAppBarScrollBehavior.nestedScrollConnection) else Modifier)
.fillMaxHeight()
@Composable
fun pageContentPadding(
innerPadding: PaddingValues,
outerPadding: PaddingValues,
isWideScreen: Boolean,
extraTop: Dp = 0.dp,
extraStart: Dp = 0.dp,
extraEnd: Dp = 0.dp,
): PaddingValues {
val topPadding = innerPadding.calculateTopPadding() + extraTop
val bottomPadding = if (isWideScreen) {
WindowInsets.navigationBars.asPaddingValues()
.calculateBottomPadding() + outerPadding.calculateBottomPadding()
} else {
outerPadding.calculateBottomPadding()
}
return remember(topPadding, bottomPadding, extraStart, extraEnd) {
PaddingValues(
top = topPadding,
start = extraStart,
end = extraEnd,
bottom = bottomPadding,
)
}
}
@Composable
fun AdaptiveTopAppBar(
title: String,
showTopAppBar: Boolean,
isWideScreen: Boolean,
scrollBehavior: ScrollBehavior,
subtitle: String = "",
color: Color = MiuixTheme.colorScheme.surface,
navigationIcon: @Composable () -> Unit = {},
actions: @Composable RowScope.() -> Unit = {},
bottomContent: @Composable () -> Unit = {},
) {
if (showTopAppBar) {
if (isWideScreen) {
SmallTopAppBar(
title = title,
subtitle = subtitle,
color = color,
scrollBehavior = scrollBehavior,
defaultWindowInsetsPadding = false,
navigationIcon = navigationIcon,
actions = actions,
bottomContent = bottomContent,
)
} else {
TopAppBar(
title = title,
subtitle = subtitle,
color = color,
scrollBehavior = scrollBehavior,
navigationIcon = navigationIcon,
actions = actions,
bottomContent = bottomContent,
)
}
}
}
@Composable
fun rememberBlurBackdrop(): LayerBackdrop? {
val appState = LocalAppState.current
if (!appState.enableBlur || !isRenderEffectSupported()) return null
val surfaceColor = MiuixTheme.colorScheme.surface
return rememberLayerBackdrop {
drawRect(surfaceColor)
drawContent()
}
}
@Composable
fun BlurredBar(
backdrop: LayerBackdrop?,
blurEnabled: Boolean,
content: @Composable () -> Unit,
) {
Box(
modifier = if (blurEnabled && backdrop != null) {
Modifier.textureBlur(
backdrop = backdrop,
shape = RectangleShape,
blurRadius = 25f,
colors = BlurColors(
blendColors = listOf(
BlendColorEntry(color = MiuixTheme.colorScheme.surface.copy(0.8f)),
),
),
)
} else {
Modifier
},
) {
content()
}
}
@@ -0,0 +1,87 @@
package io.legado.app.ui.widget.components.effect
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.spring
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import io.legado.app.ui.util.shouldShowSplitPane
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import top.yukonga.miuix.kmp.blur.isRuntimeShaderSupported
import top.yukonga.miuix.kmp.theme.MiuixTheme
import kotlin.math.floor
@Composable
fun BgEffectBackground(
dynamicBackground: Boolean,
modifier: Modifier = Modifier,
bgModifier: Modifier = Modifier,
isFullSize: Boolean = false,
effectBackground: Boolean = true,
isOs3Effect: Boolean = true,
alpha: () -> Float = { 1f },
content: @Composable (BoxScope.() -> Unit),
) {
val shaderSupported = remember { isRuntimeShaderSupported() }
if (!shaderSupported) {
Box(modifier = modifier, content = content)
return
}
Box(
modifier = modifier,
) {
val surface = MiuixTheme.colorScheme.surface
val deviceType = if (shouldShowSplitPane()) DeviceType.PAD else DeviceType.PHONE
val isDarkTheme = isSystemInDarkTheme()
val painter = remember(isOs3Effect) { BgEffectPainter(isOs3Effect) }
val preset = remember(deviceType, isDarkTheme, isOs3Effect) {
BgEffectConfig.get(deviceType, isDarkTheme, isOs3Effect)
}
val colorStage = remember { Animatable(0f) }
LaunchedEffect(dynamicBackground, preset) {
if (!dynamicBackground) return@LaunchedEffect
val animatesColors =
preset.colors1 !== preset.colors2 || preset.colors2 !== preset.colors3
if (!animatesColors) return@LaunchedEffect
var targetStage = floor(colorStage.value) + 1f
while (isActive) {
delay((preset.colorInterpPeriod * 500).toLong())
colorStage.animateTo(
targetValue = targetStage,
animationSpec = spring(dampingRatio = 0.9f, stiffness = 35f),
)
targetStage += 1f
}
}
Spacer(
modifier = Modifier
.fillMaxSize()
.then(bgModifier)
.bgEffectDraw(
painter = painter,
preset = preset,
deviceType = deviceType,
isDarkTheme = isDarkTheme,
surface = surface,
effectBackground = effectBackground,
isFullSize = isFullSize,
playing = dynamicBackground,
colorStage = { colorStage.value },
alpha = alpha,
),
)
content()
}
}
@@ -0,0 +1,181 @@
package io.legado.app.ui.widget.components.effect
internal object BgEffectConfig {
internal class Config(
val points: FloatArray,
val colors1: FloatArray,
val colors2: FloatArray,
val colors3: FloatArray,
val colorInterpPeriod: Float,
val lightOffset: Float,
val saturateOffset: Float,
val pointOffset: Float,
)
// --- 优化配色 (基于用户提供的颜色调优) ---
private val C1_L = floatArrayOf(0.68f, 0.65f, 0.74f, 1.0f) // 亮色优化 1
private val C2_L = floatArrayOf(0.92f, 0.62f, 0.73f, 1.0f) // 亮色优化 2
private val C3_L = floatArrayOf(0.98f, 0.98f, 0.98f, 1.0f) // 亮色优化 3
private val CM_L = floatArrayOf(0.85f, 0.80f, 0.90f, 1.0f) // 亮色过渡
private val C1_D = floatArrayOf(0.16f, 0.14f, 0.20f, 1.0f) // 暗色优化 1
private val C2_D = floatArrayOf(0.49f, 0.17f, 0.29f, 1.0f) // 暗色优化 2
private val C3_D = floatArrayOf(0.15f, 0.15f, 0.15f, 1.0f) // 暗色优化 3
private val CM_D = floatArrayOf(0.25f, 0.15f, 0.28f, 1.0f) // 暗色过渡
// OS3 配色组合 (A/B/C 三阶段循环实现流光感)
private val COLORS_L_A = floatArrayOf(
C1_L[0],
C1_L[1],
C1_L[2],
C1_L[3],
C2_L[0],
C2_L[1],
C2_L[2],
C2_L[3],
C3_L[0],
C3_L[1],
C3_L[2],
C3_L[3],
CM_L[0],
CM_L[1],
CM_L[2],
CM_L[3]
)
private val COLORS_L_B = floatArrayOf(
C2_L[0],
C2_L[1],
C2_L[2],
C2_L[3],
C3_L[0],
C3_L[1],
C3_L[2],
C3_L[3],
CM_L[0],
CM_L[1],
CM_L[2],
CM_L[3],
C1_L[0],
C1_L[1],
C1_L[2],
C1_L[3]
)
private val COLORS_L_C = floatArrayOf(
C3_L[0],
C3_L[1],
C3_L[2],
C3_L[3],
CM_L[0],
CM_L[1],
CM_L[2],
CM_L[3],
C1_L[0],
C1_L[1],
C1_L[2],
C1_L[3],
C2_L[0],
C2_L[1],
C2_L[2],
C2_L[3]
)
private val COLORS_D_A = floatArrayOf(
C1_D[0],
C1_D[1],
C1_D[2],
C1_D[3],
C2_D[0],
C2_D[1],
C2_D[2],
C2_D[3],
C3_D[0],
C3_D[1],
C3_D[2],
C3_D[3],
CM_D[0],
CM_D[1],
CM_D[2],
CM_D[3]
)
private val COLORS_D_B = floatArrayOf(
C2_D[0],
C2_D[1],
C2_D[2],
C2_D[3],
C3_D[0],
C3_D[1],
C3_D[2],
C3_D[3],
CM_D[0],
CM_D[1],
CM_D[2],
CM_D[3],
C1_D[0],
C1_D[1],
C1_D[2],
C1_D[3]
)
private val COLORS_D_C = floatArrayOf(
C3_D[0],
C3_D[1],
C3_D[2],
C3_D[3],
CM_D[0],
CM_D[1],
CM_D[2],
CM_D[3],
C1_D[0],
C1_D[1],
C1_D[2],
C1_D[3],
C2_D[0],
C2_D[1],
C2_D[2],
C2_D[3]
)
// OS3 Configs (参数完全对齐原版 OS3)
private val OS3_PHONE_LIGHT = Config(
points = floatArrayOf(
0.8f,
0.2f,
1.0f,
0.8f,
0.9f,
1.0f,
0.2f,
0.9f,
1.0f,
0.2f,
0.2f,
1.0f
),
colors1 = COLORS_L_A, colors2 = COLORS_L_B, colors3 = COLORS_L_C,
colorInterpPeriod = 5.0f, lightOffset = 0.1f, saturateOffset = 0.2f, pointOffset = 0.2f
)
private val OS3_PHONE_DARK = Config(
points = floatArrayOf(
0.8f,
0.2f,
1.0f,
0.8f,
0.9f,
1.0f,
0.2f,
0.9f,
1.0f,
0.2f,
0.2f,
1.0f
),
colors1 = COLORS_D_A, colors2 = COLORS_D_B, colors3 = COLORS_D_C,
colorInterpPeriod = 8.0f, lightOffset = 0.0f, saturateOffset = 0.17f, pointOffset = 0.4f
)
internal fun get(
deviceType: DeviceType,
isDark: Boolean,
isOs3: Boolean = true, // 默认且仅使用 OS3 逻辑
): Config = if (!isDark) OS3_PHONE_LIGHT else OS3_PHONE_DARK
}
@@ -0,0 +1,177 @@
package io.legado.app.ui.widget.components.effect
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.ContentDrawScope
import androidx.compose.ui.node.DrawModifierNode
import androidx.compose.ui.node.ModifierNodeElement
import androidx.compose.ui.node.invalidateDraw
import kotlinx.coroutines.Job
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
internal fun Modifier.bgEffectDraw(
painter: BgEffectPainter,
preset: BgEffectConfig.Config,
deviceType: DeviceType,
isDarkTheme: Boolean,
surface: Color,
effectBackground: Boolean,
isFullSize: Boolean,
playing: Boolean,
colorStage: () -> Float,
alpha: () -> Float,
): Modifier = this then BgEffectElement(
painter = painter,
preset = preset,
deviceType = deviceType,
isDarkTheme = isDarkTheme,
surface = surface,
effectBackground = effectBackground,
isFullSize = isFullSize,
playing = playing,
colorStage = colorStage,
alpha = alpha,
)
private data class BgEffectElement(
val painter: BgEffectPainter,
val preset: BgEffectConfig.Config,
val deviceType: DeviceType,
val isDarkTheme: Boolean,
val surface: Color,
val effectBackground: Boolean,
val isFullSize: Boolean,
val playing: Boolean,
val colorStage: () -> Float,
val alpha: () -> Float,
) : ModifierNodeElement<BgEffectNode>() {
override fun create(): BgEffectNode = BgEffectNode(
painter = painter,
preset = preset,
deviceType = deviceType,
isDarkTheme = isDarkTheme,
surface = surface,
effectBackground = effectBackground,
isFullSize = isFullSize,
playing = playing,
colorStage = colorStage,
alpha = alpha,
)
override fun update(node: BgEffectNode) {
node.update(
painter = painter,
preset = preset,
deviceType = deviceType,
isDarkTheme = isDarkTheme,
surface = surface,
effectBackground = effectBackground,
isFullSize = isFullSize,
playing = playing,
colorStage = colorStage,
alpha = alpha,
)
}
}
private class BgEffectNode(
private var painter: BgEffectPainter,
private var preset: BgEffectConfig.Config,
private var deviceType: DeviceType,
private var isDarkTheme: Boolean,
private var surface: Color,
private var effectBackground: Boolean,
private var isFullSize: Boolean,
private var playing: Boolean,
private var colorStage: () -> Float,
private var alpha: () -> Float,
) : Modifier.Node(),
DrawModifierNode {
private var animationJob: Job? = null
private var animTime: Float = 0f
private var startOffset: Float = 0f
override fun onAttach() {
if (playing) startAnimation()
}
override fun onDetach() {
animationJob?.cancel()
animationJob = null
}
fun update(
painter: BgEffectPainter,
preset: BgEffectConfig.Config,
deviceType: DeviceType,
isDarkTheme: Boolean,
surface: Color,
effectBackground: Boolean,
isFullSize: Boolean,
playing: Boolean,
colorStage: () -> Float,
alpha: () -> Float,
) {
this.painter = painter
this.preset = preset
this.deviceType = deviceType
this.isDarkTheme = isDarkTheme
this.surface = surface
this.effectBackground = effectBackground
this.isFullSize = isFullSize
this.colorStage = colorStage
this.alpha = alpha
if (this.playing != playing) {
this.playing = playing
if (playing) {
startAnimation()
} else {
animationJob?.cancel()
animationJob = null
}
}
invalidateDraw()
}
private fun startAnimation() {
animationJob?.cancel()
startOffset = animTime
animationJob = coroutineScope.launch {
val minDeltaNanos = 1_000_000_000L / 60L
val origin = withFrameNanos { it }
var lastEmit = origin
while (isActive) {
val now = withFrameNanos { it }
if (now - lastEmit < minDeltaNanos) continue
lastEmit = now
animTime = startOffset + (now - origin) / 1_000_000_000f
invalidateDraw()
}
}
}
override fun ContentDrawScope.draw() {
drawRect(surface)
if (effectBackground) {
val alphaValue = alpha()
if (alphaValue > 0f) {
val drawHeight = if (isFullSize) size.height * 0.8f else size.height * 0.5f
painter.updateResolution(size.width, size.height)
painter.updateBoundIfNeeded(drawHeight, size.height, size.width)
painter.updatePresetIfNeeded(deviceType, isDarkTheme)
painter.updateColors(preset, colorStage())
painter.updateAnimTime(animTime)
painter.updatePointsAnim(animTime, preset)
drawRect(painter.brush, alpha = alphaValue)
}
}
drawContent()
}
}
@@ -0,0 +1,175 @@
package io.legado.app.ui.widget.components.effect
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.ui.graphics.Brush
import top.yukonga.miuix.kmp.blur.RuntimeShader
import top.yukonga.miuix.kmp.blur.asBrush
import kotlin.math.cos
import kotlin.math.sin
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
internal class BgEffectPainter(
private val isOs3: Boolean = true,
) {
val runtimeShader by lazy {
val shaderCode = OS3_BG_FRAG
RuntimeShader(shaderCode).also {
initStaticUniforms(it)
}
}
val brush: Brush get() = runtimeShader.asBrush()
private val resolution = FloatArray(2)
private val bound = FloatArray(4)
private val colorsBuffer = FloatArray(16)
private val pointsAnimBuffer = FloatArray(8)
private var animTime = Float.NaN
private var isDarkCached: Boolean? = null
private var deviceTypeCached: DeviceType? = null
private var presetApplied = false
private var cachedLogoHeight = Float.NaN
private var cachedTotalHeight = Float.NaN
private var cachedTotalWidth = Float.NaN
private var cachedColorStage = Float.NaN
private var cachedColorsPreset: BgEffectConfig.Config? = null
private var cachedPointsAnimTime = Float.NaN
private var cachedPointsAnimPreset: BgEffectConfig.Config? = null
companion object {
private const val U_TRANSLATE_Y = 0f
private const val U_ALPHA_MULTI = 1f
private const val U_NOISE_SCALE = 1.5f
private const val U_POINT_RADIUS_MULTI = 1f
}
private fun initStaticUniforms(shader: RuntimeShader) {
shader.setFloatUniform("uTranslateY", U_TRANSLATE_Y)
shader.setFloatUniform("uNoiseScale", U_NOISE_SCALE)
shader.setFloatUniform("uPointRadiusMulti", U_POINT_RADIUS_MULTI)
shader.setFloatUniform("uAlphaMulti", U_ALPHA_MULTI)
}
fun updateResolution(width: Float, height: Float) {
if (resolution[0] == width && resolution[1] == height) return
resolution[0] = width
resolution[1] = height
runtimeShader.setFloatUniform("uResolution", resolution)
}
fun updateAnimTime(time: Float) {
if (animTime == time) return
animTime = time
runtimeShader.setFloatUniform("uAnimTime", animTime)
}
fun updatePointsAnim(time: Float, preset: BgEffectConfig.Config) {
if (cachedPointsAnimTime == time && cachedPointsAnimPreset === preset) return
val offset = preset.pointOffset
var i = 0
while (i < 4) {
val srcX = preset.points[i * 3]
val srcY = preset.points[i * 3 + 1]
val animX = srcX + sin(time + srcY) * offset
val animY = srcY + cos(time + animX) * offset
pointsAnimBuffer[i * 2] = animX
pointsAnimBuffer[i * 2 + 1] = animY
i++
}
runtimeShader.setFloatUniform("uPointsAnim", pointsAnimBuffer)
cachedPointsAnimTime = time
cachedPointsAnimPreset = preset
}
fun updateColors(preset: BgEffectConfig.Config, stage: Float) {
if (cachedColorsPreset === preset && cachedColorStage == stage) return
val base = stage.toInt()
val fraction = stage - base
val start = colorsForCycleIndex(preset, base)
val end = colorsForCycleIndex(preset, base + 1)
for (i in 0 until 16) {
colorsBuffer[i] = start[i] + (end[i] - start[i]) * fraction
}
runtimeShader.setFloatUniform("uColors", colorsBuffer)
cachedColorsPreset = preset
cachedColorStage = stage
}
private fun colorsForCycleIndex(preset: BgEffectConfig.Config, index: Int): FloatArray =
when (index.mod(4)) {
1 -> preset.colors1
3 -> preset.colors3
else -> preset.colors2
}
fun updateBoundIfNeeded(
logoHeight: Float,
totalHeight: Float,
totalWidth: Float,
) {
if (cachedLogoHeight == logoHeight &&
cachedTotalHeight == totalHeight &&
cachedTotalWidth == totalWidth
) {
return
}
updateBound(logoHeight, totalHeight, totalWidth)
runtimeShader.setFloatUniform("uBound", bound)
cachedLogoHeight = logoHeight
cachedTotalHeight = totalHeight
cachedTotalWidth = totalWidth
}
fun updatePresetIfNeeded(deviceType: DeviceType, isDark: Boolean) {
if (presetApplied && isDarkCached == isDark && deviceTypeCached == deviceType) return
applyPreset(deviceType, isDark)
isDarkCached = isDark
deviceTypeCached = deviceType
presetApplied = true
}
private fun applyPreset(deviceType: DeviceType, isDark: Boolean) {
val preset = BgEffectConfig.get(deviceType, isDark, isOs3)
runtimeShader.setFloatUniform("uPoints", preset.points)
runtimeShader.setFloatUniform("uLightOffset", preset.lightOffset)
runtimeShader.setFloatUniform("uSaturateOffset", preset.saturateOffset)
}
private fun updateBound(
logoHeight: Float,
totalHeight: Float,
totalWidth: Float,
) {
val heightRatio = logoHeight / totalHeight
if (totalWidth <= totalHeight) {
bound[0] = 0f
bound[1] = 1f - heightRatio
bound[2] = 1f
bound[3] = heightRatio
} else {
val aspectRatio = totalWidth / totalHeight
val contentCenterY = 1f - heightRatio / 2f
bound[0] = 0f
bound[1] = contentCenterY - aspectRatio / 2f
bound[2] = 1f
bound[3] = aspectRatio
}
}
}
@@ -0,0 +1,209 @@
package io.legado.app.ui.widget.components.effect
import androidx.compose.ui.graphics.Color
import top.yukonga.miuix.kmp.blur.BlendColorEntry
import top.yukonga.miuix.kmp.blur.BlurBlendMode
object ColorBlendToken {
// Info Series
val Info_Extra_Thin_Light = listOf(
BlendColorEntry(Color(0x3BB0B0B1), BlurBlendMode.PlusDarker),
)
val Info_Extra_Thin_Dark = listOf(
BlendColorEntry(Color(0x3BB0B0B1), BlurBlendMode.PlusLighter),
)
val Info_Thin_Light = listOf(
BlendColorEntry(Color(0x801E1E1E), BlurBlendMode.PlusLighter),
)
val Info_Thin_Dark = listOf(
BlendColorEntry(Color(0x801E1E1E), BlurBlendMode.PlusDarker),
)
val Info_Regular_Light = listOf(
BlendColorEntry(Color(0xB3141414), BlurBlendMode.PlusLighter),
)
val Info_Regular_Dark = listOf(
BlendColorEntry(Color(0xB3141414), BlurBlendMode.PlusDarker),
)
val Info_Thick_Light = listOf(
BlendColorEntry(Color(0xFF9A9A9A), BlurBlendMode.PlusLighter),
)
val Info_Thick_Dark = listOf(
BlendColorEntry(Color(0xFF9A9A9A), BlurBlendMode.PlusDarker),
)
// Info Colored
val Info_Colored_Regular = listOf(
BlendColorEntry(Color(0xFF9C27B0), BlurBlendMode.ColorDodge),
BlendColorEntry(Color(0x0FFFFFFF), BlurBlendMode.PlusLighter),
)
// Colored Series
val Colored_Extra_Thin_Light = listOf(
BlendColorEntry(Color(0x7F040404), BlurBlendMode.Overlay),
BlendColorEntry(Color(0x26F1F1F1), BlurBlendMode.ColorDodge),
BlendColorEntry(Color(0x1AC8C8C8), BlurBlendMode.SrcOver),
)
val Colored_Extra_Thin_Dark = listOf(
BlendColorEntry(Color(0x6A4A4A4A), BlurBlendMode.ColorBurn),
BlendColorEntry(Color(0x2E525252), BlurBlendMode.SrcOver),
)
val Colored_Thin_Light = listOf(
BlendColorEntry(Color(0x991C1C1C), BlurBlendMode.Overlay),
BlendColorEntry(Color(0x802B2B2B), BlurBlendMode.SoftLight),
)
val Colored_Thin_Dark = listOf(
BlendColorEntry(Color(0x1A9C9C9C), BlurBlendMode.ColorBurn),
BlendColorEntry(Color(0x337A7A7A), BlurBlendMode.PlusDarker),
)
val Colored_Regular_Light = listOf(
BlendColorEntry(Color(0x803F3F3F), BlurBlendMode.Overlay),
BlendColorEntry(Color(0x1CE6E6E6), BlurBlendMode.PlusLighter),
)
val Colored_Regular_Dark = listOf(
BlendColorEntry(Color(0x70000000), BlurBlendMode.Overlay),
BlendColorEntry(Color(0x14000000), BlurBlendMode.SrcOver),
)
val Colored_Thick_Light = listOf(
BlendColorEntry(Color(0xE6BDBDBD), BlurBlendMode.Overlay),
BlendColorEntry(Color(0x992B2B2B), BlurBlendMode.ColorDodge),
BlendColorEntry(Color(0x339C9C9C), BlurBlendMode.SrcOver),
)
val Colored_Thick_Dark = listOf(
BlendColorEntry(Color(0x667A7A7A), BlurBlendMode.ColorBurn),
BlendColorEntry(Color(0x33747474), BlurBlendMode.Overlay),
BlendColorEntry(Color(0x322B2B2B), BlurBlendMode.SrcOver),
)
val Colored_Extra_Thick_Light = listOf(
BlendColorEntry(Color(0x4DA9A9A9), BlurBlendMode.PlusLighter),
BlendColorEntry(Color(0x6BC0C0C0), BlurBlendMode.ColorDodge),
)
val Colored_Extra_Thick_Dark = listOf(
BlendColorEntry(Color(0x667A7A7A), BlurBlendMode.PlusDarker),
BlendColorEntry(Color(0x619C9C9C), BlurBlendMode.ColorBurn),
)
// Pured Series
val Pured_Extra_Thin_Light = listOf(
BlendColorEntry(Color(0x7F040404), BlurBlendMode.ColorBurn),
BlendColorEntry(Color(0x5EFFFFFF), BlurBlendMode.PlusLighter),
BlendColorEntry(Color(0x24FF2424), BlurBlendMode.SrcOver),
)
val Pured_Extra_Thin_Dark = listOf(
BlendColorEntry(Color(0xE6E6E6E6), BlurBlendMode.Overlay),
BlendColorEntry(Color(0x999C9C9C), BlurBlendMode.SrcOver),
)
val Pured_Thin_Light = listOf(
BlendColorEntry(Color(0x307A7A7A), BlurBlendMode.PlusLighter),
BlendColorEntry(Color(0x5EFFFFFF), BlurBlendMode.PlusLighter),
BlendColorEntry(Color(0x66FF6666), BlurBlendMode.SrcOver),
)
val Pured_Thin_Dark = listOf(
BlendColorEntry(Color(0x969C9C9C), BlurBlendMode.PlusDarker),
BlendColorEntry(Color(0x66000000), BlurBlendMode.SrcOver),
)
val Pured_Regular_Light = listOf(
BlendColorEntry(Color(0x340034F9), BlurBlendMode.Overlay),
BlendColorEntry(Color(0xB3FFFFFF), BlurBlendMode.HardLight),
)
val Pured_Regular_Dark = listOf(
BlendColorEntry(Color(0x75000000), BlurBlendMode.ColorBurn),
BlendColorEntry(Color(0x52000000), BlurBlendMode.SrcOver),
)
val Pured_Thick_Light = listOf(
BlendColorEntry(Color(0x4D000000), BlurBlendMode.Overlay),
BlendColorEntry(Color(0x80000000), BlurBlendMode.SrcOver),
)
val Pured_Thick_Dark = listOf(
BlendColorEntry(Color(0x4C000000), BlurBlendMode.ColorBurn),
BlendColorEntry(Color(0x80030303), BlurBlendMode.SrcOver),
)
val Pured_Extra_Thick_Light = listOf(
BlendColorEntry(Color(0x66FF6666), BlurBlendMode.PlusLighter),
BlendColorEntry(Color(0x999C9C9C), BlurBlendMode.SrcOver),
)
val Pured_Extra_Thick_Dark = listOf(
BlendColorEntry(Color(0x999C9C9C), BlurBlendMode.Luminosity),
BlendColorEntry(Color(0x54525252), BlurBlendMode.PlusLighter),
)
// Overlay Series
val Overlay_Extra_Thin_Light = listOf(
BlendColorEntry(Color(0x0F7A7A7A), BlurBlendMode.Luminosity),
)
val Overlay_Extra_Thin_Dark = listOf(
BlendColorEntry(Color(0x757A7A7A), BlurBlendMode.Luminosity),
)
val Overlay_Thin_Light = listOf(
BlendColorEntry(Color(0x4DA9A9A9), BlurBlendMode.Luminosity),
BlendColorEntry(Color(0x1A9C9C9C), BlurBlendMode.PlusDarker),
)
val Overlay_Regular_Light = listOf(
BlendColorEntry(Color(0x4DA9A9A9), BlurBlendMode.Luminosity),
BlendColorEntry(Color(0x1A2B2B2B), BlurBlendMode.PlusDarker),
)
val Overlay_Thick_Light = listOf(
BlendColorEntry(Color(0xA8A8A8A8), BlurBlendMode.Luminosity),
BlendColorEntry(Color(0xFF9A9A9A), BlurBlendMode.Overlay),
)
val Overlay_Thick_Dark = listOf(
BlendColorEntry(Color(0x66A8A8A8), BlurBlendMode.Luminosity),
BlendColorEntry(Color(0x999C9C9C), BlurBlendMode.PlusDarker),
)
val Overlay_Extra_Thick_Light = listOf(
BlendColorEntry(Color(0x99A8A8A8), BlurBlendMode.Luminosity),
BlendColorEntry(Color(0x4C000000), BlurBlendMode.ColorBurn),
)
// Deprecated
val ExtraHeavy_Light = listOf(
BlendColorEntry(Color(0x8F040404), BlurBlendMode.ColorDodge),
BlendColorEntry(Color(0xA3A3A3A3), BlurBlendMode.SrcOver),
)
val ExtraHeavy_Dark = listOf(
BlendColorEntry(Color(0x757A7A7A), BlurBlendMode.ColorBurn),
BlendColorEntry(Color(0x88888888), BlurBlendMode.SrcOver),
BlendColorEntry(Color(0x0B000000), BlurBlendMode.SrcOver),
)
val Heavy_Light = listOf(
BlendColorEntry(Color(0x949C9C9C), BlurBlendMode.ColorDodge),
BlendColorEntry(Color(0x999C9C9C), BlurBlendMode.SrcOver),
)
val Heavy_Dark = listOf(
BlendColorEntry(Color(0x7F040404), BlurBlendMode.ColorBurn),
BlendColorEntry(Color(0xB3B3B3B3), BlurBlendMode.SrcOver),
)
}
@@ -0,0 +1,6 @@
package io.legado.app.ui.widget.components.effect
enum class DeviceType {
PHONE,
PAD,
}
@@ -0,0 +1,88 @@
package io.legado.app.ui.widget.components.effect
const val OS3_BG_FRAG = """
uniform vec2 uResolution;
uniform float uAnimTime;
uniform vec4 uBound;
uniform float uTranslateY;
uniform vec3 uPoints[4];
uniform vec2 uPointsAnim[4];
uniform vec4 uColors[4];
uniform float uAlphaMulti;
uniform float uNoiseScale;
uniform float uPointRadiusMulti;
uniform float uSaturateOffset;
uniform float uLightOffset;
vec3 rgb2hsv(vec3 c) {
vec4 K = vec4(0.0, -1.0 / 3.0, 2.0 / 3.0, -1.0);
vec4 p = mix(vec4(c.bg, K.wz), vec4(c.gb, K.xy), step(c.b, c.g));
vec4 q = mix(vec4(p.xyw, c.r), vec4(c.r, p.yzx), step(p.x, c.r));
float d = q.x - min(q.w, q.y);
float e = 1.0e-10;
return vec3(abs(q.z + (q.w - q.y) / (6.0 * d + e)), d / (q.x + e), q.x);
}
vec3 hsv2rgb(vec3 c) {
vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0);
vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
}
float hash(vec2 p) {
vec3 p3 = fract(vec3(p.xyx) * 0.13);
p3 += dot(p3, p3.yzx + 3.333);
return fract((p3.x + p3.y) * p3.z);
}
float perlin(vec2 x) {
vec2 i = floor(x); vec2 f = fract(x);
float a = hash(i); float b = hash(i + vec2(1.0, 0.0));
float c = hash(i + vec2(0.0, 1.0)); float d = hash(i + vec2(1.0, 1.0));
vec2 u = f * f * (3.0 - 2.0 * f);
return mix(a, b, u.x) + (c - a) * u.y * (1.0 - u.x) + (d - b) * u.x * u.y;
}
float gradientNoise(in vec2 uv) {
return fract(52.9829189 * fract(dot(uv, vec2(0.06711056, 0.00583715))));
}
vec4 main(vec2 fragCoord){
vec2 vUv = fragCoord/uResolution;
vUv.y = 1.0-vUv.y;
vec2 uv = vUv;
uv -= vec2(0., uTranslateY);
uv.xy -= uBound.xy;
uv.xy /= uBound.zw;
vec4 color = vec4(0.0);
float noiseValue = perlin(vUv * uNoiseScale + vec2(-uAnimTime, -uAnimTime));
for (int i = 0; i < 4; i++){
vec4 pointColor = uColors[i];
pointColor.rgb *= pointColor.a;
vec2 point = uPointsAnim[i];
float rad = uPoints[i].z * uPointRadiusMulti;
float d = distance(uv, point);
float pct = smoothstep(rad, 0., d);
color.rgb = mix(color.rgb, pointColor.rgb, pct);
color.a = mix(color.a, pointColor.a, pct);
}
float oppositeNoise = smoothstep(0., 1., noiseValue);
color.rgb /= color.a;
vec3 hsv = rgb2hsv(color.rgb);
hsv.y = mix(hsv.y, 0.0, oppositeNoise * uSaturateOffset);
color.rgb = hsv2rgb(hsv);
color.rgb += oppositeNoise * uLightOffset;
color.a = clamp(color.a, 0., 1.);
color.a *= uAlphaMulti;
color += (10.0 / 255.0) * gradientNoise(fragCoord.xy) - (5.0 / 255.0);
return vec4(color.rgb * color.a, color.a);
}
"""
@@ -26,7 +26,6 @@ import androidx.compose.ui.graphics.vector.ImageVector
import io.legado.app.ui.main.MainDestination
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.ThemeResolver
import okhttp3.internal.http2.Settings
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.basic.Search
import top.yukonga.miuix.kmp.icon.extended.Album
@@ -102,25 +101,25 @@ object AppIcons {
fun mainDestination(destination: MainDestination, selected: Boolean): ImageVector {
return when (destination) {
MainDestination.Bookshelf -> if (isMiuix) {
if (selected) MiuixIcons.Heavy.ContactsBook else MiuixIcons.Regular.ContactsBook
if (selected) MiuixIcons.Regular.ContactsBook else MiuixIcons.Regular.ContactsBook
} else {
if (selected) Icons.AutoMirrored.Filled.LibraryBooks else Icons.AutoMirrored.Outlined.LibraryBooks
}
MainDestination.Explore -> if (isMiuix) {
if (selected) MiuixIcons.Heavy.Album else MiuixIcons.Regular.Album
if (selected) MiuixIcons.Regular.Album else MiuixIcons.Regular.Album
} else {
if (selected) Icons.Default.Explore else Icons.Outlined.Explore
}
MainDestination.Rss -> if (isMiuix) {
if (selected) MiuixIcons.Heavy.Favorites else MiuixIcons.Regular.Favorites
if (selected) MiuixIcons.Regular.Favorites else MiuixIcons.Regular.Favorites
} else {
if (selected) Icons.Default.RssFeed else Icons.Outlined.RssFeed
}
MainDestination.My -> if (isMiuix) {
if (selected) MiuixIcons.Heavy.Settings else MiuixIcons.Regular.Settings
if (selected) MiuixIcons.Regular.Settings else MiuixIcons.Regular.Settings
} else {
if (selected) Icons.Default.Person else Icons.Outlined.Person
}
@@ -1,36 +1,29 @@
package io.legado.app.ui.about
package io.legado.app.ui.widget.components.log
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DeleteSweep
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.legado.app.R
import io.legado.app.constant.AppLog
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.widget.components.alert.AppAlertDialog
import io.legado.app.ui.widget.components.EmptyMessage
import io.legado.app.ui.widget.components.button.MediumIconButton
import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
import io.legado.app.ui.widget.components.text.AppText
import io.legado.app.utils.LogUtils
@@ -47,62 +40,41 @@ fun AppLogSheet(
AppModalBottomSheet(
show = show,
onDismissRequest = onDismissRequest
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
AppText(
text = stringResource(R.string.log),
style = LegadoTheme.typography.titleLarge,
modifier = Modifier.weight(1f)
onDismissRequest = onDismissRequest,
title = stringResource(R.string.log),
endAction = {
MediumIconButton(
onClick = {
AppLog.clear()
logs = emptyList()
},
imageVector = Icons.Default.DeleteSweep
)
IconButton(onClick = {
AppLog.clear()
logs = emptyList()
}) {
Icon(Icons.Default.DeleteSweep, contentDescription = "Clear")
}
}
LazyColumn(
modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(bottom = 16.dp)
) {
items(logs) { item ->
LogItem(item) {
item.third?.let {
showStackTrace = it.stackTraceToString()
) {
if (logs.isEmpty()) {
EmptyMessage(message = "暂无日志")
} else {
LazyColumn(
modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(bottom = 16.dp)
) {
items(logs) { item ->
LogItem(item) {
item.third?.let {
showStackTrace = it.stackTraceToString()
}
}
}
}
}
}
AppAlertDialog(
data = showStackTrace,
onDismissRequest = { showStackTrace = null },
LogDetailSheet(
show = showStackTrace != null,
title = "Log",
content = { stackTrace ->
SelectionContainer {
Column(
modifier = Modifier
.heightIn(max = 400.dp)
.verticalScroll(rememberScrollState())
) {
AppText(
text = stackTrace,
style = LegadoTheme.typography.bodySmall
)
}
}
},
confirmText = stringResource(android.R.string.ok),
onConfirm = { showStackTrace = null }
content = showStackTrace.orEmpty(),
onDismissRequest = { showStackTrace = null }
)
}
@@ -0,0 +1,64 @@
package io.legado.app.ui.widget.components.log
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DeleteSweep
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.legado.app.R
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.widget.components.EmptyMessage
import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
import io.legado.app.ui.widget.components.text.AppText
import io.legado.app.utils.FileDoc
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CrashLogSheet(
show: Boolean,
logFiles: List<FileDoc>,
onDismissRequest: () -> Unit,
onReadFile: (FileDoc) -> Unit,
onClear: () -> Unit,
) {
AppModalBottomSheet(
show = show,
onDismissRequest = onDismissRequest,
endAction = {
IconButton(onClick = onClear) {
Icon(Icons.Default.DeleteSweep, contentDescription = "Clear")
}
},
title = stringResource(R.string.crash_log),
) {
if (logFiles.isEmpty()) {
EmptyMessage(message = "暂无崩溃日志")
} else {
LazyColumn(
modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(bottom = 16.dp)
) {
items(logFiles) { fileDoc ->
AppText(
text = fileDoc.name,
style = LegadoTheme.typography.bodyMedium,
modifier = Modifier
.fillMaxWidth()
.clickable { onReadFile(fileDoc) }
.padding(16.dp)
)
}
}
}
}
}
@@ -0,0 +1,44 @@
package io.legado.app.ui.widget.components.log
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.widget.components.alert.AppAlertDialog
import io.legado.app.ui.widget.components.text.AppText
@Composable
fun LogDetailSheet(
show: Boolean,
title: String = "Log",
content: String,
onDismissRequest: () -> Unit,
) {
AppAlertDialog(
show = show,
onDismissRequest = onDismissRequest,
title = title,
content = {
SelectionContainer {
Column(
modifier = Modifier
.heightIn(max = 400.dp)
.verticalScroll(rememberScrollState())
) {
AppText(
text = content,
style = LegadoTheme.typography.bodySmall
)
}
}
},
confirmText = stringResource(android.R.string.ok),
onConfirm = onDismissRequest
)
}
-330
View File
@@ -1,330 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context="io.legado.app.ui.about.AboutActivity">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/app_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@android:color/transparent"
android:fitsSystemWindows="true">
<com.google.android.material.appbar.CollapsingToolbarLayout
style="?attr/collapsingToolbarLayoutMediumStyle"
android:layout_width="match_parent"
android:layout_height="?attr/collapsingToolbarLayoutLargeSize"
app:collapsedTitleTextAppearance="?attr/textAppearanceTitleLarge"
app:expandedTitleTextAppearance="?attr/textAppearanceHeadlineLarge"
app:layout_scrollFlags="scroll|exitUntilCollapsed|snap"
app:titleCollapseMode="scale">
<com.google.android.material.appbar.MaterialToolbar
android:theme="@style/ThemeOverlay.Material3.Toolbar.CircleNavigationButton"
android:id="@+id/top_bar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
app:layout_collapseMode="pin"
app:navigationIcon="@drawable/ic_arrow_back"
app:title="@string/about" />
</com.google.android.material.appbar.CollapsingToolbarLayout>
</com.google.android.material.appbar.AppBarLayout>
<androidx.core.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginBottom="60dp"
android:orientation="vertical"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<ImageView
android:id="@+id/cd_about"
android:layout_width="160dp"
android:layout_height="120dp"
android:scaleType="centerCrop"
app:layout_constraintStart_toStartOf="parent"
android:src="@drawable/ic_launcher_foreground"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<TextView
android:id="@+id/tv_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:text="@string/app_name"
android:layout_marginEnd="16dp"
android:textSize="24sp"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/cd_about" />
<com.google.android.material.card.MaterialCardView
android:id="@+id/cd_ver"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_marginTop="8dp"
app:cardBackgroundColor="?attr/colorTertiaryContainer"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/tv_name"
app:strokeWidth="0dp">
<TextView
android:id="@+id/tv_version"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingHorizontal="8dp"
android:paddingVertical="4dp"
android:text="@string/text"
android:textColor="?attr/colorOnTertiaryContainer"
android:textSize="10sp"
android:textStyle="bold"
tools:ignore="SmallSp" />
</com.google.android.material.card.MaterialCardView>
<TextView
android:id="@+id/tv_detail"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/about_description"
android:textSize="14sp"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/cd_ver" />
<com.google.android.material.button.MaterialButtonGroup
android:id="@+id/ll_btn"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/tv_detail">
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_web"
style="@style/Widget.Material3Expressive.Button.IconButton.Tonal"
android:layout_width="wrap_content"
android:layout_height="match_parent"
app:icon="@drawable/ic_web_outline" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_github"
style="@style/Widget.Material3Expressive.Button.IconButton.Tonal"
android:layout_width="wrap_content"
android:layout_height="match_parent"
app:icon="@drawable/ic_github" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_update"
style="@style/Widget.Material3Expressive.Button.IconButton.Tonal"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:icon="@drawable/ic_import" />
</com.google.android.material.button.MaterialButtonGroup>
<LinearLayout
android:id="@+id/ll_contributors"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:minHeight="60dp"
android:orientation="vertical"
android:paddingHorizontal="16dp"
android:paddingVertical="16dp"
app:layout_constraintTop_toBottomOf="@id/ll_btn">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:singleLine="true"
android:text="@string/contributors"
android:textSize="16sp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:text="@string/contributors_summary"
android:textSize="13sp" />
</LinearLayout>
<LinearLayout
android:id="@+id/ll_update_log"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:minHeight="60dp"
android:orientation="vertical"
android:paddingHorizontal="16dp"
android:paddingVertical="16dp"
app:layout_constraintTop_toBottomOf="@id/ll_contributors">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:singleLine="true"
android:text="@string/update_log"
android:textSize="16sp" />
</LinearLayout>
<LinearLayout
android:id="@+id/ll_privacy_policy"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:minHeight="60dp"
android:orientation="vertical"
android:paddingHorizontal="16dp"
android:paddingVertical="16dp"
app:layout_constraintTop_toBottomOf="@id/ll_update_log">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:singleLine="true"
android:text="@string/privacy_policy"
android:textSize="16sp" />
</LinearLayout>
<LinearLayout
android:id="@+id/ll_license"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:minHeight="60dp"
android:orientation="vertical"
android:paddingHorizontal="16dp"
android:paddingVertical="16dp"
app:layout_constraintTop_toBottomOf="@id/ll_privacy_policy">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:singleLine="true"
android:text="@string/license"
android:textSize="16sp" />
</LinearLayout>
<LinearLayout
android:id="@+id/ll_disclaimer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:minHeight="60dp"
android:orientation="vertical"
android:paddingHorizontal="16dp"
android:paddingVertical="16dp"
app:layout_constraintTop_toBottomOf="@id/ll_license">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:singleLine="true"
android:text="@string/disclaimer"
android:textSize="16sp" />
</LinearLayout>
<LinearLayout
android:id="@+id/ll_crash_log"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:minHeight="60dp"
android:orientation="vertical"
android:paddingHorizontal="16dp"
android:paddingVertical="16dp"
app:layout_constraintTop_toBottomOf="@id/ll_disclaimer">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:singleLine="true"
android:text="@string/crash_log"
android:textSize="16sp" />
</LinearLayout>
<LinearLayout
android:id="@+id/ll_save_log"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:minHeight="60dp"
android:orientation="vertical"
android:paddingHorizontal="16dp"
android:paddingVertical="16dp"
app:layout_constraintTop_toBottomOf="@id/ll_crash_log">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:singleLine="true"
android:text="@string/save_log"
android:textSize="16sp" />
</LinearLayout>
<LinearLayout
android:id="@+id/ll_create_heap_dump"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:minHeight="60dp"
android:orientation="vertical"
android:paddingHorizontal="16dp"
android:paddingVertical="16dp"
app:layout_constraintTop_toBottomOf="@id/ll_save_log">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:singleLine="true"
android:text="@string/create_heap_dump"
android:textSize="16sp" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.core.widget.NestedScrollView>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
-13
View File
@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/menu_share_it"
android:title="@string/share"
android:icon="@drawable/ic_share"
app:showAsAction="always"
tools:ignore="AlwaysShowAction" />
</menu>
+3 -5
View File
@@ -45,7 +45,7 @@ coroutines = "1.10.2"
lifecycleViewmodelCompose = "2.10.0"
liveeventbus = "1.8.14"
navigation3 = "1.1.1"
markdownCompose = "0.5.0"
markdownRenderer = "0.40.2"
markwon = "4.6.2"
material = "1.14.0-rc01"
material3 = "1.5.0-alpha19"
@@ -85,7 +85,7 @@ biometricKtx = "1.1.0"
reorderable = "3.1.0"
haze = "1.7.2"
timber = "5.0.1"
miuix = "0.9.0"
miuix = "0.9.1"
backdrop = "1.0.6"
capsule = "2.1.3"
lyricViewx = "1.3.2"
@@ -134,8 +134,6 @@ androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayo
haze-core = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" }
haze-materials = { module = "dev.chrisbanes.haze:haze-materials", version.ref = "haze" }
kmp-miuix-preference = { module = "top.yukonga.miuix.kmp:miuix-preference" }
kmp-miuix-ui = { module = "top.yukonga.miuix.kmp:miuix-ui" }
koin-bom = { module = "io.insert-koin:koin-bom", version.ref = "koin-bom" }
koin-core = { module = "io.insert-koin:koin-core" }
koin-android = { group = "io.insert-koin", name = "koin-android" }
@@ -170,7 +168,7 @@ libarchive = { module = "me.zhanghai.android.libarchive:library", version.ref =
lifecycle-common-java8 = { module = "androidx.lifecycle:lifecycle-common-java8", version.ref = "lifecycle" }
lifecycle-service = { module = "androidx.lifecycle:lifecycle-service", version.ref = "lifecycle" }
markdown-compose = { module = "com.mikepenz:markdown-compose", version.ref = "markdownCompose" }
markdown-renderer-m3 = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "markdownRenderer" }
material-kolor = { module = "com.materialkolor:material-kolor", version.ref = "materialKolor" }
media-media = { module = "androidx.media:media", version.ref = "media" }
media3-datasource-okhttp = { module = "androidx.media3:media3-datasource-okhttp", version.ref = "media3" }