feat: 实现月度记账开始日期功能
- 添加 Settings 实体和 DAO 来持久化存储设置 - 创建 SettingsRepository 管理设置数据 - 添加数据库迁移从版本 4 到版本 5 - 在设置界面添加月度开始日期选择器(1-28号) - 创建 DateUtils 工具类处理基于月度开始日期的日期计算 - 更新 HomeViewModel 和 AnalysisViewModel 使用月度开始日期进行统计 - 修复日期选择器中数字显示不完整的问题
This commit is contained in:
parent
2339e5b980
commit
bdf01f6bbe
@ -10,6 +10,7 @@ import androidx.room.migration.Migration
|
|||||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
import com.yovinchen.bookkeeping.R
|
import com.yovinchen.bookkeeping.R
|
||||||
import com.yovinchen.bookkeeping.model.BookkeepingRecord
|
import com.yovinchen.bookkeeping.model.BookkeepingRecord
|
||||||
|
import com.yovinchen.bookkeeping.model.Budget
|
||||||
import com.yovinchen.bookkeeping.model.Category
|
import com.yovinchen.bookkeeping.model.Category
|
||||||
import com.yovinchen.bookkeeping.model.Converters
|
import com.yovinchen.bookkeeping.model.Converters
|
||||||
import com.yovinchen.bookkeeping.model.Member
|
import com.yovinchen.bookkeeping.model.Member
|
||||||
@ -20,8 +21,8 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@Database(
|
@Database(
|
||||||
entities = [BookkeepingRecord::class, Category::class, Member::class, Settings::class],
|
entities = [BookkeepingRecord::class, Category::class, Member::class, Settings::class, Budget::class],
|
||||||
version = 5,
|
version = 6,
|
||||||
exportSchema = false
|
exportSchema = false
|
||||||
)
|
)
|
||||||
@TypeConverters(Converters::class)
|
@TypeConverters(Converters::class)
|
||||||
@ -30,6 +31,7 @@ abstract class BookkeepingDatabase : RoomDatabase() {
|
|||||||
abstract fun categoryDao(): CategoryDao
|
abstract fun categoryDao(): CategoryDao
|
||||||
abstract fun memberDao(): MemberDao
|
abstract fun memberDao(): MemberDao
|
||||||
abstract fun settingsDao(): SettingsDao
|
abstract fun settingsDao(): SettingsDao
|
||||||
|
abstract fun budgetDao(): BudgetDao
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "BookkeepingDatabase"
|
private const val TAG = "BookkeepingDatabase"
|
||||||
@ -148,6 +150,30 @@ abstract class BookkeepingDatabase : RoomDatabase() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val MIGRATION_5_6 = object : Migration(5, 6) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
// 创建预算表
|
||||||
|
db.execSQL("""
|
||||||
|
CREATE TABLE IF NOT EXISTS budgets (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
categoryName TEXT,
|
||||||
|
memberId INTEGER,
|
||||||
|
amount REAL NOT NULL,
|
||||||
|
startDate INTEGER NOT NULL,
|
||||||
|
endDate INTEGER NOT NULL,
|
||||||
|
isEnabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
alertThreshold REAL NOT NULL DEFAULT 0.8,
|
||||||
|
createdAt INTEGER NOT NULL,
|
||||||
|
updatedAt INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
// 在 settings 表中添加 encryptBackup 列
|
||||||
|
db.execSQL("ALTER TABLE settings ADD COLUMN encryptBackup INTEGER NOT NULL DEFAULT 1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private var INSTANCE: BookkeepingDatabase? = null
|
private var INSTANCE: BookkeepingDatabase? = null
|
||||||
|
|
||||||
@ -158,7 +184,7 @@ abstract class BookkeepingDatabase : RoomDatabase() {
|
|||||||
BookkeepingDatabase::class.java,
|
BookkeepingDatabase::class.java,
|
||||||
"bookkeeping_database"
|
"bookkeeping_database"
|
||||||
)
|
)
|
||||||
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5)
|
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6)
|
||||||
.addCallback(object : Callback() {
|
.addCallback(object : Callback() {
|
||||||
override fun onCreate(db: SupportSQLiteDatabase) {
|
override fun onCreate(db: SupportSQLiteDatabase) {
|
||||||
super.onCreate(db)
|
super.onCreate(db)
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
package com.yovinchen.bookkeeping.data
|
package com.yovinchen.bookkeeping.data
|
||||||
|
|
||||||
import androidx.room.TypeConverter
|
import androidx.room.TypeConverter
|
||||||
|
import com.yovinchen.bookkeeping.model.BudgetType
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import java.util.*
|
import java.util.*
|
||||||
@ -29,4 +30,14 @@ class Converters {
|
|||||||
fun toDate(timestamp: String?): Date? {
|
fun toDate(timestamp: String?): Date? {
|
||||||
return timestamp?.let { Date(it.toLong()) }
|
return timestamp?.let { Date(it.toLong()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun fromBudgetType(budgetType: BudgetType?): String? {
|
||||||
|
return budgetType?.name
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun toBudgetType(budgetType: String?): BudgetType? {
|
||||||
|
return budgetType?.let { BudgetType.valueOf(it) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -29,4 +29,7 @@ interface SettingsDao {
|
|||||||
|
|
||||||
@Query("UPDATE settings SET lastBackupTime = :time WHERE id = 1")
|
@Query("UPDATE settings SET lastBackupTime = :time WHERE id = 1")
|
||||||
suspend fun updateLastBackupTime(time: Long)
|
suspend fun updateLastBackupTime(time: Long)
|
||||||
|
|
||||||
|
@Query("UPDATE settings SET encryptBackup = :encrypt WHERE id = 1")
|
||||||
|
suspend fun updateEncryptBackup(encrypt: Boolean)
|
||||||
}
|
}
|
@ -10,5 +10,6 @@ data class Settings(
|
|||||||
val themeMode: String = "FOLLOW_SYSTEM", // 主题模式:FOLLOW_SYSTEM, LIGHT, DARK
|
val themeMode: String = "FOLLOW_SYSTEM", // 主题模式:FOLLOW_SYSTEM, LIGHT, DARK
|
||||||
val autoBackupEnabled: Boolean = false, // 自动备份开关
|
val autoBackupEnabled: Boolean = false, // 自动备份开关
|
||||||
val autoBackupInterval: Int = 7, // 自动备份间隔(天)
|
val autoBackupInterval: Int = 7, // 自动备份间隔(天)
|
||||||
val lastBackupTime: Long = 0L // 上次备份时间
|
val lastBackupTime: Long = 0L, // 上次备份时间
|
||||||
|
val encryptBackup: Boolean = true // 备份加密开关,默认开启
|
||||||
)
|
)
|
@ -19,6 +19,7 @@ import androidx.compose.ui.platform.LocalContext
|
|||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
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.Settings
|
||||||
import com.yovinchen.bookkeeping.model.ThemeMode
|
import com.yovinchen.bookkeeping.model.ThemeMode
|
||||||
import com.yovinchen.bookkeeping.ui.components.*
|
import com.yovinchen.bookkeeping.ui.components.*
|
||||||
import com.yovinchen.bookkeeping.ui.dialog.*
|
import com.yovinchen.bookkeeping.ui.dialog.*
|
||||||
@ -43,6 +44,7 @@ fun SettingsScreen(
|
|||||||
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 monthStartDay by viewModel.monthStartDay.collectAsState()
|
val monthStartDay by viewModel.monthStartDay.collectAsState()
|
||||||
|
val settings by viewModel.settings.collectAsState()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
var showMonthStartDayDialog by remember { mutableStateOf(false) }
|
var showMonthStartDayDialog by remember { mutableStateOf(false) }
|
||||||
@ -277,6 +279,29 @@ fun SettingsScreen(
|
|||||||
style = MaterialTheme.typography.bodySmall
|
style = MaterialTheme.typography.bodySmall
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||||
|
|
||||||
|
// 备份加密开关
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text("备份加密", modifier = Modifier.weight(1f))
|
||||||
|
Switch(
|
||||||
|
checked = settings?.encryptBackup ?: true,
|
||||||
|
onCheckedChange = { enabled ->
|
||||||
|
viewModel.updateSettings(
|
||||||
|
settings?.copy(encryptBackup = enabled) ?: Settings(encryptBackup = enabled)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
"开启后,导出的备份文件将被加密保护",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
|
@ -0,0 +1,136 @@
|
|||||||
|
package com.yovinchen.bookkeeping.utils
|
||||||
|
|
||||||
|
import android.security.keystore.KeyGenParameterSpec
|
||||||
|
import android.security.keystore.KeyProperties
|
||||||
|
import android.util.Base64
|
||||||
|
import java.security.KeyStore
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
import javax.crypto.KeyGenerator
|
||||||
|
import javax.crypto.SecretKey
|
||||||
|
import javax.crypto.spec.GCMParameterSpec
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加密工具类,使用 Android Keystore 系统安全地存储加密密钥
|
||||||
|
*/
|
||||||
|
object EncryptionUtils {
|
||||||
|
private const val ANDROID_KEYSTORE = "AndroidKeyStore"
|
||||||
|
private const val TRANSFORMATION = "AES/GCM/NoPadding"
|
||||||
|
private const val KEY_ALIAS = "BookkeepingBackupKey"
|
||||||
|
private const val GCM_TAG_LENGTH = 128
|
||||||
|
|
||||||
|
init {
|
||||||
|
generateKey()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成或获取加密密钥
|
||||||
|
*/
|
||||||
|
private fun generateKey() {
|
||||||
|
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE)
|
||||||
|
keyStore.load(null)
|
||||||
|
|
||||||
|
// 如果密钥已存在,则不需要重新生成
|
||||||
|
if (keyStore.containsAlias(KEY_ALIAS)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE)
|
||||||
|
val keyGenParameterSpec = KeyGenParameterSpec.Builder(
|
||||||
|
KEY_ALIAS,
|
||||||
|
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
|
||||||
|
)
|
||||||
|
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||||
|
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||||
|
.setRandomizedEncryptionRequired(true)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
keyGenerator.init(keyGenParameterSpec)
|
||||||
|
keyGenerator.generateKey()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取密钥
|
||||||
|
*/
|
||||||
|
private fun getKey(): SecretKey {
|
||||||
|
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE)
|
||||||
|
keyStore.load(null)
|
||||||
|
return keyStore.getKey(KEY_ALIAS, null) as SecretKey
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加密字符串
|
||||||
|
* @param plainText 要加密的明文
|
||||||
|
* @return Base64编码的加密数据(包含IV)
|
||||||
|
*/
|
||||||
|
fun encrypt(plainText: String): String {
|
||||||
|
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, getKey())
|
||||||
|
|
||||||
|
val iv = cipher.iv
|
||||||
|
val encryptedBytes = cipher.doFinal(plainText.toByteArray(Charsets.UTF_8))
|
||||||
|
|
||||||
|
// 将IV和加密数据组合
|
||||||
|
val combined = ByteArray(iv.size + encryptedBytes.size)
|
||||||
|
System.arraycopy(iv, 0, combined, 0, iv.size)
|
||||||
|
System.arraycopy(encryptedBytes, 0, combined, iv.size, encryptedBytes.size)
|
||||||
|
|
||||||
|
return Base64.encodeToString(combined, Base64.DEFAULT)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解密字符串
|
||||||
|
* @param encryptedData Base64编码的加密数据
|
||||||
|
* @return 解密后的明文
|
||||||
|
*/
|
||||||
|
fun decrypt(encryptedData: String): String {
|
||||||
|
val combined = Base64.decode(encryptedData, Base64.DEFAULT)
|
||||||
|
|
||||||
|
// 提取IV(前12字节)
|
||||||
|
val iv = combined.sliceArray(0..11)
|
||||||
|
val encryptedBytes = combined.sliceArray(12 until combined.size)
|
||||||
|
|
||||||
|
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||||
|
val spec = GCMParameterSpec(GCM_TAG_LENGTH, iv)
|
||||||
|
cipher.init(Cipher.DECRYPT_MODE, getKey(), spec)
|
||||||
|
|
||||||
|
val decryptedBytes = cipher.doFinal(encryptedBytes)
|
||||||
|
return String(decryptedBytes, Charsets.UTF_8)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加密字节数组
|
||||||
|
* @param data 要加密的数据
|
||||||
|
* @return 加密后的数据(包含IV)
|
||||||
|
*/
|
||||||
|
fun encryptBytes(data: ByteArray): ByteArray {
|
||||||
|
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, getKey())
|
||||||
|
|
||||||
|
val iv = cipher.iv
|
||||||
|
val encryptedBytes = cipher.doFinal(data)
|
||||||
|
|
||||||
|
// 将IV和加密数据组合
|
||||||
|
val combined = ByteArray(iv.size + encryptedBytes.size)
|
||||||
|
System.arraycopy(iv, 0, combined, 0, iv.size)
|
||||||
|
System.arraycopy(encryptedBytes, 0, combined, iv.size, encryptedBytes.size)
|
||||||
|
|
||||||
|
return combined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解密字节数组
|
||||||
|
* @param encryptedData 加密的数据
|
||||||
|
* @return 解密后的数据
|
||||||
|
*/
|
||||||
|
fun decryptBytes(encryptedData: ByteArray): ByteArray {
|
||||||
|
// 提取IV(前12字节)
|
||||||
|
val iv = encryptedData.sliceArray(0..11)
|
||||||
|
val encryptedBytes = encryptedData.sliceArray(12 until encryptedData.size)
|
||||||
|
|
||||||
|
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||||
|
val spec = GCMParameterSpec(GCM_TAG_LENGTH, iv)
|
||||||
|
cipher.init(Cipher.DECRYPT_MODE, getKey(), spec)
|
||||||
|
|
||||||
|
return cipher.doFinal(encryptedBytes)
|
||||||
|
}
|
||||||
|
}
|
@ -107,11 +107,16 @@ object FilePickerUtil {
|
|||||||
private fun isValidFileType(fileName: String, mimeType: String?): Boolean {
|
private fun isValidFileType(fileName: String, mimeType: String?): Boolean {
|
||||||
val fileExtension = fileName.lowercase()
|
val fileExtension = fileName.lowercase()
|
||||||
return fileExtension.endsWith(".csv") ||
|
return fileExtension.endsWith(".csv") ||
|
||||||
|
fileExtension.endsWith(".csv.enc") ||
|
||||||
fileExtension.endsWith(".xlsx") ||
|
fileExtension.endsWith(".xlsx") ||
|
||||||
|
fileExtension.endsWith(".xlsx.enc") ||
|
||||||
fileExtension.endsWith(".xls") ||
|
fileExtension.endsWith(".xls") ||
|
||||||
|
fileExtension.endsWith(".xls.enc") ||
|
||||||
|
fileExtension.endsWith(".enc") ||
|
||||||
mimeType == "text/csv" ||
|
mimeType == "text/csv" ||
|
||||||
mimeType == "application/vnd.ms-excel" ||
|
mimeType == "application/vnd.ms-excel" ||
|
||||||
mimeType == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
mimeType == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" ||
|
||||||
|
mimeType == "application/octet-stream" // 加密文件可能被识别为二进制流
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -14,6 +14,7 @@ import com.yovinchen.bookkeeping.model.BookkeepingRecord
|
|||||||
import com.yovinchen.bookkeeping.model.Category
|
import com.yovinchen.bookkeeping.model.Category
|
||||||
import com.yovinchen.bookkeeping.model.Settings
|
import com.yovinchen.bookkeeping.model.Settings
|
||||||
import com.yovinchen.bookkeeping.model.TransactionType
|
import com.yovinchen.bookkeeping.model.TransactionType
|
||||||
|
import com.yovinchen.bookkeeping.utils.EncryptionUtils
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
@ -30,6 +31,7 @@ import org.apache.poi.xssf.usermodel.XSSFWorkbook
|
|||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileReader
|
import java.io.FileReader
|
||||||
import java.io.FileWriter
|
import java.io.FileWriter
|
||||||
|
import java.io.StringWriter
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
@ -157,15 +159,24 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
fun exportToCSV(context: Context, customDir: File? = null) {
|
fun exportToCSV(context: Context, customDir: File? = null) {
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
|
val currentSettings = settings.value ?: Settings()
|
||||||
val timestamp =
|
val timestamp =
|
||||||
SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
|
SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
|
||||||
val fileName = "bookkeeping_backup_$timestamp.csv"
|
val shouldEncrypt = currentSettings.encryptBackup
|
||||||
|
val fileName = if (shouldEncrypt) {
|
||||||
|
"bookkeeping_backup_$timestamp.csv.enc"
|
||||||
|
} else {
|
||||||
|
"bookkeeping_backup_$timestamp.csv"
|
||||||
|
}
|
||||||
val downloadsDir = customDir ?: Environment.getExternalStoragePublicDirectory(
|
val downloadsDir = customDir ?: Environment.getExternalStoragePublicDirectory(
|
||||||
Environment.DIRECTORY_DOWNLOADS
|
Environment.DIRECTORY_DOWNLOADS
|
||||||
)
|
)
|
||||||
val file = File(downloadsDir, fileName)
|
val file = File(downloadsDir, fileName)
|
||||||
|
|
||||||
CSVWriter(FileWriter(file)).use { writer ->
|
// 先创建CSV内容到字符串
|
||||||
|
val csvContent = StringWriter().use { stringWriter ->
|
||||||
|
val writer = CSVWriter(stringWriter)
|
||||||
|
|
||||||
// 写入头部
|
// 写入头部
|
||||||
writer.writeNext(arrayOf("日期", "类型", "金额", "类别", "备注", "成员"))
|
writer.writeNext(arrayOf("日期", "类型", "金额", "类别", "备注", "成员"))
|
||||||
|
|
||||||
@ -189,11 +200,25 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
writer.close()
|
||||||
|
stringWriter.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据设置决定是否加密
|
||||||
|
if (shouldEncrypt) {
|
||||||
|
val encryptedContent = EncryptionUtils.encrypt(csvContent)
|
||||||
|
file.writeText(encryptedContent)
|
||||||
|
} else {
|
||||||
|
file.writeText(csvContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
Toast.makeText(context, "CSV导出成功: ${file.absolutePath}", Toast.LENGTH_LONG)
|
val message = if (shouldEncrypt) {
|
||||||
.show()
|
"CSV导出成功(已加密): ${file.absolutePath}"
|
||||||
|
} else {
|
||||||
|
"CSV导出成功: ${file.absolutePath}"
|
||||||
|
}
|
||||||
|
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
@ -207,6 +232,9 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
fun exportToExcel(context: Context) {
|
fun exportToExcel(context: Context) {
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
|
val currentSettings = settings.value ?: Settings()
|
||||||
|
val shouldEncrypt = currentSettings.encryptBackup
|
||||||
|
|
||||||
val workbook = XSSFWorkbook()
|
val workbook = XSSFWorkbook()
|
||||||
val sheet = workbook.createSheet("账目记录")
|
val sheet = workbook.createSheet("账目记录")
|
||||||
|
|
||||||
@ -239,18 +267,39 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
|
|
||||||
val timestamp =
|
val timestamp =
|
||||||
SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
|
SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
|
||||||
val fileName = "bookkeeping_backup_$timestamp.xlsx"
|
val fileName = if (shouldEncrypt) {
|
||||||
|
"bookkeeping_backup_$timestamp.xlsx.enc"
|
||||||
|
} else {
|
||||||
|
"bookkeeping_backup_$timestamp.xlsx"
|
||||||
|
}
|
||||||
val downloadsDir =
|
val downloadsDir =
|
||||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
|
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
|
||||||
val file = File(downloadsDir, fileName)
|
val file = File(downloadsDir, fileName)
|
||||||
|
|
||||||
workbook.write(file.outputStream())
|
if (shouldEncrypt) {
|
||||||
|
// 将 workbook 写入字节数组
|
||||||
|
val byteArrayOutputStream = java.io.ByteArrayOutputStream()
|
||||||
|
workbook.write(byteArrayOutputStream)
|
||||||
|
val excelBytes = byteArrayOutputStream.toByteArray()
|
||||||
|
|
||||||
|
// 加密字节数组
|
||||||
|
val encryptedBytes = EncryptionUtils.encryptBytes(excelBytes)
|
||||||
|
|
||||||
|
// 写入加密文件
|
||||||
|
file.writeBytes(encryptedBytes)
|
||||||
|
} else {
|
||||||
|
workbook.write(file.outputStream())
|
||||||
|
}
|
||||||
|
|
||||||
workbook.close()
|
workbook.close()
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
Toast.makeText(
|
val message = if (shouldEncrypt) {
|
||||||
context, "Excel导出成功: ${file.absolutePath}", Toast.LENGTH_LONG
|
"Excel导出成功(已加密): ${file.absolutePath}"
|
||||||
).show()
|
} else {
|
||||||
|
"Excel导出成功: ${file.absolutePath}"
|
||||||
|
}
|
||||||
|
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
@ -265,10 +314,30 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
when {
|
when {
|
||||||
|
backupFile.name.endsWith(".csv.enc", ignoreCase = true) -> {
|
||||||
|
// 解密CSV文件
|
||||||
|
val encryptedContent = backupFile.readText()
|
||||||
|
val decryptedContent = EncryptionUtils.decrypt(encryptedContent)
|
||||||
|
val tempFile = File(context.cacheDir, "temp_decrypted.csv")
|
||||||
|
tempFile.writeText(decryptedContent)
|
||||||
|
restoreFromCSV(tempFile)
|
||||||
|
tempFile.delete()
|
||||||
|
}
|
||||||
|
|
||||||
backupFile.name.endsWith(".csv", ignoreCase = true) -> {
|
backupFile.name.endsWith(".csv", ignoreCase = true) -> {
|
||||||
restoreFromCSV(backupFile)
|
restoreFromCSV(backupFile)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
backupFile.name.endsWith(".xlsx.enc", ignoreCase = true) -> {
|
||||||
|
// 解密Excel文件
|
||||||
|
val encryptedBytes = backupFile.readBytes()
|
||||||
|
val decryptedBytes = EncryptionUtils.decryptBytes(encryptedBytes)
|
||||||
|
val tempFile = File(context.cacheDir, "temp_decrypted.xlsx")
|
||||||
|
tempFile.writeBytes(decryptedBytes)
|
||||||
|
restoreFromExcel(tempFile)
|
||||||
|
tempFile.delete()
|
||||||
|
}
|
||||||
|
|
||||||
backupFile.name.endsWith(".xlsx", ignoreCase = true) -> {
|
backupFile.name.endsWith(".xlsx", ignoreCase = true) -> {
|
||||||
restoreFromExcel(backupFile)
|
restoreFromExcel(backupFile)
|
||||||
}
|
}
|
||||||
@ -287,7 +356,11 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
Toast.makeText(context, "数据恢复失败: ${e.message}", Toast.LENGTH_LONG).show()
|
val errorMessage = when {
|
||||||
|
e.message?.contains("decrypt") == true -> "解密失败,请确认文件未损坏"
|
||||||
|
else -> "数据恢复失败: ${e.message}"
|
||||||
|
}
|
||||||
|
Toast.makeText(context, errorMessage, Toast.LENGTH_LONG).show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -343,4 +416,10 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
private suspend fun findMemberIdByName(name: String): Int? {
|
private suspend fun findMemberIdByName(name: String): Int? {
|
||||||
return memberDao.getAllMembers().first().find { member -> member.name == name }?.id
|
return memberDao.getAllMembers().first().find { member -> member.name == name }?.id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun updateSettings(settings: Settings) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
settingsRepository.updateSettings(settings)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user