为MIUIX主题添加了专属的关于界面
This commit is contained in:
@@ -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),
|
||||