diff --git a/README.md b/README.md index 3c2865e..72df06a 100644 --- a/README.md +++ b/README.md @@ -59,9 +59,10 @@ - [x] 成员图标 (家人、朋友、同事等) ### 4. 数据管理 (进行中 🚀) -- [ ] 导出 CSV/Excel 功能 -- [ ] 数据迁移工具 -- [ ] 定期自动备份 +- [x] 导出 CSV/Excel 功能 +- [x] 数据导入 +- [x] 数据迁移工具 +- [x] 定期自动备份 - [ ] 备份加密功能 ### 5. 预算管理 (计划中 💡) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9b4b951..9bfdb2e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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") } \ No newline at end of file diff --git a/app/src/main/java/com/yovinchen/bookkeeping/MainActivity.kt b/app/src/main/java/com/yovinchen/bookkeeping/MainActivity.kt index a03c616..419c442 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/MainActivity.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/MainActivity.kt @@ -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>? = null + +fun ComponentActivity.getPreregisteredFilePickerLauncher(): ActivityResultLauncher> { + 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( diff --git a/app/src/main/java/com/yovinchen/bookkeeping/data/BookkeepingDatabase.kt b/app/src/main/java/com/yovinchen/bookkeeping/data/BookkeepingDatabase.kt index c1e43b6..478364c 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/data/BookkeepingDatabase.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/data/BookkeepingDatabase.kt @@ -181,14 +181,14 @@ abstract class BookkeepingDatabase : RoomDatabase() { insertCategory(Category(name = "水果", type = TransactionType.EXPENSE, icon = R.drawable.ic_category_fruit_24dp)) insertCategory(Category(name = "零食", type = TransactionType.EXPENSE, icon = R.drawable.ic_category_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_membership_24dp)) + insertCategory(Category(name = "礼物", type = TransactionType.EXPENSE, icon = R.drawable.ic_category_gift_24dp)) insertCategory(Category(name = "其他支出", type = TransactionType.EXPENSE, icon = R.drawable.ic_category_more_24dp)) // 收入分类 insertCategory(Category(name = "工资", type = TransactionType.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)) } diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/HomeScreen.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/HomeScreen.kt index 0b83fed..2779013 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/HomeScreen.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/HomeScreen.kt @@ -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() diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/MemberDetailScreen.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/MemberDetailScreen.kt index 9b055bd..6ccf017 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/MemberDetailScreen.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/MemberDetailScreen.kt @@ -144,7 +144,7 @@ fun MemberDetailScreen( categoryData = categoryData, memberData = emptyList(), currentViewMode = false, - onCategoryClick = { selectedCategory -> + onCategoryClick = { // 暂时不处理点击事件 } ) diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/SettingsScreen.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/SettingsScreen.kt index 2284f99..a015d78 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/SettingsScreen.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/SettingsScreen.kt @@ -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,42 +144,137 @@ fun SettingsScreen( } ) } - } - // 类别管理对话框 - 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 (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 (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 (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 + )) + } + ) + } } } diff --git a/app/src/main/java/com/yovinchen/bookkeeping/utils/FilePickerUtil.kt b/app/src/main/java/com/yovinchen/bookkeeping/utils/FilePickerUtil.kt new file mode 100644 index 0000000..70e0bd8 --- /dev/null +++ b/app/src/main/java/com/yovinchen/bookkeeping/utils/FilePickerUtil.kt @@ -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 + } +} diff --git a/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/SettingsViewModel.kt b/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/SettingsViewModel.kt index 9900291..840a7c4 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/SettingsViewModel.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/SettingsViewModel.kt @@ -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 = _isAutoBackupEnabled.asStateFlow() private val _selectedCategoryType = MutableStateFlow(TransactionType.EXPENSE) val selectedCategoryType: StateFlow = _selectedCategoryType.asStateFlow() - val categories: StateFlow> = _selectedCategoryType - .flatMapLatest { type -> + val categories: StateFlow> = _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().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 + } }