Compare commits

...

5 Commits

Author SHA1 Message Date
a0d47864d8 fix: 修复分类视图展示逻辑错误 2024-12-05 11:43:44 +08:00
63149f9abb fix: 修复成员视图展示逻辑错误 2024-12-05 11:26:21 +08:00
70e79ec584 fix: 修复文字显示错误
改进导入语句和UI组件
2024-11-28 23:26:31 +08:00
882435e25a chore: 更新应用版本号到 v1.2.2 2024-11-28 18:03:35 +08:00
37b91ded7f refactor: UI界面和代码重构
1. 简化 AnalysisViewModel 使用 Flow 组合
2. 改进 AnalysisScreen 的布局结构
3. 优化 CategoryDetailScreen 的视觉层次
4. 修复统计中成员名称的处理
2024-11-28 18:01:55 +08:00
7 changed files with 211 additions and 59 deletions

View File

@ -16,8 +16,8 @@ android {
applicationId = "com.yovinchen.bookkeeping" applicationId = "com.yovinchen.bookkeeping"
minSdk = 26 minSdk = 26
targetSdk = 34 targetSdk = 34
versionCode = 1 versionCode = 4
versionName = "1.0.0" versionName = "1.2.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {

View File

@ -110,15 +110,47 @@ interface BookkeepingDao {
@Query(""" @Query("""
SELECT * FROM bookkeeping_records SELECT * FROM bookkeeping_records
WHERE memberId IN (SELECT id FROM members WHERE name = :memberName) WHERE memberId IN (SELECT id FROM members WHERE name = :memberName)
AND category = :category
AND date BETWEEN :startDate AND :endDate AND date BETWEEN :startDate AND :endDate
AND (
:transactionType IS NULL
OR type = (
CASE :transactionType
WHEN 'INCOME' THEN 'INCOME'
WHEN 'EXPENSE' THEN 'EXPENSE'
END
)
)
ORDER BY date DESC
""")
suspend fun getRecordsByMember(
memberName: String,
startDate: Date,
endDate: Date,
transactionType: TransactionType?
): List<BookkeepingRecord>
@Query("""
SELECT * FROM bookkeeping_records
WHERE memberId IN (SELECT id FROM members WHERE name = :memberName)
AND category = :category
AND date BETWEEN :startDate AND :endDate
AND (
:transactionType IS NULL
OR type = (
CASE :transactionType
WHEN 'INCOME' THEN 'INCOME'
WHEN 'EXPENSE' THEN 'EXPENSE'
END
)
)
ORDER BY date DESC ORDER BY date DESC
""") """)
suspend fun getRecordsByMemberAndCategory( suspend fun getRecordsByMemberAndCategory(
memberName: String, memberName: String,
category: String, category: String,
startDate: Date, startDate: Date,
endDate: Date endDate: Date,
transactionType: TransactionType?
): List<BookkeepingRecord> ): List<BookkeepingRecord>
} }

View File

@ -22,6 +22,7 @@ import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument import androidx.navigation.navArgument
import com.yovinchen.bookkeeping.model.AnalysisType
import com.yovinchen.bookkeeping.model.ThemeMode import com.yovinchen.bookkeeping.model.ThemeMode
import com.yovinchen.bookkeeping.ui.screen.* import com.yovinchen.bookkeeping.ui.screen.*
import java.time.YearMonth import java.time.YearMonth
@ -40,9 +41,9 @@ sealed class Screen(
return "category_detail/$category/${yearMonth.format(DateTimeFormatter.ofPattern("yyyy-MM"))}" return "category_detail/$category/${yearMonth.format(DateTimeFormatter.ofPattern("yyyy-MM"))}"
} }
} }
object MemberDetail : Screen("member_detail/{memberName}/{category}/{yearMonth}", "成员详情") { object MemberDetail : Screen("member_detail/{memberName}/{category}/{yearMonth}?type={type}", "成员详情") {
fun createRoute(memberName: String, category: String, yearMonth: YearMonth): String { fun createRoute(memberName: String, category: String, yearMonth: YearMonth, type: AnalysisType): String {
return "member_detail/$memberName/$category/${yearMonth.format(DateTimeFormatter.ofPattern("yyyy-MM"))}" return "member_detail/$memberName/$category/${yearMonth.format(DateTimeFormatter.ofPattern("yyyy-MM"))}?type=${type.name}"
} }
} }
@ -96,10 +97,8 @@ fun MainNavigation(
onNavigateToCategoryDetail = { category, yearMonth -> onNavigateToCategoryDetail = { category, yearMonth ->
navController.navigate(Screen.CategoryDetail.createRoute(category, yearMonth)) navController.navigate(Screen.CategoryDetail.createRoute(category, yearMonth))
}, },
onNavigateToMemberDetail = { memberName, yearMonth -> onNavigateToMemberDetail = { memberName, yearMonth, analysisType ->
// 在这里我们暂时使用一个默认分类,你需要根据实际情况修改这里的逻辑 navController.navigate(Screen.MemberDetail.createRoute(memberName, "", yearMonth, analysisType))
val defaultCategory = "默认"
navController.navigate(Screen.MemberDetail.createRoute(memberName, defaultCategory, yearMonth))
} }
) )
} }
@ -127,7 +126,7 @@ fun MainNavigation(
yearMonth = yearMonth, yearMonth = yearMonth,
onNavigateBack = { navController.popBackStack() }, onNavigateBack = { navController.popBackStack() },
onNavigateToMemberDetail = { memberName -> onNavigateToMemberDetail = { memberName ->
navController.navigate(Screen.MemberDetail.createRoute(memberName, category, yearMonth)) navController.navigate(Screen.MemberDetail.createRoute(memberName, category, yearMonth, AnalysisType.EXPENSE))
} }
) )
} }
@ -137,18 +136,30 @@ fun MainNavigation(
arguments = listOf( arguments = listOf(
navArgument("memberName") { type = NavType.StringType }, navArgument("memberName") { type = NavType.StringType },
navArgument("category") { type = NavType.StringType }, navArgument("category") { type = NavType.StringType },
navArgument("yearMonth") { type = NavType.StringType } navArgument("yearMonth") { type = NavType.StringType },
navArgument("type") {
type = NavType.StringType
defaultValue = AnalysisType.EXPENSE.name
}
) )
) { backStackEntry -> ) { backStackEntry ->
val memberName = backStackEntry.arguments?.getString("memberName") ?: return@composable val memberName = backStackEntry.arguments?.getString("memberName") ?: return@composable
val category = backStackEntry.arguments?.getString("category") ?: return@composable val category = backStackEntry.arguments?.getString("category") ?: return@composable
val yearMonthStr = backStackEntry.arguments?.getString("yearMonth") ?: return@composable val yearMonthStr = backStackEntry.arguments?.getString("yearMonth") ?: return@composable
val yearMonth = YearMonth.parse(yearMonthStr) val yearMonth = YearMonth.parse(yearMonthStr)
val type = backStackEntry.arguments?.getString("type")?.let {
try {
AnalysisType.valueOf(it)
} catch (e: IllegalArgumentException) {
AnalysisType.EXPENSE
}
} ?: AnalysisType.EXPENSE
MemberDetailScreen( MemberDetailScreen(
memberName = memberName, memberName = memberName,
category = category,
yearMonth = yearMonth, yearMonth = yearMonth,
category = category,
analysisType = type,
onNavigateBack = { navController.popBackStack() } onNavigateBack = { navController.popBackStack() }
) )
} }

View File

@ -29,7 +29,7 @@ enum class ViewMode {
@Composable @Composable
fun AnalysisScreen( fun AnalysisScreen(
onNavigateToCategoryDetail: (String, YearMonth) -> Unit, onNavigateToCategoryDetail: (String, YearMonth) -> Unit,
onNavigateToMemberDetail: (String, YearMonth) -> Unit onNavigateToMemberDetail: (String, YearMonth, AnalysisType) -> Unit
) { ) {
val viewModel: AnalysisViewModel = viewModel() val viewModel: AnalysisViewModel = viewModel()
val selectedMonth by viewModel.selectedMonth.collectAsState() val selectedMonth by viewModel.selectedMonth.collectAsState()
@ -138,7 +138,7 @@ fun AnalysisScreen(
if (currentViewMode == ViewMode.CATEGORY) { if (currentViewMode == ViewMode.CATEGORY) {
onNavigateToCategoryDetail(category, selectedMonth) onNavigateToCategoryDetail(category, selectedMonth)
} else { } else {
onNavigateToMemberDetail(category, selectedMonth) onNavigateToMemberDetail(category, selectedMonth, selectedAnalysisType)
} }
} }
) )
@ -149,11 +149,11 @@ fun AnalysisScreen(
items(if (currentViewMode == ViewMode.CATEGORY) categoryStats else memberStats) { stat -> items(if (currentViewMode == ViewMode.CATEGORY) categoryStats else memberStats) { stat ->
CategoryStatItem( CategoryStatItem(
stat = stat, stat = stat,
onClick = { onClick = {
if (currentViewMode == ViewMode.CATEGORY) { if (currentViewMode == ViewMode.CATEGORY) {
onNavigateToCategoryDetail(stat.category, selectedMonth) onNavigateToCategoryDetail(stat.category, selectedMonth)
} else { } else {
onNavigateToMemberDetail(stat.category, selectedMonth) onNavigateToMemberDetail(stat.category, selectedMonth, selectedAnalysisType)
} }
} }
) )

View File

@ -1,12 +1,30 @@
package com.yovinchen.bookkeeping.ui.screen package com.yovinchen.bookkeeping.ui.screen
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.* import androidx.compose.material3.Card
import androidx.compose.runtime.* import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@ -15,6 +33,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.yovinchen.bookkeeping.data.BookkeepingDatabase import com.yovinchen.bookkeeping.data.BookkeepingDatabase
import com.yovinchen.bookkeeping.model.BookkeepingRecord import com.yovinchen.bookkeeping.model.BookkeepingRecord
import com.yovinchen.bookkeeping.model.TransactionType
import com.yovinchen.bookkeeping.ui.components.CategoryPieChart import com.yovinchen.bookkeeping.ui.components.CategoryPieChart
import com.yovinchen.bookkeeping.ui.components.RecordItem import com.yovinchen.bookkeeping.ui.components.RecordItem
import com.yovinchen.bookkeeping.viewmodel.CategoryDetailViewModel import com.yovinchen.bookkeeping.viewmodel.CategoryDetailViewModel
@ -22,8 +41,7 @@ import com.yovinchen.bookkeeping.viewmodel.CategoryDetailViewModelFactory
import java.text.NumberFormat import java.text.NumberFormat
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.time.YearMonth import java.time.YearMonth
import java.time.format.DateTimeFormatter import java.util.Locale
import java.util.*
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -62,27 +80,78 @@ fun CategoryDetailScreen(
.padding(padding), .padding(padding),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
// 第一部分:总支出
item {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Column(
modifier = Modifier
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = if (records.isNotEmpty() && records.first().type == TransactionType.INCOME) "总收入" else "总支出",
style = MaterialTheme.typography.titleMedium
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = NumberFormat.getCurrencyInstance(Locale.CHINA).format(total),
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
}
}
}
// 第二部分:扇形图
item {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Column(
modifier = Modifier
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "成员分布",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(bottom = 16.dp)
)
CategoryPieChart(
categoryData = memberStats.map { Pair(it.category, it.percentage.toFloat()) },
memberData = emptyList(),
currentViewMode = false,
modifier = Modifier
.fillMaxWidth()
.height(200.dp),
onCategoryClick = { memberName ->
if (records.isNotEmpty() && records.first().type == TransactionType.EXPENSE) {
onNavigateToMemberDetail(memberName)
}
}
)
}
}
}
// 第三部分:详细信息
item { item {
Text( Text(
text = NumberFormat.getCurrencyInstance(Locale.CHINA).format(total), text = "详细记录",
style = MaterialTheme.typography.headlineMedium, style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(16.dp) modifier = Modifier.padding(16.dp)
) )
} }
item { // 按日期分组的记录列表
CategoryPieChart(
categoryData = memberStats.map { Pair(it.category, it.percentage.toFloat()) },
memberData = emptyList(),
currentViewMode = false,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
onCategoryClick = { memberName -> onNavigateToMemberDetail(memberName) }
)
}
// 按日期分组记录
val groupedRecords = records.groupBy { record -> val groupedRecords = records.groupBy { record ->
SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(record.date) SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(record.date)
}.toSortedMap(compareByDescending { it }) }.toSortedMap(compareByDescending { it })

View File

@ -1,39 +1,61 @@
package com.yovinchen.bookkeeping.ui.screen package com.yovinchen.bookkeeping.ui.screen
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.* import androidx.compose.material3.Card
import androidx.compose.runtime.* import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
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.data.Record import com.yovinchen.bookkeeping.data.Record
import com.yovinchen.bookkeeping.model.TransactionType
import com.yovinchen.bookkeeping.model.AnalysisType
import com.yovinchen.bookkeeping.ui.components.RecordItem import com.yovinchen.bookkeeping.ui.components.RecordItem
import com.yovinchen.bookkeeping.viewmodel.MemberDetailViewModel import com.yovinchen.bookkeeping.viewmodel.MemberDetailViewModel
import java.text.NumberFormat import java.text.NumberFormat
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.time.YearMonth import java.time.YearMonth
import java.time.format.DateTimeFormatter import java.util.Locale
import java.util.*
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun MemberDetailScreen( fun MemberDetailScreen(
memberName: String, memberName: String,
category: String,
yearMonth: YearMonth, yearMonth: YearMonth,
category: String = "",
analysisType: AnalysisType = AnalysisType.EXPENSE,
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
viewModel: MemberDetailViewModel = viewModel() viewModel: MemberDetailViewModel = viewModel()
) { ) {
val records by viewModel.memberRecords.collectAsState(initial = emptyList()) val records by viewModel.memberRecords.collectAsState(initial = emptyList())
val totalAmount by viewModel.totalAmount.collectAsState(initial = 0.0) val totalAmount by viewModel.totalAmount.collectAsState(initial = 0.0)
LaunchedEffect(memberName, category, yearMonth) { LaunchedEffect(memberName, category, yearMonth, analysisType) {
viewModel.loadMemberRecords(memberName, category, yearMonth) viewModel.loadMemberRecords(memberName, category, yearMonth, analysisType)
} }
val groupedRecords = remember(records) { val groupedRecords = remember(records) {
@ -46,11 +68,11 @@ fun MemberDetailScreen(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { title = {
Text("$category - $memberName") Text(memberName)
}, },
navigationIcon = { navigationIcon = {
IconButton(onClick = onNavigateBack) { IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, "返回") Icon(Icons.AutoMirrored.Filled.ArrowBack, "返回")
} }
} }
) )
@ -75,7 +97,7 @@ fun MemberDetailScreen(
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Text( Text(
text = "当前分类总支出", text = if (records.isNotEmpty() && records.first().type == TransactionType.INCOME) "总收入" else "总支出",
style = MaterialTheme.typography.titleMedium style = MaterialTheme.typography.titleMedium
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))

View File

@ -5,6 +5,8 @@ import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.yovinchen.bookkeeping.data.BookkeepingDatabase import com.yovinchen.bookkeeping.data.BookkeepingDatabase
import com.yovinchen.bookkeeping.model.BookkeepingRecord import com.yovinchen.bookkeeping.model.BookkeepingRecord
import com.yovinchen.bookkeeping.model.AnalysisType
import com.yovinchen.bookkeeping.model.TransactionType
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -22,7 +24,7 @@ class MemberDetailViewModel(application: Application) : AndroidViewModel(applica
private val _totalAmount = MutableStateFlow(0.0) private val _totalAmount = MutableStateFlow(0.0)
val totalAmount: StateFlow<Double> = _totalAmount val totalAmount: StateFlow<Double> = _totalAmount
fun loadMemberRecords(memberName: String, category: String, yearMonth: YearMonth) { fun loadMemberRecords(memberName: String, category: String, yearMonth: YearMonth, analysisType: AnalysisType) {
viewModelScope.launch { viewModelScope.launch {
val startDate = yearMonth.atDay(1).atStartOfDay() val startDate = yearMonth.atDay(1).atStartOfDay()
.atZone(ZoneId.systemDefault()) .atZone(ZoneId.systemDefault())
@ -34,12 +36,28 @@ class MemberDetailViewModel(application: Application) : AndroidViewModel(applica
.toInstant() .toInstant()
.let { Date.from(it) } .let { Date.from(it) }
val records = recordDao.getRecordsByMemberAndCategory( val transactionType = when (analysisType) {
memberName = memberName, AnalysisType.INCOME -> TransactionType.INCOME
category = category, AnalysisType.EXPENSE -> TransactionType.EXPENSE
startDate = startDate, else -> null
endDate = endDate }
)
val records = if (category.isEmpty()) {
recordDao.getRecordsByMember(
memberName = memberName,
startDate = startDate,
endDate = endDate,
transactionType = transactionType
)
} else {
recordDao.getRecordsByMemberAndCategory(
memberName = memberName,
category = category,
startDate = startDate,
endDate = endDate,
transactionType = transactionType
)
}
_memberRecords.value = records _memberRecords.value = records
_totalAmount.value = records.sumOf { it.amount } _totalAmount.value = records.sumOf { it.amount }
} }