feat: 增加数据备份功能
- 导出 CSV/Excel 功能 - 数据导入 - 数据迁移工具 - 定期自动备份
This commit is contained in:
parent
c517abce35
commit
3c7b1dc610
@ -59,9 +59,10 @@
|
|||||||
- [x] 成员图标 (家人、朋友、同事等)
|
- [x] 成员图标 (家人、朋友、同事等)
|
||||||
|
|
||||||
### 4. 数据管理 (进行中 🚀)
|
### 4. 数据管理 (进行中 🚀)
|
||||||
- [ ] 导出 CSV/Excel 功能
|
- [x] 导出 CSV/Excel 功能
|
||||||
- [ ] 数据迁移工具
|
- [x] 数据导入
|
||||||
- [ ] 定期自动备份
|
- [x] 数据迁移工具
|
||||||
|
- [x] 定期自动备份
|
||||||
- [ ] 备份加密功能
|
- [ ] 备份加密功能
|
||||||
|
|
||||||
### 5. 预算管理 (计划中 💡)
|
### 5. 预算管理 (计划中 💡)
|
||||||
|
@ -107,4 +107,9 @@ dependencies {
|
|||||||
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
|
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
|
||||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||||
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
||||||
|
|
||||||
|
// CSV Excel 库
|
||||||
|
implementation("com.opencsv:opencsv:5.7.1")
|
||||||
|
implementation("org.apache.poi:poi:5.2.3")
|
||||||
|
implementation("org.apache.poi:poi-ooxml:5.2.3")
|
||||||
}
|
}
|
@ -1,9 +1,12 @@
|
|||||||
package com.yovinchen.bookkeeping
|
package com.yovinchen.bookkeeping
|
||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.result.ActivityResultLauncher
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@ -19,6 +22,31 @@ import com.yovinchen.bookkeeping.model.ThemeMode
|
|||||||
import com.yovinchen.bookkeeping.ui.components.predefinedColors
|
import com.yovinchen.bookkeeping.ui.components.predefinedColors
|
||||||
import com.yovinchen.bookkeeping.ui.navigation.MainNavigation
|
import com.yovinchen.bookkeeping.ui.navigation.MainNavigation
|
||||||
import com.yovinchen.bookkeeping.ui.theme.BookkeepingTheme
|
import com.yovinchen.bookkeeping.ui.theme.BookkeepingTheme
|
||||||
|
import com.yovinchen.bookkeeping.utils.FilePickerUtil
|
||||||
|
|
||||||
|
private var filePickerLauncher: ActivityResultLauncher<Array<String>>? = null
|
||||||
|
|
||||||
|
fun ComponentActivity.getPreregisteredFilePickerLauncher(): ActivityResultLauncher<Array<String>> {
|
||||||
|
return filePickerLauncher ?: throw IllegalStateException("FilePickerLauncher not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
|
|
||||||
|
// 预注册文件选择器
|
||||||
|
filePickerLauncher = registerForActivityResult(
|
||||||
|
ActivityResultContracts.OpenDocument()
|
||||||
|
) { uri: Uri? ->
|
||||||
|
FilePickerUtil.handleFileSelection(this, uri)
|
||||||
|
}
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
BookkeepingApp()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SystemBarColor(isDarkTheme: Boolean) {
|
private fun SystemBarColor(isDarkTheme: Boolean) {
|
||||||
@ -88,16 +116,6 @@ fun BookkeepingApp() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
|
||||||
setContent {
|
|
||||||
BookkeepingApp()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun Greeting(name: String, modifier: Modifier = Modifier) {
|
fun Greeting(name: String, modifier: Modifier = Modifier) {
|
||||||
Text(
|
Text(
|
||||||
|
@ -181,14 +181,14 @@ abstract class BookkeepingDatabase : RoomDatabase() {
|
|||||||
insertCategory(Category(name = "水果", type = TransactionType.EXPENSE, icon = R.drawable.ic_category_fruit_24dp))
|
insertCategory(Category(name = "水果", type = TransactionType.EXPENSE, icon = R.drawable.ic_category_fruit_24dp))
|
||||||
insertCategory(Category(name = "零食", type = TransactionType.EXPENSE, icon = R.drawable.ic_category_snack_24dp))
|
insertCategory(Category(name = "零食", type = TransactionType.EXPENSE, icon = R.drawable.ic_category_snack_24dp))
|
||||||
insertCategory(Category(name = "蔬菜", type = TransactionType.EXPENSE, icon = R.drawable.ic_category_vegetable_24dp))
|
insertCategory(Category(name = "蔬菜", type = TransactionType.EXPENSE, icon = R.drawable.ic_category_vegetable_24dp))
|
||||||
|
insertCategory(Category(name = "会员", type = TransactionType.EXPENSE, icon = R.drawable.ic_category_membership_24dp))
|
||||||
|
insertCategory(Category(name = "礼物", type = TransactionType.EXPENSE, icon = R.drawable.ic_category_gift_24dp))
|
||||||
insertCategory(Category(name = "其他支出", type = TransactionType.EXPENSE, icon = R.drawable.ic_category_more_24dp))
|
insertCategory(Category(name = "其他支出", type = TransactionType.EXPENSE, icon = R.drawable.ic_category_more_24dp))
|
||||||
|
|
||||||
// 收入分类
|
// 收入分类
|
||||||
insertCategory(Category(name = "工资", type = TransactionType.INCOME, icon = R.drawable.ic_category_membership_24dp))
|
insertCategory(Category(name = "工资", type = TransactionType.INCOME, icon = R.drawable.ic_category_membership_24dp))
|
||||||
insertCategory(Category(name = "奖金", type = TransactionType.INCOME, icon = R.drawable.ic_category_gift_24dp))
|
insertCategory(Category(name = "奖金", type = TransactionType.INCOME, icon = R.drawable.ic_category_gift_24dp))
|
||||||
insertCategory(Category(name = "投资", type = TransactionType.INCOME, icon = R.drawable.ic_category_digital_24dp))
|
insertCategory(Category(name = "投资", type = TransactionType.INCOME, icon = R.drawable.ic_category_digital_24dp))
|
||||||
insertCategory(Category(name = "礼物", type = TransactionType.INCOME, icon = R.drawable.ic_category_gift_24dp))
|
|
||||||
insertCategory(Category(name = "会员费", type = TransactionType.INCOME, icon = R.drawable.ic_category_membership_24dp))
|
|
||||||
insertCategory(Category(name = "其他收入", type = TransactionType.INCOME, icon = R.drawable.ic_category_more_24dp))
|
insertCategory(Category(name = "其他收入", type = TransactionType.INCOME, icon = R.drawable.ic_category_more_24dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -54,7 +54,6 @@ fun HomeScreen(
|
|||||||
val filteredRecords by viewModel.filteredRecords.collectAsState()
|
val filteredRecords by viewModel.filteredRecords.collectAsState()
|
||||||
val categories by viewModel.categories.collectAsState(initial = emptyList())
|
val categories by viewModel.categories.collectAsState(initial = emptyList())
|
||||||
val members by viewModel.members.collectAsState(initial = emptyList())
|
val members by viewModel.members.collectAsState(initial = emptyList())
|
||||||
val selectedMember by viewModel.selectedMember.collectAsState()
|
|
||||||
val totalIncome by viewModel.totalIncome.collectAsState()
|
val totalIncome by viewModel.totalIncome.collectAsState()
|
||||||
val totalExpense by viewModel.totalExpense.collectAsState()
|
val totalExpense by viewModel.totalExpense.collectAsState()
|
||||||
|
|
||||||
|
@ -144,7 +144,7 @@ fun MemberDetailScreen(
|
|||||||
categoryData = categoryData,
|
categoryData = categoryData,
|
||||||
memberData = emptyList(),
|
memberData = emptyList(),
|
||||||
currentViewMode = false,
|
currentViewMode = false,
|
||||||
onCategoryClick = { selectedCategory ->
|
onCategoryClick = {
|
||||||
// 暂时不处理点击事件
|
// 暂时不处理点击事件
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
@ -1,36 +1,23 @@
|
|||||||
package com.yovinchen.bookkeeping.ui.screen
|
package com.yovinchen.bookkeeping.ui.screen
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.Divider
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.ListItem
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.RadioButton
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.yovinchen.bookkeeping.model.ThemeMode
|
import com.yovinchen.bookkeeping.model.ThemeMode
|
||||||
import com.yovinchen.bookkeeping.ui.components.ColorPicker
|
import com.yovinchen.bookkeeping.ui.components.*
|
||||||
import com.yovinchen.bookkeeping.ui.components.predefinedColors
|
import com.yovinchen.bookkeeping.ui.dialog.*
|
||||||
import com.yovinchen.bookkeeping.ui.dialog.CategoryManagementDialog
|
import com.yovinchen.bookkeeping.utils.FilePickerUtil
|
||||||
import com.yovinchen.bookkeeping.ui.dialog.MemberManagementDialog
|
import com.yovinchen.bookkeeping.viewmodel.*
|
||||||
import com.yovinchen.bookkeeping.viewmodel.MemberViewModel
|
|
||||||
import com.yovinchen.bookkeeping.viewmodel.SettingsViewModel
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@ -43,10 +30,13 @@ fun SettingsScreen(
|
|||||||
var showThemeDialog by remember { mutableStateOf(false) }
|
var showThemeDialog by remember { mutableStateOf(false) }
|
||||||
var showCategoryDialog by remember { mutableStateOf(false) }
|
var showCategoryDialog by remember { mutableStateOf(false) }
|
||||||
var showMemberDialog by remember { mutableStateOf(false) }
|
var showMemberDialog by remember { mutableStateOf(false) }
|
||||||
|
var showBackupDialog by remember { mutableStateOf(false) }
|
||||||
|
var showRestoreDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val categories by viewModel.categories.collectAsState()
|
val categories by viewModel.categories.collectAsState()
|
||||||
val selectedType by viewModel.selectedCategoryType.collectAsState()
|
val selectedType by viewModel.selectedCategoryType.collectAsState()
|
||||||
val members by memberViewModel.allMembers.collectAsState(initial = emptyList())
|
val members by memberViewModel.allMembers.collectAsState(initial = emptyList())
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
// 成员管理设置项
|
// 成员管理设置项
|
||||||
@ -56,7 +46,7 @@ fun SettingsScreen(
|
|||||||
modifier = Modifier.clickable { showMemberDialog = true }
|
modifier = Modifier.clickable { showMemberDialog = true }
|
||||||
)
|
)
|
||||||
|
|
||||||
Divider()
|
HorizontalDivider()
|
||||||
|
|
||||||
// 类别管理设置项
|
// 类别管理设置项
|
||||||
ListItem(
|
ListItem(
|
||||||
@ -65,7 +55,16 @@ fun SettingsScreen(
|
|||||||
modifier = Modifier.clickable { showCategoryDialog = true }
|
modifier = Modifier.clickable { showCategoryDialog = true }
|
||||||
)
|
)
|
||||||
|
|
||||||
Divider()
|
HorizontalDivider()
|
||||||
|
|
||||||
|
// 数据备份设置项
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text("数据备份") },
|
||||||
|
supportingContent = { Text("备份和恢复数据") },
|
||||||
|
modifier = Modifier.clickable { showBackupDialog = true }
|
||||||
|
)
|
||||||
|
|
||||||
|
HorizontalDivider()
|
||||||
|
|
||||||
// 主题设置项
|
// 主题设置项
|
||||||
ListItem(
|
ListItem(
|
||||||
@ -117,7 +116,7 @@ fun SettingsScreen(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
Divider(modifier = Modifier.padding(vertical = 8.dp))
|
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||||
|
|
||||||
// 颜色选择器
|
// 颜色选择器
|
||||||
Text(
|
Text(
|
||||||
@ -145,6 +144,100 @@ fun SettingsScreen(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 备份对话框
|
||||||
|
if (showBackupDialog) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showBackupDialog = false },
|
||||||
|
title = { Text("数据备份") },
|
||||||
|
text = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.exportToCSV(context) },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text("导出为CSV")
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.exportToExcel(context) },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text("导出为Excel")
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = { showRestoreDialog = true },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text("恢复数据")
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text("自动备份", modifier = Modifier.weight(1f))
|
||||||
|
Switch(
|
||||||
|
checked = viewModel.isAutoBackupEnabled.collectAsState().value,
|
||||||
|
onCheckedChange = { viewModel.setAutoBackup(it) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (viewModel.isAutoBackupEnabled.collectAsState().value) {
|
||||||
|
Text(
|
||||||
|
"自动备份将每24小时创建一次备份,保存在应用私有目录中",
|
||||||
|
style = MaterialTheme.typography.bodySmall
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = { showBackupDialog = false }) {
|
||||||
|
Text("关闭")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 恢复对话框
|
||||||
|
if (showRestoreDialog) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showRestoreDialog = false },
|
||||||
|
title = { Text("恢复数据") },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
Text("请选择要恢复的备份文件(CSV或Excel格式)")
|
||||||
|
Text(
|
||||||
|
"注意:恢复数据将覆盖当前的所有数据!",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
showRestoreDialog = false
|
||||||
|
// 启动文件选择器
|
||||||
|
val activity = context as? ComponentActivity
|
||||||
|
if (activity != null) {
|
||||||
|
FilePickerUtil.startFilePicker(activity) { file ->
|
||||||
|
viewModel.restoreData(context, file)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Toast.makeText(context, "无法启动文件选择器", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text("选择文件")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showRestoreDialog = false }) {
|
||||||
|
Text("取消")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 类别管理对话框
|
// 类别管理对话框
|
||||||
@ -182,6 +275,7 @@ fun SettingsScreen(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
@ -0,0 +1,105 @@
|
|||||||
|
package com.yovinchen.bookkeeping.utils
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import com.yovinchen.bookkeeping.getPreregisteredFilePickerLauncher
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
|
||||||
|
object FilePickerUtil {
|
||||||
|
private var currentCallback: ((File) -> Unit)? = null
|
||||||
|
|
||||||
|
fun startFilePicker(activity: ComponentActivity, onFileSelected: (File) -> Unit) {
|
||||||
|
currentCallback = onFileSelected
|
||||||
|
|
||||||
|
try {
|
||||||
|
val mimeTypes = arrayOf(
|
||||||
|
"text/csv",
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
"application/vnd.ms-excel"
|
||||||
|
)
|
||||||
|
activity.getPreregisteredFilePickerLauncher().launch(mimeTypes)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Toast.makeText(activity, "无法启动文件选择器:${e.message}", Toast.LENGTH_SHORT).show()
|
||||||
|
currentCallback = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun handleFileSelection(context: Context, uri: Uri?) {
|
||||||
|
if (uri == null) {
|
||||||
|
Toast.makeText(context, "未选择文件", Toast.LENGTH_SHORT).show()
|
||||||
|
currentCallback = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val mimeType = context.contentResolver.getType(uri)
|
||||||
|
if (!isValidFileType(uri.toString(), mimeType)) {
|
||||||
|
Toast.makeText(context, "请选择CSV或Excel文件", Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取持久性权限
|
||||||
|
val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION or
|
||||||
|
Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||||
|
context.contentResolver.takePersistableUriPermission(uri, takeFlags)
|
||||||
|
|
||||||
|
// 将选中的文件复制到应用私有目录
|
||||||
|
val tempFile = copyUriToTempFile(context, uri)
|
||||||
|
if (tempFile != null) {
|
||||||
|
currentCallback?.invoke(tempFile)
|
||||||
|
} else {
|
||||||
|
Toast.makeText(context, "文件处理失败,请重试", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
Toast.makeText(context, "文件处理出错:${e.message}", Toast.LENGTH_SHORT).show()
|
||||||
|
} finally {
|
||||||
|
currentCallback = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isValidFileType(fileName: String, mimeType: String?): Boolean {
|
||||||
|
val fileExtension = fileName.lowercase()
|
||||||
|
return fileExtension.endsWith(".csv") ||
|
||||||
|
fileExtension.endsWith(".xlsx") ||
|
||||||
|
fileExtension.endsWith(".xls") ||
|
||||||
|
mimeType == "text/csv" ||
|
||||||
|
mimeType == "application/vnd.ms-excel" ||
|
||||||
|
mimeType == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun copyUriToTempFile(context: Context, uri: Uri): File? {
|
||||||
|
return try {
|
||||||
|
val fileName = getFileName(context, uri) ?: "temp_backup_${System.currentTimeMillis()}"
|
||||||
|
val tempFile = File(context.cacheDir, fileName)
|
||||||
|
|
||||||
|
context.contentResolver.openInputStream(uri)?.use { inputStream ->
|
||||||
|
FileOutputStream(tempFile).use { outputStream ->
|
||||||
|
inputStream.copyTo(outputStream)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tempFile
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getFileName(context: Context, uri: Uri): String? {
|
||||||
|
var fileName: String? = null
|
||||||
|
context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||||
|
if (cursor.moveToFirst()) {
|
||||||
|
val displayNameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
|
||||||
|
if (displayNameIndex != -1) {
|
||||||
|
fileName = cursor.getString(displayNameIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fileName
|
||||||
|
}
|
||||||
|
}
|
@ -1,28 +1,52 @@
|
|||||||
package com.yovinchen.bookkeeping.viewmodel
|
package com.yovinchen.bookkeeping.viewmodel
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Environment
|
||||||
|
import android.widget.Toast
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.opencsv.CSVReader
|
||||||
|
import com.opencsv.CSVWriter
|
||||||
import com.yovinchen.bookkeeping.data.BookkeepingDatabase
|
import com.yovinchen.bookkeeping.data.BookkeepingDatabase
|
||||||
|
import com.yovinchen.bookkeeping.model.BookkeepingRecord
|
||||||
import com.yovinchen.bookkeeping.model.Category
|
import com.yovinchen.bookkeeping.model.Category
|
||||||
import com.yovinchen.bookkeeping.model.TransactionType
|
import com.yovinchen.bookkeeping.model.TransactionType
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.apache.poi.xssf.usermodel.XSSFWorkbook
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileReader
|
||||||
|
import java.io.FileWriter
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
class SettingsViewModel(application: Application) : AndroidViewModel(application) {
|
class SettingsViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
private val database = BookkeepingDatabase.getDatabase(application)
|
private val database = BookkeepingDatabase.getDatabase(application)
|
||||||
private val dao = database.bookkeepingDao()
|
private val dao = database.bookkeepingDao()
|
||||||
|
private val memberDao = database.memberDao()
|
||||||
|
private val _isAutoBackupEnabled = MutableStateFlow(false)
|
||||||
|
val isAutoBackupEnabled: StateFlow<Boolean> = _isAutoBackupEnabled.asStateFlow()
|
||||||
|
|
||||||
private val _selectedCategoryType = MutableStateFlow(TransactionType.EXPENSE)
|
private val _selectedCategoryType = MutableStateFlow(TransactionType.EXPENSE)
|
||||||
val selectedCategoryType: StateFlow<TransactionType> = _selectedCategoryType.asStateFlow()
|
val selectedCategoryType: StateFlow<TransactionType> = _selectedCategoryType.asStateFlow()
|
||||||
|
|
||||||
val categories: StateFlow<List<Category>> = _selectedCategoryType
|
val categories: StateFlow<List<Category>> = _selectedCategoryType.flatMapLatest { type ->
|
||||||
.flatMapLatest { type ->
|
|
||||||
dao.getCategoriesByType(type)
|
dao.getCategoriesByType(type)
|
||||||
}
|
}.stateIn(
|
||||||
.stateIn(
|
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = SharingStarted.WhileSubscribed(5000),
|
started = SharingStarted.WhileSubscribed(5000),
|
||||||
initialValue = emptyList()
|
initialValue = emptyList()
|
||||||
@ -57,4 +81,228 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
suspend fun isCategoryInUse(categoryName: String): Boolean {
|
suspend fun isCategoryInUse(categoryName: String): Boolean {
|
||||||
return dao.isCategoryInUse(categoryName)
|
return dao.isCategoryInUse(categoryName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setAutoBackup(enabled: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_isAutoBackupEnabled.value = enabled
|
||||||
|
if (enabled) {
|
||||||
|
schedulePeriodicBackup()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun schedulePeriodicBackup() {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
while (isAutoBackupEnabled.value) {
|
||||||
|
try {
|
||||||
|
// 创建自动备份
|
||||||
|
SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
|
||||||
|
val backupDir = File(
|
||||||
|
getApplication<Application>().getExternalFilesDir(null), "auto_backups"
|
||||||
|
)
|
||||||
|
if (!backupDir.exists()) {
|
||||||
|
backupDir.mkdirs()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出CSV
|
||||||
|
exportToCSV(getApplication(), backupDir)
|
||||||
|
|
||||||
|
// 等待24小时
|
||||||
|
delay(TimeUnit.HOURS.toMillis(24))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun exportToCSV(context: Context, customDir: File? = null) {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val timestamp =
|
||||||
|
SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
|
||||||
|
val fileName = "bookkeeping_backup_$timestamp.csv"
|
||||||
|
val downloadsDir = customDir ?: Environment.getExternalStoragePublicDirectory(
|
||||||
|
Environment.DIRECTORY_DOWNLOADS
|
||||||
|
)
|
||||||
|
val file = File(downloadsDir, fileName)
|
||||||
|
|
||||||
|
CSVWriter(FileWriter(file)).use { writer ->
|
||||||
|
// 写入头部
|
||||||
|
writer.writeNext(arrayOf("日期", "类型", "金额", "类别", "备注", "成员"))
|
||||||
|
|
||||||
|
// 获取所有记录和成员
|
||||||
|
val records = dao.getAllRecords().first()
|
||||||
|
val members = memberDao.getAllMembers().first()
|
||||||
|
|
||||||
|
// 写入数据行
|
||||||
|
records.forEach { record ->
|
||||||
|
val member = members.find { member -> member.id == record.memberId }
|
||||||
|
writer.writeNext(
|
||||||
|
arrayOf(
|
||||||
|
SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(
|
||||||
|
record.date
|
||||||
|
),
|
||||||
|
record.type.toString(),
|
||||||
|
record.amount.toString(),
|
||||||
|
record.category,
|
||||||
|
record.description,
|
||||||
|
member?.name ?: "自己"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
Toast.makeText(context, "CSV导出成功: ${file.absolutePath}", Toast.LENGTH_LONG)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
Toast.makeText(context, "CSV导出失败: ${e.message}", Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun exportToExcel(context: Context) {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val workbook = XSSFWorkbook()
|
||||||
|
val sheet = workbook.createSheet("账目记录")
|
||||||
|
|
||||||
|
// 创建标题行
|
||||||
|
val headerRow = sheet.createRow(0)
|
||||||
|
val headers = arrayOf("日期", "类型", "金额", "类别", "备注", "成员")
|
||||||
|
headers.forEachIndexed { index, header ->
|
||||||
|
headerRow.createCell(index).setCellValue(header)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取所有记录和成员
|
||||||
|
val records = dao.getAllRecords().first()
|
||||||
|
val members = memberDao.getAllMembers().first()
|
||||||
|
|
||||||
|
records.forEachIndexed { index, record ->
|
||||||
|
val row = sheet.createRow(index + 1)
|
||||||
|
val member = members.find { member -> member.id == record.memberId }
|
||||||
|
|
||||||
|
row.createCell(0).setCellValue(
|
||||||
|
SimpleDateFormat(
|
||||||
|
"yyyy-MM-dd HH:mm:ss", Locale.getDefault()
|
||||||
|
).format(record.date)
|
||||||
|
)
|
||||||
|
row.createCell(1).setCellValue(record.type.toString())
|
||||||
|
row.createCell(2).setCellValue(record.amount)
|
||||||
|
row.createCell(3).setCellValue(record.category)
|
||||||
|
row.createCell(4).setCellValue(record.description)
|
||||||
|
row.createCell(5).setCellValue(member?.name ?: "自己")
|
||||||
|
}
|
||||||
|
|
||||||
|
val timestamp =
|
||||||
|
SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
|
||||||
|
val fileName = "bookkeeping_backup_$timestamp.xlsx"
|
||||||
|
val downloadsDir =
|
||||||
|
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
|
||||||
|
val file = File(downloadsDir, fileName)
|
||||||
|
|
||||||
|
workbook.write(file.outputStream())
|
||||||
|
workbook.close()
|
||||||
|
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
Toast.makeText(
|
||||||
|
context, "Excel导出成功: ${file.absolutePath}", Toast.LENGTH_LONG
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
Toast.makeText(context, "Excel导出失败: ${e.message}", Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun restoreData(context: Context, backupFile: File) {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
when {
|
||||||
|
backupFile.name.endsWith(".csv", ignoreCase = true) -> {
|
||||||
|
restoreFromCSV(backupFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
backupFile.name.endsWith(".xlsx", ignoreCase = true) -> {
|
||||||
|
restoreFromExcel(backupFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
Toast.makeText(context, "不支持的文件格式", Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
Toast.makeText(context, "数据恢复成功", Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
Toast.makeText(context, "数据恢复失败: ${e.message}", Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun restoreFromCSV(file: File) {
|
||||||
|
CSVReader(FileReader(file)).use { reader ->
|
||||||
|
// 跳过标题行
|
||||||
|
reader.readNext()
|
||||||
|
|
||||||
|
// 读取数据行
|
||||||
|
var currentLine = reader.readNext()
|
||||||
|
while (currentLine != null) {
|
||||||
|
val record = BookkeepingRecord(
|
||||||
|
type = TransactionType.valueOf(currentLine[1]),
|
||||||
|
amount = currentLine[2].toDouble(),
|
||||||
|
category = currentLine[3],
|
||||||
|
description = currentLine[4],
|
||||||
|
date = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).parse(
|
||||||
|
currentLine[0]
|
||||||
|
) ?: Date(),
|
||||||
|
memberId = findMemberIdByName(currentLine[5])
|
||||||
|
)
|
||||||
|
dao.insertRecord(record)
|
||||||
|
currentLine = reader.readNext()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun restoreFromExcel(file: File) {
|
||||||
|
val workbook = XSSFWorkbook(file)
|
||||||
|
val sheet = workbook.getSheetAt(0)
|
||||||
|
|
||||||
|
// 跳过标题行
|
||||||
|
for (rowIndex in 1..sheet.lastRowNum) {
|
||||||
|
val row = sheet.getRow(rowIndex)
|
||||||
|
val record = BookkeepingRecord(
|
||||||
|
type = TransactionType.valueOf(row.getCell(1).stringCellValue),
|
||||||
|
amount = row.getCell(2).numericCellValue,
|
||||||
|
category = row.getCell(3).stringCellValue,
|
||||||
|
description = row.getCell(4).stringCellValue,
|
||||||
|
date = SimpleDateFormat(
|
||||||
|
"yyyy-MM-dd HH:mm:ss",
|
||||||
|
Locale.getDefault()
|
||||||
|
).parse(row.getCell(0).stringCellValue),
|
||||||
|
memberId = findMemberIdByName(row.getCell(5).stringCellValue)
|
||||||
|
)
|
||||||
|
dao.insertRecord(record)
|
||||||
|
}
|
||||||
|
workbook.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun findMemberIdByName(name: String): Int? {
|
||||||
|
return memberDao.getAllMembers().first().find { member -> member.name == name }?.id
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user