Compare commits

..

No commits in common. "a0d47864d872d7418c4565820986b1e1e69616c7" and "94fc7b2a7e1a1bf9b4c52473a633eb951b04e1ac" have entirely different histories.

7 changed files with 58 additions and 210 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 = 4 versionCode = 1
versionName = "1.2.2" versionName = "1.0.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {

View File

@ -108,49 +108,17 @@ interface BookkeepingDao {
@Query("UPDATE bookkeeping_records SET category = :newName WHERE category = :oldName") @Query("UPDATE bookkeeping_records SET category = :newName WHERE category = :oldName")
suspend fun updateRecordCategories(oldName: String, newName: String) suspend fun updateRecordCategories(oldName: String, newName: String)
@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 = (
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(""" @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 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 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,7 +22,6 @@ 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
@ -41,9 +40,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}?type={type}", "成员详情") { object MemberDetail : Screen("member_detail/{memberName}/{category}/{yearMonth}", "成员详情") {
fun createRoute(memberName: String, category: String, yearMonth: YearMonth, type: AnalysisType): String { fun createRoute(memberName: String, category: String, yearMonth: YearMonth): String {
return "member_detail/$memberName/$category/${yearMonth.format(DateTimeFormatter.ofPattern("yyyy-MM"))}?type=${type.name}" return "member_detail/$memberName/$category/${yearMonth.format(DateTimeFormatter.ofPattern("yyyy-MM"))}"
} }
} }
@ -97,8 +96,10 @@ 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, analysisType -> onNavigateToMemberDetail = { memberName, yearMonth ->
navController.navigate(Screen.MemberDetail.createRoute(memberName, "", yearMonth, analysisType)) // 在这里我们暂时使用一个默认分类,你需要根据实际情况修改这里的逻辑
val defaultCategory = "默认"
navController.navigate(Screen.MemberDetail.createRoute(memberName, defaultCategory, yearMonth))
} }
) )
} }
@ -126,7 +127,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, AnalysisType.EXPENSE)) navController.navigate(Screen.MemberDetail.createRoute(memberName, category, yearMonth))
} }
) )
} }
@ -136,30 +137,18 @@ 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,
yearMonth = yearMonth,
category = category, category = category,
analysisType = type, yearMonth = yearMonth,
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, AnalysisType) -> Unit onNavigateToMemberDetail: (String, YearMonth) -> 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, selectedAnalysisType) onNavigateToMemberDetail(category, selectedMonth)
} }
} }
) )
@ -153,7 +153,7 @@ fun AnalysisScreen(
if (currentViewMode == ViewMode.CATEGORY) { if (currentViewMode == ViewMode.CATEGORY) {
onNavigateToCategoryDetail(stat.category, selectedMonth) onNavigateToCategoryDetail(stat.category, selectedMonth)
} else { } else {
onNavigateToMemberDetail(stat.category, selectedMonth, selectedAnalysisType) onNavigateToMemberDetail(stat.category, selectedMonth)
} }
} }
) )

View File

@ -1,30 +1,12 @@
package com.yovinchen.bookkeeping.ui.screen package com.yovinchen.bookkeeping.ui.screen
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.*
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.Card import androidx.compose.material3.*
import androidx.compose.material3.CardDefaults import androidx.compose.runtime.*
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
@ -33,7 +15,6 @@ 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
@ -41,7 +22,8 @@ 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.util.Locale import java.time.format.DateTimeFormatter
import java.util.*
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -80,78 +62,27 @@ fun CategoryDetailScreen(
.padding(padding), .padding(padding),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
// 第一部分:总支出
item { 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(
text = NumberFormat.getCurrencyInstance(Locale.CHINA).format(total), text = NumberFormat.getCurrencyInstance(Locale.CHINA).format(total),
style = MaterialTheme.typography.headlineMedium, style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold modifier = Modifier.padding(16.dp)
) )
} }
}
}
// 第二部分:扇形图
item { 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( CategoryPieChart(
categoryData = memberStats.map { Pair(it.category, it.percentage.toFloat()) }, categoryData = memberStats.map { Pair(it.category, it.percentage.toFloat()) },
memberData = emptyList(), memberData = emptyList(),
currentViewMode = false, currentViewMode = false,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(200.dp), .padding(16.dp),
onCategoryClick = { memberName -> onCategoryClick = { memberName -> onNavigateToMemberDetail(memberName) }
if (records.isNotEmpty() && records.first().type == TransactionType.EXPENSE) {
onNavigateToMemberDetail(memberName)
}
}
)
}
}
}
// 第三部分:详细信息
item {
Text(
text = "详细记录",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(16.dp)
) )
} }
// 按日期分组记录列表 // 按日期分组记录
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,61 +1,39 @@
package com.yovinchen.bookkeeping.ui.screen package com.yovinchen.bookkeeping.ui.screen
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.*
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.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.Card import androidx.compose.material3.*
import androidx.compose.material3.CardDefaults import androidx.compose.runtime.*
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.util.Locale import java.time.format.DateTimeFormatter
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, analysisType) { LaunchedEffect(memberName, category, yearMonth) {
viewModel.loadMemberRecords(memberName, category, yearMonth, analysisType) viewModel.loadMemberRecords(memberName, category, yearMonth)
} }
val groupedRecords = remember(records) { val groupedRecords = remember(records) {
@ -68,11 +46,11 @@ fun MemberDetailScreen(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { title = {
Text(memberName) Text("$category - $memberName")
}, },
navigationIcon = { navigationIcon = {
IconButton(onClick = onNavigateBack) { IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "返回") Icon(Icons.Default.ArrowBack, "返回")
} }
} }
) )
@ -97,7 +75,7 @@ fun MemberDetailScreen(
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Text( Text(
text = if (records.isNotEmpty() && records.first().type == TransactionType.INCOME) "总收入" else "总支出", text = "当前分类总支出",
style = MaterialTheme.typography.titleMedium style = MaterialTheme.typography.titleMedium
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))

View File

@ -5,8 +5,6 @@ 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
@ -24,7 +22,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, analysisType: AnalysisType) { fun loadMemberRecords(memberName: String, category: String, yearMonth: YearMonth) {
viewModelScope.launch { viewModelScope.launch {
val startDate = yearMonth.atDay(1).atStartOfDay() val startDate = yearMonth.atDay(1).atStartOfDay()
.atZone(ZoneId.systemDefault()) .atZone(ZoneId.systemDefault())
@ -36,28 +34,12 @@ class MemberDetailViewModel(application: Application) : AndroidViewModel(applica
.toInstant() .toInstant()
.let { Date.from(it) } .let { Date.from(it) }
val transactionType = when (analysisType) { val records = recordDao.getRecordsByMemberAndCategory(
AnalysisType.INCOME -> TransactionType.INCOME
AnalysisType.EXPENSE -> TransactionType.EXPENSE
else -> null
}
val records = if (category.isEmpty()) {
recordDao.getRecordsByMember(
memberName = memberName,
startDate = startDate,
endDate = endDate,
transactionType = transactionType
)
} else {
recordDao.getRecordsByMemberAndCategory(
memberName = memberName, memberName = memberName,
category = category, category = category,
startDate = startDate, startDate = startDate,
endDate = endDate, endDate = endDate
transactionType = transactionType
) )
}
_memberRecords.value = records _memberRecords.value = records
_totalAmount.value = records.sumOf { it.amount } _totalAmount.value = records.sumOf { it.amount }
} }