Compare commits
No commits in common. "master" and "v1.3.0" have entirely different histories.
@ -59,10 +59,9 @@
|
||||
- [x] 成员图标 (家人、朋友、同事等)
|
||||
|
||||
### 4. 数据管理 (进行中 🚀)
|
||||
- [x] 导出 CSV/Excel 功能
|
||||
- [x] 数据导入
|
||||
- [x] 数据迁移工具
|
||||
- [x] 定期自动备份
|
||||
- [ ] 导出 CSV/Excel 功能
|
||||
- [ ] 数据迁移工具
|
||||
- [ ] 定期自动备份
|
||||
- [ ] 备份加密功能
|
||||
|
||||
### 5. 预算管理 (计划中 💡)
|
||||
|
@ -17,7 +17,7 @@ android {
|
||||
minSdk = 26
|
||||
targetSdk = 34
|
||||
versionCode = 6
|
||||
versionName = "1.4.0"
|
||||
versionName = "1.3.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
@ -107,9 +107,4 @@ 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")
|
||||
}
|
@ -2,9 +2,6 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
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
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
|
@ -1,12 +1,9 @@
|
||||
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
|
||||
@ -22,31 +19,6 @@ 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) {
|
||||
@ -116,6 +88,16 @@ 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(
|
||||
|
@ -165,31 +165,31 @@ abstract class BookkeepingDatabase : RoomDatabase() {
|
||||
// 初始化默认分类
|
||||
database.categoryDao().apply {
|
||||
// 支出分类
|
||||
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_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.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.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))
|
||||
}
|
||||
|
||||
|
||||
|
@ -54,6 +54,7 @@ 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()
|
||||
|
||||
|
@ -144,7 +144,7 @@ fun MemberDetailScreen(
|
||||
categoryData = categoryData,
|
||||
memberData = emptyList(),
|
||||
currentViewMode = false,
|
||||
onCategoryClick = {
|
||||
onCategoryClick = { selectedCategory ->
|
||||
// 暂时不处理点击事件
|
||||
}
|
||||
)
|
||||
|
@ -1,23 +1,36 @@
|
||||
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.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
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.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.*
|
||||
import com.yovinchen.bookkeeping.ui.dialog.*
|
||||
import com.yovinchen.bookkeeping.utils.FilePickerUtil
|
||||
import com.yovinchen.bookkeeping.viewmodel.*
|
||||
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
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@ -30,13 +43,10 @@ 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()) {
|
||||
// 成员管理设置项
|
||||
@ -46,7 +56,7 @@ fun SettingsScreen(
|
||||
modifier = Modifier.clickable { showMemberDialog = true }
|
||||
)
|
||||
|
||||
HorizontalDivider()
|
||||
Divider()
|
||||
|
||||
// 类别管理设置项
|
||||
ListItem(
|
||||
@ -55,16 +65,7 @@ fun SettingsScreen(
|
||||
modifier = Modifier.clickable { showCategoryDialog = true }
|
||||
)
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
// 数据备份设置项
|
||||
ListItem(
|
||||
headlineContent = { Text("数据备份") },
|
||||
supportingContent = { Text("备份和恢复数据") },
|
||||
modifier = Modifier.clickable { showBackupDialog = true }
|
||||
)
|
||||
|
||||
HorizontalDivider()
|
||||
Divider()
|
||||
|
||||
// 主题设置项
|
||||
ListItem(
|
||||
@ -116,7 +117,7 @@ fun SettingsScreen(
|
||||
}
|
||||
)
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
Divider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
|
||||
// 颜色选择器
|
||||
Text(
|
||||
@ -144,137 +145,42 @@ 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 (showCategoryDialog) {
|
||||
CategoryManagementDialog(
|
||||
onDismiss = { showCategoryDialog = false },
|
||||
categories = categories,
|
||||
onAddCategory = { name, type, iconResId ->
|
||||
viewModel.addCategory(name, type, iconResId)
|
||||
},
|
||||
onDeleteCategory = viewModel::deleteCategory,
|
||||
onUpdateCategory = { category, newName, iconResId ->
|
||||
viewModel.updateCategory(category, newName, iconResId)
|
||||
},
|
||||
selectedType = selectedType,
|
||||
onTypeChange = viewModel::setSelectedCategoryType
|
||||
)
|
||||
}
|
||||
|
||||
// 恢复对话框
|
||||
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("取消")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 类别管理对话框
|
||||
if (showCategoryDialog) {
|
||||
CategoryManagementDialog(
|
||||
onDismiss = { showCategoryDialog = false },
|
||||
categories = categories,
|
||||
onAddCategory = { name, type, iconResId ->
|
||||
viewModel.addCategory(name, type, iconResId)
|
||||
},
|
||||
onDeleteCategory = viewModel::deleteCategory,
|
||||
onUpdateCategory = { category, newName, iconResId ->
|
||||
viewModel.updateCategory(category, newName, iconResId)
|
||||
},
|
||||
selectedType = selectedType,
|
||||
onTypeChange = viewModel::setSelectedCategoryType
|
||||
)
|
||||
}
|
||||
|
||||
// 成员管理对话框
|
||||
if (showMemberDialog) {
|
||||
MemberManagementDialog(
|
||||
onDismiss = { showMemberDialog = false },
|
||||
members = members,
|
||||
onAddMember = { name, description, iconResId ->
|
||||
memberViewModel.addMember(name, description, iconResId)
|
||||
},
|
||||
onDeleteMember = memberViewModel::deleteMember,
|
||||
onUpdateMember = { member, name, description, iconResId ->
|
||||
memberViewModel.updateMember(member.copy(
|
||||
name = name,
|
||||
description = description,
|
||||
icon = iconResId
|
||||
))
|
||||
}
|
||||
)
|
||||
}
|
||||
// 成员管理对话框
|
||||
if (showMemberDialog) {
|
||||
MemberManagementDialog(
|
||||
onDismiss = { showMemberDialog = false },
|
||||
members = members,
|
||||
onAddMember = { name, description, iconResId ->
|
||||
memberViewModel.addMember(name, description, iconResId)
|
||||
},
|
||||
onDeleteMember = memberViewModel::deleteMember,
|
||||
onUpdateMember = { member, name, description, iconResId ->
|
||||
memberViewModel.updateMember(member.copy(
|
||||
name = name,
|
||||
description = description,
|
||||
icon = iconResId
|
||||
))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,105 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
@ -9,47 +9,48 @@ 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_supermarket_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_training_24dp,
|
||||
"宠物" to R.drawable.ic_category_pet_24dp,
|
||||
"鲜花" to R.drawable.ic_category_flower_24dp,
|
||||
"外卖" to R.drawable.ic_category_delivery_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_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_snack_24dp,
|
||||
"蔬菜" to R.drawable.ic_category_vegetable_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_membership_24dp,
|
||||
"奖金" to R.drawable.ic_category_gift_24dp,
|
||||
"投资" to R.drawable.ic_category_digital_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_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_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_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_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_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
|
||||
)
|
||||
|
||||
|
@ -1,52 +1,28 @@
|
||||
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.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.flow.*
|
||||
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()
|
||||
@ -81,228 +57,4 @@ 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
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user