Compare commits

..

2 Commits

Author SHA1 Message Date
0497e2503b fix: 修复图标显示不全问题 2024-12-17 14:10:11 +08:00
3c7b1dc610 feat: 增加数据备份功能
- 导出 CSV/Excel 功能
- 数据导入
- 数据迁移工具
- 定期自动备份
2024-12-17 13:47:57 +08:00
10 changed files with 602 additions and 132 deletions

View File

@ -59,9 +59,10 @@
- [x] 成员图标 (家人、朋友、同事等)
### 4. 数据管理 (进行中 🚀)
- [ ] 导出 CSV/Excel 功能
- [ ] 数据迁移工具
- [ ] 定期自动备份
- [x] 导出 CSV/Excel 功能
- [x] 数据导入
- [x] 数据迁移工具
- [x] 定期自动备份
- [ ] 备份加密功能
### 5. 预算管理 (计划中 💡)

View File

@ -107,4 +107,9 @@ dependencies {
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-tooling")
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")
}

View File

@ -1,9 +1,12 @@
package com.yovinchen.bookkeeping
import android.app.Activity
import android.net.Uri
import android.os.Bundle
import androidx.activity.ComponentActivity
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.layout.fillMaxSize
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.navigation.MainNavigation
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
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
fun Greeting(name: String, modifier: Modifier = Modifier) {
Text(

View File

@ -165,31 +165,32 @@ abstract class BookkeepingDatabase : RoomDatabase() {
// 初始化默认分类
database.categoryDao().apply {
// 支出分类
insertCategory(Category(name = "餐饮", type = TransactionType.EXPENSE, icon = R.drawable.ic_category_food_24dp))
insertCategory(Category(name = "交通", type = TransactionType.EXPENSE, icon = R.drawable.ic_category_taxi_24dp))
insertCategory(Category(name = "购物", type = TransactionType.EXPENSE, icon = R.drawable.ic_category_supermarket_24dp))
insertCategory(Category(name = "娱乐", type = TransactionType.EXPENSE, icon = R.drawable.ic_category_bar_24dp))
insertCategory(Category(name = "居住", type = TransactionType.EXPENSE, icon = R.drawable.ic_category_hotel_24dp))
insertCategory(Category(name = "医疗", type = TransactionType.EXPENSE, icon = R.drawable.ic_category_medicine_24dp))
insertCategory(Category(name = "教育", type = TransactionType.EXPENSE, icon = R.drawable.ic_category_training_24dp))
insertCategory(Category(name = "宠物", type = TransactionType.EXPENSE, icon = R.drawable.ic_category_pet_24dp))
insertCategory(Category(name = "花卉", type = TransactionType.EXPENSE, icon = R.drawable.ic_category_flower_24dp))
insertCategory(Category(name = "酒吧", type = TransactionType.EXPENSE, icon = R.drawable.ic_category_bar_24dp))
insertCategory(Category(name = "快递", type = TransactionType.EXPENSE, icon = R.drawable.ic_category_delivery_24dp))
insertCategory(Category(name = "数码", type = TransactionType.EXPENSE, icon = R.drawable.ic_category_digital_24dp))
insertCategory(Category(name = "化妆品", type = TransactionType.EXPENSE, icon = R.drawable.ic_category_cosmetics_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_vegetable_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_food_24dp)) // "餐饮" to R.drawable.ic_category_food_24dp
insertCategory(Category(name = "交通", type = TransactionType.EXPENSE, icon = R.drawable.ic_category_taxi_24dp)) // "交通" to R.drawable.ic_category_taxi_24dp
insertCategory(Category(name = "购物", type = TransactionType.EXPENSE, icon = R.drawable.ic_category_supermarket_24dp)) // "购物" to R.drawable.ic_category_supermarket_24dp
insertCategory(Category(name = "娱乐", type = TransactionType.EXPENSE, icon = R.drawable.ic_category_bar_24dp)) // "娱乐" to R.drawable.ic_category_bar_24dp
insertCategory(Category(name = "居住", type = TransactionType.EXPENSE, icon = R.drawable.ic_category_hotel_24dp)) // "居住" to R.drawable.ic_category_hotel_24dp
insertCategory(Category(name = "医疗", type = TransactionType.EXPENSE, icon = R.drawable.ic_category_medicine_24dp)) // "医疗" to R.drawable.ic_category_medicine_24dp
insertCategory(Category(name = "教育", type = TransactionType.EXPENSE, icon = R.drawable.ic_category_training_24dp)) // "培训" to R.drawable.ic_category_training_24dp
insertCategory(Category(name = "宠物", type = TransactionType.EXPENSE, icon = R.drawable.ic_category_pet_24dp)) // "宠物" to R.drawable.ic_category_pet_24dp
insertCategory(Category(name = "花卉", type = TransactionType.EXPENSE, icon = R.drawable.ic_category_flower_24dp)) // "鲜花" to R.drawable.ic_category_flower_24dp
insertCategory(Category(name = "酒吧", type = TransactionType.EXPENSE, icon = R.drawable.ic_category_bar_24dp)) // "娱乐" to R.drawable.ic_category_bar_24dp
insertCategory(Category(name = "快递", type = TransactionType.EXPENSE, icon = R.drawable.ic_category_delivery_24dp)) // "外卖" to R.drawable.ic_category_delivery_24dp
insertCategory(Category(name = "数码", type = TransactionType.EXPENSE, icon = R.drawable.ic_category_digital_24dp)) // "数码" to R.drawable.ic_category_digital_24dp
insertCategory(Category(name = "化妆品", type = TransactionType.EXPENSE, icon = R.drawable.ic_category_cosmetics_24dp)) // "化妆品" to R.drawable.ic_category_cosmetics_24dp
insertCategory(Category(name = "水果", type = TransactionType.EXPENSE, icon = R.drawable.ic_category_fruit_24dp)) // "水果" to R.drawable.ic_category_fruit_24dp
insertCategory(Category(name = "零食", type = TransactionType.EXPENSE, icon = R.drawable.ic_category_snack_24dp)) // "零食" to R.drawable.ic_category_snack_24dp
insertCategory(Category(name = "蔬菜", type = TransactionType.EXPENSE, icon = R.drawable.ic_category_vegetable_24dp)) // "蔬菜" to R.drawable.ic_category_vegetable_24dp
insertCategory(Category(name = "会员", type = TransactionType.EXPENSE, icon = R.drawable.ic_category_membership_24dp)) // "工资" to R.drawable.ic_category_membership_24dp
insertCategory(Category(name = "礼物", type = TransactionType.EXPENSE, icon = R.drawable.ic_category_gift_24dp)) // "礼物" to R.drawable.ic_category_gift_24dp
insertCategory(Category(name = "其他支出", type = TransactionType.EXPENSE, icon = R.drawable.ic_category_more_24dp)) // "其他" to R.drawable.ic_category_more_24dp
// 收入分类
insertCategory(Category(name = "工资", type = TransactionType.INCOME, icon = R.drawable.ic_category_membership_24dp)) // "工资" to R.drawable.ic_category_membership_24dp
insertCategory(Category(name = "奖金", type = TransactionType.INCOME, icon = R.drawable.ic_category_gift_24dp)) // "奖金" to R.drawable.ic_category_gift_24dp
insertCategory(Category(name = "投资", type = TransactionType.INCOME, icon = R.drawable.ic_category_digital_24dp)) // "投资" to R.drawable.ic_category_digital_24dp
insertCategory(Category(name = "其他收入", type = TransactionType.INCOME, icon = R.drawable.ic_category_more_24dp)) // "其他" to 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_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_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))
}

View File

@ -54,7 +54,6 @@ fun HomeScreen(
val filteredRecords by viewModel.filteredRecords.collectAsState()
val categories by viewModel.categories.collectAsState(initial = emptyList())
val members by viewModel.members.collectAsState(initial = emptyList())
val selectedMember by viewModel.selectedMember.collectAsState()
val totalIncome by viewModel.totalIncome.collectAsState()
val totalExpense by viewModel.totalExpense.collectAsState()

View File

@ -144,7 +144,7 @@ fun MemberDetailScreen(
categoryData = categoryData,
memberData = emptyList(),
currentViewMode = false,
onCategoryClick = { selectedCategory ->
onCategoryClick = {
// 暂时不处理点击事件
}
)

View File

@ -1,36 +1,23 @@
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.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
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.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.yovinchen.bookkeeping.model.ThemeMode
import com.yovinchen.bookkeeping.ui.components.ColorPicker
import com.yovinchen.bookkeeping.ui.components.predefinedColors
import com.yovinchen.bookkeeping.ui.dialog.CategoryManagementDialog
import com.yovinchen.bookkeeping.ui.dialog.MemberManagementDialog
import com.yovinchen.bookkeeping.viewmodel.MemberViewModel
import com.yovinchen.bookkeeping.viewmodel.SettingsViewModel
import com.yovinchen.bookkeeping.ui.components.*
import com.yovinchen.bookkeeping.ui.dialog.*
import com.yovinchen.bookkeeping.utils.FilePickerUtil
import com.yovinchen.bookkeeping.viewmodel.*
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -43,10 +30,13 @@ fun SettingsScreen(
var showThemeDialog by remember { mutableStateOf(false) }
var showCategoryDialog 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 selectedType by viewModel.selectedCategoryType.collectAsState()
val members by memberViewModel.allMembers.collectAsState(initial = emptyList())
val context = LocalContext.current
Column(modifier = Modifier.fillMaxSize()) {
// 成员管理设置项
@ -56,7 +46,7 @@ fun SettingsScreen(
modifier = Modifier.clickable { showMemberDialog = true }
)
Divider()
HorizontalDivider()
// 类别管理设置项
ListItem(
@ -65,7 +55,16 @@ fun SettingsScreen(
modifier = Modifier.clickable { showCategoryDialog = true }
)
Divider()
HorizontalDivider()
// 数据备份设置项
ListItem(
headlineContent = { Text("数据备份") },
supportingContent = { Text("备份和恢复数据") },
modifier = Modifier.clickable { showBackupDialog = true }
)
HorizontalDivider()
// 主题设置项
ListItem(
@ -117,7 +116,7 @@ fun SettingsScreen(
}
)
Divider(modifier = Modifier.padding(vertical = 8.dp))
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
// 颜色选择器
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

View File

@ -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
}
}

View File

@ -9,48 +9,47 @@ import com.yovinchen.bookkeeping.R
object IconManager {
// 类别图标映射
private val categoryIcons = mapOf(
"食品" to R.drawable.ic_category_food_24dp,
"餐饮" to R.drawable.ic_category_food_24dp,
"交通" to R.drawable.ic_category_taxi_24dp,
"娱乐" to R.drawable.ic_category_bar_24dp,
"购物" to R.drawable.ic_category_supermarket_24dp,
"工资" to R.drawable.ic_category_membership_24dp,
"服装" to R.drawable.ic_category_clothes_24dp,
"数码" to R.drawable.ic_category_digital_24dp,
"饮料" to R.drawable.ic_category_drink_24dp,
"娱乐" to R.drawable.ic_category_bar_24dp,
"居住" to R.drawable.ic_category_hotel_24dp,
"医疗" to R.drawable.ic_category_medicine_24dp,
"旅行" to R.drawable.ic_category_travel_24dp,
"便利店" to R.drawable.ic_category_convenience_24dp,
"化妆品" to R.drawable.ic_category_cosmetics_24dp,
"外卖" to R.drawable.ic_category_delivery_24dp,
"鲜花" to R.drawable.ic_category_flower_24dp,
"水果" to R.drawable.ic_category_fruit_24dp,
"礼物" to R.drawable.ic_category_gift_24dp,
"住宿" to R.drawable.ic_category_hotel_24dp,
"宠物" to R.drawable.ic_category_pet_24dp,
"景点" to R.drawable.ic_category_scenic_24dp,
"零食" to R.drawable.ic_category_snack_24dp,
"培训" to R.drawable.ic_category_training_24dp,
"宠物" to R.drawable.ic_category_pet_24dp,
"鲜花" to R.drawable.ic_category_flower_24dp,
"娱乐" to R.drawable.ic_category_bar_24dp,
"外卖" to R.drawable.ic_category_delivery_24dp,
"数码" to R.drawable.ic_category_digital_24dp,
"化妆品" to R.drawable.ic_category_cosmetics_24dp,
"水果" to R.drawable.ic_category_fruit_24dp,
"零食" to R.drawable.ic_category_snack_24dp,
"蔬菜" to R.drawable.ic_category_vegetable_24dp,
"婴儿" to R.drawable.ic_category_baby_24dp,
"餐饮" to R.drawable.ic_category_food_24dp, // 添加餐饮分类
"居住" to R.drawable.ic_category_hotel_24dp, // 添加居住分类
"工资" to R.drawable.ic_category_membership_24dp,
"礼物" to R.drawable.ic_category_gift_24dp,
"其他" to R.drawable.ic_category_more_24dp,
"工资" to R.drawable.ic_category_membership_24dp,
"奖金" to R.drawable.ic_category_gift_24dp,
"投资" to R.drawable.ic_category_digital_24dp,
"其他" to R.drawable.ic_category_more_24dp
)
// 成员图标映射
private val memberIcons = mapOf(
"自己" to R.drawable.ic_member_boy_24dp,
"老婆" to R.drawable.ic_member_bride_24dp,
"老公" to R.drawable.ic_member_groom_24dp,
"家庭" to R.drawable.ic_member_family_24dp,
"父亲" to R.drawable.ic_member_father_24dp,
"母亲" to R.drawable.ic_member_mother_24dp,
"男宝" to R.drawable.ic_member_baby_boy_24dp,
"女宝" to R.drawable.ic_member_baby_girl_24dp,
"新娘" to R.drawable.ic_member_bride_24dp,
"新郎" to R.drawable.ic_member_groom_24dp,
"儿子" to R.drawable.ic_member_baby_boy_24dp,
"女儿" to R.drawable.ic_member_baby_girl_24dp,
"爸爸" to R.drawable.ic_member_father_24dp,
"妈妈" to R.drawable.ic_member_mother_24dp,
"爷爷" to R.drawable.ic_member_grandfather_24dp,
"奶奶" to R.drawable.ic_member_grandmother_24dp,
"男生" to R.drawable.ic_member_boy_24dp,
"女生" to R.drawable.ic_member_girl_24dp,
"外公" to R.drawable.ic_member_grandfather_24dp,
"外婆" to R.drawable.ic_member_grandmother_24dp,
"其他" to R.drawable.ic_member_girl_24dp
)

View File

@ -1,28 +1,52 @@
package com.yovinchen.bookkeeping.viewmodel
import android.app.Application
import android.content.Context
import android.os.Environment
import android.widget.Toast
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.opencsv.CSVReader
import com.opencsv.CSVWriter
import com.yovinchen.bookkeeping.data.BookkeepingDatabase
import com.yovinchen.bookkeeping.model.BookkeepingRecord
import com.yovinchen.bookkeeping.model.Category
import com.yovinchen.bookkeeping.model.TransactionType
import kotlinx.coroutines.Dispatchers
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.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)
class SettingsViewModel(application: Application) : AndroidViewModel(application) {
private val database = BookkeepingDatabase.getDatabase(application)
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)
val selectedCategoryType: StateFlow<TransactionType> = _selectedCategoryType.asStateFlow()
val categories: StateFlow<List<Category>> = _selectedCategoryType
.flatMapLatest { type ->
val categories: StateFlow<List<Category>> = _selectedCategoryType.flatMapLatest { type ->
dao.getCategoriesByType(type)
}
.stateIn(
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = emptyList()
@ -57,4 +81,228 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
suspend fun isCategoryInUse(categoryName: String): Boolean {
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
}
}