From 316c2648aec52ca91b9df5ffbbd2e0dc14fa6018 Mon Sep 17 00:00:00 2001 From: yovinchen Date: Tue, 26 Nov 2024 23:49:02 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=86=E7=B1=BB=E8=BF=81=E7=A7=BB=E5=88=B0?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=20=E5=A2=9E=E5=8A=A0=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E5=88=86=E7=B1=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../bookkeeping/ui/screen/HomeScreen.kt | 245 +++++++++++------- .../bookkeeping/ui/screen/SettingsScreen.kt | 34 ++- .../bookkeeping/viewmodel/HomeViewModel.kt | 177 ++++++++----- 3 files changed, 290 insertions(+), 166 deletions(-) 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 6c80531..2c22ac0 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 @@ -7,67 +7,54 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.lifecycle.viewmodel.compose.viewModel import com.yovinchen.bookkeeping.model.BookkeepingRecord import com.yovinchen.bookkeeping.model.TransactionType import com.yovinchen.bookkeeping.ui.dialog.AddRecordDialog -import com.yovinchen.bookkeeping.ui.dialog.CategoryManagementDialog import com.yovinchen.bookkeeping.ui.dialog.RecordEditDialog import com.yovinchen.bookkeeping.viewmodel.HomeViewModel +import java.time.YearMonth import java.text.SimpleDateFormat import java.util.* @OptIn(ExperimentalMaterial3Api::class) @Composable fun HomeScreen( - modifier: Modifier = Modifier, - viewModel: HomeViewModel = viewModel() + modifier: Modifier = Modifier, viewModel: HomeViewModel = viewModel() ) { - val records by viewModel.filteredRecords.collectAsState() + val filteredRecords by viewModel.filteredRecords.collectAsState() val totalIncome by viewModel.totalIncome.collectAsState() val totalExpense by viewModel.totalExpense.collectAsState() val categories by viewModel.categories.collectAsState() - val selectedType by viewModel.selectedCategoryType.collectAsState() val selectedRecordType by viewModel.selectedRecordType.collectAsState() - + val selectedMonth by viewModel.selectedMonth.collectAsState() + var showAddDialog by remember { mutableStateOf(false) } - var showCategoryDialog by remember { mutableStateOf(false) } var selectedRecord by remember { mutableStateOf(null) } - Scaffold( - modifier = modifier.fillMaxSize(), - floatingActionButton = { - FloatingActionButton( - onClick = { showAddDialog = true } - ) { - Icon(Icons.Default.Add, contentDescription = "添加记录") - } - }, - floatingActionButtonPosition = FabPosition.End, - topBar = { - TopAppBar( - title = { Text("记账本") }, - actions = { - IconButton(onClick = { showCategoryDialog = true }) { - Icon(Icons.Default.Settings, contentDescription = "类别管理") - } - } - ) + Scaffold(modifier = modifier.fillMaxSize(), floatingActionButton = { + FloatingActionButton(onClick = { showAddDialog = true }) { + Icon(Icons.Default.Add, contentDescription = "添加记录") } - ) { padding -> + }, floatingActionButtonPosition = FabPosition.End, topBar = { + TopAppBar(title = { Text("记账本") }) + }) { padding -> Column( modifier = Modifier .fillMaxSize() .padding(padding) + .background(MaterialTheme.colorScheme.background) ) { // 顶部统计信息 MonthlyStatistics( @@ -76,21 +63,90 @@ fun HomeScreen( onIncomeClick = { viewModel.setSelectedRecordType(TransactionType.INCOME) }, onExpenseClick = { viewModel.setSelectedRecordType(TransactionType.EXPENSE) }, selectedType = selectedRecordType, - onClearFilter = { viewModel.setSelectedRecordType(null) } + onClearFilter = { viewModel.setSelectedRecordType(null) }, + selectedMonth = selectedMonth, + onPreviousMonth = { viewModel.setSelectedMonth(selectedMonth.minusMonths(1)) }, + onNextMonth = { viewModel.setSelectedMonth(selectedMonth.plusMonths(1)) } ) // 记录列表 LazyColumn( modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + verticalArrangement = Arrangement.spacedBy(12.dp) ) { - items(records) { record -> - RecordItem( - record = record, - onClick = { selectedRecord = record }, - onDelete = { viewModel.deleteRecord(record) } - ) + filteredRecords.forEach { (date, records) -> + item { + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f), + shape = RoundedCornerShape(12.dp), + tonalElevation = 2.dp + ) { + Column( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp) + ) { + // 日期标签 + Text( + text = SimpleDateFormat( + "yyyy年MM月dd日 E", Locale.CHINESE + ).format(date), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // 当天的记录 + records.forEachIndexed { index, record -> + RecordItem(record = record, + onClick = { selectedRecord = record }, + onDelete = { viewModel.deleteRecord(record) }) + + if (index < records.size - 1) { + HorizontalDivider( + modifier = Modifier.padding(vertical = 8.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + thickness = 0.5.dp + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + // 当天统计 + HorizontalDivider( + color = MaterialTheme.colorScheme.surfaceVariant, + thickness = 0.5.dp + ) + + val dayIncome = records.filter { it.type == TransactionType.INCOME } + .sumOf { it.amount } + val dayExpense = + records.filter { it.type == TransactionType.EXPENSE } + .sumOf { it.amount } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "收入: ¥%.2f".format(dayIncome), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = "支出: ¥%.2f".format(dayExpense), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error + ) + } + } + } + } } } } @@ -98,9 +154,10 @@ fun HomeScreen( // 添加记录对话框 if (showAddDialog) { val selectedDateTime by viewModel.selectedDateTime.collectAsState() + val selectedCategoryType by viewModel.selectedCategoryType.collectAsState() AddRecordDialog( - onDismiss = { - showAddDialog = false + onDismiss = { + showAddDialog = false viewModel.resetSelectedDateTime() }, onConfirm = { type, amount, category, description -> @@ -108,23 +165,10 @@ fun HomeScreen( showAddDialog = false }, categories = categories, - selectedType = selectedType, - onTypeChange = { viewModel.setSelectedCategoryType(it) }, + selectedType = selectedCategoryType, + onTypeChange = viewModel::setSelectedCategoryType, selectedDateTime = selectedDateTime, - onDateTimeSelected = { viewModel.setSelectedDateTime(it) } - ) - } - - // 类别管理对话框 - if (showCategoryDialog) { - CategoryManagementDialog( - onDismiss = { showCategoryDialog = false }, - categories = categories, - onAddCategory = { name, type -> viewModel.addCategory(name, type) }, - onDeleteCategory = { category -> viewModel.deleteCategory(category) }, - onUpdateCategory = { category, newName -> viewModel.updateCategory(category, newName) }, - selectedType = selectedType, - onTypeChange = { viewModel.setSelectedCategoryType(it) } + onDateTimeSelected = viewModel::setSelectedDateTime ) } @@ -151,6 +195,9 @@ fun MonthlyStatistics( onExpenseClick: () -> Unit, selectedType: TransactionType?, onClearFilter: () -> Unit, + selectedMonth: YearMonth, + onPreviousMonth: () -> Unit, + onNextMonth: () -> Unit, modifier: Modifier = Modifier ) { Card( @@ -164,30 +211,42 @@ fun MonthlyStatistics( .fillMaxWidth() .padding(16.dp) ) { - Text( - text = "本月统计", - style = MaterialTheme.typography.titleLarge, - modifier = Modifier.padding(bottom = 8.dp) - ) + // 月份选择器 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = onPreviousMonth) { + Icon(Icons.AutoMirrored.Filled.KeyboardArrowLeft, "上个月") + } + + Text( + text = "${selectedMonth.year}年${selectedMonth.monthValue}月", + style = MaterialTheme.typography.titleLarge + ) + + IconButton(onClick = onNextMonth) { + Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, "下个月") + } + } + + Spacer(modifier = Modifier.height(16.dp)) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween ) { // 收入统计 - Column( - modifier = Modifier - .weight(1f) - .clickable { onIncomeClick() } - .background( - if (selectedType == TransactionType.INCOME) - MaterialTheme.colorScheme.primaryContainer - else - Color.Transparent, - RoundedCornerShape(8.dp) - ) - .padding(8.dp) - ) { + Column(modifier = Modifier + .weight(1f) + .clickable { onIncomeClick() } + .background( + if (selectedType == TransactionType.INCOME) MaterialTheme.colorScheme.primaryContainer + else Color.Transparent, + RoundedCornerShape(8.dp) + ) + .padding(8.dp)) { Text( text = "收入", style = MaterialTheme.typography.titleMedium @@ -202,19 +261,15 @@ fun MonthlyStatistics( Spacer(modifier = Modifier.width(16.dp)) // 支出统计 - Column( - modifier = Modifier - .weight(1f) - .clickable { onExpenseClick() } - .background( - if (selectedType == TransactionType.EXPENSE) - MaterialTheme.colorScheme.primaryContainer - else - Color.Transparent, - RoundedCornerShape(8.dp) - ) - .padding(8.dp) - ) { + Column(modifier = Modifier + .weight(1f) + .clickable { onExpenseClick() } + .background( + if (selectedType == TransactionType.EXPENSE) MaterialTheme.colorScheme.primaryContainer + else Color.Transparent, + RoundedCornerShape(8.dp) + ) + .padding(8.dp)) { Text( text = "支出", style = MaterialTheme.typography.titleMedium @@ -261,8 +316,7 @@ fun RecordItem( ) { Column(modifier = Modifier.weight(1f)) { Text( - text = record.category, - style = MaterialTheme.typography.titleMedium + text = record.category, style = MaterialTheme.typography.titleMedium ) if (record.description.isNotEmpty()) { Text( @@ -272,8 +326,9 @@ fun RecordItem( ) } Text( - text = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) - .format(record.date), + text = SimpleDateFormat( + "yyyy-MM-dd HH:mm", Locale.getDefault() + ).format(record.date), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -284,18 +339,14 @@ fun RecordItem( ) { Text( text = if (record.type == TransactionType.EXPENSE) "-" else "+", - color = if (record.type == TransactionType.EXPENSE) - MaterialTheme.colorScheme.error - else - MaterialTheme.colorScheme.primary, + color = if (record.type == TransactionType.EXPENSE) MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.titleMedium ) Text( text = String.format("%.2f", record.amount), - color = if (record.type == TransactionType.EXPENSE) - MaterialTheme.colorScheme.error - else - MaterialTheme.colorScheme.primary, + color = if (record.type == TransactionType.EXPENSE) MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(end = 8.dp) ) 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 4ede4b0..daffcca 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 @@ -7,19 +7,38 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.yovinchen.bookkeeping.model.Category import com.yovinchen.bookkeeping.model.ThemeMode +import com.yovinchen.bookkeeping.model.TransactionType 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.viewmodel.SettingsViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable fun SettingsScreen( currentTheme: ThemeMode, - onThemeChange: (ThemeMode) -> Unit + onThemeChange: (ThemeMode) -> Unit, + viewModel: SettingsViewModel = viewModel() ) { var showThemeDialog by remember { mutableStateOf(false) } + var showCategoryDialog by remember { mutableStateOf(false) } + + val categories by viewModel.categories.collectAsState() + val selectedType by viewModel.selectedCategoryType.collectAsState() Column(modifier = Modifier.fillMaxSize()) { + // 类别管理设置项 + ListItem( + headlineContent = { Text("类别管理") }, + supportingContent = { Text("管理收入和支出类别") }, + modifier = Modifier.clickable { showCategoryDialog = true } + ) + + Divider() + // 主题设置项 ListItem( headlineContent = { Text("主题设置") }, @@ -99,6 +118,19 @@ fun SettingsScreen( ) } } + + // 类别管理对话框 + if (showCategoryDialog) { + CategoryManagementDialog( + onDismiss = { showCategoryDialog = false }, + categories = categories, + onAddCategory = viewModel::addCategory, + onDeleteCategory = viewModel::deleteCategory, + onUpdateCategory = viewModel::updateCategory, + selectedType = selectedType, + onTypeChange = viewModel::setSelectedCategoryType + ) + } } @Composable diff --git a/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/HomeViewModel.kt b/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/HomeViewModel.kt index 781f4d2..37eb2ea 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/HomeViewModel.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/HomeViewModel.kt @@ -8,33 +8,19 @@ 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.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import java.time.LocalDateTime import java.time.ZoneId +import java.time.YearMonth import java.util.Date import java.util.Calendar +@OptIn(ExperimentalCoroutinesApi::class) class HomeViewModel(application: Application) : AndroidViewModel(application) { private val TAG = "HomeViewModel" - private val database = BookkeepingDatabase.getDatabase(application) - private val dao = database.bookkeepingDao() - - val records = dao.getAllRecords() - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5000), - initialValue = emptyList() - ) - - private val _totalIncome = MutableStateFlow(0.0) - val totalIncome: StateFlow = _totalIncome.asStateFlow() - - private val _totalExpense = MutableStateFlow(0.0) - val totalExpense: StateFlow = _totalExpense.asStateFlow() - - private val _selectedCategoryType = MutableStateFlow(TransactionType.EXPENSE) - val selectedCategoryType: StateFlow = _selectedCategoryType.asStateFlow() + private val dao = BookkeepingDatabase.getDatabase(application).bookkeepingDao() private val _selectedRecordType = MutableStateFlow(null) val selectedRecordType: StateFlow = _selectedRecordType.asStateFlow() @@ -42,6 +28,19 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) { private val _selectedDateTime = MutableStateFlow(LocalDateTime.now()) val selectedDateTime: StateFlow = _selectedDateTime.asStateFlow() + private val _selectedCategoryType = MutableStateFlow(TransactionType.EXPENSE) + val selectedCategoryType: StateFlow = _selectedCategoryType.asStateFlow() + + private val _selectedMonth = MutableStateFlow(YearMonth.now()) + val selectedMonth: StateFlow = _selectedMonth.asStateFlow() + + private val records = dao.getAllRecords() + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = emptyList() + ) + val categories: StateFlow> = _selectedCategoryType .flatMapLatest { type -> dao.getCategoriesByType(type) @@ -52,38 +51,91 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) { initialValue = emptyList() ) - val filteredRecords = combine(records, selectedRecordType) { records, type -> - when (type) { - null -> records.sortedByDescending { it.date } - else -> records.filter { it.type == type }.sortedByDescending { it.date } - } + val filteredRecords = combine( + records, + _selectedRecordType, + _selectedMonth + ) { records, selectedType, selectedMonth -> + records + .filter { record -> + val recordDate = record.date.toInstant() + .atZone(ZoneId.systemDefault()) + .toLocalDate() + val recordYearMonth = YearMonth.from(recordDate) + + val typeMatches = selectedType?.let { record.type == it } ?: true + val monthMatches = recordYearMonth == selectedMonth + + typeMatches && monthMatches + } + .sortedByDescending { it.date } + .groupBy { record -> + val calendar = Calendar.getInstance().apply { time = record.date } + calendar.apply { + set(Calendar.HOUR_OF_DAY, 0) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + }.time + } }.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5000), - initialValue = emptyList() + viewModelScope, + SharingStarted.WhileSubscribed(5000), + emptyMap() ) - private val _uiState = MutableStateFlow(UiState()) - val uiState: StateFlow = _uiState.asStateFlow() + val totalIncome = combine( + records, + _selectedMonth + ) { records, selectedMonth -> + records + .filter { record -> + val recordDate = record.date.toInstant() + .atZone(ZoneId.systemDefault()) + .toLocalDate() + val recordYearMonth = YearMonth.from(recordDate) + + record.type == TransactionType.INCOME && recordYearMonth == selectedMonth + } + .sumOf { it.amount } + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + 0.0 + ) + + val totalExpense = combine( + records, + _selectedMonth + ) { records, selectedMonth -> + records + .filter { record -> + val recordDate = record.date.toInstant() + .atZone(ZoneId.systemDefault()) + .toLocalDate() + val recordYearMonth = YearMonth.from(recordDate) + + record.type == TransactionType.EXPENSE && recordYearMonth == selectedMonth + } + .sumOf { it.amount } + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + 0.0 + ) + + private fun updateTotals() { + // 移除未使用的参数 + } init { viewModelScope.launch { - records.collect { recordsList -> - updateTotals(recordsList) + records.collect { + updateTotals() } } } - private fun updateTotals(records: List) { - _totalIncome.value = records - .filter { it.type == TransactionType.INCOME } - .sumOf { it.amount } - - _totalExpense.value = records - .filter { it.type == TransactionType.EXPENSE } - .sumOf { it.amount } - } - fun addRecord(type: TransactionType, amount: Double, category: String, description: String) { viewModelScope.launch { val record = BookkeepingRecord( @@ -102,37 +154,31 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) { _selectedDateTime.value = dateTime } - fun setSelectedCategoryType(type: TransactionType) { - _selectedCategoryType.value = type - } - fun setSelectedRecordType(type: TransactionType?) { _selectedRecordType.value = type } + fun setSelectedCategoryType(type: TransactionType) { + _selectedCategoryType.value = type + } + + fun setSelectedMonth(yearMonth: YearMonth) { + _selectedMonth.value = yearMonth + } + + fun moveMonth(forward: Boolean) { + val current = _selectedMonth.value + _selectedMonth.value = if (forward) { + current.plusMonths(1) + } else { + current.minusMonths(1) + } + } + fun resetSelectedDateTime() { _selectedDateTime.value = LocalDateTime.now() } - fun addCategory(name: String, type: TransactionType) { - viewModelScope.launch { - val category = Category(name = name, type = type) - dao.insertCategory(category) - } - } - - fun updateCategory(category: Category, newName: String) { - viewModelScope.launch { - dao.updateCategory(category.copy(name = newName)) - } - } - - fun deleteCategory(category: Category) { - viewModelScope.launch { - dao.deleteCategory(category) - } - } - fun updateRecord(record: BookkeepingRecord) { viewModelScope.launch { dao.updateRecord(record) @@ -167,11 +213,6 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) { return dao.getRecordsByDateRange(start, end) } - // 获取指定类别的记录 - fun getRecordsByCategory(category: String): Flow> { - return dao.getRecordsByCategory(category) - } - // 获取指定类型的记录 fun getRecordsByType(type: TransactionType): Flow> { return dao.getRecordsByType(type)