Răsfoiți Sursa

feat: 欢迎页迁移至 Compose (feature/onboarding)

- 新增 feature/onboarding:OnboardingContract/ViewModel/Screen/RouteScreen,
  四步引导(隐私、WebDav、书籍文件夹、主题)改为 Crossfade 页面 + UDF 状态
- 复用主题 screen 的 ThemeCard/ThemeColorSelector/ThemeModeSelector 组件,
  WebDav 备份选择/恢复改用 AppAlertDialog 与 Compose 加载对话框
- WelcomeActivity 保留为 BaseComposeActivity 薄宿主(Manifest/首启入口兼容)
- 删除 PrivacyFragment/WebDavFragment/BookFolderFragment/ThemeFragment 及
  activity_welcome/fragment_*/pref_strat_theme XML
HapeLee 2 luni în urmă
părinte
comite
8311bd137a

+ 2 - 0
app/src/main/java/io/legado/app/di/appModule.kt

@@ -289,6 +289,7 @@ import io.legado.app.ui.config.translation.TranslationConfigViewModel
 import io.legado.app.ui.dict.DictViewModel
 import io.legado.app.ui.dict.DictViewModel
 import io.legado.app.ui.dict.rule.DictRuleViewModel
 import io.legado.app.ui.dict.rule.DictRuleViewModel
 import io.legado.app.ui.highlightTagRule.HighlightTagRuleViewModel
 import io.legado.app.ui.highlightTagRule.HighlightTagRuleViewModel
+import io.legado.app.feature.onboarding.OnboardingViewModel
 import io.legado.app.ui.login.SourceLoginViewModel
 import io.legado.app.ui.login.SourceLoginViewModel
 import io.legado.app.ui.main.MainRouteSearchContent
 import io.legado.app.ui.main.MainRouteSearchContent
 import io.legado.app.ui.main.MainViewModel
 import io.legado.app.ui.main.MainViewModel
@@ -588,6 +589,7 @@ val appModule = module {
     viewModelOf(::DownloadCacheConfigViewModel)
     viewModelOf(::DownloadCacheConfigViewModel)
     viewModelOf(::ThemeConfigViewModel)
     viewModelOf(::ThemeConfigViewModel)
     viewModelOf(::ThemeManageViewModel)
     viewModelOf(::ThemeManageViewModel)
+    viewModelOf(::OnboardingViewModel)
     viewModelOf(::BackupConfigViewModel)
     viewModelOf(::BackupConfigViewModel)
     viewModelOf(::LabConfigViewModel)
     viewModelOf(::LabConfigViewModel)
     viewModelOf(::TranslationConfigViewModel)
     viewModelOf(::TranslationConfigViewModel)

+ 62 - 0
app/src/main/java/io/legado/app/feature/onboarding/OnboardingContract.kt

@@ -0,0 +1,62 @@
+package io.legado.app.feature.onboarding
+
+import androidx.compose.runtime.Stable
+import io.legado.app.domain.model.settings.ThemeSettings
+import kotlinx.collections.immutable.ImmutableList
+
+/**
+ * 欢迎引导流程(隐私 → WebDav → 书籍文件夹 → 主题)的 Compose 契约。
+ * 页面文案沿用旧 WelcomeActivity 的中文硬编码文案。
+ */
+@Stable
+data class OnboardingUiState(
+    val page: Int = 0,
+    val pageCount: Int = 4,
+    val privacyPolicy: String = "",
+    val disclaimer: String = "",
+    val webDavUrl: String = "",
+    val webDavAccount: String = "",
+    val webDavPassword: String = "",
+    val appAccessPassword: String = "",
+    val bookFolderUri: String? = null,
+    val theme: ThemeSettings = ThemeSettings(),
+    val themeMode: String = "0",
+    val busyText: String? = null,
+    val backupNames: ImmutableList<String>? = null,
+    val restoreErrorMessage: String? = null,
+)
+
+/** 用户动作;与 Android Intent 无关,命名见 skill 约定。 */
+sealed interface OnboardingIntent {
+    data object Next : OnboardingIntent
+    data object Prev : OnboardingIntent
+    data class UpdateWebDavUrl(val value: String) : OnboardingIntent
+    data class UpdateWebDavAccount(val value: String) : OnboardingIntent
+    data class UpdateWebDavPassword(val value: String) : OnboardingIntent
+
+    /** 应用访问密码,旧实现即时写入 LocalConfig.password */
+    data class UpdateAppAccessPassword(val value: String) : OnboardingIntent
+    data object SaveAndTestWebDav : OnboardingIntent
+    data object FetchBackups : OnboardingIntent
+    data class RestoreBackup(val name: String) : OnboardingIntent
+    data object DismissBackupSelector : OnboardingIntent
+    data object StartLocalRestore : OnboardingIntent
+    data class RestoreLocalFile(val uri: String) : OnboardingIntent
+    data object SelectFolder : OnboardingIntent
+    data class SelectBookFolder(val uri: String) : OnboardingIntent
+    data class SelectTheme(val value: String) : OnboardingIntent
+    data class SetThemeMode(val value: String) : OnboardingIntent
+    data object DismissRestoreError : OnboardingIntent
+    data object CancelBusy : OnboardingIntent
+}
+
+sealed interface OnboardingEffect {
+    data object NavigateHome : OnboardingEffect
+
+    /** 首页按返回键:直接结束引导(不进入主界面),保持旧行为 */
+    data object Finish : OnboardingEffect
+    data object OpenBookFolderPicker : OnboardingEffect
+    data object OpenRestoreFilePicker : OnboardingEffect
+    data object ApplyDayNight : OnboardingEffect
+    data class ShowToast(val resId: Int) : OnboardingEffect
+}

+ 60 - 0
app/src/main/java/io/legado/app/feature/onboarding/OnboardingRouteScreen.kt

@@ -0,0 +1,60 @@
+package io.legado.app.feature.onboarding
+
+import android.content.Intent
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.platform.LocalContext
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import io.legado.app.help.config.ThemeConfigStore
+import io.legado.app.utils.takePersistablePermissionSafely
+import io.legado.app.utils.toastOnUi
+import kotlinx.coroutines.flow.collectLatest
+import org.koin.androidx.compose.koinViewModel
+
+@Composable
+fun OnboardingRouteScreen(
+    onFinish: () -> Unit,
+    onNavigateHome: () -> Unit,
+    viewModel: OnboardingViewModel = koinViewModel(),
+) {
+    val context = LocalContext.current
+    val state by viewModel.uiState.collectAsStateWithLifecycle()
+
+    val folderPicker = rememberLauncherForActivityResult(
+        ActivityResultContracts.OpenDocumentTree()
+    ) { uri ->
+        if (uri != null) {
+            uri.takePersistablePermissionSafely(context, Intent.FLAG_GRANT_READ_URI_PERMISSION)
+            viewModel.onIntent(OnboardingIntent.SelectBookFolder(uri.toString()))
+        }
+    }
+    val restoreFilePicker = rememberLauncherForActivityResult(
+        ActivityResultContracts.OpenDocument()
+    ) { uri ->
+        if (uri != null) {
+            viewModel.onIntent(OnboardingIntent.RestoreLocalFile(uri.toString()))
+        }
+    }
+
+    LaunchedEffect(Unit) {
+        viewModel.effects.collectLatest { effect ->
+            when (effect) {
+                OnboardingEffect.NavigateHome -> onNavigateHome()
+                OnboardingEffect.Finish -> onFinish()
+                OnboardingEffect.OpenBookFolderPicker -> folderPicker.launch(null)
+                OnboardingEffect.OpenRestoreFilePicker ->
+                    restoreFilePicker.launch(arrayOf("application/zip"))
+                OnboardingEffect.ApplyDayNight -> ThemeConfigStore.applyDayNightLive()
+                is OnboardingEffect.ShowToast -> context.toastOnUi(effect.resId)
+            }
+        }
+    }
+
+    OnboardingScreen(
+        state = state,
+        onIntent = viewModel::onIntent,
+    )
+}

+ 393 - 0
app/src/main/java/io/legado/app/feature/onboarding/OnboardingScreen.kt

@@ -0,0 +1,393 @@
+package io.legado.app.feature.onboarding
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.animation.Crossfade
+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.WindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.safeDrawing
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowForward
+import androidx.compose.material3.Icon
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringArrayResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import io.legado.app.R
+import io.legado.app.ui.config.themeConfig.ThemeCard
+import io.legado.app.ui.config.themeConfig.ThemeColorSelector
+import io.legado.app.ui.config.themeConfig.ThemeModeSelector
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.widget.components.AppTextField
+import io.legado.app.ui.widget.components.alert.AppAlertDialog
+import io.legado.app.ui.widget.components.button.series.MediumTonalButton
+import io.legado.app.ui.widget.components.card.GlassCard
+import io.legado.app.ui.widget.components.progressIndicator.AppCircularProgressIndicator
+import io.legado.app.ui.widget.components.progressIndicator.AppLinearProgressIndicator
+import io.legado.app.ui.widget.components.text.AppText
+
+@Composable
+fun OnboardingScreen(
+    state: OnboardingUiState,
+    onIntent: (OnboardingIntent) -> Unit,
+) {
+    BackHandler {
+        onIntent(OnboardingIntent.Prev)
+    }
+
+    Column(
+        modifier = Modifier
+            .fillMaxSize()
+            .windowInsetsPadding(WindowInsets.safeDrawing)
+    ) {
+        Column(modifier = Modifier.padding(horizontal = 16.dp)) {
+            Icon(
+                painter = painterResource(R.drawable.ic_launcher_foreground),
+                contentDescription = null,
+                tint = LegadoTheme.colorScheme.primary,
+                modifier = Modifier
+                    .padding(top = 16.dp)
+                    .size(56.dp)
+            )
+            AppText(
+                text = pageTitle(state.page),
+                style = LegadoTheme.typography.headlineLargeEmphasized,
+                modifier = Modifier.padding(top = 8.dp)
+            )
+            AppText(
+                text = pageSummary(state.page),
+                style = LegadoTheme.typography.bodyLargeEmphasized,
+                modifier = Modifier.padding(top = 4.dp, bottom = 12.dp)
+            )
+            AppLinearProgressIndicator(
+                progress = state.page * 1f / state.pageCount,
+                modifier = Modifier.fillMaxWidth()
+            )
+        }
+
+        Crossfade(
+            targetState = state.page,
+            label = "onboarding-page",
+            modifier = Modifier
+                .fillMaxWidth()
+                .weight(1f)
+        ) { page ->
+            when (page) {
+                0 -> PrivacyPage(state)
+                1 -> WebDavPage(state, onIntent)
+                2 -> BookFolderPage(state, onIntent)
+                else -> ThemePage(state, onIntent)
+            }
+        }
+
+        Box(
+            modifier = Modifier
+                .fillMaxWidth()
+                .padding(16.dp),
+            contentAlignment = Alignment.CenterEnd
+        ) {
+            MediumTonalButton(
+                onClick = { onIntent(OnboardingIntent.Next) },
+                text = nextButtonText(state.page, state.pageCount),
+                icon = Icons.AutoMirrored.Filled.ArrowForward
+            )
+        }
+    }
+
+    BusyDialog(state, onIntent)
+    BackupSelectorDialog(state, onIntent)
+    RestoreErrorDialog(state, onIntent)
+}
+
+@Composable
+private fun BusyDialog(state: OnboardingUiState, onIntent: (OnboardingIntent) -> Unit) {
+    val busyText = state.busyText ?: return
+    AppAlertDialog(
+        show = true,
+        onDismissRequest = { onIntent(OnboardingIntent.CancelBusy) },
+        content = {
+            Row(
+                horizontalArrangement = Arrangement.spacedBy(16.dp),
+                verticalAlignment = Alignment.CenterVertically,
+                modifier = Modifier.fillMaxWidth()
+            ) {
+                AppCircularProgressIndicator(modifier = Modifier.size(28.dp))
+                AppText(text = busyText)
+            }
+        }
+    )
+}
+
+@Composable
+private fun BackupSelectorDialog(state: OnboardingUiState, onIntent: (OnboardingIntent) -> Unit) {
+    val backupNames = state.backupNames ?: return
+    AppAlertDialog(
+        show = true,
+        onDismissRequest = { onIntent(OnboardingIntent.DismissBackupSelector) },
+        title = stringResource(R.string.select_restore_file),
+        onDismiss = { onIntent(OnboardingIntent.DismissBackupSelector) },
+        content = {
+            Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
+                backupNames.forEach { name ->
+                    AppText(
+                        text = name,
+                        maxLines = 2,
+                        overflow = TextOverflow.Ellipsis,
+                        modifier = Modifier
+                            .fillMaxWidth()
+                            .clip(RoundedCornerShape(8.dp))
+                            .clickable { onIntent(OnboardingIntent.RestoreBackup(name)) }
+                            .padding(vertical = 10.dp, horizontal = 4.dp)
+                    )
+                }
+            }
+        }
+    )
+}
+
+@Composable
+private fun RestoreErrorDialog(state: OnboardingUiState, onIntent: (OnboardingIntent) -> Unit) {
+    val message = state.restoreErrorMessage ?: return
+    AppAlertDialog(
+        show = true,
+        onDismissRequest = { onIntent(OnboardingIntent.DismissRestoreError) },
+        title = stringResource(R.string.restore),
+        text = message,
+        confirmText = stringResource(R.string.ok),
+        onConfirm = { onIntent(OnboardingIntent.StartLocalRestore) },
+        onDismiss = { onIntent(OnboardingIntent.DismissRestoreError) }
+    )
+}
+
+@Composable
+private fun PrivacyPage(state: OnboardingUiState) {
+    Column(
+        modifier = Modifier
+            .fillMaxSize()
+            .verticalScroll(rememberScrollState())
+            .padding(horizontal = 16.dp, vertical = 8.dp)
+    ) {
+        AppText(
+            text = stringResource(R.string.privacy_policy),
+            style = LegadoTheme.typography.titleMedium,
+            color = LegadoTheme.colorScheme.secondary,
+            modifier = Modifier.padding(bottom = 8.dp)
+        )
+        AppText(
+            text = state.privacyPolicy,
+            style = LegadoTheme.typography.bodyMedium,
+            modifier = Modifier.padding(bottom = 16.dp)
+        )
+        AppText(
+            text = stringResource(R.string.disclaimer),
+            style = LegadoTheme.typography.titleMedium,
+            color = LegadoTheme.colorScheme.secondary,
+            modifier = Modifier.padding(bottom = 8.dp)
+        )
+        AppText(
+            text = state.disclaimer,
+            style = LegadoTheme.typography.bodyMedium
+        )
+    }
+}
+
+@Composable
+private fun WebDavPage(state: OnboardingUiState, onIntent: (OnboardingIntent) -> Unit) {
+    val presets = remember {
+        listOf(
+            "https://dav.jianguoyun.com/dav/",
+            "https://webdav.aliyundrive.com/",
+            "https://soya.infini-cloud.net/dav/"
+        )
+    }
+    Column(
+        modifier = Modifier
+            .fillMaxSize()
+            .verticalScroll(rememberScrollState())
+            .padding(horizontal = 16.dp, vertical = 8.dp),
+        verticalArrangement = Arrangement.spacedBy(8.dp)
+    ) {
+        AppTextField(
+            value = state.webDavUrl,
+            onValueChange = { onIntent(OnboardingIntent.UpdateWebDavUrl(it)) },
+            label = stringResource(R.string.web_dav_url)
+        )
+        Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+            presets.forEach { preset ->
+                GlassCard(
+                    onClick = { onIntent(OnboardingIntent.UpdateWebDavUrl(preset)) },
+                    modifier = Modifier.weight(1f)
+                ) {
+                    AppText(
+                        text = preset.removePrefix("https://").removeSuffix("/"),
+                        style = LegadoTheme.typography.labelSmall,
+                        maxLines = 1,
+                        overflow = TextOverflow.Ellipsis,
+                        modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp)
+                    )
+                }
+            }
+        }
+        AppTextField(
+            value = state.webDavAccount,
+            onValueChange = { onIntent(OnboardingIntent.UpdateWebDavAccount(it)) },
+            label = stringResource(R.string.web_dav_account)
+        )
+        AppTextField(
+            value = state.webDavPassword,
+            onValueChange = { onIntent(OnboardingIntent.UpdateWebDavPassword(it)) },
+            label = stringResource(R.string.web_dav_pw)
+        )
+        Row(
+            horizontalArrangement = Arrangement.spacedBy(12.dp),
+            modifier = Modifier.padding(top = 4.dp)
+        ) {
+            MediumTonalButton(
+                onClick = { onIntent(OnboardingIntent.FetchBackups) },
+                text = stringResource(R.string.restore),
+                modifier = Modifier.weight(1f)
+            )
+            MediumTonalButton(
+                onClick = { onIntent(OnboardingIntent.SaveAndTestWebDav) },
+                text = stringResource(R.string.action_save),
+                modifier = Modifier.weight(1f)
+            )
+        }
+        AppText(
+            text = stringResource(R.string.set_local_password_summary),
+            style = LegadoTheme.typography.labelSmall,
+            modifier = Modifier.padding(top = 8.dp)
+        )
+        AppTextField(
+            value = state.appAccessPassword,
+            onValueChange = { onIntent(OnboardingIntent.UpdateAppAccessPassword(it)) },
+            label = stringResource(R.string.set_local_password)
+        )
+    }
+}
+
+@Composable
+private fun BookFolderPage(state: OnboardingUiState, onIntent: (OnboardingIntent) -> Unit) {
+    Column(
+        modifier = Modifier
+            .fillMaxSize()
+            .verticalScroll(rememberScrollState())
+            .padding(horizontal = 16.dp, vertical = 8.dp)
+    ) {
+        AppText(
+            text = stringResource(R.string.welcome_book_folder_tip),
+            style = LegadoTheme.typography.bodySmall,
+            color = LegadoTheme.colorScheme.onSurfaceVariant
+        )
+        GlassCard(
+            modifier = Modifier
+                .fillMaxWidth()
+                .padding(top = 12.dp)
+        ) {
+            Column(modifier = Modifier.padding(14.dp)) {
+                AppText(
+                    text = stringResource(R.string.select_book_folder),
+                    style = LegadoTheme.typography.titleMedium
+                )
+                AppText(
+                    text = state.bookFolderUri
+                        ?: stringResource(R.string.welcome_book_folder_not_selected),
+                    style = LegadoTheme.typography.bodySmall,
+                    color = LegadoTheme.colorScheme.onSurfaceVariant,
+                    maxLines = 3,
+                    overflow = TextOverflow.Ellipsis,
+                    modifier = Modifier.padding(top = 8.dp)
+                )
+                MediumTonalButton(
+                    onClick = { onIntent(OnboardingIntent.SelectFolder) },
+                    text = stringResource(R.string.select_folder),
+                    modifier = Modifier.padding(top = 12.dp)
+                )
+            }
+        }
+    }
+}
+
+@Composable
+private fun ThemePage(state: OnboardingUiState, onIntent: (OnboardingIntent) -> Unit) {
+    val context = LocalContext.current
+    val isDark = LegadoTheme.isDark
+    val themeItems = stringArrayResource(R.array.themes_item)
+    val themeValues = stringArrayResource(R.array.themes_value)
+    val themes = remember(themeItems, themeValues) {
+        themeItems.zip(themeValues).toList()
+    }
+    Column(
+        modifier = Modifier
+            .fillMaxSize()
+            .verticalScroll(rememberScrollState())
+            .padding(horizontal = 16.dp, vertical = 8.dp)
+    ) {
+        ThemeCard(
+            context = context,
+            value = state.theme.appTheme,
+            isDark = isDark,
+            isAmoled = state.theme.isPureBlack,
+            paletteStyle = state.theme.paletteStyle,
+            customLightSeedColor = state.theme.customPrimary,
+            customNightSeedColor = state.theme.customNightPrimary
+        )
+        Box(modifier = Modifier.padding(top = 16.dp)) {
+            ThemeModeSelector(
+                selectedMode = state.themeMode,
+                onModeSelected = { onIntent(OnboardingIntent.SetThemeMode(it)) }
+            )
+        }
+        Box(modifier = Modifier.padding(top = 16.dp)) {
+            ThemeColorSelector(
+                context = context,
+                themes = themes,
+                selectedTheme = state.theme.appTheme,
+                isDark = isDark,
+                isAmoled = state.theme.isPureBlack,
+                paletteStyle = state.theme.paletteStyle,
+                customLightSeedColor = state.theme.customPrimary,
+                customNightSeedColor = state.theme.customNightPrimary,
+                onThemeSelected = { onIntent(OnboardingIntent.SelectTheme(it)) }
+            )
+        }
+    }
+}
+
+private fun pageTitle(page: Int): String = when (page) {
+    0 -> "欢迎!"
+    1 -> "备份与恢复"
+    2 -> "书籍文件夹"
+    else -> "主题样式"
+}
+
+private fun pageSummary(page: Int): String = when (page) {
+    0 -> "请先阅读应用的服务条款与用户协议。"
+    1 -> "此处可设置云同步与恢复应用备份。"
+    2 -> "请选择保存本地书籍的文件夹。"
+    else -> "在这里设置您喜爱的样式。"
+}
+
+private fun nextButtonText(page: Int, pageCount: Int): String = when {
+    page == 0 -> "阅读并同意"
+    page == pageCount - 1 -> "完成"
+    else -> "下一步"
+}

+ 247 - 0
app/src/main/java/io/legado/app/feature/onboarding/OnboardingViewModel.kt

@@ -0,0 +1,247 @@
+package io.legado.app.feature.onboarding
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import androidx.core.net.toUri
+import io.legado.app.R
+import io.legado.app.constant.AppLog
+import io.legado.app.domain.gateway.AppShellSettingsGateway
+import io.legado.app.domain.gateway.BackupSettingsGateway
+import io.legado.app.domain.gateway.OtherSettingsGateway
+import io.legado.app.domain.gateway.ThemeSettingsGateway
+import io.legado.app.domain.usecase.WebDavBackupUseCase
+import io.legado.app.exception.NoStackTraceException
+import io.legado.app.help.config.LocalConfig
+import io.legado.app.help.storage.Restore
+import io.legado.app.utils.toastOnUi
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.ensureActive
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import splitties.init.appCtx
+
+class OnboardingViewModel(
+    private val themeSettingsGateway: ThemeSettingsGateway,
+    private val appShellSettingsGateway: AppShellSettingsGateway,
+    private val otherSettingsGateway: OtherSettingsGateway,
+    private val backupSettingsGateway: BackupSettingsGateway,
+    private val webDavBackupUseCase: WebDavBackupUseCase,
+) : ViewModel() {
+
+    private val _uiState = MutableStateFlow(
+        OnboardingUiState(
+            webDavUrl = backupSettingsGateway.currentSettings.webDavUrl,
+            webDavAccount = backupSettingsGateway.currentSettings.webDavAccount,
+            webDavPassword = backupSettingsGateway.currentSettings.webDavPassword,
+            appAccessPassword = LocalConfig.password ?: "",
+            bookFolderUri = otherSettingsGateway.currentSettings.defaultBookTreeUri,
+            theme = themeSettingsGateway.currentSettings,
+            themeMode = appShellSettingsGateway.currentSettings.themeMode,
+        )
+    )
+    val uiState = _uiState.asStateFlow()
+
+    private val _effects = MutableSharedFlow<OnboardingEffect>(extraBufferCapacity = 16)
+    val effects = _effects.asSharedFlow()
+
+    private var busyJob: Job? = null
+
+    init {
+        viewModelScope.launch {
+            val privacy = withContext(Dispatchers.IO) {
+                runCatching { appCtx.assets.open("privacyPolicy.md").readBytes().decodeToString() }
+                    .getOrDefault("")
+            }
+            val disclaimer = withContext(Dispatchers.IO) {
+                runCatching { appCtx.assets.open("disclaimer.md").readBytes().decodeToString() }
+                    .getOrDefault("")
+            }
+            _uiState.update { it.copy(privacyPolicy = privacy, disclaimer = disclaimer) }
+        }
+        viewModelScope.launch {
+            themeSettingsGateway.settings.collect { theme ->
+                _uiState.update { it.copy(theme = theme) }
+            }
+        }
+    }
+
+    fun onIntent(intent: OnboardingIntent) {
+        when (intent) {
+            OnboardingIntent.Next -> nextPage()
+            OnboardingIntent.Prev -> previousPage()
+            is OnboardingIntent.UpdateWebDavUrl ->
+                _uiState.update { it.copy(webDavUrl = intent.value) }
+            is OnboardingIntent.UpdateWebDavAccount ->
+                _uiState.update { it.copy(webDavAccount = intent.value) }
+            is OnboardingIntent.UpdateWebDavPassword ->
+                _uiState.update { it.copy(webDavPassword = intent.value) }
+            is OnboardingIntent.UpdateAppAccessPassword -> {
+                LocalConfig.password = intent.value
+                _uiState.update { it.copy(appAccessPassword = intent.value) }
+            }
+            OnboardingIntent.SaveAndTestWebDav -> viewModelScope.launch {
+                saveWebDavConfig()
+                runCatching { webDavBackupUseCase.test() }
+                    .onFailure {
+                        AppLog.put("WebDav测试连接出错\n${it.localizedMessage}", it)
+                        appCtx.toastOnUi("WebDavError\n${it.localizedMessage}")
+                    }
+            }
+            OnboardingIntent.FetchBackups -> fetchBackups()
+            is OnboardingIntent.RestoreBackup -> restoreWebDav(intent.name)
+            OnboardingIntent.DismissBackupSelector ->
+                _uiState.update { it.copy(backupNames = null) }
+            OnboardingIntent.StartLocalRestore -> {
+                _uiState.update { it.copy(restoreErrorMessage = null) }
+                _effects.tryEmit(OnboardingEffect.OpenRestoreFilePicker)
+            }
+            is OnboardingIntent.RestoreLocalFile -> restoreLocal(intent.uri)
+            OnboardingIntent.SelectFolder -> _effects.tryEmit(OnboardingEffect.OpenBookFolderPicker)
+            is OnboardingIntent.SelectBookFolder -> viewModelScope.launch {
+                otherSettingsGateway.update {
+                    it.copy(defaultBookTreeUri = intent.uri)
+                }
+                _uiState.update { it.copy(bookFolderUri = intent.uri) }
+            }
+            is OnboardingIntent.SelectTheme -> selectTheme(intent.value)
+            is OnboardingIntent.SetThemeMode -> viewModelScope.launch {
+                appShellSettingsGateway.update { it.copy(themeMode = intent.value) }
+                _uiState.update { it.copy(themeMode = intent.value) }
+                _effects.tryEmit(OnboardingEffect.ApplyDayNight)
+            }
+            OnboardingIntent.DismissRestoreError ->
+                _uiState.update { it.copy(restoreErrorMessage = null) }
+            OnboardingIntent.CancelBusy -> {
+                busyJob?.cancel()
+                busyJob = null
+                _uiState.update { it.copy(busyText = null) }
+            }
+        }
+    }
+
+    private fun nextPage() {
+        val state = _uiState.value
+        if (state.page == 0) {
+            LocalConfig.privacyPolicyOk = true
+        }
+        if (state.page >= state.pageCount - 1) {
+            _effects.tryEmit(OnboardingEffect.NavigateHome)
+        } else {
+            _uiState.update { it.copy(page = it.page + 1) }
+        }
+    }
+
+    private fun previousPage() {
+        val state = _uiState.value
+        if (state.page > 0) {
+            _uiState.update { it.copy(page = it.page - 1) }
+        } else {
+            _effects.tryEmit(OnboardingEffect.Finish)
+        }
+    }
+
+    private suspend fun saveWebDavConfig() {
+        val state = _uiState.value
+        backupSettingsGateway.update {
+            it.copy(
+                webDavUrl = state.webDavUrl,
+                webDavAccount = state.webDavAccount,
+                webDavPassword = state.webDavPassword,
+            )
+        }
+    }
+
+    private fun fetchBackups() {
+        busyJob?.cancel()
+        busyJob = viewModelScope.launch {
+            _uiState.update {
+                it.copy(busyText = appCtx.getString(R.string.loading), backupNames = null)
+            }
+            try {
+                saveWebDavConfig()
+                webDavBackupUseCase.refreshConfig()
+                val names = webDavBackupUseCase.getBackupNames()
+                if (webDavBackupUseCase.isJianGuoYun && names.size > 700) {
+                    appCtx.toastOnUi("由于坚果云限制列出文件数量,部分备份可能未显示,请及时清理旧备份")
+                }
+                if (names.isEmpty()) {
+                    throw NoStackTraceException("Web dav no back up file")
+                }
+                ensureActive()
+                _uiState.update {
+                    it.copy(busyText = null, backupNames = names.toImmutableList())
+                }
+            } catch (e: CancellationException) {
+                throw e
+            } catch (e: Exception) {
+                AppLog.put("恢复备份出错WebDavError\n${e.localizedMessage}", e)
+                _uiState.update {
+                    it.copy(
+                        busyText = null,
+                        restoreErrorMessage = "WebDavError\n${e.localizedMessage}\n将从本地备份恢复。",
+                    )
+                }
+            }
+        }
+    }
+
+    private fun restoreWebDav(name: String) {
+        busyJob?.cancel()
+        busyJob = viewModelScope.launch {
+            _uiState.update { it.copy(backupNames = null, busyText = "恢复中…") }
+            try {
+                webDavBackupUseCase.restore(name)
+            } catch (e: CancellationException) {
+                throw e
+            } catch (e: Exception) {
+                AppLog.put("WebDav恢复出错\n${e.localizedMessage}", e)
+                appCtx.toastOnUi("WebDav恢复出错\n${e.localizedMessage}")
+            } finally {
+                _uiState.update { it.copy(busyText = null) }
+            }
+        }
+    }
+
+    private fun restoreLocal(uriString: String) {
+        busyJob?.cancel()
+        busyJob = viewModelScope.launch {
+            _uiState.update { it.copy(busyText = "恢复中…") }
+            try {
+                Restore.restore(appCtx, uriString.toUri())
+            } catch (e: CancellationException) {
+                throw e
+            } catch (e: Exception) {
+                AppLog.put("本地恢复出错\n${e.localizedMessage}", e)
+                appCtx.toastOnUi("本地恢复出错\n${e.localizedMessage}")
+            } finally {
+                _uiState.update { it.copy(busyText = null) }
+            }
+        }
+    }
+
+    private fun selectTheme(value: String) {
+        val theme = _uiState.value.theme
+        if (value == "13" &&
+            (theme.backgroundImageLight.isNullOrEmpty() || theme.backgroundImageDark.isNullOrEmpty())
+        ) {
+            _effects.tryEmit(OnboardingEffect.ShowToast(R.string.transparent_theme_alarm))
+            return
+        }
+        viewModelScope.launch {
+            themeSettingsGateway.update {
+                it.copy(
+                    appTheme = value,
+                    containerOpacity = if (value == "13") 0 else it.containerOpacity,
+                )
+            }
+        }
+    }
+}

+ 0 - 44
app/src/main/java/io/legado/app/ui/welcome/BookFolderFragment.kt

@@ -1,44 +0,0 @@
-package io.legado.app.ui.welcome
-
-import android.os.Bundle
-import android.view.View
-import io.legado.app.R
-import io.legado.app.base.BaseFragment
-import io.legado.app.databinding.FragmentBookFolderBinding
-import io.legado.app.domain.gateway.OtherSettingsGateway
-import io.legado.app.ui.file.HandleFileContract
-import io.legado.app.utils.viewbindingdelegate.viewBinding
-import androidx.lifecycle.lifecycleScope
-import kotlinx.coroutines.launch
-import org.koin.android.ext.android.inject
-
-class BookFolderFragment : BaseFragment(R.layout.fragment_book_folder) {
-
-    private val binding by viewBinding(FragmentBookFolderBinding::bind)
-    private val otherSettingsGateway by inject<OtherSettingsGateway>()
-
-    private val selectBookFolder = registerForActivityResult(HandleFileContract()) { result ->
-        result.uri?.let { treeUri ->
-            lifecycleScope.launch {
-                otherSettingsGateway.update { it.copy(defaultBookTreeUri = treeUri.toString()) }
-                updatePathText()
-            }
-        }
-    }
-
-    override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
-        binding.btnSelectFolder.setOnClickListener {
-            selectBookFolder.launch {
-                title = getString(R.string.select_book_folder)
-                mode = HandleFileContract.DIR_SYS
-            }
-        }
-        updatePathText()
-    }
-
-    private fun updatePathText() {
-        binding.tvFolderPath.text =
-            otherSettingsGateway.currentSettings.defaultBookTreeUri
-                ?: getString(R.string.welcome_book_folder_not_selected)
-    }
-}

+ 0 - 23
app/src/main/java/io/legado/app/ui/welcome/PrivacyFragment.kt

@@ -1,23 +0,0 @@
-package io.legado.app.ui.welcome
-
-import android.os.Bundle
-import android.view.View
-import io.legado.app.R
-import io.legado.app.base.BaseFragment
-import io.legado.app.databinding.FragmentPrivacyBinding
-import io.legado.app.utils.viewbindingdelegate.viewBinding
-
-class PrivacyFragment : BaseFragment(R.layout.fragment_privacy) {
-
-    private val binding by viewBinding(FragmentPrivacyBinding::bind)
-
-    override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
-
-        binding.tvPrivacy.text =
-            String(requireContext().assets.open("privacyPolicy.md").readBytes())
-        binding.tvDisclaimer.text =
-            String(requireContext().assets.open("disclaimer.md").readBytes())
-
-    }
-
-}

+ 0 - 27
app/src/main/java/io/legado/app/ui/welcome/ThemeFragment.kt

@@ -1,27 +0,0 @@
-package io.legado.app.ui.welcome
-
-import android.os.Bundle
-import androidx.preference.PreferenceFragmentCompat
-import io.legado.app.R
-import io.legado.app.constant.PreferKey
-import io.legado.app.lib.prefs.ThemeCardPreference
-import io.legado.app.lib.prefs.ThemeModePreference
-
-class ThemeFragment : PreferenceFragmentCompat() {
-
-    override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
-        setPreferencesFromResource(R.xml.pref_strat_theme, rootKey)
-
-        findPreference<ThemeModePreference>(PreferKey.themeMode)?.let {
-            it.setOnPreferenceChangeListener { _, _ ->
-                true
-            }
-        }
-
-        findPreference<ThemeCardPreference>(PreferKey.themePref)?.let {
-            it.setOnPreferenceChangeListener { _, _ ->
-                true
-            }
-        }
-    }
-}

+ 0 - 230
app/src/main/java/io/legado/app/ui/welcome/WebDavFragment.kt

@@ -1,230 +0,0 @@
-package io.legado.app.ui.welcome
-
-import android.content.Context
-import android.os.Bundle
-import android.text.InputType
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import android.widget.ArrayAdapter
-import androidx.core.widget.doAfterTextChanged
-import androidx.lifecycle.lifecycleScope
-import io.legado.app.R
-import io.legado.app.base.BaseFragment
-import io.legado.app.constant.AppLog
-import io.legado.app.databinding.FragmentWebdavAuthBinding
-import io.legado.app.domain.usecase.WebDavBackupUseCase
-import io.legado.app.domain.gateway.BackupSettingsGateway
-import io.legado.app.exception.NoStackTraceException
-import io.legado.app.help.config.LocalConfig
-import io.legado.app.help.coroutine.Coroutine
-import io.legado.app.help.storage.Restore
-import io.legado.app.lib.dialogs.alert
-import io.legado.app.lib.dialogs.selector
-import io.legado.app.ui.file.HandleFileContract
-import io.legado.app.ui.widget.dialog.WaitDialog
-import io.legado.app.utils.gone
-import io.legado.app.utils.toastOnUi
-import io.legado.app.utils.visible
-import kotlinx.coroutines.Dispatchers.IO
-import kotlinx.coroutines.Dispatchers.Main
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.ensureActive
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-import org.koin.android.ext.android.inject
-import splitties.init.appCtx
-import kotlin.coroutines.coroutineContext
-
-class WebDavFragment : BaseFragment(R.layout.fragment_webdav_auth) {
-
-    private val webDavBackupUseCase by inject<WebDavBackupUseCase>()
-    private val backupSettingsGateway by inject<BackupSettingsGateway>()
-    private var _binding: FragmentWebdavAuthBinding? = null
-    private val binding get() = _binding!!
-    private var restoreJob: Job? = null
-    private val waitDialog by lazy { WaitDialog(requireContext()) }
-
-    private val restoreDoc = registerForActivityResult(HandleFileContract()) {
-        it.uri?.let { uri ->
-            waitDialog.setText("恢复中…")
-            waitDialog.show()
-            val task = Coroutine.async {
-                Restore.restore(appCtx, uri)
-            }.onFinally {
-                waitDialog.dismiss()
-            }
-            waitDialog.setOnCancelListener {
-                task.cancel()
-            }
-        }
-    }
-
-    override fun onCreateView(
-        inflater: LayoutInflater, container: ViewGroup?,
-        savedInstanceState: Bundle?
-    ): View {
-        _binding = FragmentWebdavAuthBinding.inflate(inflater, container, false)
-        return binding.root
-    }
-
-    override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
-
-        val urls = listOf(
-            "https://dav.jianguoyun.com/dav/",
-            "https://webdav.aliyundrive.com/",
-            "https://soya.infini-cloud.net/dav/"
-        )
-
-        val adapter = ArrayAdapter(
-            requireContext(),
-            android.R.layout.simple_dropdown_item_1line,
-            urls
-        )
-
-        binding.editUrl.setAdapter(adapter)
-
-        val backupSettings = backupSettingsGateway.currentSettings
-        binding.editAccount.setText(backupSettings.webDavAccount)
-        binding.editPassword.setText(backupSettings.webDavPassword)
-        binding.editPassword.inputType =
-            InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
-
-        binding.etPassword.setText(LocalConfig.password ?: "")
-
-        binding.etPassword.doAfterTextChanged {
-            LocalConfig.password = it.toString()
-        }
-
-        binding.btnCheck.setOnClickListener {
-            val config = WebDavConfig(
-                url = binding.editUrl.text.toString(),
-                account = binding.editAccount.text.toString(),
-                password = binding.editPassword.text.toString()
-            )
-            lifecycleScope.launch {
-                saveWebDavConfig(config)
-                webDavBackupUseCase.test()
-            }
-        }
-
-        binding.btnRestore.setOnClickListener {
-            val config = WebDavConfig(
-                url = binding.editUrl.text.toString(),
-                account = binding.editAccount.text.toString(),
-                password = binding.editPassword.text.toString()
-            )
-            lifecycleScope.launch {
-                try {
-                    binding.progressRestore.visible()
-                    binding.btnRestore.text = ""
-                    saveWebDavConfig(config)
-                    webDavBackupUseCase.refreshConfig()
-                    restore()
-                } finally {
-                    binding.progressRestore.gone()
-                    binding.btnRestore.text = "获取备份"
-                }
-            }
-        }
-
-    }
-
-    suspend fun saveWebDavConfig(config: WebDavConfig, onSaved: (() -> Unit)? = null) {
-        backupSettingsGateway.update {
-            it.copy(
-                webDavUrl = config.url,
-                webDavAccount = config.account,
-                webDavPassword = config.password,
-            )
-        }
-        onSaved?.invoke()
-    }
-
-    fun restore() {
-        waitDialog.setText(R.string.loading)
-        waitDialog.setOnCancelListener {
-            restoreJob?.cancel()
-        }
-        waitDialog.show()
-        Coroutine.async {
-            restoreJob = coroutineContext[Job]
-            showRestoreDialog(requireContext())
-        }.onError {
-            AppLog.put("恢复备份出错WebDavError\n${it.localizedMessage}", it)
-            if (context == null) {
-                return@onError
-            }
-            alert {
-                setTitle(R.string.restore)
-                setMessage("WebDavError\n${it.localizedMessage}\n将从本地备份恢复。")
-                okButton {
-                    restoreFromLocal()
-                }
-                cancelButton()
-            }
-        }.onFinally {
-            waitDialog.dismiss()
-        }
-    }
-
-    private suspend fun showRestoreDialog(context: Context) {
-        val names = withContext(IO) { webDavBackupUseCase.getBackupNames() }
-        if (webDavBackupUseCase.isJianGuoYun && names.size > 700) {
-            context.toastOnUi("由于坚果云限制列出文件数量,部分备份可能未显示,请及时清理旧备份")
-        }
-        if (names.isNotEmpty()) {
-            coroutineContext.ensureActive()
-            withContext(Main) {
-                context.selector(
-                    title = context.getString(R.string.select_restore_file),
-                    items = names
-                ) { _, index ->
-                    if (index in names.indices) {
-                        restoreWebDav(names[index])
-                    }
-                }
-            }
-        } else {
-            throw NoStackTraceException("Web dav no back up file")
-        }
-    }
-
-    private fun restoreWebDav(name: String) {
-        waitDialog.setText("恢复中…")
-        waitDialog.show()
-        val task = Coroutine.async {
-            webDavBackupUseCase.restore(name)
-        }.onError {
-            AppLog.put("WebDav恢复出错\n${it.localizedMessage}", it)
-            appCtx.toastOnUi("WebDav恢复出错\n${it.localizedMessage}")
-        }.onFinally {
-            waitDialog.dismiss()
-        }
-        waitDialog.setOnCancelListener {
-            task.cancel()
-        }
-    }
-
-    private fun restoreFromLocal() {
-        restoreDoc.launch {
-            title = getString(R.string.select_restore_file)
-            mode = HandleFileContract.FILE
-            allowExtensions = arrayOf("zip")
-        }
-    }
-
-
-    override fun onDestroyView() {
-        super.onDestroyView()
-        waitDialog.dismiss() // 确保释放
-        _binding = null
-    }
-
-}
-
-data class WebDavConfig(
-    val url: String,
-    val account: String,
-    val password: String
-)

+ 16 - 95
app/src/main/java/io/legado/app/ui/welcome/WelcomeActivity.kt

@@ -1,103 +1,24 @@
 package io.legado.app.ui.welcome
 package io.legado.app.ui.welcome
 
 
-
-import android.os.Bundle
-import androidx.activity.addCallback
-import androidx.viewpager2.adapter.FragmentStateAdapter
-import androidx.viewpager2.widget.ViewPager2
-import io.legado.app.base.BaseActivity
-import io.legado.app.databinding.ActivityWelcomeBinding
-import io.legado.app.help.config.LocalConfig
+import androidx.compose.runtime.Composable
+import io.legado.app.base.BaseComposeActivity
+import io.legado.app.feature.onboarding.OnboardingRouteScreen
 import io.legado.app.ui.main.MainActivity
 import io.legado.app.ui.main.MainActivity
-import io.legado.app.utils.viewbindingdelegate.viewBinding
-
-class WelcomeActivity : BaseActivity<ActivityWelcomeBinding>() {
-
-    override val binding by viewBinding(ActivityWelcomeBinding::inflate)
-
-    private val pages = listOf(
-        PrivacyFragment(),
-        WebDavFragment(),
-        BookFolderFragment(),
-        ThemeFragment()
-    )
-
-    override fun onCreate(savedInstanceState: Bundle?) {
-        super.onCreate(savedInstanceState)
 
 
-        onBackPressedDispatcher.addCallback(this) {
-            val current = binding.viewPager.currentItem
-            if (current > 0) {
-                binding.viewPager.currentItem = current - 1
-            } else {
+/**
+ * 欢迎引导兼容宿主。Manifest 入口与 MainActivity 首启 Intent 依赖本类名,
+ * 故保留 Activity;实际状态与行为在 feature/onboarding。
+ */
+class WelcomeActivity : BaseComposeActivity() {
+
+    @Composable
+    override fun Content() {
+        OnboardingRouteScreen(
+            onFinish = { finish() },
+            onNavigateHome = {
+                startActivity(MainActivity.createHomeIntent(this@WelcomeActivity))
                 finish()
                 finish()
             }
             }
-        }
-
-        updateProgress(0)
-
-        binding.viewPager.adapter = object : FragmentStateAdapter(this) {
-            override fun getItemCount() = pages.size
-            override fun createFragment(position: Int) = pages[position]
-        }
-
-        binding.viewPager.isUserInputEnabled = false
-
-        binding.viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
-            override fun onPageSelected(position: Int) {
-                super.onPageSelected(position)
-                binding.btnNext.text = when (position) {
-                    0 -> "阅读并同意" // PrivacyFragment
-                    pages.lastIndex -> "完成"
-                    else -> "下一步"
-                }
-                binding.tvTitle.text = when (position) {
-                    0 -> "欢迎!" // PrivacyFragment
-                    1 -> "备份与恢复"
-                    2 -> "书籍文件夹"
-                    else -> "主题样式"
-                }
-                binding.tvSummary.text = when (position) {
-                    0 -> "请先阅读应用的服务条款与用户协议。"
-                    1 -> "此处可设置云同步与恢复应用备份。"
-                    2 -> "请选择保存本地书籍的文件夹。"
-                    else -> "在这里设置您喜爱的样式。"
-                }
-                updateProgress(position)
-            }
-        })
-
-        // 初始化按钮文字
-        binding.btnNext.text = "阅读并同意"
-
-        binding.btnNext.setOnClickListener {
-            val current = binding.viewPager.currentItem
-            when (current) {
-                0 -> {
-                    LocalConfig.privacyPolicyOk = true
-                    binding.viewPager.currentItem = 1
-                }
-
-                pages.lastIndex -> {
-                    updateProgress(2)
-                    finishSetup()
-                }
-
-                else -> {
-                    binding.viewPager.currentItem = current + 1
-                }
-            }
-        }
-    }
-
-    private fun updateProgress(position: Int) {
-        val progressMax = pages.size
-        val progress = (position * 100 / progressMax)
-        binding.progressBar.setProgress(progress, true)
-    }
-
-    private fun finishSetup() {
-        startActivity(MainActivity.createHomeIntent(this))
-        finish()
+        )
     }
     }
 }
 }

+ 0 - 77
app/src/main/res/layout/activity_welcome.xml

@@ -1,77 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    android:orientation="vertical"
-    android:layout_width="match_parent"
-    android:layout_height="match_parent"
-    android:animateLayoutChanges="true"
-    android:fitsSystemWindows="true">
-
-    <LinearLayout
-        android:id="@+id/ll_info"
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:orientation="vertical"
-        app:layout_constraintStart_toStartOf="parent"
-        app:layout_constraintTop_toTopOf="parent">
-
-        <ImageView
-            android:layout_width="56dp"
-            android:layout_height="wrap_content"
-            android:layout_marginStart="16dp"
-            android:layout_marginTop="48dp"
-            android:importantForAccessibility="no"
-            android:scaleType="centerCrop"
-            android:src="@drawable/ic_launcher_foreground" />
-
-        <TextView
-            android:id="@+id/tv_title"
-            android:layout_width="match_parent"
-            android:layout_height="wrap_content"
-            android:layout_marginHorizontal="16dp"
-            android:text="@string/welcome_title"
-            android:textSize="36sp"
-            android:textStyle="bold" />
-
-        <TextView
-            android:id="@+id/tv_summary"
-            android:layout_width="match_parent"
-            android:layout_height="wrap_content"
-            android:textSize="16sp"
-            android:layout_marginHorizontal="16dp"
-            android:layout_marginTop="8dp"
-            android:layout_marginBottom="16dp"
-            android:text="@string/first_read"
-            android:textStyle="bold" />
-
-        <com.google.android.material.progressindicator.LinearProgressIndicator
-            android:id="@+id/progress_bar"
-            android:layout_width="match_parent"
-            android:layout_height="wrap_content"
-            app:indicatorColor="?attr/colorSecondary"
-            app:waveAmplitude="4dp"
-            app:waveSpeed="32dp"
-            app:wavelength="80dp" />
-    </LinearLayout>
-
-    <androidx.viewpager2.widget.ViewPager2
-        android:id="@+id/viewPager"
-        android:layout_width="match_parent"
-        android:layout_height="0dp"
-        android:layout_marginTop="8dp"
-        android:layout_weight="1"
-        app:layout_constraintBottom_toTopOf="@id/btnNext"
-        app:layout_constraintTop_toBottomOf="@id/ll_info" />
-
-    <com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
-        android:id="@+id/btnNext"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:layout_gravity="end"
-        android:layout_margin="16dp"
-        android:text="@string/text"
-        app:icon="@drawable/ic_arrow_right"
-        app:layout_constraintBottom_toBottomOf="parent"
-        app:layout_constraintEnd_toEndOf="parent" />
-
-</androidx.constraintlayout.widget.ConstraintLayout>

+ 0 - 61
app/src/main/res/layout/fragment_book_folder.xml

@@ -1,61 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    android:layout_width="match_parent"
-    android:layout_height="match_parent"
-    android:orientation="vertical"
-    android:paddingHorizontal="16dp">
-
-    <TextView
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:text="@string/welcome_book_folder_tip"
-        android:textColor="?attr/colorOnSurfaceVariant"
-        android:textSize="14sp" />
-
-    <com.google.android.material.card.MaterialCardView
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:layout_marginTop="12dp"
-        app:cardBackgroundColor="?attr/colorSurfaceContainerLow"
-        app:cardCornerRadius="16dp"
-        app:cardElevation="0dp"
-        app:strokeColor="?attr/colorOutlineVariant"
-        app:strokeWidth="1dp">
-
-        <LinearLayout
-            android:layout_width="match_parent"
-            android:layout_height="wrap_content"
-            android:orientation="vertical"
-            android:padding="14dp">
-
-            <TextView
-                android:layout_width="match_parent"
-                android:layout_height="wrap_content"
-                android:text="@string/select_book_folder"
-                android:textColor="?attr/colorOnSurface"
-                android:textStyle="bold" />
-
-            <TextView
-                android:id="@+id/tvFolderPath"
-                android:layout_width="match_parent"
-                android:layout_height="wrap_content"
-                android:layout_marginTop="8dp"
-                android:maxLines="3"
-                android:textColor="?attr/colorOnSurfaceVariant"
-                android:textSize="13sp" />
-
-            <com.google.android.material.button.MaterialButton
-                android:id="@+id/btnSelectFolder"
-                style="@style/Widget.Material3Expressive.Button.OutlinedButton"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:layout_marginTop="12dp"
-                android:text="@string/select_folder" />
-
-        </LinearLayout>
-
-    </com.google.android.material.card.MaterialCardView>
-
-</LinearLayout>
-

+ 0 - 67
app/src/main/res/layout/fragment_privacy.xml

@@ -1,67 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    android:layout_width="match_parent"
-    android:layout_height="match_parent"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    android:orientation="vertical"
-    android:paddingHorizontal="16dp"
-    android:paddingBottom="8dp">
-
-    <androidx.core.widget.NestedScrollView
-        android:layout_width="match_parent"
-        android:layout_height="match_parent"
-        app:layout_constraintBottom_toBottomOf="parent"
-        app:layout_constraintEnd_toEndOf="parent"
-        app:layout_constraintStart_toStartOf="parent"
-        app:layout_constraintTop_toTopOf="parent">
-
-        <LinearLayout
-            android:layout_width="match_parent"
-            android:layout_height="wrap_content"
-            android:paddingBottom="40dp"
-            android:orientation="vertical">
-
-            <TextView
-                android:layout_width="match_parent"
-                android:layout_height="wrap_content"
-                android:layout_marginBottom="8dp"
-                android:text="@string/privacy_policy"
-                android:textColor="?attr/colorSecondary"
-                android:textSize="16sp"
-                android:textStyle="bold" />
-
-            <TextView
-                android:id="@+id/tvPrivacy"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:layout_marginBottom="16dp"
-                android:text="@string/text" />
-
-            <TextView
-                android:layout_width="match_parent"
-                android:layout_height="wrap_content"
-                android:layout_marginBottom="8dp"
-                android:text="@string/disclaimer"
-                android:textColor="?attr/colorSecondary"
-                android:textSize="16sp"
-                android:textStyle="bold" />
-
-            <TextView
-                android:id="@+id/tvDisclaimer"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:text="@string/text" />
-        </LinearLayout>
-
-    </androidx.core.widget.NestedScrollView>
-
-    <ImageView
-        android:layout_width="match_parent"
-        android:layout_height="60dp"
-        android:importantForAccessibility="no"
-        android:src="@drawable/bg_gradient_mask"
-        app:layout_constraintBottom_toBottomOf="parent"
-        app:layout_constraintEnd_toEndOf="parent"
-        app:layout_constraintStart_toStartOf="parent" />
-
-</androidx.constraintlayout.widget.ConstraintLayout>

+ 0 - 118
app/src/main/res/layout/fragment_webdav_auth.xml

@@ -1,118 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    android:layout_width="match_parent"
-    android:layout_height="match_parent"
-    android:orientation="vertical"
-    android:paddingHorizontal="16dp">
-
-    <com.google.android.material.textfield.TextInputLayout
-        style="@style/Widget.Material3.TextInputLayout.OutlinedBox.Dense.ExposedDropdownMenu"
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:layout_marginBottom="8dp"
-        android:hint="@string/web_dav_url">
-
-        <com.google.android.material.textfield.MaterialAutoCompleteTextView
-            android:id="@+id/editUrl"
-            android:layout_width="match_parent"
-            android:layout_height="wrap_content"
-            android:completionThreshold="1"
-            android:imeOptions="actionDone"
-            android:inputType="textUri" />
-    </com.google.android.material.textfield.TextInputLayout>
-
-    <com.google.android.material.textfield.TextInputLayout
-        style="@style/Widget.Material3.TextInputLayout.OutlinedBox.Dense"
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:layout_marginBottom="8dp"
-        android:hint="@string/web_dav_account">
-
-        <com.google.android.material.textfield.TextInputEditText
-            android:id="@+id/editAccount"
-            android:layout_width="match_parent"
-            android:layout_height="wrap_content"
-            android:text="@string/set_local_password" />
-    </com.google.android.material.textfield.TextInputLayout>
-
-    <com.google.android.material.textfield.TextInputLayout
-        style="@style/Widget.Material3.TextInputLayout.OutlinedBox.Dense"
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:layout_marginBottom="8dp"
-        android:hint="@string/web_dav_pw"
-        app:endIconMode="password_toggle">
-
-        <com.google.android.material.textfield.TextInputEditText
-            android:id="@+id/editPassword"
-            android:layout_width="match_parent"
-            android:layout_height="wrap_content"
-            android:text="@string/set_local_password" />
-    </com.google.android.material.textfield.TextInputLayout>
-
-    <LinearLayout
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:orientation="horizontal">
-
-        <androidx.constraintlayout.widget.ConstraintLayout
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            android:layout_marginEnd="8dp"
-            android:layout_weight="1">
-
-            <com.google.android.material.button.MaterialButton
-                android:id="@+id/btnRestore"
-                style="@style/Widget.Material3Expressive.Button.OutlinedButton"
-                android:layout_width="match_parent"
-                android:layout_height="wrap_content"
-                android:text="@string/restore"
-                app:layout_constraintBottom_toBottomOf="parent"
-                app:layout_constraintEnd_toEndOf="parent"
-                app:layout_constraintStart_toStartOf="parent"
-                app:layout_constraintTop_toTopOf="parent" />
-
-            <com.google.android.material.loadingindicator.LoadingIndicator
-                android:id="@+id/progressRestore"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:visibility="gone"
-                app:layout_constraintBottom_toBottomOf="parent"
-                app:layout_constraintEnd_toEndOf="parent"
-                app:layout_constraintStart_toStartOf="parent"
-                app:layout_constraintTop_toTopOf="parent" />
-
-        </androidx.constraintlayout.widget.ConstraintLayout>
-
-        <com.google.android.material.button.MaterialButton
-            android:id="@+id/btnCheck"
-            style="@style/Widget.Material3Expressive.Button.TonalButton"
-            android:layout_width="0dp"
-            android:layout_height="wrap_content"
-            android:layout_marginStart="8dp"
-            android:layout_weight="1"
-            android:text="@string/action_save" />
-
-    </LinearLayout>
-
-    <TextView
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:layout_marginTop="8dp"
-        android:text="@string/set_local_password_summary"
-        android:textSize="12sp" />
-
-    <com.google.android.material.textfield.TextInputLayout
-        style="@style/Widget.Material3.TextInputLayout.OutlinedBox.Dense"
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:hint="@string/set_local_password">
-
-        <com.google.android.material.textfield.TextInputEditText
-            android:id="@+id/etPassword"
-            android:layout_width="match_parent"
-            android:layout_height="wrap_content" />
-    </com.google.android.material.textfield.TextInputLayout>
-
-</LinearLayout>

+ 0 - 14
app/src/main/res/xml/pref_strat_theme.xml

@@ -1,14 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<androidx.preference.PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
-    <io.legado.app.lib.prefs.ThemeModePreference
-        android:defaultValue="0"
-        android:entries="@array/theme_mode"
-        android:entryValues="@array/theme_mode_v"
-        android:key="themeMode" />
-
-    <io.legado.app.lib.prefs.ThemeCardPreference
-        android:defaultValue="0"
-        android:entries="@array/themes_item"
-        android:entryValues="@array/themes_value"
-        android:key="app_theme" />
-</androidx.preference.PreferenceScreen>