Procházet zdrojové kódy

初步用Compose重构关于界面
增加了以后会使用的一些自定义Compose控件
尝试让机器人自动发布预发布版本
修复透明模式下搜索框"删除所有"文字不明显 #354
忙忙忙咩咩咩咩

HapeLee před 11 měsíci
rodič
revize
58893de5c2

+ 152 - 58
.github/workflows/test.yml

@@ -33,23 +33,67 @@ jobs:
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
     if: ${{ !startsWith(github.event.head_commit.message, 'Merge pull request') }}
     if: ${{ !startsWith(github.event.head_commit.message, 'Merge pull request') }}
     outputs:
     outputs:
-      version: ${{ steps.set-ver.outputs.version }}
-      versionL: ${{ steps.set-ver.outputs.versionL }}
-      lanzou: ${{ steps.check.outputs.lanzou }}
-      telegram: ${{ steps.check.outputs.telegram }}
+      # versionL: 完整版本号,例如 3.26.3-beta.1
+      versionL: ${{ steps.set-ver.outputs.full_version_name }}
+      # version: 基础版本号,例如 3.26.3
+      version: ${{ steps.set-ver.outputs.current_version }}
+      # build_number: 增量构建号,例如 1
+      build_number: ${{ steps.set-ver.outputs.build_number }}
+      # latest_tag: 最近一个用于版本递增的 tag,例如 v3.26.2-beta.5
+      latest_tag: ${{ steps.set-ver.outputs.last_tag }}
     steps:
     steps:
-      - id: set-ver
-        run: |
-          echo "version=$(date -d "8 hour" -u +3.%y.%m%d%H)" >> $GITHUB_OUTPUT
-          echo "versionL=$(date -d "8 hour" -u +3.%y.%m%d%H%M)" >> $GITHUB_OUTPUT
-      - id: check
+      - uses: actions/checkout@v4
+        with:
+          # 确保拉取所有标签,用于版本号计算
+          fetch-depth: 0
+
+      - name: 计算版本号和构建号
+        id: set-ver
         run: |
         run: |
-          if [ ${{ secrets.LANZOU_ID }} ]; then
-            echo "lanzou=yes" >> $GITHUB_OUTPUT
-          fi
-          if [ ${{ secrets.BOT_TOKEN }} ]; then
-            echo "telegram=yes" >> $GITHUB_OUTPUT
+          # 1. 读取 version.properties 文件获取基础版本号
+          MAJOR=$(grep 'VERSION_MAJOR' version.properties | cut -d'=' -f2)
+          MINOR=$(grep 'VERSION_MINOR' version.properties | cut -d'=' -f2)
+          PATCH=$(grep 'VERSION_PATCH' version.properties | cut -d'=' -f2)
+          CURRENT_VERSION="$MAJOR.$MINOR.$PATCH"
+          
+          # 2. 尝试获取最近一次成功的构建号 (beta build number)
+          # 查找最近的、格式为 vX.Y.Z-beta.N 的标签,且 X.Y.Z 部分与当前版本匹配
+          # 只有在推送到 main 分支时才进行自增计算
+          if [[ "${{ github.event_name }}" == "push" && "${{ github.ref }}" == "refs/heads/main" ]]; then
+              LAST_TAG=$(git tag --list "v*.*.*-beta.*" --sort=-committerdate | head -n 1)
+          
+              # 查找匹配当前 CURRENT_VERSION 的上一个 TAG
+              LAST_MATCHING_TAG=$(git tag --list "v$CURRENT_VERSION-beta.*" --sort=-committerdate | head -n 1)
+          
+              if [[ $LAST_MATCHING_TAG =~ v[0-9]+\.[0-9]+\.[0-9]+-beta\.([0-9]+) ]]; then
+                # 如果找到匹配当前 PATCH 版本的 beta 标签,则递增
+                LAST_BUILD_NUM="${BASH_REMATCH[1]}"
+                NEW_BUILD_NUM=$((LAST_BUILD_NUM + 1))
+                LAST_TAG_FOR_LOG="$LAST_MATCHING_TAG"
+                echo "版本 $CURRENT_VERSION 未变更,构建号自增:$LAST_BUILD_NUM -> $NEW_BUILD_NUM"
+              else
+                # 如果找不到匹配的标签 (可能是 PATCH 刚修改,或首次构建),则重置为 1
+                NEW_BUILD_NUM=1
+                # 如果是 PATCH 首次构建,则使用仓库中最近的那个 tag 作为日志起点
+                LAST_TAG_FOR_LOG="$LAST_TAG"
+                echo "版本 $CURRENT_VERSION 为新版本或首次构建,构建号重置为 $NEW_BUILD_NUM"
+              fi
+          else
+              # 对于 PR 或其他分支,使用一个固定的构建号 0
+              NEW_BUILD_NUM=0
+              LAST_TAG_FOR_LOG=$(git tag --sort=-committerdate | head -n 1)
+              echo "非 main 分支推送,构建号设置为 $NEW_BUILD_NUM"
           fi
           fi
+          
+          # 3. 构造最终版本信息
+          VERSION_SUFFIX_NAME="-beta.$NEW_BUILD_NUM"
+          FULL_VERSION_NAME="${CURRENT_VERSION}${VERSION_SUFFIX_NAME}"
+          
+          # 4. 设置输出供后续作业使用
+          echo "current_version=$CURRENT_VERSION" >> $GITHUB_OUTPUT
+          echo "full_version_name=$FULL_VERSION_NAME" >> $GITHUB_OUTPUT
+          echo "build_number=$NEW_BUILD_NUM" >> $GITHUB_OUTPUT
+          echo "last_tag=$LAST_TAG_FOR_LOG" >> $GITHUB_OUTPUT
 
 
   build:
   build:
     needs: prepare
     needs: prepare
@@ -62,8 +106,10 @@ jobs:
     env:
     env:
       product: ${{ matrix.product }}
       product: ${{ matrix.product }}
       type: ${{ matrix.type }}
       type: ${{ matrix.type }}
+      # 使用 prepare job 的输出
       VERSION: ${{ needs.prepare.outputs.version }}
       VERSION: ${{ needs.prepare.outputs.version }}
       VERSIONL: ${{ needs.prepare.outputs.versionL }}
       VERSIONL: ${{ needs.prepare.outputs.versionL }}
+      BUILD_NUMBER: ${{ needs.prepare.outputs.build_number }}
     steps:
     steps:
       - uses: actions/checkout@v5
       - uses: actions/checkout@v5
         with:
         with:
@@ -88,12 +134,24 @@ jobs:
       - name: Set up Gradle
       - name: Set up Gradle
         uses: gradle/actions/setup-gradle@v4
         uses: gradle/actions/setup-gradle@v4
 
 
+      - name: 统一版本号
+        run: |
+          # 1. 临时修改 version.properties 中的 VERSION_SUFFIX,供 build.gradle 读取 versionName
+          echo "VERSION_SUFFIX=-beta.${{ env.BUILD_NUMBER }}" >> $GITHUB_WORKSPACE/version.properties
+          
+          # 2. 覆盖 build.gradle 中复杂的 versionCode 计算逻辑
+          # 将 versionCode 设为 10000 + BUILD_NUMBER,忽略 gitCommits
+          # 替换 app/build.gradle 中 versionCode 所在行
+          sed -i "s/versionCode 10000 + gitCommits/versionCode 10000 + ${{ env.BUILD_NUMBER }}/" $GITHUB_WORKSPACE/app/build.gradle
+          
+          echo "versionName 已设置为 ${{ env.VERSIONL }}"
+          echo "versionCode 已设置为 10000 + ${{ env.BUILD_NUMBER }}"
+
       - name: Build With Gradle
       - name: Build With Gradle
         run: |
         run: |
-          echo "统一版本号"
-          sed -i "s/ext.versionName = .*/ext.versionName = \"${{ env.VERSION }}\"/" $GITHUB_WORKSPACE/app/build.gradle
           echo "开始${{ env.product }}${{ env.type }}构建"
           echo "开始${{ env.product }}${{ env.type }}构建"
           chmod +x gradlew
           chmod +x gradlew
+          # Gradle 现在会读取修改后的 version.properties 来获取 versionName
           ./gradlew assemble${{ env.product }}Release
           ./gradlew assemble${{ env.product }}Release
 
 
       - name: Move Missing Rules Files
       - name: Move Missing Rules Files
@@ -145,31 +203,6 @@ jobs:
           if-no-files-found: ignore
           if-no-files-found: ignore
           path: ${{ github.workspace }}/mapping/mapping.txt
           path: ${{ github.workspace }}/mapping/mapping.txt
 
 
-  lanzou:
-    needs: [ prepare, build ]
-    if: ${{ github.event_name != 'pull_request' && needs.prepare.outputs.lanzou == 'yes' }}
-    runs-on: ubuntu-latest
-    env:
-      # 登录蓝奏云后在控制台运行document.cookie
-      ylogin: ${{ secrets.LANZOU_ID }}
-      phpdisk_info: ${{ secrets.LANZOU_PSD }}
-      # 蓝奏云里的文件夹ID(阅读3测试版:2670621)
-      LANZOU_FOLDER_ID: ${{ secrets.LANZOU_FOLDER_ID }}
-      #蓝奏云分享链接
-      LANZOU_URL: ${{ secrets.LANZOU_URL }}
-    steps:
-      - uses: actions/checkout@v5
-      - uses: actions/download-artifact@v5
-        with:
-          path: apk/
-      - working-directory: apk/
-        run: mv */*.apk . ;rm -rf */
-      - name: Upload To Lanzou
-        continue-on-error: true
-        run: |
-          path="$GITHUB_WORKSPACE/apk/"
-          python3 $GITHUB_WORKSPACE/.github/scripts/lzy_web.py "$path" "$LANZOU_FOLDER_ID"
-          echo "[$(date -u -d '+8 hour' '+%Y.%m.%d %H:%M:%S')] 分享链接: $LANZOU_URL"
 
 
   test_Branch:
   test_Branch:
     needs: [ prepare, build ]
     needs: [ prepare, build ]
@@ -194,26 +227,87 @@ jobs:
           git commit -m "${{ needs.prepare.outputs.versionL }}"
           git commit -m "${{ needs.prepare.outputs.versionL }}"
           git push -f -u origin test
           git push -f -u origin test
 
 
-  telegram:
+  create_pre_release:
     needs: [ prepare, build ]
     needs: [ prepare, build ]
-    if: ${{ github.event_name != 'pull_request' && needs.prepare.outputs.telegram == 'yes' }}
+    if: |
+      github.event_name == 'push' && 
+      github.ref == 'refs/heads/main' && 
+      needs.prepare.outputs.build_number > 0 &&
+      success()
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
     steps:
     steps:
-      - uses: actions/checkout@v5
-      - uses: actions/download-artifact@v5
+      - uses: actions/checkout@v4
         with:
         with:
-          path: apk/
-      - working-directory: apk/
+          fetch-depth: 0
+
+      - name: 获取所有 Commit 消息作为 Release Body
+        id: get_commits
         run: |
         run: |
-          for file in `ls */*.apk`; do
-            mv "$file" "$(echo "$file"|sed -e 's#.*\/##g' -e "s/_/ /g" -e 's/legado/阅读/')"
-          done
-          rm -rf */
-      - name: Post to channel
-        uses: xireiki/channel-post@v1
+          # 获取从上一个 tag 到当前 commit 之间的所有 commit 消息
+          # 如果 latest_tag 为空,则从第一个 commit 开始获取
+          
+          LATEST_TAG="${{ needs.prepare.outputs.latest_tag }}"
+          
+          if [ -z "$LATEST_TAG" ]; then
+            # 如果没有找到任何 tag,则从第一个 commit 开始
+            COMMIT_LOG=$(git log --pretty=format:'* %s (%an)' --reverse)
+          else
+            # 从最近的 tag 到当前 HEAD
+            # 使用 ^$LATEST_TAG 来排除 LATEST_TAG 本身的 commit
+            COMMIT_LOG=$(git log $LATEST_TAG..HEAD --pretty=format:'* %s (%an)')
+          fi
+          
+          # 检查是否获取到日志
+          if [ -z "$COMMIT_LOG" ]; then
+            RELEASE_BODY="本次发布无新增 Commit。"
+          else
+            RELEASE_BODY="# 本次更新内容\n\n$COMMIT_LOG"
+          fi
+          
+          # 输出给下一步使用
+          echo "release_body<<EOF" >> $GITHUB_OUTPUT
+          echo "$RELEASE_BODY" >> $GITHUB_OUTPUT
+          echo "EOF" >> $GITHUB_OUTPUT
+
+      - name:
+          创建 Pre-Release Tag: v${{ needs.prepare.outputs.versionL }}
+        # 创建一个 Tag,供下一轮计算版本号时使用
+        run: |
+          git tag v${{ needs.prepare.outputs.versionL }}
+          git push origin v${{ needs.prepare.outputs.versionL }}
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+      - name: 创建 Pre-Release
+        id: create_release
+        uses: actions/create-release@v1
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+        with:
+          # Tag 名称:3.26.3-beta.1
+          tag_name: ${{ needs.prepare.outputs.versionL }}
+          # Release 标题:3.26.3-beta.1
+          release_name: ${{ needs.prepare.outputs.versionL }}
+          # Release 内容为所有 Commit 消息
+          body: ${{ steps.get_commits.outputs.release_body }}
+          draft: false
+          # 标记为预发布版本
+          prerelease: true
+
+      - name: 下载所有 APK Artifacts
+        uses: actions/download-artifact@v4
+        with:
+          pattern: '*-apk'
+          path: artifacts
+          merge-multiple: true
+
+      - name: 上传 Release Assets
+        uses: actions/upload-release-asset@v1
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
         with:
         with:
-          chat_id: ${{ secrets.CHANNEL_ID }}
-          bot_token: ${{ secrets.BOT_TOKEN }}
-          context: "#阅读 #Legado #Beta ${{ needs.prepare.outputs.versionL }}"
-          path: apk/*
-          method: sendFile
+          upload_url: ${{ steps.create_release.outputs.upload_url }}
+          asset_path: artifacts/*.apk
+          # 重新命名上传的 APK 文件,使其包含版本号
+          asset_name: legado-${{ needs.prepare.outputs.versionL }}.apk
+          asset_content_type: application/zip

+ 11 - 5
app/build.gradle

@@ -356,9 +356,6 @@ dependencies {
     // Activity
     // Activity
     implementation libs.androidx.activity.compose
     implementation libs.androidx.activity.compose
 
 
-    // Navigation(用于弹框或未来扩展)
-    implementation libs.navigation.compose
-
     // Coil
     // Coil
     implementation libs.coil.compose
     implementation libs.coil.compose
 
 
@@ -366,15 +363,24 @@ dependencies {
     implementation libs.accompanist.webview
     implementation libs.accompanist.webview
 
 
     // Markdown 渲染(Compose MD)
     // Markdown 渲染(Compose MD)
-    implementation libs.markdown.compose
+    // implementation libs.markdown.compose
 
 
     // 协程
     // 协程
     implementation libs.kotlinx.coroutines.android
     implementation libs.kotlinx.coroutines.android
-    implementation libs.androidx.lifecycle.viewmodel.compose
     implementation libs.androidx.lifecycle.runtime.compose
     implementation libs.androidx.lifecycle.runtime.compose
+    implementation libs.androidx.lifecycle.viewmodel.compose
     implementation libs.androidx.compose.animation
     implementation libs.androidx.compose.animation
     implementation libs.androidx.compose.foundation
     implementation libs.androidx.compose.foundation
     implementation libs.androidx.constraintlayout.compose
     implementation libs.androidx.constraintlayout.compose
     implementation libs.androidx.compose.ui.viewbinding
     implementation libs.androidx.compose.ui.viewbinding
+    implementation libs.androidx.navigation.compose
+    implementation libs.androidx.compose.material
+    implementation libs.compose.materialIcons
+
+    implementation(platform(libs.koin.bom))
+    implementation(libs.koin.core)
+    implementation(libs.koin.android)
+    implementation(libs.koin.compose)
+    implementation(libs.koin.compose.viewmodel)
 
 
 }
 }

+ 148 - 0
app/src/main/java/io/legado/app/base/BaseComposeActivity.kt

@@ -0,0 +1,148 @@
+package io.legado.app.base
+
+import android.graphics.BitmapFactory
+import android.os.Bundle
+import androidx.activity.compose.setContent
+import androidx.appcompat.app.AppCompatActivity
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.Composable
+import androidx.core.graphics.drawable.toDrawable
+import androidx.core.graphics.scale
+import androidx.core.view.WindowCompat
+import com.google.android.material.color.DynamicColors
+import com.google.android.material.color.DynamicColorsOptions
+import io.legado.app.R
+import io.legado.app.constant.EventBus
+import io.legado.app.constant.PreferKey
+import io.legado.app.constant.Theme
+import io.legado.app.help.config.AppConfig
+import io.legado.app.help.config.ThemeConfig
+import io.legado.app.lib.theme.primaryColor
+import io.legado.app.utils.disableAutoFill
+import io.legado.app.utils.fullScreen
+import io.legado.app.utils.getPrefString
+import io.legado.app.utils.observeEvent
+import io.legado.app.utils.setStatusBarColorAuto
+import io.legado.app.utils.themeColor
+import io.legado.app.utils.windowSize
+import java.io.File
+
+abstract class BaseComposeActivity(
+    val fullScreen: Boolean = true,
+    private val toolBarTheme: Theme = Theme.Auto,
+    private val transparent: Boolean = false,
+    private val imageBg: Boolean = true
+) : AppCompatActivity() {
+
+    @Composable
+    protected abstract fun Content()
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        initTheme()
+        window.decorView.disableAutoFill()
+        AppContextWrapper.applyLocaleAndFont(this)
+
+        super.onCreate(savedInstanceState)
+
+        // Compose 入口
+        setContent {
+            AppTheme {
+                Surface(color = MaterialTheme.colorScheme.background) {
+                    Content()
+                }
+            }
+        }
+
+        setupSystemBar()
+
+        if (imageBg) {
+            upBackgroundImage()
+        }
+
+        observeLiveBus()
+    }
+
+    open fun setupSystemBar() {
+        WindowCompat.setDecorFitsSystemWindows(window, false)
+
+        if (fullScreen) fullScreen()
+
+        setStatusBarColorAuto(
+            themeColor(com.google.android.material.R.attr.colorSurface),
+            true,
+            fullScreen
+        )
+    }
+
+    open fun upBackgroundImage() {
+        try {
+            ThemeConfig.getBgImage(this, windowManager.windowSize)?.let {
+                window.setBackgroundDrawable(it.toDrawable(resources))
+            }
+        } catch (_: Exception) {}
+    }
+
+    open fun observeLiveBus() {
+        observeEvent<String>(EventBus.RECREATE) {
+            recreate()
+        }
+    }
+
+    open fun initTheme() {
+        when (getPrefString("app_theme", "0")) {
+            "0" -> {
+                DynamicColors.applyToActivitiesIfAvailable(application)
+            }
+            "1" -> setTheme(R.style.Theme_Base_GR)
+            "2" -> setTheme(R.style.Theme_Base_Lemon)
+            "3" -> setTheme(R.style.Theme_Base_WH)
+            "4" -> setTheme(R.style.Theme_Base_Elink)
+            "5" -> setTheme(R.style.Theme_Base_Sora)
+            "6" -> setTheme(R.style.Theme_Base_August)
+            "7" -> setTheme(R.style.Theme_Base_Carlotta)
+            "8" -> setTheme(R.style.Theme_Base_Koharu)
+            "9" -> setTheme(R.style.Theme_Base_Yuuka)
+            "10" -> setTheme(R.style.Theme_Base_Phoebe)
+            "11" -> setTheme(R.style.Theme_Base_Mujika)
+            "12" -> {
+                if (AppConfig.customMode == "accent")
+                    setTheme(R.style.ThemeOverlay_WhiteBackground)
+
+                val colorImagePath = getPrefString(PreferKey.colorImage)
+                if (!colorImagePath.isNullOrBlank()) {
+                    val file = File(colorImagePath)
+                    if (file.exists()) {
+                        val bitmap = BitmapFactory.decodeFile(file.absolutePath)
+                        if (bitmap != null) {
+                            val colorAccuracy = true
+                            val targetWidth = if (colorAccuracy) (bitmap.width / 4).coerceAtMost(256) else 16
+                            val targetHeight = if (colorAccuracy) (bitmap.height / 4).coerceAtMost(256) else 16
+                            val scaledBitmap = bitmap.scale(targetWidth, targetHeight, false)
+
+                            val options = DynamicColorsOptions.Builder()
+                                .setContentBasedSource(scaledBitmap)
+                                .build()
+
+                            DynamicColors.applyToActivitiesIfAvailable(application, options)
+                            bitmap.recycle()
+                        }
+                    }
+                }else{
+                    DynamicColors.applyToActivitiesIfAvailable(
+                        application,
+                        DynamicColorsOptions.Builder()
+                            .setContentBasedSource(application.primaryColor)
+                            .build()
+                    )
+                }
+            }
+
+            "13" -> setTheme(R.style.AppTheme_Transparent)
+        }
+
+        if (AppConfig.pureBlack)
+            setTheme(R.style.ThemeOverlay_PureBlack)
+    }
+
+}

+ 37 - 0
app/src/main/java/io/legado/app/base/Theme.kt

@@ -0,0 +1,37 @@
+package io.legado.app.base
+
+import androidx.compose.material3.*
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.foundation.isSystemInDarkTheme
+
+@Composable
+fun AppTheme(
+    darkTheme: Boolean = isSystemInDarkTheme(),
+    dynamicColor: Boolean = true,
+    content: @Composable () -> Unit
+) {
+    val context = LocalContext.current
+
+    val colorScheme = when {
+        dynamicColor && android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S ->
+            if (darkTheme) dynamicDarkColorScheme(context)
+            else dynamicLightColorScheme(context)
+
+        darkTheme -> darkColorScheme()
+        else -> lightColorScheme()
+    }
+
+    MaterialTheme(
+        colorScheme = colorScheme,
+        typography = AppTypography,
+        shapes = AppShapes,
+        content = content
+    )
+}
+
+// Material3 Typography
+val AppTypography = Typography()
+
+// Material3 Shapes
+val AppShapes = Shapes()

+ 27 - 40
app/src/main/java/io/legado/app/ui/about/AboutActivity.kt

@@ -1,7 +1,5 @@
 package io.legado.app.ui.about
 package io.legado.app.ui.about
 
 
-//import io.legado.app.lib.theme.accentColor
-//import io.legado.app.lib.theme.filletBackground
 import android.os.Bundle
 import android.os.Bundle
 import android.view.Menu
 import android.view.Menu
 import android.view.MenuItem
 import android.view.MenuItem
@@ -9,21 +7,16 @@ import androidx.activity.compose.setContent
 import androidx.annotation.StringRes
 import androidx.annotation.StringRes
 import androidx.appcompat.app.AppCompatActivity
 import androidx.appcompat.app.AppCompatActivity
 import androidx.compose.material3.MaterialTheme
 import androidx.compose.material3.MaterialTheme
-import androidx.core.app.ComponentActivity
+import androidx.compose.runtime.Composable
 import androidx.core.net.toUri
 import androidx.core.net.toUri
 import androidx.lifecycle.lifecycleScope
 import androidx.lifecycle.lifecycleScope
-import io.legado.app.BuildConfig
 import io.legado.app.R
 import io.legado.app.R
-import io.legado.app.base.BaseActivity
-import io.legado.app.constant.AppConst.appInfo
+import io.legado.app.base.BaseComposeActivity
 import io.legado.app.constant.AppLog
 import io.legado.app.constant.AppLog
-import io.legado.app.databinding.ActivityAboutBinding
 import io.legado.app.help.CrashHandler
 import io.legado.app.help.CrashHandler
 import io.legado.app.help.config.AppConfig
 import io.legado.app.help.config.AppConfig
-import io.legado.app.help.config.LocalConfig
 import io.legado.app.help.coroutine.Coroutine
 import io.legado.app.help.coroutine.Coroutine
 import io.legado.app.help.update.AppUpdate
 import io.legado.app.help.update.AppUpdate
-import io.legado.app.help.update.AppUpdateGitHub
 import io.legado.app.ui.widget.dialog.TextDialog
 import io.legado.app.ui.widget.dialog.TextDialog
 import io.legado.app.ui.widget.dialog.WaitDialog
 import io.legado.app.ui.widget.dialog.WaitDialog
 import io.legado.app.utils.FileDoc
 import io.legado.app.utils.FileDoc
@@ -40,18 +33,24 @@ import io.legado.app.utils.openUrl
 import io.legado.app.utils.share
 import io.legado.app.utils.share
 import io.legado.app.utils.showDialogFragment
 import io.legado.app.utils.showDialogFragment
 import io.legado.app.utils.toastOnUi
 import io.legado.app.utils.toastOnUi
-import io.legado.app.utils.viewbindingdelegate.viewBinding
-import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.delay
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
 import splitties.init.appCtx
 import splitties.init.appCtx
 import java.io.File
 import java.io.File
-import kotlin.coroutines.resume
-import kotlin.coroutines.suspendCoroutine
 
 
 
 
-class AboutActivity : AppCompatActivity() {
+class AboutActivity : BaseComposeActivity() {
+
+    @Composable
+    override fun Content() {
+        AboutScreen(
+            onCheckUpdate = { checkUpdate() },
+            onOpenUrl = { openUrl(it) },
+            onShowMdFile = { title, file -> showMdFile(title, file) },
+            onSaveLog = { saveLog() },
+            onCreateHeapDump = { createHeapDump() },
+            onShowCrashLogs = { showDialogFragment<CrashLogsDialog>() },
+        )
+    }
 
 
     private val waitDialog by lazy {
     private val waitDialog by lazy {
         WaitDialog(this).setText(R.string.checking_update)
         WaitDialog(this).setText(R.string.checking_update)
@@ -60,18 +59,6 @@ class AboutActivity : AppCompatActivity() {
     override fun onCreate(savedInstanceState: Bundle?) {
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
         super.onCreate(savedInstanceState)
 
 
-        setContent {
-            MaterialTheme {
-                AboutScreen(
-                    onCheckUpdate = { checkUpdate() },
-                    onOpenUrl = { openUrl(it) },
-                    onShowMdFile = { title, file -> showMdFile(title, file) },
-                    onSaveLog = { saveLog() },
-                    onCreateHeapDump = { createHeapDump() },
-                    onShowCrashLogs = { showDialogFragment<CrashLogsDialog>() }
-                )
-            }
-        }
     }
     }
 
 
     override fun onCreateOptionsMenu(menu: Menu?): Boolean {
     override fun onCreateOptionsMenu(menu: Menu?): Boolean {
@@ -100,9 +87,9 @@ class AboutActivity : AppCompatActivity() {
     private fun checkUpdate() {
     private fun checkUpdate() {
         waitDialog.show()
         waitDialog.show()
         AppUpdate.gitHubUpdate?.run {
         AppUpdate.gitHubUpdate?.run {
-            kotlin.check(lifecycleScope)
+            check(lifecycleScope)
                 .onSuccess {
                 .onSuccess {
-                    showDialogFragment(UpdateDialog(it))
+                    showDialogFragment(UpdateDialog(it, UpdateDialog.Mode.UPDATE))
                 }.onError {
                 }.onError {
                     appCtx.toastOnUi("${getString(R.string.check_update)}\n${it.localizedMessage}")
                     appCtx.toastOnUi("${getString(R.string.check_update)}\n${it.localizedMessage}")
                 }.onFinally {
                 }.onFinally {
@@ -119,17 +106,17 @@ class AboutActivity : AppCompatActivity() {
     private fun saveLog() {
     private fun saveLog() {
         Coroutine.async {
         Coroutine.async {
             val backupPath = AppConfig.backupPath ?: let {
             val backupPath = AppConfig.backupPath ?: let {
-                appCtx.toastOnUi("未设置备份目录")
+                toastOnUi("未设置备份目录")
                 return@async
                 return@async
             }
             }
             if (!AppConfig.recordLog) {
             if (!AppConfig.recordLog) {
-                appCtx.toastOnUi("未开启日志记录,请去其他设置里打开记录日志")
+                toastOnUi("未开启日志记录,请去其他设置里打开记录日志")
                 delay(3000)
                 delay(3000)
             }
             }
             val doc = FileDoc.fromUri(backupPath.toUri(), true)
             val doc = FileDoc.fromUri(backupPath.toUri(), true)
             copyLogs(doc)
             copyLogs(doc)
             copyHeapDump(doc)
             copyHeapDump(doc)
-            appCtx.toastOnUi("已保存至备份目录")
+            toastOnUi("已保存至备份目录")
         }.onError {
         }.onError {
             AppLog.put("保存日志出错\n${it.localizedMessage}", it, true)
             AppLog.put("保存日志出错\n${it.localizedMessage}", it, true)
         }
         }
@@ -138,21 +125,21 @@ class AboutActivity : AppCompatActivity() {
     private fun createHeapDump() {
     private fun createHeapDump() {
         Coroutine.async {
         Coroutine.async {
             val backupPath = AppConfig.backupPath ?: let {
             val backupPath = AppConfig.backupPath ?: let {
-                appCtx.toastOnUi("未设置备份目录")
+                toastOnUi("未设置备份目录")
                 return@async
                 return@async
             }
             }
             if (!AppConfig.recordHeapDump) {
             if (!AppConfig.recordHeapDump) {
-                appCtx.toastOnUi("未开启堆转储记录,请去其他设置里打开记录堆转储")
+                toastOnUi("未开启堆转储记录,请去其他设置里打开记录堆转储")
                 delay(3000)
                 delay(3000)
             }
             }
-            appCtx.toastOnUi("开始创建堆转储")
+            toastOnUi("开始创建堆转储")
             System.gc()
             System.gc()
             CrashHandler.doHeapDump(true)
             CrashHandler.doHeapDump(true)
             val doc = FileDoc.fromUri(backupPath.toUri(), true)
             val doc = FileDoc.fromUri(backupPath.toUri(), true)
             if (!copyHeapDump(doc)) {
             if (!copyHeapDump(doc)) {
-                appCtx.toastOnUi("未找到堆转储文件")
+                toastOnUi("未找到堆转储文件")
             } else {
             } else {
-                appCtx.toastOnUi("已保存至备份目录")
+                toastOnUi("已保存至备份目录")
             }
             }
         }.onError {
         }.onError {
             AppLog.put("保存堆转储失败\n${it.localizedMessage}", it)
             AppLog.put("保存堆转储失败\n${it.localizedMessage}", it)
@@ -160,7 +147,7 @@ class AboutActivity : AppCompatActivity() {
     }
     }
 
 
     private fun copyLogs(doc: FileDoc) {
     private fun copyLogs(doc: FileDoc) {
-        val cacheDir = appCtx.externalCache
+        val cacheDir = externalCache
         val logFiles = File(cacheDir, "logs")
         val logFiles = File(cacheDir, "logs")
         val crashFiles = File(cacheDir, "crash")
         val crashFiles = File(cacheDir, "crash")
         val logcatFile = File(cacheDir, "logcat.txt")
         val logcatFile = File(cacheDir, "logcat.txt")
@@ -182,7 +169,7 @@ class AboutActivity : AppCompatActivity() {
     }
     }
 
 
     private fun copyHeapDump(doc: FileDoc): Boolean {
     private fun copyHeapDump(doc: FileDoc): Boolean {
-        val heapFile = FileDoc.fromFile(File(appCtx.externalCache, "heapDump")).list()
+        val heapFile = FileDoc.fromFile(File(externalCache, "heapDump")).list()
             ?.firstOrNull() ?: return false
             ?.firstOrNull() ?: return false
         doc.find("heapDump")?.delete()
         doc.find("heapDump")?.delete()
         val heapDumpDoc = doc.createFolderIfNotExist("heapDump")
         val heapDumpDoc = doc.createFolderIfNotExist("heapDump")

+ 209 - 57
app/src/main/java/io/legado/app/ui/about/AboutScreen.kt

@@ -1,47 +1,147 @@
 package io.legado.app.ui.about
 package io.legado.app.ui.about
 
 
+import android.content.res.Configuration
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.wrapContentWidth
 import androidx.compose.foundation.rememberScrollState
 import androidx.compose.foundation.rememberScrollState
 import androidx.compose.foundation.verticalScroll
 import androidx.compose.foundation.verticalScroll
-import androidx.compose.material3.Button
-import androidx.compose.material3.DividerDefaults
-import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.ButtonGroup
+import androidx.compose.material3.ButtonGroupDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.FilledTonalIconButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
 import androidx.compose.material3.MaterialTheme
 import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.MaterialTheme.colorScheme
 import androidx.compose.material3.Scaffold
 import androidx.compose.material3.Scaffold
 import androidx.compose.material3.Text
 import androidx.compose.material3.Text
 import androidx.compose.material3.TextButton
 import androidx.compose.material3.TextButton
 import androidx.compose.material3.TopAppBar
 import androidx.compose.material3.TopAppBar
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.Composable
-import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.ContentScale
 import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.tooling.preview.Preview
 import androidx.compose.ui.tooling.preview.Preview
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.dp
 import io.legado.app.R
 import io.legado.app.R
+import io.legado.app.base.AppTheme
 import io.legado.app.constant.AppConst.appInfo
 import io.legado.app.constant.AppConst.appInfo
+import io.legado.app.ui.widget.compose.SettingItem
+import io.legado.app.ui.widget.compose.SplicedColumnGroup
+import io.legado.app.ui.widget.compose.TextCard
 
 
+@Preview(showBackground = true, name = "Light Mode")
+@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES, name = "Dark Mode")
+@Composable
+fun PreviewAboutScreen() {
+    AppTheme {
+        AboutScreen(
+            versionName = "1.0",
+            onCheckUpdate = {},
+            onOpenUrl = {},
+            onShowMdFile = { _, _ -> },
+            onSaveLog = {},
+            onCreateHeapDump = {},
+            onShowCrashLogs = {}
+        )
+    }
+}
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun ButtonGroup(
+    onOpenUrl: (String) -> Unit,
+    onCheckUpdate: () -> Unit
+) {
+
+    ButtonGroup(
+        modifier = Modifier.fillMaxWidth(),
+        overflowIndicator = { menuState ->
+            ButtonGroupDefaults.OverflowIndicator(menuState = menuState)
+        },
+        verticalAlignment = Alignment.CenterVertically
+    ) {
+        clickableItem(
+            onClick = { onOpenUrl("https://example.com") },
+            weight = 1f,
+            icon = {
+                Icon(
+                    painter = painterResource(R.drawable.ic_web_outline),
+                    contentDescription = stringResource(R.string.back),
+                    tint = colorScheme.onSurface
+                )
+            },
+            label = ""
+        )
 
 
+        clickableItem(
+            onClick = { onOpenUrl("https://github.com/HapeLee/legado-with-MD3") },
+            weight = 1f,
+            icon = {
+                Icon(
+                    painter = painterResource(R.drawable.ic_github),
+                    contentDescription = stringResource(R.string.back),
+                    tint = colorScheme.onSurface
+                )
+            },
+            label = ""
+        )
+
+        clickableItem(
+            onClick = { onCheckUpdate() },
+            weight = 1f,
+            icon = {
+                Icon(
+                    painter = painterResource(R.drawable.ic_import),
+                    contentDescription = stringResource(R.string.back),
+                    tint = colorScheme.onSurface
+                )
+            },
+            label = ""
+        )
+    }
+}
+
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
 @Composable
 @Composable
 fun AboutScreen(
 fun AboutScreen(
+    versionName: String = appInfo.versionName,
+    onNavigateBack: () -> Unit = {},
     onCheckUpdate: () -> Unit = {},
     onCheckUpdate: () -> Unit = {},
     onOpenUrl: (String) -> Unit = {},
     onOpenUrl: (String) -> Unit = {},
     onShowMdFile: (String, String) -> Unit = { _, _ -> },
     onShowMdFile: (String, String) -> Unit = { _, _ -> },
     onSaveLog: () -> Unit = {},
     onSaveLog: () -> Unit = {},
     onCreateHeapDump: () -> Unit = {},
     onCreateHeapDump: () -> Unit = {},
-    onShowCrashLogs: () -> Unit = {},
-    versionName: String = "1.0.0"
+    onShowCrashLogs: () -> Unit = {}
 ) {
 ) {
-    val context = LocalContext.current
-    val version = remember { appInfo.versionName }
+    LocalContext.current
 
 
     Scaffold(
     Scaffold(
         topBar = {
         topBar = {
             TopAppBar(
             TopAppBar(
-                title = { Text(stringResource(R.string.about)) }
+                title = { Text(stringResource(R.string.about)) },
+                navigationIcon = {
+                    IconButton(onClick = onNavigateBack) {
+                        Icon(
+                            imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+                            contentDescription = stringResource(R.string.back)
+                        )
+                    }
+                }
             )
             )
         }
         }
     ) { padding ->
     ) { padding ->
@@ -51,57 +151,109 @@ fun AboutScreen(
                 .padding(16.dp)
                 .padding(16.dp)
                 .verticalScroll(rememberScrollState())
                 .verticalScroll(rememberScrollState())
         ) {
         ) {
-            Text(text = "版本:$version", style = MaterialTheme.typography.bodyLarge)
-
-            Spacer(Modifier.height(8.dp))
-            Button(onClick = onCheckUpdate) {
-                Text(stringResource(R.string.check_update))
-            }
-
-            Spacer(Modifier.height(8.dp))
-            Button(onClick = { onOpenUrl(context.getString(R.string.github_url)) }) {
-                Text(stringResource(R.string.github_url))
-            }
-
-            Button(onClick = { onOpenUrl(context.getString(R.string.legado_url)) }) {
-                Text(stringResource(R.string.web_service))
-            }
-
-            HorizontalDivider(
-                Modifier.padding(vertical = 8.dp),
-                DividerDefaults.Thickness,
-                DividerDefaults.color
+            Image(
+                painter = painterResource(R.drawable.ic_launcher_foreground),
+                contentDescription = "",
+                contentScale = ContentScale.Crop,
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .wrapContentWidth(Alignment.CenterHorizontally)
             )
             )
+            Text(
+                text = stringResource(R.string.app_name), style = MaterialTheme.typography.bodyLarge,
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .wrapContentWidth(Alignment.CenterHorizontally))
+            TextCard(
+                text = versionName,
+                backgroundColor = colorScheme.tertiaryContainer,
+                contentColor = colorScheme.onTertiaryContainer,
+                cornerRadius = 8.dp,
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .wrapContentWidth(Alignment.CenterHorizontally)
+            )
+            Text(
+                text = stringResource(R.string.about_description), style = MaterialTheme.typography.bodyLarge,
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .wrapContentWidth(Alignment.CenterHorizontally))
+            Row (modifier = Modifier
+                .fillMaxWidth(),
+                horizontalArrangement = Arrangement.Center){
+                FilledTonalIconButton ( onClick = { onOpenUrl("https://example.com") } ) {
+                    Icon(
+                        painter = painterResource(R.drawable.ic_web_outline),
+                        contentDescription = stringResource(R.string.back)
+                    )
+                }
 
 
-            Text(text = "文档与政策", style = MaterialTheme.typography.titleMedium)
-            Spacer(Modifier.height(4.dp))
-            TextButton(onClick = { onShowMdFile(context.getString(R.string.privacy_policy), "privacyPolicy.md") }) {
-                Text(stringResource(R.string.privacy_policy))
-            }
-            TextButton(onClick = { onShowMdFile(context.getString(R.string.license), "LICENSE.md") }) {
-                Text(stringResource(R.string.license))
-            }
-            TextButton(onClick = { onShowMdFile(context.getString(R.string.disclaimer), "disclaimer.md") }) {
-                Text(stringResource(R.string.disclaimer))
+                FilledTonalIconButton (onClick = { onOpenUrl("https://github.com/HapeLee/legado-with-MD3") }) {
+                    Icon(
+                        painter = painterResource(R.drawable.ic_github),
+                        contentDescription = stringResource(R.string.back)
+                    )
+                }
+
+                FilledTonalIconButton (onClick = { onCheckUpdate }) {
+                    Icon(
+                        painter = painterResource(R.drawable.ic_import),
+                        contentDescription = stringResource(R.string.back)
+                    )
+                }
             }
             }
 
 
-            HorizontalDivider(
-                Modifier.padding(vertical = 8.dp),
-                DividerDefaults.Thickness,
-                DividerDefaults.color
+            SplicedColumnGroup(
+                title = stringResource(R.string.about),
+                content = listOf(
+                    {
+                        SettingItem(
+                            modifier = Modifier.background(colorScheme.surfaceBright),
+                            title = stringResource(R.string.contributors),
+                            description = "",
+                            onClick = {
+                                onOpenUrl("https://github.com/gedoor/legado/graphs/contributors")
+                            }
+                        )
+                        SettingItem(
+                            title = stringResource(R.string.privacy_policy),
+                            description = "",
+                            onClick = {
+                                onShowMdFile("隐私政策", "privacyPolicy.md")
+                            }
+                        )
+                        SettingItem(
+                            title = stringResource(R.string.license),
+                            description = "",
+                            onClick = {
+                                onShowMdFile("许可证", "LICENSE.md")
+                            }
+                        )
+                        SettingItem(
+                            title = stringResource(R.string.disclaimer),
+                            description = "",
+                            onClick = {
+                                onShowMdFile("免责声明", "disclaimer.md")
+                            }
+                        )
+                        SettingItem(
+                            title = stringResource(R.string.crash_log),
+                            description = "",
+                            onClick = onShowCrashLogs
+                        )
+                        SettingItem(
+                            title = stringResource(R.string.save_log),
+                            description = "",
+                            onClick = onSaveLog
+                        )
+                        SettingItem(
+                            title = stringResource(R.string.create_heap_dump),
+                            description = "",
+                            onClick = onCreateHeapDump
+                        )
+                    }
+                )
             )
             )
-
-            Text(text = "开发工具", style = MaterialTheme.typography.titleMedium)
-            Spacer(Modifier.height(4.dp))
-            TextButton(onClick = onShowCrashLogs) {
-                Text(stringResource(R.string.crash_log))
-            }
-            TextButton(onClick = onSaveLog) {
-                Text(stringResource(R.string.save_log))
-            }
-            TextButton(onClick = onCreateHeapDump) {
-                Text(stringResource(R.string.create_heap_dump))
-            }
         }
         }
     }
     }
-}
+}

+ 152 - 0
app/src/main/java/io/legado/app/ui/widget/compose/AnimatedText.kt

@@ -0,0 +1,152 @@
+package io.legado.app.ui.widget.compose
+
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutVertically
+import androidx.compose.animation.togetherWith
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.material3.LocalTextStyle
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.SideEffect
+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.graphics.Color
+import androidx.compose.ui.text.TextLayoutResult
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextDecoration
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.TextUnit
+
+@Composable
+fun AnimatedText(
+    text: String,
+    modifier: Modifier = Modifier,
+    color: Color = Color.Unspecified,
+    fontSize: TextUnit = TextUnit.Unspecified,
+    fontStyle: FontStyle? = null,
+    fontWeight: FontWeight? = null,
+    fontFamily: FontFamily? = null,
+    letterSpacing: TextUnit = TextUnit.Unspecified,
+    textDecoration: TextDecoration? = null,
+    textAlign: TextAlign? = null,
+    lineHeight: TextUnit = TextUnit.Unspecified,
+    overflow: TextOverflow = TextOverflow.Clip,
+    softWrap: Boolean = true,
+    maxLines: Int = Int.MAX_VALUE,
+    minLines: Int = 1,
+    onTextLayout: ((TextLayoutResult) -> Unit)? = null,
+    style: TextStyle = LocalTextStyle.current
+) {
+    Row(
+        modifier = modifier,
+        verticalAlignment = Alignment.CenterVertically,
+    ) {
+        for (i in text.indices) {
+            val char = text[i]
+            Box {
+                AnimatedContent(
+                    targetState = char,
+                    transitionSpec = {
+                        (slideInVertically(initialOffsetY = { it })).togetherWith(
+                            slideOutVertically(targetOffsetY = { -it })
+                        )
+                    },
+                    label = ""
+                ) {
+                    Text(
+                        style = style,
+                        color = color,
+                        softWrap = softWrap,
+                        text = it.toString(),
+                        fontSize = fontSize,
+                        fontStyle = fontStyle,
+                        fontWeight = fontWeight,
+                        fontFamily = fontFamily,
+                        letterSpacing = letterSpacing,
+                        textDecoration = textDecoration,
+                        textAlign = textAlign,
+                        lineHeight = lineHeight,
+                        overflow = overflow,
+                        maxLines = maxLines,
+                        minLines = minLines,
+                        onTextLayout = onTextLayout
+                    )
+                }
+            }
+        }
+    }
+}
+
+/**
+ * 动画文本控件
+ *
+ * 区别于 AnimatedText, 该控件在文本变化时,提供整行的滑动动画效果
+ */
+@Composable
+fun AnimatedTextLine(
+    text: String,
+    modifier: Modifier = Modifier,
+    color: Color = Color.Unspecified,
+    fontSize: TextUnit = TextUnit.Unspecified,
+    fontStyle: FontStyle? = null,
+    fontWeight: FontWeight? = null,
+    fontFamily: FontFamily? = null,
+    letterSpacing: TextUnit = TextUnit.Unspecified,
+    textDecoration: TextDecoration? = null,
+    textAlign: TextAlign? = null,
+    lineHeight: TextUnit = TextUnit.Unspecified,
+    overflow: TextOverflow = TextOverflow.Clip,
+    softWrap: Boolean = true,
+    maxLines: Int = Int.MAX_VALUE,
+    minLines: Int = 1,
+    onTextLayout: ((TextLayoutResult) -> Unit)? = null,
+    style: TextStyle = LocalTextStyle.current
+) {
+    var currentText by remember { mutableStateOf(text) }
+    SideEffect { currentText = text }
+
+    Row(
+        modifier = modifier,
+        verticalAlignment = Alignment.CenterVertically,
+    ) {
+        AnimatedContent(
+            targetState = currentText,
+            transitionSpec = {
+                (slideInVertically(initialOffsetY = { it })).togetherWith(
+                    slideOutVertically(targetOffsetY = { -it })
+                )
+            },
+            label = ""
+        ) { text ->
+            Text(
+                text = text,
+                modifier = modifier,
+                style = style,
+                color = color,
+                softWrap = softWrap,
+                fontSize = fontSize,
+                fontStyle = fontStyle,
+                fontWeight = fontWeight,
+                fontFamily = fontFamily,
+                letterSpacing = letterSpacing,
+                textDecoration = textDecoration,
+                textAlign = textAlign,
+                lineHeight = lineHeight,
+                overflow = overflow,
+                maxLines = maxLines,
+                minLines = minLines,
+                onTextLayout = onTextLayout
+            )
+        }
+    }
+}

+ 92 - 0
app/src/main/java/io/legado/app/ui/widget/compose/SettingItem.kt

@@ -0,0 +1,92 @@
+package io.legado.app.ui.widget.compose
+
+import androidx.compose.foundation.clickable
+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.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme.colorScheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.painter.Painter
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import io.legado.app.base.AppTypography
+
+@Composable
+fun SettingItem(
+    modifier: Modifier = Modifier,
+    painter: Painter? = null,
+    title: String,
+    option: String? = null,
+    trailingContent: (@Composable () -> Unit)? = null,
+    description: String,
+    onClick: () -> Unit
+) {
+    Row(
+        modifier = Modifier
+            .clip(RoundedCornerShape(4.dp))
+            .then(modifier)
+            .fillMaxWidth()
+            .clickable { onClick() }
+            .padding(horizontal = 8.dp)
+            .padding(vertical = 12.dp),
+        verticalAlignment = Alignment.CenterVertically
+    ) {
+        painter?.let {
+            Icon(
+                modifier = Modifier.padding(end = 8.dp).size(24.dp),
+                painter = it,
+                tint = colorScheme.onSurfaceVariant,
+                contentDescription = "Icon"
+            )
+        }
+
+        Column(
+            modifier = Modifier.weight(1f),
+            verticalArrangement = Arrangement.spacedBy(3.dp)
+        ) {
+            Text(
+                text = title,
+                color = colorScheme.onSurface,
+                style = AppTypography.titleMedium,
+                fontWeight = FontWeight.Normal
+            )
+            Text(
+                text = description,
+                color = colorScheme.onSurfaceVariant,
+                style = AppTypography.labelMedium
+            )
+            option?.let {
+                AnimatedText(
+                    text = it,
+                    style = AppTypography.labelMedium,
+                    color = colorScheme.primary,
+                    maxLines = 3,
+                    overflow = TextOverflow.Ellipsis
+                )
+            }
+        }
+
+        trailingContent?.let { composable ->
+            Box(
+                modifier = Modifier.fillMaxHeight()
+                    .width(55.dp),
+                contentAlignment = Alignment.Center
+            ) {
+                composable()
+            }
+        }
+    }
+}

+ 84 - 0
app/src/main/java/io/legado/app/ui/widget/compose/SplicedColumnGroup.kt

@@ -0,0 +1,84 @@
+package io.legado.app.ui.widget.compose
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.unit.dp
+
+/**
+ * Settings Group Container by https://github.com/wxxsfxyzm/InstallerX-Revived
+ *
+ * @param title the title of the group
+ * @param content a list of composable that will be displayed in the group
+ * @param modifier Modifier
+ */
+@Composable
+fun SplicedColumnGroup(
+    modifier: Modifier = Modifier,
+    title: String = "",
+    content: List<@Composable () -> Unit>,
+) {
+    if (content.isEmpty()) return
+
+    val cornerRadius = 16.dp
+    val connectionRadius = 5.dp
+    // Define shapes for different positions.
+    val topShape = RoundedCornerShape(
+        topStart = cornerRadius,
+        topEnd = cornerRadius,
+        bottomStart = connectionRadius,
+        bottomEnd = connectionRadius
+    )
+    val middleShape = RoundedCornerShape(connectionRadius)
+    val bottomShape = RoundedCornerShape(
+        topStart = connectionRadius,
+        topEnd = connectionRadius,
+        bottomStart = cornerRadius,
+        bottomEnd = cornerRadius
+    )
+    val singleShape = RoundedCornerShape(cornerRadius)
+
+    Column(modifier = modifier.padding(vertical = 8.dp)) {
+        // Group title
+        if (title != "")
+            Text(
+                text = title,
+                style = MaterialTheme.typography.titleSmall,
+                color = MaterialTheme.colorScheme.primary,
+                modifier = Modifier.padding(start = 16.dp, bottom = 8.dp)
+            )
+
+        // The container for setting items.
+        Column(
+            modifier = Modifier.clip(
+                // Clip the whole column to ensure content stays within the rounded bounds.
+                if (content.size == 1) singleShape else RoundedCornerShape(cornerRadius)
+            ),
+            verticalArrangement = Arrangement.spacedBy(2.dp)
+        ) {
+            content.forEachIndexed { index, itemContent ->
+                // Determine the shape based on the pkg's position.
+                val shape = when {
+                    content.size == 1 -> singleShape
+                    index == 0 -> topShape
+                    index == content.size - 1 -> bottomShape
+                    else -> middleShape
+                }
+
+                // Apply background with the correct shape to the pkg.
+                Column(
+                    modifier = Modifier.background(MaterialTheme.colorScheme.surfaceBright, shape)
+                ) {
+                    itemContent()
+                }
+            }
+        }
+    }
+}

+ 67 - 0
app/src/main/java/io/legado/app/ui/widget/compose/TextCard.kt

@@ -0,0 +1,67 @@
+package io.legado.app.ui.widget.compose
+
+import androidx.compose.ui.graphics.Color
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.MaterialTheme.colorScheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.TextUnit
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+
+@Preview(showBackground = true)
+@Composable
+fun PreviewTextCard() {
+    MaterialTheme {
+        TextCard(
+            text = "v1.0.0"
+        )
+    }
+}
+
+@Composable
+fun TextCard(
+    text: String,
+    modifier: Modifier = Modifier,
+    backgroundColor: Color = colorScheme.tertiaryContainer,
+    contentColor: Color = colorScheme.onTertiaryContainer,
+    cornerRadius: Dp = 12.dp,
+    paddingHorizontal: Dp = 8.dp,
+    paddingVertical: Dp = 0.dp,
+    textSize: TextUnit = 10.sp,
+    bold: Boolean = true,
+    onClick: (() -> Unit)? = null,
+) {
+    Card(
+        modifier = modifier
+            .then(
+                if (onClick != null) Modifier.clickable { onClick() }
+                else Modifier
+            ),
+        colors = CardDefaults.cardColors(
+            containerColor = backgroundColor,
+            contentColor = contentColor
+        ),
+        shape = RoundedCornerShape(cornerRadius),
+        elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
+    ) {
+        Text(
+            text = text,
+            modifier = Modifier.padding(
+                horizontal = paddingHorizontal,
+                vertical = paddingVertical
+            ),
+            fontSize = textSize,
+            fontWeight = if (bold) FontWeight.Bold else FontWeight.Normal
+        )
+    }
+}

+ 4 - 4
app/src/main/res/layout/activity_book_search.xml

@@ -191,13 +191,13 @@
                     android:id="@+id/tv_clear_history"
                     android:id="@+id/tv_clear_history"
                     style="@style/Widget.Material3Expressive.Button"
                     style="@style/Widget.Material3Expressive.Button"
                     android:text="@string/delete_all"
                     android:text="@string/delete_all"
-                    app:backgroundTint="?attr/colorTertiary"
-                    app:iconTint="?attr/colorOnTertiary"
-                    android:textColor="?attr/colorOnTertiary"
+                    app:backgroundTint="?attr/colorSecondaryContainer"
+                    app:iconTint="?attr/colorOnSecondaryContainer"
+                    android:textColor="?attr/colorOnSecondaryContainer"
                     android:layout_width="wrap_content"
                     android:layout_width="wrap_content"
                     android:layout_height="wrap_content"
                     android:layout_height="wrap_content"
                     app:icon="@drawable/ic_clear_all"
                     app:icon="@drawable/ic_clear_all"
-                    app:strokeColor="?attr/colorTertiary"
+                    app:strokeColor="?attr/colorSecondaryContainer"
                     app:iconGravity="textStart" />
                     app:iconGravity="textStart" />
 
 
             </LinearLayout>
             </LinearLayout>

+ 1 - 1
app/version.properties

@@ -1,4 +1,4 @@
 VERSION_MAJOR=3
 VERSION_MAJOR=3
 VERSION_MINOR=26
 VERSION_MINOR=26
-VERSION_PATCH=3
+VERSION_PATCH=4
 VERSION_SUFFIX=
 VERSION_SUFFIX=

+ 33 - 10
gradle/libs.versions.toml

@@ -1,13 +1,16 @@
 [versions]
 [versions]
 
 
 accompanistWebview = "0.36.0"
 accompanistWebview = "0.36.0"
+animation = "1.9.4"
 coilCompose = "2.7.0"
 coilCompose = "2.7.0"
-composeBom = "2025.11.00"
-coreSplashscreen = "1.2.0-rc01"
+composeBom = "2025.11.01"
+constraintlayoutCompose = "1.1.1"
+coreSplashscreen = "1.2.0"
 datastorePreferences = "1.1.7"
 datastorePreferences = "1.1.7"
-kotlin = "2.2.20"
-kotlinxCoroutinesAndroid = "1.8.1"
-ksp = "2.2.20-2.0.4"
+foundation = "1.9.4"
+kotlin = "2.2.21"
+kotlinxCoroutinesAndroid = "1.10.2"
+ksp = "2.2.21-2.0.4"
 agp = "8.13.1"
 agp = "8.13.1"
 appcompat = "1.7.1"
 appcompat = "1.7.1"
 colorpicker = "1.1.0"
 colorpicker = "1.1.0"
@@ -22,7 +25,7 @@ fragment = "1.8.9"
 hutool = "5.8.22"
 hutool = "5.8.22"
 
 
 libarchive = "1.1.6"
 libarchive = "1.1.6"
-lifecycle = "2.9.4"
+lifecycle = "2.10.0"
 glide = "5.0.5"
 glide = "5.0.5"
 gson = "2.13.2"
 gson = "2.13.2"
 jsonPath = "2.9.0"
 jsonPath = "2.9.0"
@@ -31,13 +34,18 @@ jsonPath = "2.9.0"
 jsoup = "1.16.2"
 jsoup = "1.16.2"
 jsoupxpath = "2.5.3"
 jsoupxpath = "2.5.3"
 coroutines = "1.10.2"
 coroutines = "1.10.2"
+lifecycleViewmodelCompose = "2.10.0"
 liveeventbus = "1.8.14"
 liveeventbus = "1.8.14"
 markdownCompose = "0.5.0"
 markdownCompose = "0.5.0"
 markwon = "4.6.2"
 markwon = "4.6.2"
-material = "1.14.0-alpha06"
+material = "1.14.0-alpha07"
+material3 = "1.5.0-alpha09"
+material3IconsExtended = "1.2.0"
+materialIconsExtended = "1.7.8"
 media = "1.7.1"
 media = "1.7.1"
 media3 = "1.8.0"
 media3 = "1.8.0"
 nanoHttpd = "2.3.1"
 nanoHttpd = "2.3.1"
+navigationCompose = "2.9.6"
 okhttp = "5.2.0"
 okhttp = "5.2.0"
 palette = "1.0.0"
 palette = "1.0.0"
 preference = "1.2.1"
 preference = "1.2.1"
@@ -55,13 +63,14 @@ swiperefreshlayout = "1.1.0"
 #noinspection GradleDependency
 #noinspection GradleDependency
 recyclerview = "1.4.0"
 recyclerview = "1.4.0"
 #noinspection GradleDependency
 #noinspection GradleDependency
+uiViewbinding = "1.9.4"
 viewpager2 = "1.1.0"
 viewpager2 = "1.1.0"
 webkit = "1.14.0"
 webkit = "1.14.0"
 collection = "1.5.0"
 collection = "1.5.0"
 
 
 zxingLite = "3.3.0"
 zxingLite = "3.3.0"
 
 
-
+koin-bom = "4.1.1"
 [libraries]
 [libraries]
 
 
 accompanist-webview = { module = "com.google.accompanist:accompanist-webview", version.ref = "accompanistWebview" }
 accompanist-webview = { module = "com.google.accompanist:accompanist-webview", version.ref = "accompanistWebview" }
@@ -74,19 +83,33 @@ androidx-annotation = { group = "androidx.annotation", name = "annotation", vers
 #androidx-annotation-experimental = { group = "androidx.annotation", name = "annotation-experimental", version = "1.3.1" }
 #androidx-annotation-experimental = { group = "androidx.annotation", name = "annotation-experimental", version = "1.3.1" }
 
 
 androidx-collection = { module = "androidx.collection:collection", version.ref = "collection" }
 androidx-collection = { module = "androidx.collection:collection", version.ref = "collection" }
+androidx-compose-animation = { module = "androidx.compose.animation:animation", version.ref = "animation" }
 androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" }
 androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" }
-androidx-compose-material3 = { module = "androidx.compose.material3:material3" }
+androidx-compose-foundation = { module = "androidx.compose.foundation:foundation", version.ref = "foundation" }
+androidx-compose-material = { module = "androidx.compose.material:material" }
+compose-materialIcons = { group = "androidx.compose.material", name = "material-icons-extended", version = "1.7.8" }
+androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" }
+androidx-compose-material3-icons-extended = { module = "androidx.compose.material3:material3-icons-extended", version.ref = "material3IconsExtended" }
 androidx-compose-ui = { module = "androidx.compose.ui:ui" }
 androidx-compose-ui = { module = "androidx.compose.ui:ui" }
 androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
 androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
 androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
 androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
+androidx-compose-ui-viewbinding = { module = "androidx.compose.ui:ui-viewbinding", version.ref = "uiViewbinding" }
+androidx-constraintlayout-compose = { module = "androidx.constraintlayout:constraintlayout-compose", version.ref = "constraintlayoutCompose" }
 androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreen" }
 androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreen" }
 androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" }
 androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" }
 androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose" }
 androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose" }
+androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelCompose" }
+androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
 androidx-palette = { module = "androidx.palette:palette", version.ref = "palette" }
 androidx-palette = { module = "androidx.palette:palette", version.ref = "palette" }
 androidx-startup-runtime = { module = "androidx.startup:startup-runtime", version.ref = "startupRuntime" }
 androidx-startup-runtime = { module = "androidx.startup:startup-runtime", version.ref = "startupRuntime" }
 appcompat-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" }
 appcompat-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" }
 androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintlayout" }
 androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintlayout" }
 
 
+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" }
+koin-compose = { group = "io.insert-koin", name = "koin-androidx-compose" }
+koin-compose-viewmodel = { group = "io.insert-koin", name = "koin-compose-viewmodel" }
 
 
 coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coilCompose" }
 coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coilCompose" }
 core-core = { module = "androidx.core:core", version.ref = "core" }
 core-core = { module = "androidx.core:core", version.ref = "core" }
@@ -194,7 +217,7 @@ coroutines = ["kotlinx-coroutines-core", "kotlinx-coroutines-android"]
 androidTest = ["androidx-espresso-core", "androidx-junit", "androidx-runner"]
 androidTest = ["androidx-espresso-core", "androidx-junit", "androidx-runner"]
 
 
 [plugins]
 [plugins]
-
+compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
 android-application = { id = "com.android.application", version.ref = "agp" }
 android-application = { id = "com.android.application", version.ref = "agp" }
 android-library = { id = "com.android.library", version.ref = "agp" }
 android-library = { id = "com.android.library", version.ref = "agp" }
 android-test = { id = "com.android.test", version.ref = "agp" }
 android-test = { id = "com.android.test", version.ref = "agp" }