feat: 增强时间范围筛选和成员统计显示
1. 更新 BookkeepingDao 支持时间范围筛选 2. 重构 CategoryDetailViewModel 及其工厂类 3. 为 MemberStat 添加 Room 注解 4. 改进 CategoryDetailScreen,结合饼状图和列表视图 5. 优化数据库查询和状态管理
This commit is contained in:
parent
c92cc18dde
commit
3296f6d154
@ -3,7 +3,7 @@ package com.yovinchen.bookkeeping.data
|
|||||||
import androidx.room.*
|
import androidx.room.*
|
||||||
import com.yovinchen.bookkeeping.model.BookkeepingRecord
|
import com.yovinchen.bookkeeping.model.BookkeepingRecord
|
||||||
import com.yovinchen.bookkeeping.model.Category
|
import com.yovinchen.bookkeeping.model.Category
|
||||||
import com.yovinchen.bookkeeping.model.CategoryStat
|
import com.yovinchen.bookkeeping.model.MemberStat
|
||||||
import com.yovinchen.bookkeeping.model.TransactionType
|
import com.yovinchen.bookkeeping.model.TransactionType
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
@ -51,15 +51,11 @@ interface BookkeepingDao {
|
|||||||
): Flow<List<BookkeepingRecord>>
|
): Flow<List<BookkeepingRecord>>
|
||||||
|
|
||||||
@Query("""
|
@Query("""
|
||||||
SELECT m.name as category,
|
SELECT
|
||||||
SUM(r.amount) as amount,
|
m.name as member,
|
||||||
COUNT(*) as count,
|
SUM(r.amount) as amount,
|
||||||
(SUM(r.amount) * 100.0 / (
|
COUNT(*) as count,
|
||||||
SELECT SUM(amount)
|
(SUM(r.amount) * 100.0 / (SELECT SUM(amount) FROM bookkeeping_records WHERE category = :category AND strftime('%Y-%m', datetime(date/1000, 'unixepoch')) = :yearMonth)) as percentage
|
||||||
FROM bookkeeping_records
|
|
||||||
WHERE category = :category
|
|
||||||
AND strftime('%Y-%m', datetime(date/1000, 'unixepoch')) = :yearMonth
|
|
||||||
)) as percentage
|
|
||||||
FROM bookkeeping_records r
|
FROM bookkeeping_records r
|
||||||
JOIN members m ON r.memberId = m.id
|
JOIN members m ON r.memberId = m.id
|
||||||
WHERE r.category = :category
|
WHERE r.category = :category
|
||||||
@ -70,7 +66,7 @@ interface BookkeepingDao {
|
|||||||
fun getMemberStatsByCategory(
|
fun getMemberStatsByCategory(
|
||||||
category: String,
|
category: String,
|
||||||
yearMonth: String
|
yearMonth: String
|
||||||
): Flow<List<CategoryStat>>
|
): Flow<List<MemberStat>>
|
||||||
|
|
||||||
@Query("""
|
@Query("""
|
||||||
SELECT * FROM bookkeeping_records
|
SELECT * FROM bookkeeping_records
|
||||||
@ -81,6 +77,67 @@ interface BookkeepingDao {
|
|||||||
category: String
|
category: String
|
||||||
): Flow<List<BookkeepingRecord>>
|
): Flow<List<BookkeepingRecord>>
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
SELECT * FROM bookkeeping_records
|
||||||
|
WHERE category = :category
|
||||||
|
AND date BETWEEN :startDate AND :endDate
|
||||||
|
ORDER BY date DESC
|
||||||
|
""")
|
||||||
|
fun getRecordsByCategoryAndDateRange(
|
||||||
|
category: String,
|
||||||
|
startDate: Date,
|
||||||
|
endDate: Date
|
||||||
|
): Flow<List<BookkeepingRecord>>
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
SELECT * FROM bookkeeping_records
|
||||||
|
WHERE memberId IN (SELECT id FROM members WHERE name = :memberName)
|
||||||
|
AND date BETWEEN :startDate AND :endDate
|
||||||
|
AND (:transactionType IS NULL OR type = :transactionType)
|
||||||
|
ORDER BY date DESC
|
||||||
|
""")
|
||||||
|
fun getRecordsByMemberAndDateRange(
|
||||||
|
memberName: String,
|
||||||
|
startDate: Date,
|
||||||
|
endDate: Date,
|
||||||
|
transactionType: TransactionType?
|
||||||
|
): Flow<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 = :transactionType)
|
||||||
|
ORDER BY date DESC
|
||||||
|
""")
|
||||||
|
fun getRecordsByMemberCategoryAndDateRange(
|
||||||
|
memberName: String,
|
||||||
|
category: String,
|
||||||
|
startDate: Date,
|
||||||
|
endDate: Date,
|
||||||
|
transactionType: TransactionType?
|
||||||
|
): Flow<List<BookkeepingRecord>>
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
SELECT
|
||||||
|
m.name as member,
|
||||||
|
SUM(r.amount) as amount,
|
||||||
|
COUNT(*) as count,
|
||||||
|
(SUM(r.amount) * 100.0 / (SELECT SUM(amount) FROM bookkeeping_records WHERE category = :category AND date BETWEEN :startDate AND :endDate)) as percentage
|
||||||
|
FROM bookkeeping_records r
|
||||||
|
JOIN members m ON r.memberId = m.id
|
||||||
|
WHERE r.category = :category
|
||||||
|
AND r.date BETWEEN :startDate AND :endDate
|
||||||
|
GROUP BY m.name
|
||||||
|
ORDER BY amount DESC
|
||||||
|
""")
|
||||||
|
fun getMemberStatsByCategoryAndDateRange(
|
||||||
|
category: String,
|
||||||
|
startDate: Date,
|
||||||
|
endDate: Date
|
||||||
|
): Flow<List<MemberStat>>
|
||||||
|
|
||||||
@Insert
|
@Insert
|
||||||
suspend fun insertRecord(record: BookkeepingRecord): Long
|
suspend fun insertRecord(record: BookkeepingRecord): Long
|
||||||
|
|
||||||
|
@ -1,8 +1,17 @@
|
|||||||
package com.yovinchen.bookkeeping.model
|
package com.yovinchen.bookkeeping.model
|
||||||
|
|
||||||
|
import androidx.room.ColumnInfo
|
||||||
|
|
||||||
data class MemberStat(
|
data class MemberStat(
|
||||||
|
@ColumnInfo(name = "member")
|
||||||
val member: String,
|
val member: String,
|
||||||
|
|
||||||
|
@ColumnInfo(name = "amount")
|
||||||
val amount: Double,
|
val amount: Double,
|
||||||
|
|
||||||
|
@ColumnInfo(name = "count")
|
||||||
val count: Int,
|
val count: Int,
|
||||||
|
|
||||||
|
@ColumnInfo(name = "percentage")
|
||||||
val percentage: Double = 0.0
|
val percentage: Double = 0.0
|
||||||
)
|
)
|
||||||
|
@ -36,14 +36,30 @@ sealed class Screen(
|
|||||||
object Home : Screen("home", "记账", Icons.AutoMirrored.Filled.List)
|
object Home : Screen("home", "记账", Icons.AutoMirrored.Filled.List)
|
||||||
object Analysis : Screen("analysis", "分析", Icons.Default.Analytics)
|
object Analysis : Screen("analysis", "分析", Icons.Default.Analytics)
|
||||||
object Settings : Screen("settings", "设置", Icons.Default.Settings)
|
object Settings : Screen("settings", "设置", Icons.Default.Settings)
|
||||||
object CategoryDetail : Screen("category_detail/{category}/{yearMonth}", "分类详情") {
|
object CategoryDetail : Screen(
|
||||||
fun createRoute(category: String, yearMonth: YearMonth): String {
|
"category_detail/{category}/{startMonth}/{endMonth}",
|
||||||
return "category_detail/$category/${yearMonth.format(DateTimeFormatter.ofPattern("yyyy-MM"))}"
|
"分类详情"
|
||||||
|
) {
|
||||||
|
fun createRoute(
|
||||||
|
category: String,
|
||||||
|
startMonth: YearMonth,
|
||||||
|
endMonth: YearMonth
|
||||||
|
): String {
|
||||||
|
return "category_detail/$category/${startMonth.format(DateTimeFormatter.ofPattern("yyyy-MM"))}/${endMonth.format(DateTimeFormatter.ofPattern("yyyy-MM"))}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
object MemberDetail : Screen("member_detail/{memberName}/{category}/{yearMonth}?type={type}", "成员详情") {
|
object MemberDetail : Screen(
|
||||||
fun createRoute(memberName: String, category: String, yearMonth: YearMonth, type: AnalysisType): String {
|
"member_detail/{memberName}/{category}/{startMonth}/{endMonth}?type={type}",
|
||||||
return "member_detail/$memberName/$category/${yearMonth.format(DateTimeFormatter.ofPattern("yyyy-MM"))}?type=${type.name}"
|
"成员详情"
|
||||||
|
) {
|
||||||
|
fun createRoute(
|
||||||
|
memberName: String,
|
||||||
|
category: String,
|
||||||
|
startMonth: YearMonth,
|
||||||
|
endMonth: YearMonth,
|
||||||
|
type: AnalysisType
|
||||||
|
): String {
|
||||||
|
return "member_detail/$memberName/$category/${startMonth.format(DateTimeFormatter.ofPattern("yyyy-MM"))}/${endMonth.format(DateTimeFormatter.ofPattern("yyyy-MM"))}?type=${type.name}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -94,11 +110,11 @@ fun MainNavigation(
|
|||||||
|
|
||||||
composable(Screen.Analysis.route) {
|
composable(Screen.Analysis.route) {
|
||||||
AnalysisScreen(
|
AnalysisScreen(
|
||||||
onNavigateToCategoryDetail = { category, yearMonth ->
|
onNavigateToCategoryDetail = { category, startMonth, endMonth ->
|
||||||
navController.navigate(Screen.CategoryDetail.createRoute(category, yearMonth))
|
navController.navigate(Screen.CategoryDetail.createRoute(category, startMonth, endMonth))
|
||||||
},
|
},
|
||||||
onNavigateToMemberDetail = { memberName, yearMonth, analysisType ->
|
onNavigateToMemberDetail = { memberName, startMonth, endMonth, analysisType ->
|
||||||
navController.navigate(Screen.MemberDetail.createRoute(memberName, "", yearMonth, analysisType))
|
navController.navigate(Screen.MemberDetail.createRoute(memberName, "", startMonth, endMonth, analysisType))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -114,51 +130,68 @@ fun MainNavigation(
|
|||||||
route = Screen.CategoryDetail.route,
|
route = Screen.CategoryDetail.route,
|
||||||
arguments = listOf(
|
arguments = listOf(
|
||||||
navArgument("category") { type = NavType.StringType },
|
navArgument("category") { type = NavType.StringType },
|
||||||
navArgument("yearMonth") { type = NavType.StringType }
|
navArgument("startMonth") { type = NavType.StringType },
|
||||||
|
navArgument("endMonth") { type = NavType.StringType }
|
||||||
)
|
)
|
||||||
) { backStackEntry ->
|
) { backStackEntry ->
|
||||||
val category = backStackEntry.arguments?.getString("category") ?: return@composable
|
val category = backStackEntry.arguments?.getString("category") ?: ""
|
||||||
val yearMonthStr = backStackEntry.arguments?.getString("yearMonth") ?: return@composable
|
val startMonth = YearMonth.parse(
|
||||||
val yearMonth = YearMonth.parse(yearMonthStr)
|
backStackEntry.arguments?.getString("startMonth") ?: "",
|
||||||
|
DateTimeFormatter.ofPattern("yyyy-MM")
|
||||||
|
)
|
||||||
|
val endMonth = YearMonth.parse(
|
||||||
|
backStackEntry.arguments?.getString("endMonth") ?: "",
|
||||||
|
DateTimeFormatter.ofPattern("yyyy-MM")
|
||||||
|
)
|
||||||
CategoryDetailScreen(
|
CategoryDetailScreen(
|
||||||
category = category,
|
category = category,
|
||||||
yearMonth = yearMonth,
|
startMonth = startMonth,
|
||||||
|
endMonth = endMonth,
|
||||||
onNavigateBack = { navController.popBackStack() },
|
onNavigateBack = { navController.popBackStack() },
|
||||||
onNavigateToMemberDetail = { memberName ->
|
onNavigateToMemberDetail = { memberName ->
|
||||||
navController.navigate(Screen.MemberDetail.createRoute(memberName, category, yearMonth, AnalysisType.EXPENSE))
|
navController.navigate(
|
||||||
|
Screen.MemberDetail.createRoute(
|
||||||
|
memberName = memberName,
|
||||||
|
category = category,
|
||||||
|
startMonth = startMonth,
|
||||||
|
endMonth = endMonth,
|
||||||
|
type = AnalysisType.EXPENSE
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
composable(
|
composable(
|
||||||
route = Screen.MemberDetail.route,
|
route = Screen.MemberDetail.route,
|
||||||
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("startMonth") { type = NavType.StringType },
|
||||||
navArgument("type") {
|
navArgument("endMonth") { type = NavType.StringType },
|
||||||
|
navArgument("type") {
|
||||||
type = NavType.StringType
|
type = NavType.StringType
|
||||||
defaultValue = AnalysisType.EXPENSE.name
|
defaultValue = AnalysisType.EXPENSE.name
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
) { backStackEntry ->
|
) { backStackEntry ->
|
||||||
val memberName = backStackEntry.arguments?.getString("memberName") ?: return@composable
|
val memberName = backStackEntry.arguments?.getString("memberName") ?: ""
|
||||||
val category = backStackEntry.arguments?.getString("category") ?: return@composable
|
val category = backStackEntry.arguments?.getString("category") ?: ""
|
||||||
val yearMonthStr = backStackEntry.arguments?.getString("yearMonth") ?: return@composable
|
val startMonth = YearMonth.parse(
|
||||||
val yearMonth = YearMonth.parse(yearMonthStr)
|
backStackEntry.arguments?.getString("startMonth") ?: "",
|
||||||
val type = backStackEntry.arguments?.getString("type")?.let {
|
DateTimeFormatter.ofPattern("yyyy-MM")
|
||||||
try {
|
)
|
||||||
AnalysisType.valueOf(it)
|
val endMonth = YearMonth.parse(
|
||||||
} catch (e: IllegalArgumentException) {
|
backStackEntry.arguments?.getString("endMonth") ?: "",
|
||||||
AnalysisType.EXPENSE
|
DateTimeFormatter.ofPattern("yyyy-MM")
|
||||||
}
|
)
|
||||||
} ?: AnalysisType.EXPENSE
|
val type = AnalysisType.valueOf(
|
||||||
|
backStackEntry.arguments?.getString("type") ?: AnalysisType.EXPENSE.name
|
||||||
|
)
|
||||||
MemberDetailScreen(
|
MemberDetailScreen(
|
||||||
memberName = memberName,
|
memberName = memberName,
|
||||||
yearMonth = yearMonth,
|
|
||||||
category = category,
|
category = category,
|
||||||
|
startMonth = startMonth,
|
||||||
|
endMonth = endMonth,
|
||||||
analysisType = type,
|
analysisType = type,
|
||||||
onNavigateBack = { navController.popBackStack() }
|
onNavigateBack = { navController.popBackStack() }
|
||||||
)
|
)
|
||||||
|
@ -48,8 +48,9 @@ enum class ViewMode {
|
|||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun AnalysisScreen(
|
fun AnalysisScreen(
|
||||||
onNavigateToCategoryDetail: (String, YearMonth) -> Unit,
|
onNavigateToCategoryDetail: (String, YearMonth, YearMonth) -> Unit,
|
||||||
onNavigateToMemberDetail: (String, YearMonth, AnalysisType) -> Unit
|
onNavigateToMemberDetail: (String, YearMonth, YearMonth, AnalysisType) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val viewModel: AnalysisViewModel = viewModel()
|
val viewModel: AnalysisViewModel = viewModel()
|
||||||
val startMonth by viewModel.startMonth.collectAsState()
|
val startMonth by viewModel.startMonth.collectAsState()
|
||||||
@ -61,11 +62,13 @@ fun AnalysisScreen(
|
|||||||
var showViewModeMenu by remember { mutableStateOf(false) }
|
var showViewModeMenu by remember { mutableStateOf(false) }
|
||||||
var currentViewMode by rememberSaveable { mutableStateOf(ViewMode.CATEGORY) }
|
var currentViewMode by rememberSaveable { mutableStateOf(ViewMode.CATEGORY) }
|
||||||
|
|
||||||
Scaffold { padding ->
|
Scaffold(
|
||||||
|
modifier = modifier.fillMaxSize()
|
||||||
|
) { paddingValues ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(padding)
|
.padding(paddingValues)
|
||||||
) {
|
) {
|
||||||
// 时间区间选择
|
// 时间区间选择
|
||||||
DateRangePicker(
|
DateRangePicker(
|
||||||
@ -151,9 +154,9 @@ fun AnalysisScreen(
|
|||||||
.padding(bottom = 16.dp),
|
.padding(bottom = 16.dp),
|
||||||
onCategoryClick = { category ->
|
onCategoryClick = { category ->
|
||||||
if (currentViewMode == ViewMode.CATEGORY) {
|
if (currentViewMode == ViewMode.CATEGORY) {
|
||||||
onNavigateToCategoryDetail(category, startMonth)
|
onNavigateToCategoryDetail(category, startMonth, endMonth)
|
||||||
} else {
|
} else {
|
||||||
onNavigateToMemberDetail(category, startMonth, selectedAnalysisType)
|
onNavigateToMemberDetail(category, startMonth, endMonth, selectedAnalysisType)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@ -169,9 +172,9 @@ fun AnalysisScreen(
|
|||||||
stat = stat,
|
stat = stat,
|
||||||
onClick = {
|
onClick = {
|
||||||
if (currentViewMode == ViewMode.CATEGORY && category != null) {
|
if (currentViewMode == ViewMode.CATEGORY && category != null) {
|
||||||
onNavigateToCategoryDetail(category, startMonth)
|
onNavigateToCategoryDetail(category, startMonth, endMonth)
|
||||||
} else if (currentViewMode == ViewMode.MEMBER && member != null) {
|
} else if (currentViewMode == ViewMode.MEMBER && member != null) {
|
||||||
onNavigateToMemberDetail(member, startMonth, selectedAnalysisType)
|
onNavigateToMemberDetail(member, startMonth, endMonth, selectedAnalysisType)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
package com.yovinchen.bookkeeping.ui.screen
|
package com.yovinchen.bookkeeping.ui.screen
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@ -17,6 +18,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
|||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@ -33,6 +35,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.MemberStat
|
||||||
import com.yovinchen.bookkeeping.model.TransactionType
|
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
|
||||||
@ -47,17 +50,20 @@ import java.util.Locale
|
|||||||
@Composable
|
@Composable
|
||||||
fun CategoryDetailScreen(
|
fun CategoryDetailScreen(
|
||||||
category: String,
|
category: String,
|
||||||
yearMonth: YearMonth,
|
startMonth: YearMonth,
|
||||||
|
endMonth: YearMonth,
|
||||||
onNavigateBack: () -> Unit,
|
onNavigateBack: () -> Unit,
|
||||||
onNavigateToMemberDetail: (String) -> Unit,
|
onNavigateToMemberDetail: (String) -> Unit,
|
||||||
|
viewModel: CategoryDetailViewModel = viewModel(
|
||||||
|
factory = CategoryDetailViewModelFactory(
|
||||||
|
database = BookkeepingDatabase.getDatabase(LocalContext.current),
|
||||||
|
category = category,
|
||||||
|
startMonth = startMonth,
|
||||||
|
endMonth = endMonth
|
||||||
|
)
|
||||||
|
),
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
|
||||||
val database = remember { BookkeepingDatabase.getDatabase(context) }
|
|
||||||
val viewModel: CategoryDetailViewModel = viewModel(
|
|
||||||
factory = CategoryDetailViewModelFactory(database, category, yearMonth)
|
|
||||||
)
|
|
||||||
|
|
||||||
val records by viewModel.records.collectAsState()
|
val records by viewModel.records.collectAsState()
|
||||||
val memberStats by viewModel.memberStats.collectAsState()
|
val memberStats by viewModel.memberStats.collectAsState()
|
||||||
val total by viewModel.total.collectAsState()
|
val total by viewModel.total.collectAsState()
|
||||||
@ -107,16 +113,16 @@ fun CategoryDetailScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 第二部分:扇形图
|
// 第二部分:成员统计
|
||||||
item {
|
item {
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
.padding(16.dp)
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
|
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
@ -125,19 +131,33 @@ fun CategoryDetailScreen(
|
|||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
modifier = Modifier.padding(bottom = 16.dp)
|
modifier = Modifier.padding(bottom = 16.dp)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 饼状图
|
||||||
CategoryPieChart(
|
CategoryPieChart(
|
||||||
categoryData = memberStats.map { Pair(it.category, it.percentage.toFloat()) },
|
categoryData = emptyList(),
|
||||||
memberData = emptyList(),
|
memberData = memberStats.map { Pair(it.member, it.percentage.toFloat()) },
|
||||||
currentViewMode = false,
|
currentViewMode = true,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(200.dp),
|
.height(200.dp),
|
||||||
onCategoryClick = { memberName ->
|
onCategoryClick = { memberName ->
|
||||||
if (records.isNotEmpty() && records.first().type == TransactionType.EXPENSE) {
|
onNavigateToMemberDetail(memberName)
|
||||||
onNavigateToMemberDetail(memberName)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
HorizontalDivider()
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// 成员列表
|
||||||
|
Column {
|
||||||
|
memberStats.forEach { stat ->
|
||||||
|
MemberStatItem(
|
||||||
|
stat = stat,
|
||||||
|
onClick = { onNavigateToMemberDetail(stat.member) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -208,6 +228,29 @@ fun CategoryDetailScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MemberStatItem(
|
||||||
|
stat: MemberStat,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(stat.member) },
|
||||||
|
supportingContent = {
|
||||||
|
Text(
|
||||||
|
buildString {
|
||||||
|
append("金额: ¥%.2f".format(stat.amount))
|
||||||
|
append(" | ")
|
||||||
|
append("次数: ${stat.count}")
|
||||||
|
append(" | ")
|
||||||
|
append("占比: %.1f%%".format(stat.percentage))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = modifier.clickable(onClick = onClick)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun RecordItem(
|
private fun RecordItem(
|
||||||
record: BookkeepingRecord,
|
record: BookkeepingRecord,
|
||||||
|
@ -44,7 +44,8 @@ import java.util.Locale
|
|||||||
@Composable
|
@Composable
|
||||||
fun MemberDetailScreen(
|
fun MemberDetailScreen(
|
||||||
memberName: String,
|
memberName: String,
|
||||||
yearMonth: YearMonth,
|
startMonth: YearMonth,
|
||||||
|
endMonth: YearMonth,
|
||||||
category: String = "",
|
category: String = "",
|
||||||
analysisType: AnalysisType = AnalysisType.EXPENSE,
|
analysisType: AnalysisType = AnalysisType.EXPENSE,
|
||||||
onNavigateBack: () -> Unit,
|
onNavigateBack: () -> Unit,
|
||||||
@ -53,8 +54,14 @@ fun MemberDetailScreen(
|
|||||||
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, analysisType) {
|
LaunchedEffect(memberName, category, startMonth, endMonth, analysisType) {
|
||||||
viewModel.loadMemberRecords(memberName, category, yearMonth, analysisType)
|
viewModel.loadMemberRecords(
|
||||||
|
memberName = memberName,
|
||||||
|
category = category,
|
||||||
|
startMonth = startMonth,
|
||||||
|
endMonth = endMonth,
|
||||||
|
analysisType = analysisType
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val groupedRecords = remember(records) {
|
val groupedRecords = remember(records) {
|
||||||
|
@ -5,24 +5,25 @@ import androidx.lifecycle.ViewModelProvider
|
|||||||
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.CategoryStat
|
import com.yovinchen.bookkeeping.model.MemberStat
|
||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.*
|
||||||
import java.time.YearMonth
|
import java.time.YearMonth
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.ZoneId
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
class CategoryDetailViewModel(
|
class CategoryDetailViewModel(
|
||||||
private val database: BookkeepingDatabase,
|
private val database: BookkeepingDatabase,
|
||||||
private val category: String,
|
private val category: String,
|
||||||
private val month: YearMonth
|
private val startMonth: YearMonth,
|
||||||
|
private val endMonth: YearMonth
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val recordDao = database.bookkeepingDao()
|
private val recordDao = database.bookkeepingDao()
|
||||||
private val yearMonthStr = month.format(DateTimeFormatter.ofPattern("yyyy-MM"))
|
|
||||||
|
|
||||||
private val _records = MutableStateFlow<List<BookkeepingRecord>>(emptyList())
|
private val _records = MutableStateFlow<List<BookkeepingRecord>>(emptyList())
|
||||||
val records: StateFlow<List<BookkeepingRecord>> = _records.asStateFlow()
|
val records: StateFlow<List<BookkeepingRecord>> = _records.asStateFlow()
|
||||||
|
|
||||||
private val _memberStats = MutableStateFlow<List<CategoryStat>>(emptyList())
|
private val _memberStats = MutableStateFlow<List<MemberStat>>(emptyList())
|
||||||
val memberStats: StateFlow<List<CategoryStat>> = _memberStats.asStateFlow()
|
val memberStats: StateFlow<List<MemberStat>> = _memberStats.asStateFlow()
|
||||||
|
|
||||||
val total: StateFlow<Double> = records
|
val total: StateFlow<Double> = records
|
||||||
.map { records -> records.sumOf { it.amount } }
|
.map { records -> records.sumOf { it.amount } }
|
||||||
@ -33,22 +34,30 @@ class CategoryDetailViewModel(
|
|||||||
)
|
)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
recordDao.getRecordsByCategory(category)
|
val startDate = startMonth.atDay(1).atStartOfDay()
|
||||||
.onEach { records ->
|
.atZone(ZoneId.systemDefault())
|
||||||
_records.value = records.filter { record ->
|
.toInstant()
|
||||||
val recordMonth = YearMonth.from(
|
.let { Date.from(it) }
|
||||||
DateTimeFormatter.ofPattern("yyyy-MM")
|
|
||||||
.parse(yearMonthStr)
|
|
||||||
)
|
|
||||||
YearMonth.from(record.date.toInstant().atZone(java.time.ZoneId.systemDefault()).toLocalDateTime()) == recordMonth
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.launchIn(viewModelScope)
|
|
||||||
|
|
||||||
recordDao.getMemberStatsByCategory(category, yearMonthStr)
|
val endDate = endMonth.atEndOfMonth().atTime(23, 59, 59)
|
||||||
.onEach { stats ->
|
.atZone(ZoneId.systemDefault())
|
||||||
_memberStats.value = stats
|
.toInstant()
|
||||||
}
|
.let { Date.from(it) }
|
||||||
.launchIn(viewModelScope)
|
|
||||||
|
recordDao.getRecordsByCategoryAndDateRange(
|
||||||
|
category = category,
|
||||||
|
startDate = startDate,
|
||||||
|
endDate = endDate
|
||||||
|
)
|
||||||
|
.onEach { records -> _records.value = records }
|
||||||
|
.launchIn(viewModelScope)
|
||||||
|
|
||||||
|
recordDao.getMemberStatsByCategoryAndDateRange(
|
||||||
|
category = category,
|
||||||
|
startDate = startDate,
|
||||||
|
endDate = endDate
|
||||||
|
)
|
||||||
|
.onEach { stats -> _memberStats.value = stats }
|
||||||
|
.launchIn(viewModelScope)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -8,12 +8,13 @@ import java.time.YearMonth
|
|||||||
class CategoryDetailViewModelFactory(
|
class CategoryDetailViewModelFactory(
|
||||||
private val database: BookkeepingDatabase,
|
private val database: BookkeepingDatabase,
|
||||||
private val category: String,
|
private val category: String,
|
||||||
private val month: YearMonth
|
private val startMonth: YearMonth,
|
||||||
|
private val endMonth: YearMonth
|
||||||
) : ViewModelProvider.Factory {
|
) : ViewModelProvider.Factory {
|
||||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||||
if (modelClass.isAssignableFrom(CategoryDetailViewModel::class.java)) {
|
if (modelClass.isAssignableFrom(CategoryDetailViewModel::class.java)) {
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
return CategoryDetailViewModel(database, category, month) as T
|
return CategoryDetailViewModel(database, category, startMonth, endMonth) as T
|
||||||
}
|
}
|
||||||
throw IllegalArgumentException("Unknown ViewModel class")
|
throw IllegalArgumentException("Unknown ViewModel class")
|
||||||
}
|
}
|
||||||
|
@ -7,9 +7,7 @@ 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.AnalysisType
|
||||||
import com.yovinchen.bookkeeping.model.TransactionType
|
import com.yovinchen.bookkeeping.model.TransactionType
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.*
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import java.time.YearMonth
|
import java.time.YearMonth
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
@ -19,47 +17,56 @@ class MemberDetailViewModel(application: Application) : AndroidViewModel(applica
|
|||||||
private val recordDao = database.bookkeepingDao()
|
private val recordDao = database.bookkeepingDao()
|
||||||
|
|
||||||
private val _memberRecords = MutableStateFlow<List<BookkeepingRecord>>(emptyList())
|
private val _memberRecords = MutableStateFlow<List<BookkeepingRecord>>(emptyList())
|
||||||
val memberRecords: StateFlow<List<BookkeepingRecord>> = _memberRecords
|
val memberRecords: StateFlow<List<BookkeepingRecord>> = _memberRecords.asStateFlow()
|
||||||
|
|
||||||
private val _totalAmount = MutableStateFlow(0.0)
|
private val _totalAmount = MutableStateFlow(0.0)
|
||||||
val totalAmount: StateFlow<Double> = _totalAmount
|
val totalAmount: StateFlow<Double> = _totalAmount.asStateFlow()
|
||||||
|
|
||||||
fun loadMemberRecords(memberName: String, category: String, yearMonth: YearMonth, analysisType: AnalysisType) {
|
fun loadMemberRecords(
|
||||||
viewModelScope.launch {
|
memberName: String,
|
||||||
val startDate = yearMonth.atDay(1).atStartOfDay()
|
category: String,
|
||||||
.atZone(ZoneId.systemDefault())
|
startMonth: YearMonth,
|
||||||
.toInstant()
|
endMonth: YearMonth,
|
||||||
.let { Date.from(it) }
|
analysisType: AnalysisType
|
||||||
|
) {
|
||||||
|
val startDate = startMonth.atDay(1).atStartOfDay()
|
||||||
|
.atZone(ZoneId.systemDefault())
|
||||||
|
.toInstant()
|
||||||
|
.let { Date.from(it) }
|
||||||
|
|
||||||
val endDate = yearMonth.atEndOfMonth().atTime(23, 59, 59)
|
val endDate = endMonth.atEndOfMonth().atTime(23, 59, 59)
|
||||||
.atZone(ZoneId.systemDefault())
|
.atZone(ZoneId.systemDefault())
|
||||||
.toInstant()
|
.toInstant()
|
||||||
.let { Date.from(it) }
|
.let { Date.from(it) }
|
||||||
|
|
||||||
val transactionType = when (analysisType) {
|
val transactionType = when (analysisType) {
|
||||||
AnalysisType.INCOME -> TransactionType.INCOME
|
AnalysisType.INCOME -> TransactionType.INCOME
|
||||||
AnalysisType.EXPENSE -> TransactionType.EXPENSE
|
AnalysisType.EXPENSE -> TransactionType.EXPENSE
|
||||||
else -> null
|
else -> null
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
_totalAmount.value = records.sumOf { it.amount }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val recordsFlow = if (category.isEmpty()) {
|
||||||
|
recordDao.getRecordsByMemberAndDateRange(
|
||||||
|
memberName = memberName,
|
||||||
|
startDate = startDate,
|
||||||
|
endDate = endDate,
|
||||||
|
transactionType = transactionType
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
recordDao.getRecordsByMemberCategoryAndDateRange(
|
||||||
|
memberName = memberName,
|
||||||
|
category = category,
|
||||||
|
startDate = startDate,
|
||||||
|
endDate = endDate,
|
||||||
|
transactionType = transactionType
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
recordsFlow
|
||||||
|
.onEach { records ->
|
||||||
|
_memberRecords.value = records
|
||||||
|
_totalAmount.value = records.sumOf { it.amount }
|
||||||
|
}
|
||||||
|
.launchIn(viewModelScope)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user