Просмотр исходного кода

refactor(更新)
- 备份完成

周文健 2 месяцев назад
Родитель
Сommit
ab14373930
37 измененных файлов с 929 добавлено и 38 удалено
  1. 23 1
      app/src/main/assets/i18n/zh-CN.csv
  2. 185 7
      app/src/main/java/com/grkj/iscs/features/main/fragment/data_manage/BackupAndRestoreFragment.kt
  3. 68 4
      app/src/main/java/com/grkj/iscs/features/main/viewmodel/data_manage/BackupAndRestoreViewModel.kt
  4. 9 0
      app/src/main/res/drawable/bg_tip_red.xml
  5. 37 5
      app/src/main/res/layout-land/fragment_backup_and_restore.xml
  6. 40 8
      app/src/main/res/layout/fragment_backup_and_restore.xml
  7. 2 2
      app/src/main/res/layout/item_backup.xml
  8. 5 0
      data/src/main/java/com/grkj/data/data/EventConstants.kt
  9. 27 5
      data/src/main/java/com/grkj/data/database/RoomBackupManager.kt
  10. 3 0
      data/src/main/java/com/grkj/data/database/RoomBackupWorker.kt
  11. 5 5
      data/src/main/java/com/grkj/data/database/SimpleBackupPrefs.kt
  12. 65 0
      data/src/main/java/com/grkj/data/enums/BackupFrequencyWeekEnum.kt
  13. 25 0
      data/src/main/java/com/grkj/data/utils/event/BackupCompleteEvent.kt
  14. 53 0
      shared/src/main/java/com/grkj/shared/utils/FilePickerUtils.kt
  15. 196 0
      shared/src/main/java/com/grkj/shared/utils/SAFHelper.kt
  16. 1 1
      shared/src/main/java/com/grkj/shared/utils/event/EventHelper.kt
  17. 58 0
      ui-base/src/main/java/com/grkj/ui_base/dialog/WheelTimePickerDialog.kt
  18. 1 0
      ui-base/src/main/java/com/grkj/ui_base/utils/event/BottomNavVisibilityEvent.kt
  19. 1 0
      ui-base/src/main/java/com/grkj/ui_base/utils/event/CardSwipeEvent.kt
  20. 1 0
      ui-base/src/main/java/com/grkj/ui_base/utils/event/CurrentModeEvent.kt
  21. 1 0
      ui-base/src/main/java/com/grkj/ui_base/utils/event/DeviceExceptionEvent.kt
  22. 1 0
      ui-base/src/main/java/com/grkj/ui_base/utils/event/DeviceTakeUpdateEvent.kt
  23. 1 0
      ui-base/src/main/java/com/grkj/ui_base/utils/event/FlashTipEvent.kt
  24. 1 0
      ui-base/src/main/java/com/grkj/ui_base/utils/event/GetTicketStatusEvent.kt
  25. 1 0
      ui-base/src/main/java/com/grkj/ui_base/utils/event/JumpViewEvent.kt
  26. 1 0
      ui-base/src/main/java/com/grkj/ui_base/utils/event/LoadingEvent.kt
  27. 1 0
      ui-base/src/main/java/com/grkj/ui_base/utils/event/LogoutEvent.kt
  28. 1 0
      ui-base/src/main/java/com/grkj/ui_base/utils/event/ModbusInitCompleteEvent.kt
  29. 1 0
      ui-base/src/main/java/com/grkj/ui_base/utils/event/RFIDCardReadEvent.kt
  30. 1 0
      ui-base/src/main/java/com/grkj/ui_base/utils/event/RestartAppEvent.kt
  31. 1 0
      ui-base/src/main/java/com/grkj/ui_base/utils/event/StartModbusEvent.kt
  32. 1 0
      ui-base/src/main/java/com/grkj/ui_base/utils/event/SwitchCollectionUpdateEvent.kt
  33. 1 0
      ui-base/src/main/java/com/grkj/ui_base/utils/event/TicketFinishedEvent.kt
  34. 1 0
      ui-base/src/main/java/com/grkj/ui_base/utils/event/UpdateTicketProgressEvent.kt
  35. 45 0
      ui-base/src/main/res/layout-land/dialog_wheel_time_pick.xml
  36. 45 0
      ui-base/src/main/res/layout/dialog_wheel_time_pick.xml
  37. 20 0
      ui-base/src/main/res/layout/layout_no_backup.xml

+ 23 - 1
app/src/main/assets/i18n/zh-CN.csv

@@ -650,4 +650,26 @@ backup_range,text,备份数量上限提示,范围文本,范围:{0}
 restore,text,还原文本,还原
 common_batch_export,text,通用批量导出文本,批量导出
 common_batch_delete,text,通用批量删除文本,批量删除
-common_export,text,通用导出文本,导出
+common_export,text,通用导出文本,导出
+MON,text,周一,星期一
+TUE,text,周二,星期二
+WED,text,周三,星期三
+THU,text,周四,星期四
+FRI,text,周五,星期五
+SAT,text,周六,星期六
+SUN,text,周日,星期日
+backup_frequency_every_day,text,备份频率每天,每天
+please_select_backup_frequency,text,选择备份频率提示,请选择备份频率
+maximumNumberOfBackupsNotCorrect,text,备份数量上限不正确提示,请填写正确的备份数量上限
+please_select_time,text,通用选择时间标题,请选择时间
+backup_now_please_wait,text,备份等待文本,正在备份中,请稍等……
+backup_success,text,备份成功文本,备份成功
+backup_failed,text,备份失败文本,备份失败
+delete_backup_file_confirm,text,删除备份提示,是否确认删除该备份,删除后备份无法恢复。
+delete_selected_backup_file_confirm,text,删除选中备份提示,是否确认删除选中备份,删除后备份无法恢复。
+restore_backup_confirm,text,还原备份提醒,还原备份将清除备份日期到当前时间的所有数据,是否确认还原备份?
+restore_backup_success,text,还原备份成功文本,备份还原成功
+export_success,text,通用导出成功,导出成功
+no_backup_data,text,暂无备份数据,暂无备份数据
+loading_backup,text,加载备份中,正在读取备份文件
+max_backup_tip,text,备份达到上限,备份数量已经达到上限,继续备份将移除最老的数据。

+ 185 - 7
app/src/main/java/com/grkj/iscs/features/main/fragment/data_manage/BackupAndRestoreFragment.kt

@@ -1,17 +1,30 @@
 package com.grkj.iscs.features.main.fragment.data_manage
 
+import androidx.activity.result.ActivityResultCallback
 import androidx.fragment.app.viewModels
 import com.drake.brv.BindingAdapter
 import com.drake.brv.utils.linear
 import com.drake.brv.utils.models
 import com.drake.brv.utils.setup
+import com.grkj.data.data.EventConstants
 import com.grkj.data.database.BackupScheduler
+import com.grkj.data.database.ISCSDatabase
 import com.grkj.data.database.RoomBackupManager
+import com.grkj.data.enums.BackupFrequencyWeekEnum
+import com.grkj.data.utils.event.BackupCompleteEvent
 import com.grkj.iscs.R
 import com.grkj.iscs.databinding.FragmentBackupAndRestoreBinding
 import com.grkj.iscs.databinding.ItemBackupBinding
+import com.grkj.iscs.features.main.dialog.TextDropDownDialog
 import com.grkj.iscs.features.main.viewmodel.data_manage.BackupAndRestoreViewModel
+import com.grkj.shared.model.EventBean
+import com.grkj.shared.utils.FilePickerUtils
+import com.grkj.shared.utils.SAFHelper.moveFileToDir
+import com.grkj.shared.utils.i18n.I18nManager
 import com.grkj.ui_base.base.BaseFragment
+import com.grkj.ui_base.dialog.TipDialog
+import com.grkj.ui_base.dialog.WheelTimePickerDialog
+import com.grkj.ui_base.utils.event.LoadingEvent
 import com.sik.sikcore.extension.setDebouncedClickListener
 import dagger.hilt.android.AndroidEntryPoint
 
@@ -26,17 +39,111 @@ class BackupAndRestoreFragment : BaseFragment<FragmentBackupAndRestoreBinding>()
         return R.layout.fragment_backup_and_restore
     }
 
+    /**
+     * 文件选择器
+     */
+    private lateinit var filePickerUtils: FilePickerUtils
+
     override fun initView() {
         binding.back.setDebouncedClickListener {
             navController.popBackStack()
         }
+        binding.maximumNumberOfBackups.setText("${viewModel.scheduleData.keep}")
+        binding.statusRg.setOnCheckedChangeListener(null)
+        binding.enableRb.isChecked = viewModel.scheduleData.enabled
+        binding.disableRb.isChecked = !viewModel.scheduleData.enabled
+        filePickerUtils = FilePickerUtils(this)
+        binding.statusRg.setOnCheckedChangeListener { _, checkedId ->
+            viewModel.scheduleData.enabled = checkedId == binding.enableRb.id
+        }
+        viewModel.selectedBackupFrequencyData =
+            BackupFrequencyWeekEnum.getSelectedDaysText(viewModel.scheduleData.daysMask)
+        binding.backupFrequency.text =
+            if (viewModel.selectedBackupFrequencyData.size == 7) I18nManager.t("backup_frequency_every_day")
+            else viewModel.selectedBackupFrequencyData.joinToString(",") { it.showText }
+        binding.backupFrequency.setDebouncedClickListener {
+            TextDropDownDialog.showMulti(
+                viewModel.backupFrequencyData,
+                binding.backupFrequency
+            ) { selectedData ->
+                viewModel.selectedBackupFrequencyData = BackupFrequencyWeekEnum.values()
+                    .filter {
+                        selectedData?.map { it.getShowText() }?.contains(it.showText) == true
+                    }
+                binding.backupFrequency.text =
+                    if (viewModel.selectedBackupFrequencyData.size == 7) I18nManager.t("backup_frequency_every_day")
+                    else viewModel.selectedBackupFrequencyData.joinToString(",") { it.showText }
+            }
+        }
+        binding.save.setDebouncedClickListener {
+            if (checkSchedule()) {
+                viewModel.scheduleData.keep = binding.maximumNumberOfBackups.text.toString().toInt()
+                viewModel.selectedBackupFrequencyData.map {
+                    it.type
+                }.let {
+                    viewModel.scheduleData.daysMask =
+                        BackupFrequencyWeekEnum.getMaskFromTypes(it)
+                }
+                viewModel.setAndApply().observe(this) {
+                    showToast(I18nManager.t("save_success"))
+                }
+            }
+        }
+        binding.backupPath.text = RoomBackupManager.backupDir.absolutePath
         binding.backupPath.isEnabled = false
+        binding.backupTime.text = "${viewModel.scheduleData.hour}:${viewModel.scheduleData.minute}"
+        binding.backupTime.setDebouncedClickListener {
+            val selectedTime = "${viewModel.scheduleData.hour}:${viewModel.scheduleData.minute}"
+            WheelTimePickerDialog.show(selectedTime) {
+                val split = it.split(":")
+                viewModel.scheduleData.hour = split[0].toInt()
+                viewModel.scheduleData.minute = split[1].toInt()
+                binding.backupTime.text = it
+            }
+        }
         binding.backupNow.setDebouncedClickListener {
-            BackupScheduler.backupNow(requireContext())
+            if (viewModel.backupItemDatas.size == viewModel.scheduleData.keep) {
+                TipDialog.showInfo(I18nManager.t("max_backup_tip"), onConfirmClick = {
+                    LoadingEvent.sendLoadingEvent(I18nManager.t("backup_now_please_wait"))
+                    BackupScheduler.backupNow(requireContext())
+                })
+            } else {
+                LoadingEvent.sendLoadingEvent(I18nManager.t("backup_now_please_wait"))
+                BackupScheduler.backupNow(requireContext())
+            }
+        }
+        binding.batchDelete.setDebouncedClickListener {
+            TipDialog.showInfo(
+                I18nManager.t("delete_selected_backup_file_confirm"),
+                onConfirmClick = {
+                    viewModel.deleteBackupFiles(viewModel.backupItemDatas.filter { it.isSelected })
+                        .observe(this) {
+                            getData()
+                            showToast(I18nManager.t("delete_success"))
+                            viewModel.backupItemDatas.forEach {
+                                it.isSelected = false
+                            }
+                            checkAndSetSelectAllListener()
+                            binding.listRv.adapter?.notifyDataSetChanged()
+                        }
+                })
         }
         binding.batchExport.setDebouncedClickListener {
-
+            filePickerUtils.pickDirectory { treeUri ->
+                viewModel.backupItemDatas.filter { it.isSelected }.forEach {
+                    treeUri?.let { treeUri ->
+                        requireContext().moveFileToDir(treeUri, it.file)
+                    }
+                }
+                viewModel.backupItemDatas.forEach {
+                    it.isSelected = false
+                }
+                checkAndSetSelectAllListener()
+                binding.listRv.adapter?.notifyDataSetChanged()
+                showToast(I18nManager.t("export_success"))
+            }
         }
+        binding.state.emptyLayout = com.grkj.ui_base.R.layout.layout_no_backup
         binding.listRv.linear().setup {
             addType<RoomBackupManager.BackupItem>(R.layout.item_backup)
             onBind {
@@ -45,14 +152,67 @@ class BackupAndRestoreFragment : BaseFragment<FragmentBackupAndRestoreBinding>()
         }
     }
 
+    /**
+     * 检查并设置是否全选
+     */
+    private fun checkAndSetSelectAllListener() {
+        binding.selectAll.setOnCheckedChangeListener(null)
+        binding.selectAll.isChecked = viewModel.backupItemDatas.all { it.isSelected }
+        binding.selectAll.setOnCheckedChangeListener { v, isChecked ->
+            viewModel.backupItemDatas.forEach { it.isSelected = isChecked }
+            binding.listRv.adapter?.notifyDataSetChanged()
+        }
+    }
+
+    override fun onEvent(event: EventBean<Any>) {
+        super.onEvent(event)
+        when (event.code) {
+            EventConstants.EVENT_BACKUP_COMPLETE_CODE -> {
+                (event.data as BackupCompleteEvent).let {
+                    LoadingEvent.sendLoadingEvent()
+                    if (it.backupResult) {
+                        showToast(I18nManager.t("backup_success"))
+                    } else {
+                        showToast(I18nManager.t("backup_failed"))
+                    }
+                    getData()
+                }
+            }
+        }
+    }
+
+    /**
+     * 检查计划
+     */
+    private fun checkSchedule(): Boolean {
+        if (viewModel.selectedBackupFrequencyData.isEmpty()) {
+            showToast(I18nManager.t("please_select_backup_frequency"))
+            return false
+        }
+        val keep = binding.maximumNumberOfBackups.text.toString().toInt()
+        if (keep !in 5..20) {
+            showToast(I18nManager.t("maximumNumberOfBackupsNotCorrect"))
+            return false
+        }
+        return true
+    }
+
     override fun initData() {
         super.initData()
         getData()
     }
 
     private fun getData() {
+        LoadingEvent.sendLoadingEvent(I18nManager.t("loading_backup"))
         viewModel.getBackupList().observe(this) {
+            LoadingEvent.sendLoadingEvent()
+            if (it.isEmpty()) {
+                binding.state.showEmpty()
+            } else {
+                binding.state.showContent()
+            }
             binding.listRv.models = it
+            checkAndSetSelectAllListener()
         }
     }
 
@@ -60,16 +220,34 @@ class BackupAndRestoreFragment : BaseFragment<FragmentBackupAndRestoreBinding>()
         val item = getModel<RoomBackupManager.BackupItem>()
         val itemBinding = getBinding<ItemBackupBinding>()
         itemBinding.backupName.text = item.name
+        itemBinding.select.setOnCheckedChangeListener(null)
+        itemBinding.select.isChecked = item.isSelected
+        itemBinding.select.setOnCheckedChangeListener { v, isChecked ->
+            item.isSelected = isChecked
+            checkAndSetSelectAllListener()
+        }
         itemBinding.delete.setDebouncedClickListener {
-
+            TipDialog.showInfo(I18nManager.t("delete_backup_file_confirm"), onConfirmClick = {
+                viewModel.deleteBackupFile(item).observe(this@BackupAndRestoreFragment) {
+                    showToast(I18nManager.t("delete_success"))
+                    getData()
+                }
+            })
         }
         itemBinding.export.setDebouncedClickListener {
-
+            filePickerUtils.pickDirectory { treeUri ->
+                treeUri?.let { treeUri ->
+                    requireContext().moveFileToDir(treeUri, item.file)
+                }
+                showToast(I18nManager.t("export_success"))
+            }
         }
         itemBinding.restore.setDebouncedClickListener {
-            viewModel.restoreBackUp(item).observe(this@BackupAndRestoreFragment) {
-                showToast("还原成功")
-            }
+            TipDialog.showInfo(I18nManager.t("restore_backup_confirm"), onConfirmClick = {
+                viewModel.restoreBackUp(item).observe(this@BackupAndRestoreFragment) {
+                    showToast(I18nManager.t("restore_backup_success"))
+                }
+            })
         }
     }
 }

+ 68 - 4
app/src/main/java/com/grkj/iscs/features/main/viewmodel/data_manage/BackupAndRestoreViewModel.kt

@@ -2,12 +2,16 @@ package com.grkj.iscs.features.main.viewmodel.data_manage
 
 import androidx.lifecycle.LiveData
 import androidx.lifecycle.liveData
+import com.grkj.data.database.BackupScheduler
 import com.grkj.data.database.RoomBackupManager
+import com.grkj.data.database.SimpleBackupConfig
+import com.grkj.data.database.SimpleBackupPrefs
+import com.grkj.data.enums.BackupFrequencyWeekEnum
+import com.grkj.iscs.features.main.dialog.TextDropDownDialog
 import com.grkj.ui_base.base.BaseViewModel
 import com.sik.sikcore.SIKCore
 import dagger.hilt.android.lifecycle.HiltViewModel
 import kotlinx.coroutines.Dispatchers
-import okhttp3.Dispatcher
 import javax.inject.Inject
 
 /**
@@ -15,6 +19,52 @@ import javax.inject.Inject
  */
 @HiltViewModel
 class BackupAndRestoreViewModel @Inject constructor() : BaseViewModel() {
+    /**
+     * 计划数据
+     */
+    var scheduleData: SimpleBackupConfig = SimpleBackupPrefs.get(SIKCore.getApplication())
+
+    /**
+     * 备份数据
+     */
+    var backupItemDatas: MutableList<RoomBackupManager.BackupItem> = mutableListOf()
+
+    /**
+     * 备份频率数据
+     */
+    val backupFrequencyData: List<TextDropDownDialog.SimpleTextDropDownEntity>
+        get() = BackupFrequencyWeekEnum.values().map {
+            TextDropDownDialog.SimpleTextDropDownEntity(
+                dataId = it.type.toLong(),
+                dataText = it.showText,
+            ).apply {
+                setSelected(selectedBackupFrequencyData.map { it.showText }
+                    .contains(getShowText()))
+            }
+        }
+
+    /**
+     * 选择的备份频率数据
+     */
+    var selectedBackupFrequencyData: List<BackupFrequencyWeekEnum> = listOf()
+
+    /**
+     * 设置并应用
+     */
+    fun setAndApply(): LiveData<Boolean> {
+        return liveData(Dispatchers.IO) {
+            BackupScheduler.setAndApply(
+                SIKCore.getApplication(),
+                scheduleData.enabled,
+                scheduleData.hour,
+                scheduleData.minute,
+                scheduleData.daysMask,
+                scheduleData.keep
+            )
+            emit(true)
+        }
+    }
+
     /**
      * 备份还原
      */
@@ -30,17 +80,31 @@ class BackupAndRestoreViewModel @Inject constructor() : BaseViewModel() {
      */
     fun getBackupList(): LiveData<List<RoomBackupManager.BackupItem>> {
         return liveData(Dispatchers.IO) {
-            emit(RoomBackupManager.listBackups(SIKCore.getApplication()))
+            backupItemDatas =
+                RoomBackupManager.listBackups(SIKCore.getApplication()).toMutableList()
+            emit(backupItemDatas)
         }
     }
 
     /**
      * 删除备份文件
      */
-    fun deleteBackupFile(item: RoomBackupManager.BackupItem): LiveData<Boolean>{
-        return liveData(Dispatchers.IO){
+    fun deleteBackupFile(item: RoomBackupManager.BackupItem): LiveData<Boolean> {
+        return liveData(Dispatchers.IO) {
             emit(RoomBackupManager.deleteBackup(item.file))
         }
     }
 
+    /**
+     * 删除多备份文件
+     */
+    fun deleteBackupFiles(items: List<RoomBackupManager.BackupItem>): LiveData<Boolean> {
+        return liveData(Dispatchers.IO) {
+            for (item in items) {
+                RoomBackupManager.deleteBackup(item.file)
+            }
+            emit(true)
+        }
+    }
+
 }

+ 9 - 0
app/src/main/res/drawable/bg_tip_red.xml

@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+<shape xmlns:android="http://schemas.android.com/apk/res/android"
+    android:shape="rectangle">
+    <corners android:radius="4dp" />
+    <solid android:color="#fa5252" />
+    <stroke
+        android:width="1dp"
+        android:color="#e43636" />
+</shape>

+ 37 - 5
app/src/main/res/layout-land/fragment_backup_and_restore.xml

@@ -93,6 +93,7 @@
                         android:layout_height="1dp"
                         android:layout_weight="1" />
 
+
                     <TextView
                         android:id="@+id/backup_now"
                         android:layout_width="wrap_content"
@@ -158,16 +159,17 @@
                         app:layout_constraintEnd_toEndOf="@+id/end_line"
                         app:layout_constraintTop_toBottomOf="@+id/backup_path_tv" />
 
-                    <TextView
+                    <EditText
                         android:id="@+id/maximum_number_of_backups"
                         android:layout_width="0dp"
                         android:layout_height="wrap_content"
                         android:layout_marginLeft="@dimen/common_spacing"
+                        android:layout_marginRight="@dimen/common_spacing"
                         android:background="@drawable/bg_common_input"
+                        android:inputType="number"
                         android:maxLines="1"
                         android:paddingHorizontal="@dimen/common_spacing"
                         android:paddingVertical="2dp"
-                        android:layout_marginRight="@dimen/common_spacing"
                         android:singleLine="true"
                         android:textColor="@color/black"
                         android:textSize="@dimen/common_text_size"
@@ -236,7 +238,7 @@
                         app:layout_constraintTop_toBottomOf="@+id/status_tv" />
 
                     <TextView
-                        android:id="@+id/backup_quency"
+                        android:id="@+id/backup_frequency"
                         android:layout_width="0dp"
                         android:layout_height="wrap_content"
                         android:layout_marginLeft="@dimen/common_spacing"
@@ -262,7 +264,7 @@
                         android:textSize="@dimen/common_text_size"
                         app:i18nKey='@{"backup_frequency"}'
                         app:layout_constraintEnd_toEndOf="@+id/end_line"
-                        app:layout_constraintTop_toBottomOf="@+id/status_tv" />
+                        app:layout_constraintTop_toBottomOf="@+id/backup_frequency_tv" />
 
                     <TextView
                         android:id="@+id/backup_time"
@@ -287,6 +289,36 @@
                         android:layout_height="wrap_content"
                         app:barrierDirection="right"
                         app:constraint_referenced_ids="maximum_number_of_backups_tv,backup_path_tv" />
+
+                    <TextView
+                        android:id="@+id/backup_tip"
+                        android:layout_width="wrap_content"
+                        android:layout_height="wrap_content"
+                        android:layout_marginTop="@dimen/common_spacing"
+                        android:background="@drawable/bg_tip_red"
+                        android:paddingHorizontal="@dimen/common_spacing"
+                        android:textColor="@color/white"
+                        android:textSize="@dimen/common_text_size"
+                        app:i18nKey='@{"backup_tip"}'
+                        app:layout_constraintEnd_toEndOf="parent"
+                        app:layout_constraintStart_toStartOf="parent"
+                        app:layout_constraintTop_toBottomOf="@+id/backup_time" />
+
+                    <TextView
+                        android:id="@+id/save"
+                        android:layout_width="wrap_content"
+                        android:layout_height="wrap_content"
+                        android:layout_marginRight="@dimen/common_spacing_2x"
+                        android:layout_marginBottom="@dimen/common_spacing_2x"
+                        android:background="@drawable/common_btn"
+                        android:gravity="center"
+                        android:minHeight="@dimen/common_btn_height"
+                        android:paddingHorizontal="@dimen/common_spacing_2x"
+                        android:textColor="@color/black"
+                        android:textSize="@dimen/common_btn_text_size"
+                        app:i18nKey='@{"save"}'
+                        app:layout_constraintBottom_toBottomOf="parent"
+                        app:layout_constraintEnd_toEndOf="parent" />
                 </androidx.constraintlayout.widget.ConstraintLayout>
             </LinearLayout>
 
@@ -372,7 +404,7 @@
                     <TextView
                         android:layout_width="0dp"
                         android:layout_height="match_parent"
-                        android:layout_weight="1"
+                        android:layout_weight="2"
                         android:gravity="center"
                         android:textSize="@dimen/common_text_size"
                         app:i18nKey='@{"backup"}' />

+ 40 - 8
app/src/main/res/layout/fragment_backup_and_restore.xml

@@ -158,12 +158,13 @@
                         app:layout_constraintEnd_toEndOf="@+id/end_line"
                         app:layout_constraintTop_toBottomOf="@+id/backup_path_tv" />
 
-                    <TextView
+                    <EditText
                         android:id="@+id/maximum_number_of_backups"
                         android:layout_width="0dp"
                         android:layout_height="wrap_content"
                         android:layout_marginLeft="@dimen/common_spacing"
                         android:background="@drawable/bg_common_input"
+                        android:inputType="number"
                         android:maxLines="1"
                         android:paddingHorizontal="@dimen/common_spacing"
                         android:paddingVertical="2dp"
@@ -172,7 +173,7 @@
                         android:textSize="@dimen/common_text_size"
                         app:layout_constraintBottom_toBottomOf="@+id/maximum_number_of_backups_tv"
                         app:layout_constraintEnd_toStartOf="@+id/maximum_number_of_backups_range"
-                        app:layout_constraintStart_toEndOf="@+id/maximum_number_of_backups_tv" />
+                        app:layout_constraintStart_toEndOf="@+id/end_line" />
 
                     <TextView
                         android:id="@+id/maximum_number_of_backups_range"
@@ -236,7 +237,7 @@
                         app:layout_constraintTop_toBottomOf="@+id/status_tv" />
 
                     <TextView
-                        android:id="@+id/backup_quency"
+                        android:id="@+id/backup_frequency"
                         android:layout_width="0dp"
                         android:layout_height="wrap_content"
                         android:layout_marginLeft="@dimen/common_spacing"
@@ -250,7 +251,7 @@
                         android:textSize="@dimen/common_text_size"
                         app:layout_constraintBottom_toBottomOf="@+id/backup_frequency_tv"
                         app:layout_constraintEnd_toEndOf="parent"
-                        app:layout_constraintStart_toEndOf="@+id/backup_frequency_tv"
+                        app:layout_constraintStart_toEndOf="@+id/end_line"
                         app:layout_constraintTop_toTopOf="@+id/backup_frequency_tv" />
 
                     <com.grkj.ui_base.widget.RequiredTextView
@@ -262,7 +263,7 @@
                         android:textSize="@dimen/common_text_size"
                         app:i18nKey='@{"backup_frequency"}'
                         app:layout_constraintEnd_toEndOf="@+id/end_line"
-                        app:layout_constraintTop_toBottomOf="@+id/status_tv" />
+                        app:layout_constraintTop_toBottomOf="@+id/backup_frequency_tv" />
 
                     <TextView
                         android:id="@+id/backup_time"
@@ -278,15 +279,46 @@
                         android:textSize="@dimen/common_text_size"
                         app:layout_constraintBottom_toBottomOf="@+id/backup_time_tv"
                         app:layout_constraintEnd_toEndOf="parent"
-                        app:layout_constraintStart_toEndOf="@+id/backup_time_tv"
+                        app:layout_constraintStart_toEndOf="@+id/end_line"
                         app:layout_constraintTop_toTopOf="@+id/backup_time_tv" />
 
                     <androidx.constraintlayout.widget.Barrier
                         android:id="@+id/end_line"
                         android:layout_width="wrap_content"
                         android:layout_height="wrap_content"
-                        app:barrierDirection="left"
+                        app:barrierDirection="right"
                         app:constraint_referenced_ids="maximum_number_of_backups_tv,backup_path_tv" />
+
+                    <TextView
+                        android:id="@+id/backup_tip"
+                        android:layout_width="wrap_content"
+                        android:layout_height="wrap_content"
+                        android:layout_marginTop="@dimen/common_spacing"
+                        android:layout_marginBottom="@dimen/common_spacing_2x"
+                        android:background="@drawable/bg_tip_red"
+                        android:paddingHorizontal="@dimen/common_spacing"
+                        android:textColor="@color/white"
+                        android:textSize="@dimen/common_text_size"
+                        app:i18nKey='@{"backup_tip"}'
+                        app:layout_constraintEnd_toEndOf="parent"
+                        app:layout_constraintStart_toStartOf="parent"
+                        app:layout_constraintTop_toBottomOf="@+id/backup_time" />
+
+                    <TextView
+                        android:id="@+id/save"
+                        android:layout_width="wrap_content"
+                        android:layout_height="wrap_content"
+                        android:layout_marginRight="@dimen/common_spacing_2x"
+                        android:layout_marginBottom="@dimen/common_spacing_2x"
+                        android:background="@drawable/common_btn"
+                        android:gravity="center"
+                        android:minHeight="@dimen/common_btn_height"
+                        android:paddingHorizontal="@dimen/common_spacing_2x"
+                        android:textColor="@color/black"
+                        android:textSize="@dimen/common_btn_text_size"
+                        app:i18nKey='@{"save"}'
+                        app:layout_constraintBottom_toBottomOf="parent"
+                        app:layout_constraintEnd_toEndOf="parent" />
                 </androidx.constraintlayout.widget.ConstraintLayout>
             </LinearLayout>
 
@@ -372,7 +404,7 @@
                     <TextView
                         android:layout_width="0dp"
                         android:layout_height="match_parent"
-                        android:layout_weight="1"
+                        android:layout_weight="2"
                         android:gravity="center"
                         android:textSize="@dimen/common_text_size"
                         app:i18nKey='@{"backup"}' />

+ 2 - 2
app/src/main/res/layout/item_backup.xml

@@ -11,7 +11,7 @@
         android:showDividers="middle">
 
         <CheckBox
-            android:id="@+id/select_all"
+            android:id="@+id/select"
             android:layout_width="30dp"
             android:layout_height="30dp"
             android:layout_gravity="center"
@@ -21,7 +21,7 @@
             android:id="@+id/backup_name"
             android:layout_width="0dp"
             android:layout_height="match_parent"
-            android:layout_weight="1"
+            android:layout_weight="2"
             android:gravity="center"
             android:textSize="@dimen/common_text_size" />
 

+ 5 - 0
data/src/main/java/com/grkj/data/data/EventConstants.kt

@@ -61,6 +61,11 @@ object EventConstants {
      */
     const val EVENT_FLASH_TIP_CODE: Int = 100_000_011
 
+    /**
+     * 备份完成推送
+     */
+    const val EVENT_BACKUP_COMPLETE_CODE: Int = 100_000_012
+
     //---------------------------作业票------------------------
     const val EVENT_GET_TICKET_STATUS: Int = 100_001_001
 

+ 27 - 5
data/src/main/java/com/grkj/data/database/RoomBackupManager.kt

@@ -58,6 +58,10 @@ import java.util.Locale
  * ```
  */
 object RoomBackupManager {
+    // ---------- 对外常量----------
+    val backupDir = File(Environment.getExternalStorageDirectory(), "ISCS/backup/").apply {
+        if (!exists()) mkdirs()
+    }
 
     // ---------- 对外数据模型 ----------
 
@@ -75,7 +79,9 @@ object RoomBackupManager {
         val sizeBytes: Long = file.length(),
         val lastModified: Long = file.lastModified(),
         val verified: Boolean
-    )
+    ) {
+        var isSelected: Boolean = false
+    }
 
     // ---------- 对外 API(全部为挂起函数,自动切换到 IO 线程) ----------
 
@@ -216,7 +222,12 @@ object RoomBackupManager {
                             if (bak.exists()) {
                                 safeCopyTo(bak, target)
                                 runCatching { ISCSDatabase.warmReopen() }
-                                    .getOrElse { throw IllegalStateException("还原失败且回滚失败:${it.message}", it) }
+                                    .getOrElse {
+                                        throw IllegalStateException(
+                                            "还原失败且回滚失败:${it.message}",
+                                            it
+                                        )
+                                    }
                             }
                             throw IllegalStateException("还原失败:${e.message}", e)
                         }
@@ -285,7 +296,11 @@ object RoomBackupManager {
             // 读取源库的 user_version
             val userVersion = run {
                 val c = db.rawQuery("PRAGMA user_version", null)
-                try { if (c.moveToFirst()) c.getInt(0) else 0 } finally { c.close() }
+                try {
+                    if (c.moveToFirst()) c.getInt(0) else 0
+                } finally {
+                    c.close()
+                }
             }
 
             // 目标路径与密钥
@@ -327,7 +342,8 @@ object RoomBackupManager {
         dst.parentFile?.mkdirs()
         java.io.FileInputStream(src).channel.use { inCh ->
             java.io.FileOutputStream(dst).channel.use { outCh ->
-                var pos = 0L; val size = inCh.size()
+                var pos = 0L;
+                val size = inCh.size()
                 while (pos < size) pos += inCh.transferTo(pos, size - pos, outCh)
                 outCh.force(true) // fsync
             }
@@ -339,7 +355,13 @@ object RoomBackupManager {
      * 注意:这里同样用 openOrCreateDatabase 的 File 重载,避免签名不匹配。
      */
     private fun verifyCanOpen(file: File, pass: ByteArray): Boolean = runCatching {
-        val db = net.zetetic.database.sqlcipher.SQLiteDatabase.openOrCreateDatabase(file, pass, null, null, null)
+        val db = net.zetetic.database.sqlcipher.SQLiteDatabase.openOrCreateDatabase(
+            file,
+            pass,
+            null,
+            null,
+            null
+        )
         // 尽量再跑个轻量校验
         db.rawQuery("PRAGMA user_version", null).use { /* no-op */ }
         db.close(); true

+ 3 - 0
data/src/main/java/com/grkj/data/database/RoomBackupWorker.kt

@@ -6,6 +6,7 @@ import android.os.Environment
 import androidx.work.Data
 import androidx.work.Worker
 import androidx.work.WorkerParameters
+import com.grkj.data.utils.event.BackupCompleteEvent
 import com.grkj.shared.config.AESConfig
 import com.sik.sikcore.date.TimeUtils
 import net.zetetic.database.sqlcipher.SQLiteDatabase as CipherDB
@@ -58,11 +59,13 @@ class RoomBackupWorker(ctx: Context, params: WorkerParameters) : Worker(ctx, par
             runCatching { ISCSDatabase.warmReopen() }
 
             BackupScheduler.scheduleNextFromPrefsSync(applicationContext)
+            BackupCompleteEvent.sendBackupCompleteEvent(true)
             Result.success()
         } catch (t: Throwable) {
             t.printStackTrace()
             // 失败也尽量恢复 Room
             runCatching { ISCSDatabase.warmReopen() }
+            BackupCompleteEvent.sendBackupCompleteEvent(false)
             Result.retry()
         }
     }

+ 5 - 5
data/src/main/java/com/grkj/data/database/SimpleBackupPrefs.kt

@@ -11,11 +11,11 @@ import android.content.SharedPreferences
  * - keep      保留备份份数(给 Work 传参用)
  */
 data class SimpleBackupConfig(
-    val enabled: Boolean = true,
-    val hour: Int = 0,      // 0..23
-    val minute: Int = 0,    // 0..59
-    val daysMask: Int = ALL_DAYS,
-    val keep: Int = 10
+    var enabled: Boolean = true,
+    var hour: Int = 0,      // 0..23
+    var minute: Int = 0,    // 0..59
+    var daysMask: Int = ALL_DAYS,
+    var keep: Int = 10
 )
 
 const val ALL_DAYS = 0b0111_1111  // 七天全开

+ 65 - 0
data/src/main/java/com/grkj/data/enums/BackupFrequencyWeekEnum.kt

@@ -0,0 +1,65 @@
+package com.grkj.data.enums
+
+import com.grkj.shared.utils.i18n.I18nManager
+import com.sik.sikcore.bit.BitTypeUtils
+
+/**
+ * 备份频率星期枚举
+ */
+enum class BackupFrequencyWeekEnum(val type: Int, val showText: String) {
+    /**
+     * 周一
+     */
+    MON(1 shl 0, I18nManager.t("MON")),
+
+    /**
+     * 周二
+     */
+    TUE(1 shl 1, I18nManager.t("TUE")),
+
+    /**
+     * 周三
+     */
+    WED(1 shl 2, I18nManager.t("WED")),
+
+    /**
+     * 周四
+     */
+    THU(1 shl 3, I18nManager.t("THU")),
+
+    /**
+     * 周五
+     */
+    FRI(1 shl 4, I18nManager.t("FRI")),
+
+    /**
+     * 周六
+     */
+    SAT(1 shl 5, I18nManager.t("SAT")),
+
+    /**
+     * 周日
+     */
+    SUN(1 shl 6, I18nManager.t("SUN")), ;
+
+    companion object {
+        /**
+         * 根据掩码获取选择日
+         */
+        fun getSelectedDaysText(mask: Int): List<BackupFrequencyWeekEnum> {
+            return BackupFrequencyWeekEnum.values()
+                .filter { BitTypeUtils.hasType(mask, it.type) } // 取出选中的天
+        }
+
+        /**
+         * 根据类型获取掩码
+         */
+        fun getMaskFromTypes(types: List<Int>): Int {
+            var mask = 0
+            for (type in types) {
+                mask = BitTypeUtils.addType(mask, type)
+            }
+            return mask
+        }
+    }
+}

+ 25 - 0
data/src/main/java/com/grkj/data/utils/event/BackupCompleteEvent.kt

@@ -0,0 +1,25 @@
+package com.grkj.data.utils.event
+
+import com.grkj.data.data.EventConstants
+import com.grkj.shared.model.EventBean
+import com.grkj.shared.utils.event.EventHelper
+
+/**
+ * 备份完成推送
+ */
+class BackupCompleteEvent(val backupResult: Boolean = true) {
+
+    companion object {
+        /**
+         * 发送备份完成事件
+         */
+        @JvmStatic
+        fun sendBackupCompleteEvent(backupResult: Boolean = true) {
+            val backupCompleteEvent = BackupCompleteEvent(backupResult)
+            val backupCompleteEventBean = EventBean<BackupCompleteEvent>(
+                EventConstants.EVENT_BACKUP_COMPLETE_CODE, backupCompleteEvent
+            )
+            EventHelper.sendEvent(backupCompleteEventBean)
+        }
+    }
+}

+ 53 - 0
shared/src/main/java/com/grkj/shared/utils/FilePickerUtils.kt

@@ -0,0 +1,53 @@
+package com.grkj.shared.utils
+
+import android.net.Uri
+import android.os.Build
+import androidx.activity.result.ActivityResultCaller
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.annotation.RequiresApi
+
+/**
+ * 文件/目录选择工具类
+ *
+ * 支持:
+ * - 选择单文件
+ * - 选择目录
+ * - 自定义 MIME 类型
+ * - 回调返回 Uri
+ */
+class FilePickerUtils(caller: ActivityResultCaller) {
+
+    private var onFilePicked: ((Uri?) -> Unit)? = null
+    private var onDirPicked: ((Uri?) -> Unit)? = null
+
+    // 选择文件 Launcher
+    private val filePickerLauncher =
+        caller.registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
+            onFilePicked?.invoke(uri)
+        }
+
+    // 选择目录 Launcher(Android 5.0+)
+    private val dirPickerLauncher =
+        caller.registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
+            onDirPicked?.invoke(uri)
+        }
+
+    /**
+     * 打开文件选择器
+     * @param mimeTypes 文件类型,例如 arrayOf("application/pdf", "image")
+     *
+     */
+    fun pickFile(mimeTypes: Array<String> = arrayOf("*/*"), callback: (Uri?) -> Unit) {
+        onFilePicked = callback
+        filePickerLauncher.launch(mimeTypes)
+    }
+
+    /**
+     * 打开目录选择器(Android 5.0+)
+     */
+    @RequiresApi(Build.VERSION_CODES.LOLLIPOP)
+    fun pickDirectory(callback: (Uri?) -> Unit) {
+        onDirPicked = callback
+        dirPickerLauncher.launch(null)
+    }
+}

+ 196 - 0
shared/src/main/java/com/grkj/shared/utils/SAFHelper.kt

@@ -0,0 +1,196 @@
+package com.grkj.shared.utils
+
+import android.content.Context
+import android.content.Intent
+import android.database.Cursor
+import android.net.Uri
+import android.provider.OpenableColumns
+import androidx.documentfile.provider.DocumentFile
+import java.io.File
+import java.io.FileInputStream
+import java.io.FileOutputStream
+import java.io.InputStream
+import java.io.OutputStream
+
+/**
+ * Storage Access Framework 助手:
+ * - Context.moveFileToDir(treeUri, file): 把本地 File “移动”到指定目录 Uri(treeUri)下
+ * - Context.uriToFile(uri): 把任意 Uri 落地为可直接访问的临时 File
+ *
+ * 依赖:
+ * implementation("androidx.documentfile:documentfile:1.0.1")
+ */
+object SAFHelper {
+
+    /**
+     * 把 [file] 移动到 [treeUri] 指向的目录下。
+     * 这里只能“复制+删除源文件”,因为 File → SAF 不支持直接 rename/move。
+     *
+     * 规则:
+     * - 目标文件名用 file.name;若同名存在,自动重命名为 "name (1).ext"。
+     * - MIME 基于扩展名简单判断。
+     *
+     * @return 新文件的 Uri,失败返回 null
+     */
+    fun Context.moveFileToDir(treeUri: Uri, file: File): Uri? {
+        // 尝试持久化读写权限(若已授权会静默成功)
+        takePersistedRW(treeUri)
+
+        val parent = DocumentFile.fromTreeUri(this, treeUri) ?: return null
+        if (!parent.isDirectory) return null
+
+        val displayName = uniqueName(parent, file.name)
+        val mime = guessMimeFromName(file.name)
+
+        // 目标占位
+        val target = parent.createFile(mime, displayName) ?: return null
+
+        runCatching {
+            copyStreams(FileInputStream(file), contentResolver.openOutputStream(target.uri, "w")!!)
+        }.onFailure {
+            // 复制失败,清理占位文件
+            runCatching { target.delete() }
+            return null
+        }
+
+        // 复制成功后删除源文件;若删除失败,算“搬运未完成”,回滚目标以避免重复
+        if (!file.delete()) {
+            runCatching { target.delete() }
+            return null
+        }
+
+        return target.uri
+    }
+
+    /**
+     * 将任意 [uri] 转换为可直接访问的临时 [File]。
+     * - file:// 直接返回对应 File
+     * - content:// / 其他:复制到 app 的 cacheDir/uri2file 下并返回
+     */
+    fun Context.uriToFile(uri: Uri): File? {
+        when (uri.scheme?.lowercase()) {
+            "file" -> {
+                val f = File(uri.path ?: return null)
+                return if (f.exists()) f else null
+            }
+        }
+
+        val (nameGuess, _) = queryDisplayNameAndSize(uri)
+        val safeName = (nameGuess ?: "tmp_${System.currentTimeMillis()}").replace('/', '_')
+        val outDir = File(cacheDir, "uri2file").apply { mkdirs() }
+        // 尽量不覆盖已有文件
+        val outFile = uniqueLocalName(outDir, safeName)
+
+        runCatching {
+            contentResolver.openInputStream(uri).use { input ->
+                if (input == null) throw IllegalStateException("openInputStream null for $uri")
+                FileOutputStream(outFile).use { output ->
+                    copyStreams(input, output)
+                }
+            }
+        }.onFailure {
+            // 失败时清理中间文件
+            runCatching { outFile.delete() }
+            return null
+        }
+
+        return outFile
+    }
+
+    // ———————————— Helpers ————————————
+
+    private fun Context.takePersistedRW(treeUri: Uri) {
+        val cr = contentResolver
+        val haveRead = cr.persistedUriPermissions.any { it.uri == treeUri && it.isReadPermission }
+        val haveWrite = cr.persistedUriPermissions.any { it.uri == treeUri && it.isWritePermission }
+        if (!haveRead || !haveWrite) {
+            runCatching {
+                cr.takePersistableUriPermission(
+                    treeUri,
+                    Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
+                )
+            }
+        }
+    }
+
+    /** 查 display name 与 size(有些 Provider 不给 size,做兜底即可) */
+    private fun Context.queryDisplayNameAndSize(uri: Uri): Pair<String?, Long?> {
+        var name: String? = null
+        var size: Long? = null
+        runCatching {
+            contentResolver.query(
+                uri,
+                arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE),
+                null,
+                null,
+                null
+            )
+                ?.use { c: Cursor ->
+                    if (c.moveToFirst()) {
+                        name = c.getString(0)
+                        if (!c.isNull(1)) size = c.getLong(1)
+                    }
+                }
+        }
+        return name to size
+    }
+
+    private fun guessMimeFromName(name: String): String {
+        val lower = name.lowercase()
+        return when {
+            lower.endsWith(".png") -> "image/png"
+            lower.endsWith(".jpg") || lower.endsWith(".jpeg") -> "image/jpeg"
+            lower.endsWith(".gif") -> "image/gif"
+            lower.endsWith(".webp") -> "image/webp"
+            lower.endsWith(".pdf") -> "application/pdf"
+            lower.endsWith(".txt") -> "text/plain"
+            lower.endsWith(".csv") -> "text/csv"
+            lower.endsWith(".json") -> "application/json"
+            lower.endsWith(".zip") -> "application/zip"
+            else -> "application/octet-stream"
+        }
+    }
+
+    /** 给 DocumentFile 目录生成不冲突的文件名 */
+    private fun uniqueName(parent: DocumentFile, desired: String): String {
+        if (parent.findFile(desired) == null) return desired
+        val dot = desired.lastIndexOf('.')
+        val base = if (dot <= 0) desired else desired.substring(0, dot)
+        val ext = if (dot <= 0) "" else desired.substring(dot) // 包含点
+        var i = 1
+        while (true) {
+            val candidate = "$base ($i)$ext"
+            if (parent.findFile(candidate) == null) return candidate
+            i++
+        }
+    }
+
+    /** 给本地目录生成不冲突的文件名 */
+    private fun uniqueLocalName(dir: File, desired: String): File {
+        val f0 = File(dir, desired)
+        if (!f0.exists()) return f0
+        val dot = desired.lastIndexOf('.')
+        val base = if (dot <= 0) desired else desired.substring(0, dot)
+        val ext = if (dot <= 0) "" else desired.substring(dot)
+        var i = 1
+        while (true) {
+            val candidate = File(dir, "$base ($i)$ext")
+            if (!candidate.exists()) return candidate
+            i++
+        }
+    }
+
+    private fun copyStreams(
+        input: InputStream,
+        output: OutputStream,
+        bufferSize: Int = 256 * 1024
+    ) {
+        val buf = ByteArray(bufferSize)
+        while (true) {
+            val n = input.read(buf)
+            if (n == -1) break
+            output.write(buf, 0, n)
+        }
+        output.flush()
+    }
+}

+ 1 - 1
ui-base/src/main/java/com/grkj/ui_base/utils/event/EventHelper.kt → shared/src/main/java/com/grkj/shared/utils/event/EventHelper.kt

@@ -1,4 +1,4 @@
-package com.grkj.ui_base.utils.event
+package com.grkj.shared.utils.event
 
 import com.grkj.shared.model.EventBean
 import org.greenrobot.eventbus.EventBus

+ 58 - 0
ui-base/src/main/java/com/grkj/ui_base/dialog/WheelTimePickerDialog.kt

@@ -0,0 +1,58 @@
+package com.grkj.ui_base.dialog
+
+import android.icu.text.SimpleDateFormat
+import android.icu.util.Calendar
+import android.view.View
+import com.grkj.ui_base.R
+import com.grkj.ui_base.databinding.DialogWheelDateRangeBinding
+import com.grkj.ui_base.databinding.DialogWheelTimePickBinding
+import com.grkj.ui_base.utils.CommonUtils
+import com.grkj.ui_base.utils.extension.tip
+import com.kongzue.dialogx.dialogs.BottomDialog
+import com.kongzue.dialogx.dialogs.PopTip
+import com.kongzue.dialogx.interfaces.OnBindView
+import com.sik.sikcore.extension.setDebouncedClickListener
+import java.util.Locale
+
+class WheelTimePickerDialog(
+    val selectedTime: String,
+    val onConfirm: (selectedTime: String) -> Unit
+) : OnBindView<BottomDialog>(R.layout.dialog_wheel_time_pick) {
+
+    private lateinit var binding: DialogWheelTimePickBinding
+
+    override fun onBind(dialog: BottomDialog, v: View) {
+        binding = DialogWheelTimePickBinding.bind(v)
+        dialog.setAllowInterceptTouch(false)
+        dialog.setMaskColor(CommonUtils.getColor(R.color.scrim))
+        val hourMinData = selectedTime.split(":")
+        val hour = hourMinData[0]
+        val min = hourMinData[1]
+        var selectedTime = this.selectedTime
+        binding.timePicker.setTime(
+            hour.toInt(), min.toInt()
+        )
+        binding.timePicker.setOnTimeSelectedListener { hour, min ->
+            selectedTime = "${hour}:${min}"
+        }
+
+        // 确认按钮:将当前选中的开始和结束时间回传
+        binding.confirm.setDebouncedClickListener {
+            onConfirm(selectedTime)
+            dialog.dismiss()
+        }
+    }
+
+    companion object {
+        /**
+         * 显示弹窗入口
+         */
+        @JvmStatic
+        fun show(
+            selectedTime: String,
+            onConfirm: (String) -> Unit
+        ) {
+            BottomDialog.show(WheelTimePickerDialog(selectedTime, onConfirm))
+        }
+    }
+}

+ 1 - 0
ui-base/src/main/java/com/grkj/ui_base/utils/event/BottomNavVisibilityEvent.kt

@@ -2,6 +2,7 @@ package com.grkj.ui_base.utils.event
 
 import com.grkj.shared.model.EventBean
 import com.grkj.data.data.EventConstants
+import com.grkj.shared.utils.event.EventHelper
 
 /**
  * 底部栏显隐

+ 1 - 0
ui-base/src/main/java/com/grkj/ui_base/utils/event/CardSwipeEvent.kt

@@ -2,6 +2,7 @@ package com.grkj.ui_base.utils.event
 
 import com.grkj.shared.model.EventBean
 import com.grkj.data.data.EventConstants
+import com.grkj.shared.utils.event.EventHelper
 
 /**
  * 刷卡事件

+ 1 - 0
ui-base/src/main/java/com/grkj/ui_base/utils/event/CurrentModeEvent.kt

@@ -2,6 +2,7 @@ package com.grkj.ui_base.utils.event
 
 import com.grkj.shared.model.EventBean
 import com.grkj.data.data.EventConstants
+import com.grkj.shared.utils.event.EventHelper
 import com.grkj.ui_base.utils.ble.BleBean
 
 /**

+ 1 - 0
ui-base/src/main/java/com/grkj/ui_base/utils/event/DeviceExceptionEvent.kt

@@ -2,6 +2,7 @@ package com.grkj.ui_base.utils.event
 
 import com.grkj.shared.model.EventBean
 import com.grkj.data.data.EventConstants
+import com.grkj.shared.utils.event.EventHelper
 
 /**
  * 设备异常事件

+ 1 - 0
ui-base/src/main/java/com/grkj/ui_base/utils/event/DeviceTakeUpdateEvent.kt

@@ -2,6 +2,7 @@ package com.grkj.ui_base.utils.event
 
 import com.grkj.shared.model.EventBean
 import com.grkj.data.data.EventConstants
+import com.grkj.shared.utils.event.EventHelper
 
 /**
  * 设备取出事件

+ 1 - 0
ui-base/src/main/java/com/grkj/ui_base/utils/event/FlashTipEvent.kt

@@ -2,6 +2,7 @@ package com.grkj.ui_base.utils.event
 
 import com.grkj.shared.model.EventBean
 import com.grkj.data.data.EventConstants
+import com.grkj.shared.utils.event.EventHelper
 
 /**
  * 闪烁提醒事件

+ 1 - 0
ui-base/src/main/java/com/grkj/ui_base/utils/event/GetTicketStatusEvent.kt

@@ -2,6 +2,7 @@ package com.grkj.ui_base.utils.event
 
 import com.grkj.shared.model.EventBean
 import com.grkj.data.data.EventConstants
+import com.grkj.shared.utils.event.EventHelper
 import com.huyuhui.fastble.data.BleDevice
 
 /**

+ 1 - 0
ui-base/src/main/java/com/grkj/ui_base/utils/event/JumpViewEvent.kt

@@ -2,6 +2,7 @@ package com.grkj.ui_base.utils.event
 
 import com.grkj.shared.model.EventBean
 import com.grkj.data.data.EventConstants
+import com.grkj.shared.utils.event.EventHelper
 
 class JumpViewEvent(val navGraphId: Int, val targetId: Int, val fromQuickEntry: Boolean) {
     companion object {

+ 1 - 0
ui-base/src/main/java/com/grkj/ui_base/utils/event/LoadingEvent.kt

@@ -2,6 +2,7 @@ package com.grkj.ui_base.utils.event
 
 import com.grkj.shared.model.EventBean
 import com.grkj.data.data.EventConstants
+import com.grkj.shared.utils.event.EventHelper
 
 /**
  * 加载事件

+ 1 - 0
ui-base/src/main/java/com/grkj/ui_base/utils/event/LogoutEvent.kt

@@ -2,6 +2,7 @@ package com.grkj.ui_base.utils.event
 
 import com.grkj.shared.model.EventBean
 import com.grkj.data.data.EventConstants
+import com.grkj.shared.utils.event.EventHelper
 
 /**
  * 退出登录

+ 1 - 0
ui-base/src/main/java/com/grkj/ui_base/utils/event/ModbusInitCompleteEvent.kt

@@ -2,6 +2,7 @@ package com.grkj.ui_base.utils.event
 
 import com.grkj.shared.model.EventBean
 import com.grkj.data.data.EventConstants
+import com.grkj.shared.utils.event.EventHelper
 
 /**
  * 启动串口完成

+ 1 - 0
ui-base/src/main/java/com/grkj/ui_base/utils/event/RFIDCardReadEvent.kt

@@ -2,6 +2,7 @@ package com.grkj.ui_base.utils.event
 
 import com.grkj.shared.model.EventBean
 import com.grkj.data.data.EventConstants
+import com.grkj.shared.utils.event.EventHelper
 
 /**
  * RFID读取事件

+ 1 - 0
ui-base/src/main/java/com/grkj/ui_base/utils/event/RestartAppEvent.kt

@@ -2,6 +2,7 @@ package com.grkj.ui_base.utils.event
 
 import com.grkj.shared.model.EventBean
 import com.grkj.data.data.EventConstants
+import com.grkj.shared.utils.event.EventHelper
 
 /**
  * 重启App事件

+ 1 - 0
ui-base/src/main/java/com/grkj/ui_base/utils/event/StartModbusEvent.kt

@@ -2,6 +2,7 @@ package com.grkj.ui_base.utils.event
 
 import com.grkj.shared.model.EventBean
 import com.grkj.data.data.EventConstants
+import com.grkj.shared.utils.event.EventHelper
 
 /**
  * 启动串口

+ 1 - 0
ui-base/src/main/java/com/grkj/ui_base/utils/event/SwitchCollectionUpdateEvent.kt

@@ -2,6 +2,7 @@ package com.grkj.ui_base.utils.event
 
 import com.grkj.shared.model.EventBean
 import com.grkj.data.data.EventConstants
+import com.grkj.shared.utils.event.EventHelper
 
 /**
  * 开关采集更新事件

+ 1 - 0
ui-base/src/main/java/com/grkj/ui_base/utils/event/TicketFinishedEvent.kt

@@ -2,6 +2,7 @@ package com.grkj.ui_base.utils.event
 
 import com.grkj.shared.model.EventBean
 import com.grkj.data.data.EventConstants
+import com.grkj.shared.utils.event.EventHelper
 
 /**
  * 作业票已结束事件

+ 1 - 0
ui-base/src/main/java/com/grkj/ui_base/utils/event/UpdateTicketProgressEvent.kt

@@ -2,6 +2,7 @@ package com.grkj.ui_base.utils.event
 
 import com.grkj.shared.model.EventBean
 import com.grkj.data.data.EventConstants
+import com.grkj.shared.utils.event.EventHelper
 
 /**
  * 更新作业票事件

+ 45 - 0
ui-base/src/main/res/layout-land/dialog_wheel_time_pick.xml

@@ -0,0 +1,45 @@
+<?xml version="1.0" encoding="utf-8"?>
+<layout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto">
+
+    <LinearLayout
+        android:id="@+id/root"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:background="@android:color/white"
+        android:orientation="vertical">
+
+        <RelativeLayout
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:orientation="horizontal"
+            android:padding="@dimen/common_spacing">
+
+            <TextView
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:layout_centerInParent="true"
+                android:textColor="@color/black"
+                android:textSize="@dimen/common_text_size"
+                app:i18nKey='@{"please_select_time"}' />
+
+            <TextView
+                android:id="@+id/confirm"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:layout_alignParentRight="true"
+                android:gravity="center"
+                android:paddingHorizontal="@dimen/common_spacing_2x"
+                android:textColor="@color/main_color"
+                android:textSize="@dimen/common_text_size"
+                app:i18nKey='@{"confirm"}' />
+        </RelativeLayout>
+
+        <com.ycuwq.datepicker.time.HourAndMinutePicker
+            android:id="@+id/timePicker"
+            android:layout_width="match_parent"
+            android:layout_height="200dp"
+            app:itemTextSize="@dimen/common_text_size_small"
+            app:selectedTextSize="@dimen/common_text_size" />
+    </LinearLayout>
+</layout>

+ 45 - 0
ui-base/src/main/res/layout/dialog_wheel_time_pick.xml

@@ -0,0 +1,45 @@
+<?xml version="1.0" encoding="utf-8"?>
+<layout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto">
+
+    <LinearLayout
+        android:id="@+id/root"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:background="@android:color/white"
+        android:orientation="vertical">
+
+        <RelativeLayout
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:orientation="horizontal"
+            android:padding="@dimen/common_spacing">
+
+            <TextView
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:layout_centerInParent="true"
+                android:textColor="@color/black"
+                android:textSize="@dimen/common_text_size"
+                app:i18nKey='@{"please_select_time"}' />
+
+            <TextView
+                android:id="@+id/confirm"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:layout_alignParentRight="true"
+                android:gravity="center"
+                android:paddingHorizontal="@dimen/common_spacing_2x"
+                android:textColor="@color/main_color"
+                android:textSize="@dimen/common_text_size"
+                app:i18nKey='@{"confirm"}' />
+        </RelativeLayout>
+
+        <com.ycuwq.datepicker.time.HourAndMinutePicker
+            android:id="@+id/timePicker"
+            android:layout_width="match_parent"
+            android:layout_height="200dp"
+            app:itemTextSize="@dimen/common_text_size_small"
+            app:selectedTextSize="@dimen/common_text_size" />
+    </LinearLayout>
+</layout>

+ 20 - 0
ui-base/src/main/res/layout/layout_no_backup.xml

@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="utf-8"?>
+<layout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto">
+
+    <FrameLayout
+        android:layout_width="match_parent"
+        android:layout_height="match_parent">
+
+        <TextView
+            android:id="@+id/tv_content"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_gravity="center"
+            app:i18nKey='@{"no_backup_data"}'
+            android:textColor="@color/black"
+            android:textSize="@dimen/common_text_size_big" />
+
+    </FrameLayout>
+
+</layout>