Compare commits

...

10 Commits

Author SHA1 Message Date
0d40a8abb0 "Merge changes and finalize updates" 2024-12-17 14:42:58 +08:00
0804791bd2 "End merge process" 2024-12-17 14:41:48 +08:00
5d5414d51d "Merge changes and update permissions" 2024-12-17 14:40:24 +08:00
046c6a6ae0 Merge remote-tracking branch 'origin/feature/icon' into feature/icon 2024-12-17 14:37:21 +08:00
1b0c6982e8 fix: 修复图标缺失错误 2024-12-17 14:36:54 +08:00
3080e98caa fix: 修复权限缺失问题 2024-12-17 14:36:26 +08:00
d0b3b9a663 fix: 夫妇图标缺失错误 2024-12-17 14:35:12 +08:00
9382e7adde update: 升级版本1.4 2024-12-17 14:13:45 +08:00
0497e2503b fix: 修复图标显示不全问题 2024-12-17 14:10:11 +08:00
3c7b1dc610 feat: 增加数据备份功能
- 导出 CSV/Excel 功能
- 数据导入
- 数据迁移工具
- 定期自动备份
2024-12-17 13:47:57 +08:00
11 changed files with 604 additions and 132 deletions

View File

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

View File

@ -17,7 +17,7 @@ android {
minSdk = 26 minSdk = 26
targetSdk = 34 targetSdk = 34
versionCode = 6 versionCode = 6
versionName = "1.3.0" versionName = "1.4.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {
@ -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")
} }

View File

@ -2,6 +2,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<application <application
android:allowBackup="true" android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"

View File

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

View File

@ -165,31 +165,31 @@ abstract class BookkeepingDatabase : RoomDatabase() {
// 初始化默认分类 // 初始化默认分类
database.categoryDao().apply { 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_food_24dp)) // "餐饮" to 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_taxi_24dp)) // "交通" to 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_supermarket_24dp)) // "购物" to 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_bar_24dp)) // "娱乐" to 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_hotel_24dp)) // "居住" to 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_medicine_24dp)) // "医疗" to 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_training_24dp)) // "教育" to 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_pet_24dp)) // "宠物" to 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_flower_24dp)) // "鲜花" to 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)) // "外卖" to R.drawable.ic_category_delivery_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)) // "数码" to R.drawable.ic_category_digital_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)) // "化妆品" to R.drawable.ic_category_cosmetics_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)) // "水果" to 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)) // "零食" to 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)) // "蔬菜" to 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)) // "工资" to R.drawable.ic_category_membership_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_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 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()

View File

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

View File

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

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 { object IconManager {
// 类别图标映射 // 类别图标映射
private val categoryIcons = mapOf( 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_taxi_24dp,
"娱乐" to R.drawable.ic_category_bar_24dp,
"购物" to R.drawable.ic_category_supermarket_24dp, "购物" to R.drawable.ic_category_supermarket_24dp,
"工资" to R.drawable.ic_category_membership_24dp, "娱乐" to R.drawable.ic_category_bar_24dp,
"服装" to R.drawable.ic_category_clothes_24dp, "居住" to R.drawable.ic_category_hotel_24dp,
"数码" to R.drawable.ic_category_digital_24dp,
"饮料" to R.drawable.ic_category_drink_24dp,
"医疗" to R.drawable.ic_category_medicine_24dp, "医疗" to R.drawable.ic_category_medicine_24dp,
"旅行" to R.drawable.ic_category_travel_24dp, "教育" to R.drawable.ic_category_training_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_pet_24dp,
"景点" to R.drawable.ic_category_scenic_24dp, "鲜花" to R.drawable.ic_category_flower_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_snack_24dp,
"培训" to R.drawable.ic_category_training_24dp,
"蔬菜" to R.drawable.ic_category_vegetable_24dp, "蔬菜" to R.drawable.ic_category_vegetable_24dp,
"婴儿" to R.drawable.ic_category_baby_24dp, "工资" to R.drawable.ic_category_membership_24dp,
"餐饮" to R.drawable.ic_category_food_24dp, // 添加餐饮分类 "礼物" to R.drawable.ic_category_gift_24dp,
"居住" to R.drawable.ic_category_hotel_24dp, // 添加居住分类 "其他" to R.drawable.ic_category_more_24dp,
"工资" to R.drawable.ic_category_membership_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 "其他" to R.drawable.ic_category_more_24dp
) )
// 成员图标映射 // 成员图标映射
private val memberIcons = mapOf( private val memberIcons = mapOf(
"自己" to R.drawable.ic_member_boy_24dp, "自己" 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_family_24dp,
"父亲" to R.drawable.ic_member_father_24dp, "儿子" to R.drawable.ic_member_baby_boy_24dp,
"母亲" to R.drawable.ic_member_mother_24dp, "女儿" to R.drawable.ic_member_baby_girl_24dp,
"男宝" to R.drawable.ic_member_baby_boy_24dp, "爸爸" to R.drawable.ic_member_father_24dp,
"女宝" to R.drawable.ic_member_baby_girl_24dp, "妈妈" to R.drawable.ic_member_mother_24dp,
"新娘" to R.drawable.ic_member_bride_24dp,
"新郎" to R.drawable.ic_member_groom_24dp,
"爷爷" to R.drawable.ic_member_grandfather_24dp, "爷爷" to R.drawable.ic_member_grandfather_24dp,
"奶奶" to R.drawable.ic_member_grandmother_24dp, "奶奶" to R.drawable.ic_member_grandmother_24dp,
"男生" to R.drawable.ic_member_boy_24dp, "男生" to R.drawable.ic_member_boy_24dp,
"女生" to R.drawable.ic_member_girl_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 "其他" to R.drawable.ic_member_girl_24dp
) )

View File

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