基础构建

This commit is contained in:
2024-11-26 22:47:37 +08:00
commit bb619bed78
65 changed files with 3225 additions and 0 deletions

84
app/build.gradle.kts Normal file
View File

@@ -0,0 +1,84 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("com.google.devtools.ksp")
}
android {
namespace = "com.yovinchen.bookkeeping"
compileSdk = 34
defaultConfig {
applicationId = "com.yovinchen.bookkeeping"
minSdk = 26
targetSdk = 34
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.1"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
implementation("androidx.activity:activity-compose:1.8.2")
implementation(platform("androidx.compose:compose-bom:2024.02.00"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
// ViewModel
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0")
implementation(libs.androidx.room.common)
implementation(libs.androidx.navigation.common.ktx)
implementation(libs.androidx.navigation.compose)
// Room
val roomVersion = "2.6.1"
implementation("androidx.room:room-runtime:$roomVersion")
implementation("androidx.room:room-ktx:$roomVersion")
ksp("androidx.room:room-compiler:$roomVersion")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
androidTestImplementation(platform("androidx.compose:compose-bom:2024.02.00"))
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
}

21
app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@@ -0,0 +1,24 @@
package com.yovinchen.bookkeeping
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.yovinchen.bookkeeping", appContext.packageName)
}
}

View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Bookkeeping"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.Bookkeeping">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,121 @@
package com.yovinchen.bookkeeping
import android.app.Activity
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.tooling.preview.Preview
import androidx.core.view.WindowCompat
import com.yovinchen.bookkeeping.model.ThemeMode
import com.yovinchen.bookkeeping.ui.components.predefinedColors
import com.yovinchen.bookkeeping.ui.navigation.MainNavigation
import com.yovinchen.bookkeeping.ui.theme.BookkeepingTheme
@Composable
private fun SystemBarColor(isDarkTheme: Boolean) {
val view = LocalView.current
if (!view.isInEditMode) {
val surfaceColor = MaterialTheme.colorScheme.surface.toArgb()
val currentWindow = (view.context as? Activity)?.window
SideEffect {
currentWindow?.let { window ->
window.statusBarColor = surfaceColor
window.navigationBarColor = surfaceColor
WindowCompat.getInsetsController(window, view).apply {
isAppearanceLightStatusBars = !isDarkTheme
isAppearanceLightNavigationBars = !isDarkTheme
}
}
}
}
}
@Composable
fun BookkeepingApp() {
var themeMode by remember { mutableStateOf<ThemeMode>(ThemeMode.FOLLOW_SYSTEM) }
val isDarkTheme = when (themeMode) {
is ThemeMode.FOLLOW_SYSTEM -> isSystemInDarkTheme()
is ThemeMode.LIGHT -> false
is ThemeMode.DARK -> true
is ThemeMode.CUSTOM -> isSystemInDarkTheme()
}
val customColorScheme = when (themeMode) {
is ThemeMode.CUSTOM -> {
val primaryColor = (themeMode as ThemeMode.CUSTOM).primaryColor
if (isDarkTheme) {
MaterialTheme.colorScheme.copy(
primary = primaryColor,
secondary = primaryColor.copy(alpha = 0.7f),
tertiary = primaryColor.copy(alpha = 0.5f)
)
} else {
MaterialTheme.colorScheme.copy(
primary = primaryColor,
secondary = primaryColor.copy(alpha = 0.7f),
tertiary = primaryColor.copy(alpha = 0.5f)
)
}
}
else -> null
}
BookkeepingTheme(
darkTheme = isDarkTheme,
customColorScheme = customColorScheme
) {
SystemBarColor(isDarkTheme)
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.surface
) {
MainNavigation(
currentTheme = themeMode,
onThemeChange = { themeMode = it }
)
}
}
}
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
setContent {
BookkeepingApp()
}
}
}
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
Text(
text = "Hello 你好 $name!",
modifier = modifier
)
}
@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
BookkeepingTheme {
Greeting("Android")
}
}
@Preview(showBackground = true)
@Composable
fun BookkeepingAppPreview() {
BookkeepingApp()
}

View File

@@ -0,0 +1,32 @@
package com.yovinchen.bookkeeping.data
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
@Database(entities = [Record::class], version = 1, exportSchema = false)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun recordDao(): RecordDao
companion object {
@Volatile
private var INSTANCE: AppDatabase? = null
fun getDatabase(context: Context): AppDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"bookkeeping_database"
)
.fallbackToDestructiveMigration()
.build()
INSTANCE = instance
instance
}
}
}
}

View File

@@ -0,0 +1,64 @@
package com.yovinchen.bookkeeping.data
import androidx.room.*
import com.yovinchen.bookkeeping.model.BookkeepingRecord
import com.yovinchen.bookkeeping.model.Category
import com.yovinchen.bookkeeping.model.TransactionType
import kotlinx.coroutines.flow.Flow
import java.util.Date
@Dao
interface BookkeepingDao {
@Query("SELECT * FROM bookkeeping_records ORDER BY date DESC")
fun getAllRecords(): Flow<List<BookkeepingRecord>>
@Insert
suspend fun insertRecord(record: BookkeepingRecord)
@Delete
suspend fun deleteRecord(record: BookkeepingRecord)
@Update
suspend fun updateRecord(record: BookkeepingRecord)
@Query("SELECT * FROM bookkeeping_records WHERE type = 'INCOME'")
fun getAllIncome(): Flow<List<BookkeepingRecord>>
@Query("SELECT * FROM bookkeeping_records WHERE type = 'EXPENSE'")
fun getAllExpense(): Flow<List<BookkeepingRecord>>
// 按日期查询
@Query("SELECT * FROM bookkeeping_records WHERE date >= :startOfDay AND date < :endOfDay ORDER BY date DESC")
fun getRecordsByDate(startOfDay: Date, endOfDay: Date): Flow<List<BookkeepingRecord>>
// 按日期范围查询
@Query("SELECT * FROM bookkeeping_records WHERE date BETWEEN :startDate AND :endDate ORDER BY date DESC")
fun getRecordsByDateRange(startDate: Date, endDate: Date): Flow<List<BookkeepingRecord>>
// 按类别查询
@Query("SELECT * FROM bookkeeping_records WHERE category = :category ORDER BY date DESC")
fun getRecordsByCategory(category: String): Flow<List<BookkeepingRecord>>
// 按类型查询
@Query("SELECT * FROM bookkeeping_records WHERE type = :type ORDER BY date DESC")
fun getRecordsByType(type: TransactionType): Flow<List<BookkeepingRecord>>
// Category related queries
@Query("SELECT * FROM categories WHERE type = :type ORDER BY name ASC")
fun getCategoriesByType(type: TransactionType): Flow<List<Category>>
@Insert
suspend fun insertCategory(category: Category)
@Delete
suspend fun deleteCategory(category: Category)
@Update
suspend fun updateCategory(category: Category)
@Query("SELECT EXISTS(SELECT 1 FROM bookkeeping_records WHERE category = :categoryName LIMIT 1)")
suspend fun isCategoryInUse(categoryName: String): Boolean
@Query("UPDATE bookkeeping_records SET category = :newName WHERE category = :oldName")
suspend fun updateRecordCategories(oldName: String, newName: String)
}

View File

@@ -0,0 +1,170 @@
package com.yovinchen.bookkeeping.data
import android.content.Context
import android.util.Log
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.yovinchen.bookkeeping.model.BookkeepingRecord
import com.yovinchen.bookkeeping.model.Category
import com.yovinchen.bookkeeping.model.Converters
import com.yovinchen.bookkeeping.model.TransactionType
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Database(entities = [BookkeepingRecord::class, Category::class], version = 2, exportSchema = false)
@TypeConverters(Converters::class)
abstract class BookkeepingDatabase : RoomDatabase() {
abstract fun bookkeepingDao(): BookkeepingDao
companion object {
private const val TAG = "BookkeepingDatabase"
@Volatile
private var Instance: BookkeepingDatabase? = null
private val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(database: SupportSQLiteDatabase) {
try {
Log.d(TAG, "Starting migration from version 1 to 2")
// 检查表是否存在
val cursor = database.query("SELECT name FROM sqlite_master WHERE type='table' AND name='categories'")
val tableExists = cursor.moveToFirst()
cursor.close()
if (tableExists) {
// 如果表存在,执行迁移
Log.d(TAG, "Categories table exists, performing migration")
database.execSQL("ALTER TABLE categories RENAME TO categories_old")
database.execSQL("""
CREATE TABLE categories (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
name TEXT NOT NULL,
type TEXT NOT NULL
)
""")
database.execSQL("""
INSERT INTO categories (name, type)
SELECT name, type FROM categories_old
""")
database.execSQL("DROP TABLE categories_old")
} else {
// 如果表不存在,直接创建新表
Log.d(TAG, "Categories table does not exist, creating new table")
database.execSQL("""
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
name TEXT NOT NULL,
type TEXT NOT NULL
)
""")
}
// 确保 bookkeeping_records 表存在
database.execSQL("""
CREATE TABLE IF NOT EXISTS bookkeeping_records (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
type TEXT NOT NULL,
amount REAL NOT NULL,
category TEXT NOT NULL,
description TEXT NOT NULL,
date INTEGER NOT NULL
)
""")
Log.d(TAG, "Migration completed successfully")
} catch (e: Exception) {
Log.e(TAG, "Error during migration", e)
throw e
}
}
}
private suspend fun populateDefaultCategories(dao: BookkeepingDao) {
try {
Log.d(TAG, "Starting to populate default categories")
// 支出类别
listOf(
"餐饮",
"交通",
"购物",
"娱乐",
"医疗",
"住房",
"其他支出"
).forEach { name ->
try {
dao.insertCategory(Category(name = name, type = TransactionType.EXPENSE))
Log.d(TAG, "Added expense category: $name")
} catch (e: Exception) {
Log.e(TAG, "Error adding expense category: $name", e)
}
}
// 收入类别
listOf(
"工资",
"奖金",
"投资",
"其他收入"
).forEach { name ->
try {
dao.insertCategory(Category(name = name, type = TransactionType.INCOME))
Log.d(TAG, "Added income category: $name")
} catch (e: Exception) {
Log.e(TAG, "Error adding income category: $name", e)
}
}
Log.d(TAG, "Finished populating default categories")
} catch (e: Exception) {
Log.e(TAG, "Error during category population", e)
}
}
fun getDatabase(context: Context): BookkeepingDatabase {
return Instance ?: synchronized(this) {
try {
Log.d(TAG, "Creating new database instance")
val instance = Room.databaseBuilder(
context.applicationContext,
BookkeepingDatabase::class.java,
"bookkeeping_database"
)
.addCallback(object : RoomDatabase.Callback() {
override fun onCreate(db: SupportSQLiteDatabase) {
super.onCreate(db)
Log.d(TAG, "Database created, initializing default categories")
CoroutineScope(Dispatchers.IO).launch {
try {
Instance?.let { database ->
populateDefaultCategories(database.bookkeepingDao())
}
} catch (e: Exception) {
Log.e(TAG, "Error in onCreate callback", e)
}
}
}
})
.addMigrations(MIGRATION_1_2)
.fallbackToDestructiveMigration() // 如果迁移失败,允许重建数据库
.build()
Instance = instance
Log.d(TAG, "Database instance created successfully")
instance
} catch (e: Exception) {
Log.e(TAG, "Error creating database", e)
throw e
}
}
}
}
}

View File

@@ -0,0 +1,21 @@
package com.yovinchen.bookkeeping.data
import androidx.room.TypeConverter
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
class Converters {
private val formatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME
@TypeConverter
fun fromTimestamp(value: String?): LocalDateTime? {
return value?.let {
return LocalDateTime.parse(it, formatter)
}
}
@TypeConverter
fun dateToTimestamp(date: LocalDateTime?): String? {
return date?.format(formatter)
}
}

View File

@@ -0,0 +1,16 @@
package com.yovinchen.bookkeeping.data
import androidx.room.Entity
import androidx.room.PrimaryKey
import java.time.LocalDateTime
@Entity(tableName = "records")
data class Record(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
val amount: Double,
val category: String,
val description: String,
val dateTime: LocalDateTime = LocalDateTime.now(),
val isExpense: Boolean = true
)

View File

@@ -0,0 +1,29 @@
package com.yovinchen.bookkeeping.data
import androidx.room.*
import kotlinx.coroutines.flow.Flow
import java.time.LocalDateTime
@Dao
interface RecordDao {
@Query("SELECT * FROM records ORDER BY dateTime DESC")
fun getAllRecords(): Flow<List<Record>>
@Query("SELECT * FROM records WHERE dateTime BETWEEN :startDate AND :endDate ORDER BY dateTime DESC")
fun getRecordsByDateRange(startDate: LocalDateTime, endDate: LocalDateTime): Flow<List<Record>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertRecord(record: Record)
@Update
suspend fun updateRecord(record: Record)
@Delete
suspend fun deleteRecord(record: Record)
@Query("SELECT SUM(amount) FROM records WHERE isExpense = :isExpense AND dateTime BETWEEN :startDate AND :endDate")
fun getTotalAmountByType(isExpense: Boolean, startDate: LocalDateTime, endDate: LocalDateTime): Flow<Double?>
@Query("SELECT * FROM records WHERE category = :category ORDER BY dateTime DESC")
fun getRecordsByCategory(category: String): Flow<List<Record>>
}

View File

@@ -0,0 +1,23 @@
package com.yovinchen.bookkeeping.data
import kotlinx.coroutines.flow.Flow
import java.time.LocalDateTime
class RecordRepository(private val recordDao: RecordDao) {
fun getAllRecords(): Flow<List<Record>> = recordDao.getAllRecords()
fun getRecordsByDateRange(startDate: LocalDateTime, endDate: LocalDateTime): Flow<List<Record>> =
recordDao.getRecordsByDateRange(startDate, endDate)
suspend fun insertRecord(record: Record) = recordDao.insertRecord(record)
suspend fun updateRecord(record: Record) = recordDao.updateRecord(record)
suspend fun deleteRecord(record: Record) = recordDao.deleteRecord(record)
fun getTotalAmountByType(isExpense: Boolean, startDate: LocalDateTime, endDate: LocalDateTime): Flow<Double?> =
recordDao.getTotalAmountByType(isExpense, startDate, endDate)
fun getRecordsByCategory(category: String): Flow<List<Record>> =
recordDao.getRecordsByCategory(category)
}

View File

@@ -0,0 +1,45 @@
package com.yovinchen.bookkeeping.model
import androidx.room.Entity
import androidx.room.PrimaryKey
import androidx.room.TypeConverter
import androidx.room.TypeConverters
import java.util.Date
enum class TransactionType {
INCOME, EXPENSE
}
class Converters {
@TypeConverter
fun fromTimestamp(value: Long?): Date? {
return value?.let { Date(it) }
}
@TypeConverter
fun dateToTimestamp(date: Date?): Long? {
return date?.time
}
@TypeConverter
fun fromTransactionType(value: String): TransactionType {
return enumValueOf<TransactionType>(value)
}
@TypeConverter
fun transactionTypeToString(type: TransactionType): String {
return type.name
}
}
@Entity(tableName = "bookkeeping_records")
@TypeConverters(Converters::class)
data class BookkeepingRecord(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
val amount: Double,
val type: TransactionType,
val category: String,
val description: String,
val date: Date
)

View File

@@ -0,0 +1,12 @@
package com.yovinchen.bookkeeping.model
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "categories")
data class Category(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
val name: String,
val type: TransactionType
)

View File

@@ -0,0 +1,10 @@
package com.yovinchen.bookkeeping.model
import androidx.compose.ui.graphics.Color
sealed class ThemeMode {
object FOLLOW_SYSTEM : ThemeMode()
object LIGHT : ThemeMode()
object DARK : ThemeMode()
data class CUSTOM(val primaryColor: Color) : ThemeMode()
}

View File

@@ -0,0 +1,162 @@
package com.yovinchen.bookkeeping.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.unit.dp
val predefinedColors = listOf(
Color(0xFF6200EE), // 默认紫色
Color(0xFF3700B3),
Color(0xFF03DAC6),
Color(0xFF018786),
Color(0xFFE91E63), // Pink
Color(0xFFF44336), // Red
Color(0xFFFF9800), // Orange
Color(0xFFFFEB3B), // Yellow
Color(0xFF4CAF50), // Green
Color(0xFF2196F3), // Blue
Color(0xFF9C27B0), // Purple
Color(0xFF795548), // Brown
)
@Composable
fun ColorPicker(
selectedColor: Color,
onColorSelected: (Color) -> Unit
) {
var showCustomColorPicker by remember { mutableStateOf(false) }
Column(modifier = Modifier.padding(16.dp)) {
// 预定义颜色网格
LazyVerticalGrid(
columns = GridCells.Fixed(6),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(bottom = 16.dp)
) {
items(predefinedColors) { color ->
ColorItem(
color = color,
isSelected = selectedColor == color,
onClick = { onColorSelected(color) }
)
}
}
// 自定义颜色按钮
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { showCustomColorPicker = !showCustomColorPicker }
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "自定义颜色",
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f)
)
}
// 自定义颜色选择器
if (showCustomColorPicker) {
CustomColorPicker(
initialColor = selectedColor,
onColorSelected = onColorSelected
)
}
}
}
@Composable
private fun ColorItem(
color: Color,
isSelected: Boolean,
onClick: () -> Unit
) {
Box(
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(color)
.clickable(onClick = onClick)
.then(
if (isSelected) {
Modifier.border(2.dp, MaterialTheme.colorScheme.onSurface, CircleShape)
} else {
Modifier
}
),
contentAlignment = Alignment.Center
) {
if (isSelected) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Selected",
tint = if (color.luminance() > 0.5f) Color.Black else Color.White
)
}
}
}
@Composable
private fun CustomColorPicker(
initialColor: Color,
onColorSelected: (Color) -> Unit
) {
var red by remember { mutableStateOf(initialColor.red) }
var green by remember { mutableStateOf(initialColor.green) }
var blue by remember { mutableStateOf(initialColor.blue) }
Column(modifier = Modifier.padding(8.dp)) {
ColorSlider("红色", red) { red = it }
ColorSlider("绿色", green) { green = it }
ColorSlider("蓝色", blue) { blue = it }
// 预览颜色
val currentColor = Color(red, green, blue)
Box(
modifier = Modifier
.padding(top = 16.dp)
.size(48.dp)
.clip(CircleShape)
.background(currentColor)
.clickable { onColorSelected(currentColor) }
)
}
}
@Composable
private fun ColorSlider(
label: String,
value: Float,
onValueChange: (Float) -> Unit
) {
Column {
Text(text = label, style = MaterialTheme.typography.bodyMedium)
Slider(
value = value,
onValueChange = onValueChange,
valueRange = 0f..1f,
modifier = Modifier.padding(horizontal = 8.dp)
)
}
}

View File

@@ -0,0 +1,150 @@
package com.yovinchen.bookkeeping.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DateTimePicker(
selectedDateTime: LocalDateTime,
onDateTimeSelected: (LocalDateTime) -> Unit,
modifier: Modifier = Modifier
) {
var showDatePicker by remember { mutableStateOf(false) }
var showTimePicker by remember { mutableStateOf(false) }
val dateFormatter = remember { DateTimeFormatter.ofPattern("yyyy年MM月dd日") }
val timeFormatter = remember { DateTimeFormatter.ofPattern("HH:mm") }
Column(modifier = modifier) {
// 日期选择
OutlinedCard(
modifier = Modifier
.fillMaxWidth()
.clickable { showDatePicker = true }
) {
Text(
text = selectedDateTime.format(dateFormatter),
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.bodyLarge
)
}
Spacer(modifier = Modifier.height(8.dp))
// 时间选择
OutlinedCard(
modifier = Modifier
.fillMaxWidth()
.clickable { showTimePicker = true }
) {
Text(
text = selectedDateTime.format(timeFormatter),
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.bodyLarge
)
}
}
// 日期选择器对话框
if (showDatePicker) {
val datePickerState = rememberDatePickerState(
initialSelectedDateMillis = selectedDateTime
.toLocalDate()
.atStartOfDay()
.toInstant(java.time.ZoneOffset.UTC)
.toEpochMilli()
)
DatePickerDialog(
onDismissRequest = { showDatePicker = false },
confirmButton = {
TextButton(
onClick = {
datePickerState.selectedDateMillis?.let { millis ->
val newDate = java.time.Instant.ofEpochMilli(millis)
.atZone(java.time.ZoneOffset.UTC)
.toLocalDate()
val newDateTime = newDate.atTime(
selectedDateTime.hour,
selectedDateTime.minute
)
onDateTimeSelected(newDateTime)
}
showDatePicker = false
}
) {
Text("确定")
}
},
dismissButton = {
TextButton(onClick = { showDatePicker = false }) {
Text("取消")
}
}
) {
DatePicker(
state = datePickerState,
showModeToggle = false,
modifier = Modifier.padding(16.dp)
)
}
}
// 时间选择器对话框
if (showTimePicker) {
val timePickerState = rememberTimePickerState(
initialHour = selectedDateTime.hour,
initialMinute = selectedDateTime.minute
)
TimePickerDialog(
onDismissRequest = { showTimePicker = false },
confirmButton = {
TextButton(
onClick = {
val newDateTime = selectedDateTime
.withHour(timePickerState.hour)
.withMinute(timePickerState.minute)
onDateTimeSelected(newDateTime)
showTimePicker = false
}
) {
Text("确定")
}
},
dismissButton = {
TextButton(onClick = { showTimePicker = false }) {
Text("取消")
}
}
) {
TimePicker(
state = timePickerState,
modifier = Modifier.padding(16.dp)
)
}
}
}
@Composable
private fun TimePickerDialog(
onDismissRequest: () -> Unit,
confirmButton: @Composable () -> Unit,
dismissButton: @Composable () -> Unit,
content: @Composable () -> Unit
) {
AlertDialog(
onDismissRequest = onDismissRequest,
confirmButton = confirmButton,
dismissButton = dismissButton,
text = { content() }
)
}

View File

@@ -0,0 +1,166 @@
package com.yovinchen.bookkeeping.ui.dialog
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.yovinchen.bookkeeping.model.Category
import com.yovinchen.bookkeeping.model.TransactionType
import com.yovinchen.bookkeeping.ui.components.DateTimePicker
import java.time.LocalDateTime
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddRecordDialog(
onDismiss: () -> Unit,
onConfirm: (TransactionType, Double, String, String) -> Unit,
categories: List<Category>,
selectedType: TransactionType,
onTypeChange: (TransactionType) -> Unit,
selectedDateTime: LocalDateTime,
onDateTimeSelected: (LocalDateTime) -> Unit
) {
var amount by remember { mutableStateOf("") }
var selectedCategory by remember { mutableStateOf<Category?>(null) }
var description by remember { mutableStateOf("") }
var expanded by remember { mutableStateOf(false) }
// 根据当前选择的类型过滤类别
val filteredCategories = categories.filter { it.type == selectedType }
Dialog(onDismissRequest = onDismiss) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text(
text = "添加记录",
style = MaterialTheme.typography.titleLarge
)
Spacer(modifier = Modifier.height(16.dp))
// 类型选择
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
FilterChip(
selected = selectedType == TransactionType.EXPENSE,
onClick = {
onTypeChange(TransactionType.EXPENSE)
selectedCategory = null
},
label = { Text("支出") }
)
FilterChip(
selected = selectedType == TransactionType.INCOME,
onClick = {
onTypeChange(TransactionType.INCOME)
selectedCategory = null
},
label = { Text("收入") }
)
}
Spacer(modifier = Modifier.height(16.dp))
// 日期时间选择
DateTimePicker(
selectedDateTime = selectedDateTime,
onDateTimeSelected = onDateTimeSelected,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
// 金额输入
OutlinedTextField(
value = amount,
onValueChange = { amount = it },
label = { Text("金额") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
// 类别选择
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = it }
) {
OutlinedTextField(
value = selectedCategory?.name ?: "",
onValueChange = {},
readOnly = true,
label = { Text("类别") },
modifier = Modifier
.fillMaxWidth()
.menuAnchor()
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
filteredCategories.forEach { category ->
DropdownMenuItem(
text = { Text(category.name) },
onClick = {
selectedCategory = category
expanded = false
}
)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
// 描述输入
OutlinedTextField(
value = description,
onValueChange = { description = it },
label = { Text("描述") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
// 按钮
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
TextButton(onClick = onDismiss) {
Text("取消")
}
Spacer(modifier = Modifier.width(8.dp))
Button(
onClick = {
val amountValue = amount.toDoubleOrNull() ?: 0.0
selectedCategory?.let { category ->
onConfirm(selectedType, amountValue, category.name, description)
onDismiss()
}
},
enabled = amount.isNotEmpty() && selectedCategory != null
) {
Text("确定")
}
}
}
}
}
}

View File

@@ -0,0 +1,256 @@
package com.yovinchen.bookkeeping.ui.dialog
import android.util.Log
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.yovinchen.bookkeeping.model.Category
import com.yovinchen.bookkeeping.model.TransactionType
private const val TAG = "CategoryManagementDialog"
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CategoryManagementDialog(
onDismiss: () -> Unit,
categories: List<Category>,
onAddCategory: (String, TransactionType) -> Unit,
onDeleteCategory: (Category) -> Unit,
onUpdateCategory: (Category, String) -> Unit,
selectedType: TransactionType,
onTypeChange: (TransactionType) -> Unit
) {
var newCategoryName by remember { mutableStateOf("") }
var showDialog by remember { mutableStateOf(true) }
var showDeleteDialog by remember { mutableStateOf(false) }
var showEditDialog by remember { mutableStateOf(false) }
var selectedCategory: Category? by remember { mutableStateOf(null) }
var editingCategoryName by remember { mutableStateOf("") }
val filteredCategories = categories.filter { it.type == selectedType }
Log.d(TAG, "Dialog state - showDialog: $showDialog, showDeleteDialog: $showDeleteDialog")
Log.d(TAG, "Selected category: ${selectedCategory?.name}")
if (showDialog) {
AlertDialog(
onDismissRequest = {
Log.d(TAG, "Main dialog dismiss requested")
showDialog = false
onDismiss()
},
title = { Text("类别管理") },
text = {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
) {
// 类型选择
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
FilterChip(
selected = selectedType == TransactionType.EXPENSE,
onClick = {
Log.d(TAG, "Switching to EXPENSE type")
onTypeChange(TransactionType.EXPENSE)
},
label = { Text("支出") }
)
FilterChip(
selected = selectedType == TransactionType.INCOME,
onClick = {
Log.d(TAG, "Switching to INCOME type")
onTypeChange(TransactionType.INCOME)
},
label = { Text("收入") }
)
}
Spacer(modifier = Modifier.height(16.dp))
// 添加新类别
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
value = newCategoryName,
onValueChange = { newCategoryName = it },
label = { Text("新类别名称") },
modifier = Modifier.weight(1f)
)
Spacer(modifier = Modifier.width(8.dp))
IconButton(
onClick = {
if (newCategoryName.isNotBlank()) {
Log.d(TAG, "Adding new category: $newCategoryName")
onAddCategory(newCategoryName, selectedType)
newCategoryName = ""
}
}
) {
Icon(Icons.Default.Add, contentDescription = "添加类别")
}
}
Spacer(modifier = Modifier.height(16.dp))
// 类别列表
LazyColumn(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(filteredCategories) { category ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = category.name,
modifier = Modifier
.weight(1f)
.clickable {
selectedCategory = category
editingCategoryName = category.name
showEditDialog = true
}
)
IconButton(
onClick = {
Log.d(TAG, "Selected category for deletion: ${category.name}")
selectedCategory = category
showDeleteDialog = true
}
) {
Icon(Icons.Default.Delete, contentDescription = "删除类别")
}
}
}
}
}
},
confirmButton = {
TextButton(
onClick = {
Log.d(TAG, "Main dialog confirmed")
showDialog = false
onDismiss()
}
) {
Text("完成")
}
}
)
}
// 删除确认对话框
if (showDeleteDialog && selectedCategory != null) {
AlertDialog(
onDismissRequest = {
Log.d(TAG, "Delete dialog dismissed")
showDeleteDialog = false
selectedCategory = null
},
title = { Text("确认删除") },
text = {
Text(
text = buildString {
append("确定要删除类别 ")
append(selectedCategory?.name ?: "")
append(" 吗?")
}
)
},
confirmButton = {
TextButton(
onClick = {
try {
selectedCategory?.let { category ->
Log.d(TAG, "Confirming deletion of category: ${category.name}")
onDeleteCategory(category)
}
} catch (e: Exception) {
Log.e(TAG, "Error during category deletion callback", e)
e.printStackTrace()
} finally {
showDeleteDialog = false
selectedCategory = null
}
}
) {
Text("确定")
}
},
dismissButton = {
TextButton(
onClick = {
Log.d(TAG, "Canceling deletion")
showDeleteDialog = false
selectedCategory = null
}
) {
Text("取消")
}
}
)
}
// 编辑类别对话框
if (showEditDialog && selectedCategory != null) {
AlertDialog(
onDismissRequest = {
showEditDialog = false
selectedCategory = null
editingCategoryName = ""
},
title = { Text("编辑类别") },
text = {
OutlinedTextField(
value = editingCategoryName,
onValueChange = { editingCategoryName = it },
label = { Text("类别名称") }
)
},
confirmButton = {
TextButton(
onClick = {
if (editingCategoryName.isNotBlank()) {
selectedCategory?.let { category ->
onUpdateCategory(category, editingCategoryName)
}
}
showEditDialog = false
selectedCategory = null
editingCategoryName = ""
}
) {
Text("确定")
}
},
dismissButton = {
TextButton(
onClick = {
showEditDialog = false
selectedCategory = null
editingCategoryName = ""
}
) {
Text("取消")
}
}
)
}
}

View File

@@ -0,0 +1,97 @@
@file:OptIn(ExperimentalMaterial3Api::class)
package com.yovinchen.bookkeeping.ui.dialog
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.yovinchen.bookkeeping.model.BookkeepingRecord
import com.yovinchen.bookkeeping.model.Category
import java.util.Date
@Composable
fun RecordEditDialog(
record: BookkeepingRecord,
categories: List<Category>,
onDismiss: () -> Unit,
onConfirm: (BookkeepingRecord) -> Unit
) {
var amount by remember { mutableStateOf(record.amount.toString()) }
var selectedCategory by remember { mutableStateOf(record.category) }
var description by remember { mutableStateOf(record.description) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("编辑记录") },
text = {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedTextField(
value = amount,
onValueChange = { amount = it },
label = { Text("金额") },
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = description,
onValueChange = { description = it },
label = { Text("描述") },
modifier = Modifier.fillMaxWidth()
)
ExposedDropdownMenuBox(
expanded = false,
onExpandedChange = {},
) {
OutlinedTextField(
value = selectedCategory,
onValueChange = {},
readOnly = true,
label = { Text("类别") },
modifier = Modifier.fillMaxWidth()
)
DropdownMenu(
expanded = false,
onDismissRequest = { },
) {
categories.filter { it.type == record.type }.forEach { category ->
DropdownMenuItem(
text = { Text(category.name) },
onClick = { selectedCategory = category.name }
)
}
}
}
}
},
confirmButton = {
TextButton(
onClick = {
val updatedRecord = record.copy(
amount = amount.toDoubleOrNull() ?: record.amount,
category = selectedCategory,
description = description,
date = Date()
)
onConfirm(updatedRecord)
onDismiss()
}
) {
Text("确认")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("取消")
}
}
)
}

View File

@@ -0,0 +1,94 @@
package com.yovinchen.bookkeeping.ui.navigation
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.NavHost
import androidx.compose.ui.graphics.Color
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.yovinchen.bookkeeping.model.ThemeMode
import com.yovinchen.bookkeeping.ui.screen.HomeScreen
import com.yovinchen.bookkeeping.ui.screen.SettingsScreen
sealed class Screen(val route: String, val icon: @Composable () -> Unit, val label: String) {
object Home : Screen(
route = "home",
icon = { Icon(Icons.Default.Home, contentDescription = "主页") },
label = "主页"
)
object Settings : Screen(
route = "settings",
icon = { Icon(Icons.Default.Settings, contentDescription = "设置") },
label = "设置"
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainNavigation(
currentTheme: ThemeMode,
onThemeChange: (ThemeMode) -> Unit
) {
val navController = rememberNavController()
val items = listOf(Screen.Home, Screen.Settings)
Scaffold(
bottomBar = {
NavigationBar(
containerColor = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.onSurface,
) {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
items.forEach { screen ->
NavigationBarItem(
icon = screen.icon,
label = { Text(screen.label) },
selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true,
onClick = {
navController.navigate(screen.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
},
colors = NavigationBarItemDefaults.colors(
selectedIconColor = MaterialTheme.colorScheme.primary,
selectedTextColor = MaterialTheme.colorScheme.primary,
unselectedIconColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
unselectedTextColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
indicatorColor = MaterialTheme.colorScheme.surfaceVariant
)
)
}
}
}
) { paddingValues ->
NavHost(
navController = navController,
startDestination = Screen.Home.route,
modifier = Modifier.padding(paddingValues)
) {
composable(Screen.Home.route) {
HomeScreen()
}
composable(Screen.Settings.route) {
SettingsScreen(
currentTheme = currentTheme,
onThemeChange = onThemeChange
)
}
}
}
}

View File

@@ -0,0 +1,312 @@
package com.yovinchen.bookkeeping.ui.screen
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
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.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.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.text.SimpleDateFormat
import java.util.*
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(
modifier: Modifier = Modifier,
viewModel: HomeViewModel = viewModel()
) {
val records 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()
var showAddDialog by remember { mutableStateOf(false) }
var showCategoryDialog by remember { mutableStateOf(false) }
var selectedRecord by remember { mutableStateOf<BookkeepingRecord?>(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 = "类别管理")
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
// 顶部统计信息
MonthlyStatistics(
totalIncome = totalIncome,
totalExpense = totalExpense,
onIncomeClick = { viewModel.setSelectedRecordType(TransactionType.INCOME) },
onExpenseClick = { viewModel.setSelectedRecordType(TransactionType.EXPENSE) },
selectedType = selectedRecordType,
onClearFilter = { viewModel.setSelectedRecordType(null) }
)
// 记录列表
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(records) { record ->
RecordItem(
record = record,
onClick = { selectedRecord = record },
onDelete = { viewModel.deleteRecord(record) }
)
}
}
}
// 添加记录对话框
if (showAddDialog) {
val selectedDateTime by viewModel.selectedDateTime.collectAsState()
AddRecordDialog(
onDismiss = {
showAddDialog = false
viewModel.resetSelectedDateTime()
},
onConfirm = { type, amount, category, description ->
viewModel.addRecord(type, amount, category, description)
showAddDialog = false
},
categories = categories,
selectedType = selectedType,
onTypeChange = { viewModel.setSelectedCategoryType(it) },
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) }
)
}
// 编辑记录对话框
selectedRecord?.let { record ->
RecordEditDialog(
record = record,
categories = categories,
onDismiss = { selectedRecord = null },
onConfirm = { updatedRecord ->
viewModel.updateRecord(updatedRecord)
selectedRecord = null
}
)
}
}
}
@Composable
fun MonthlyStatistics(
totalIncome: Double,
totalExpense: Double,
onIncomeClick: () -> Unit,
onExpenseClick: () -> Unit,
selectedType: TransactionType?,
onClearFilter: () -> Unit,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier
.fillMaxWidth()
.padding(16.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text(
text = "本月统计",
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(bottom = 8.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)
) {
Text(
text = "收入",
style = MaterialTheme.typography.titleMedium
)
Text(
text = "¥${String.format("%.2f", totalIncome)}",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.primary
)
}
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)
) {
Text(
text = "支出",
style = MaterialTheme.typography.titleMedium
)
Text(
text = "¥${String.format("%.2f", totalExpense)}",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.error
)
}
}
if (selectedType != null) {
TextButton(
onClick = onClearFilter,
modifier = Modifier.align(Alignment.End)
) {
Text("清除筛选")
}
}
}
}
}
@Composable
fun RecordItem(
record: BookkeepingRecord,
onClick: () -> Unit = {},
onDelete: () -> Unit = {},
modifier: Modifier = Modifier
) {
Card(
modifier = modifier
.fillMaxWidth()
.clickable(onClick = onClick),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = record.category,
style = MaterialTheme.typography.titleMedium
)
if (record.description.isNotEmpty()) {
Text(
text = record.description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Text(
text = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault())
.format(record.date),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = if (record.type == TransactionType.EXPENSE) "-" else "+",
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,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(end = 8.dp)
)
IconButton(onClick = onDelete) {
Icon(
Icons.Default.Delete,
contentDescription = "删除",
tint = MaterialTheme.colorScheme.error
)
}
}
}
}
}

View File

@@ -0,0 +1,123 @@
package com.yovinchen.bookkeeping.ui.screen
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.yovinchen.bookkeeping.model.ThemeMode
import com.yovinchen.bookkeeping.ui.components.ColorPicker
import com.yovinchen.bookkeeping.ui.components.predefinedColors
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
currentTheme: ThemeMode,
onThemeChange: (ThemeMode) -> Unit
) {
var showThemeDialog by remember { mutableStateOf(false) }
Column(modifier = Modifier.fillMaxSize()) {
// 主题设置项
ListItem(
headlineContent = { Text("主题设置") },
supportingContent = {
Text(
when (currentTheme) {
is ThemeMode.FOLLOW_SYSTEM -> "跟随系统"
is ThemeMode.LIGHT -> "浅色"
is ThemeMode.DARK -> "深色"
is ThemeMode.CUSTOM -> "自定义颜色"
}
)
},
modifier = Modifier.clickable { showThemeDialog = true }
)
if (showThemeDialog) {
AlertDialog(
onDismissRequest = { showThemeDialog = false },
title = { Text("选择主题") },
text = {
Column {
// 基本主题选项
ThemeOption(
text = "跟随系统",
selected = currentTheme is ThemeMode.FOLLOW_SYSTEM,
onClick = {
onThemeChange(ThemeMode.FOLLOW_SYSTEM)
showThemeDialog = false
}
)
ThemeOption(
text = "浅色",
selected = currentTheme is ThemeMode.LIGHT,
onClick = {
onThemeChange(ThemeMode.LIGHT)
showThemeDialog = false
}
)
ThemeOption(
text = "深色",
selected = currentTheme is ThemeMode.DARK,
onClick = {
onThemeChange(ThemeMode.DARK)
showThemeDialog = false
}
)
Divider(modifier = Modifier.padding(vertical = 8.dp))
// 颜色选择器
Text(
text = "自定义颜色",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(vertical = 8.dp)
)
ColorPicker(
selectedColor = when (currentTheme) {
is ThemeMode.CUSTOM -> currentTheme.primaryColor
else -> predefinedColors[0]
},
onColorSelected = { color ->
onThemeChange(ThemeMode.CUSTOM(color))
showThemeDialog = false
}
)
}
},
confirmButton = {
TextButton(onClick = { showThemeDialog = false }) {
Text("关闭")
}
}
)
}
}
}
@Composable
private fun ThemeOption(
text: String,
selected: Boolean,
onClick: () -> Unit
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(vertical = 8.dp)
) {
RadioButton(
selected = selected,
onClick = onClick
)
Text(text, modifier = Modifier.padding(start = 8.dp))
}
}

View File

@@ -0,0 +1,17 @@
package com.yovinchen.bookkeeping.ui.theme
import androidx.compose.ui.graphics.Color
// Dark Theme Colors
val DarkPrimary = Color(0xFF9B7EE3) // 深紫色
val DarkSecondary = Color(0xFF6C5B7B) // 暗紫灰色
val DarkBackground = Color(0xFF121212) // 深黑色
val DarkSurface = Color(0xFF1E1E1E) // 深灰色
val DarkError = Color(0xFFCF6679) // 深红色
// Light Theme Colors
val LightPrimary = Color(0xFF6200EE) // 亮紫色
val LightSecondary = Color(0xFF8E8E93) // 浅灰色
val LightBackground = Color(0xFFF5F5F5) // 浅灰白色
val LightSurface = Color(0xFFFFFFFF) // 纯白色
val LightError = Color(0xFFB00020) // 红色

View File

@@ -0,0 +1,76 @@
package com.yovinchen.bookkeeping.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
private val DarkColorScheme = darkColorScheme(
primary = DarkPrimary,
secondary = DarkSecondary,
background = DarkBackground,
surface = DarkSurface,
error = DarkError,
onPrimary = Color.White,
onSecondary = Color.White,
onBackground = Color.White,
onSurface = Color.White,
onError = Color.Black
)
private val LightColorScheme = lightColorScheme(
primary = LightPrimary,
secondary = LightSecondary,
background = LightBackground,
surface = LightSurface,
error = LightError,
onPrimary = Color.White,
onSecondary = Color.White,
onBackground = Color.Black,
onSurface = Color.Black,
onError = Color.White
)
@Composable
fun BookkeepingTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
customColorScheme: ColorScheme? = null,
content: @Composable () -> Unit
) {
val colorScheme = when {
customColorScheme != null -> customColorScheme
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
window.statusBarColor = colorScheme.primary.toArgb()
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
}
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}

View File

@@ -0,0 +1,34 @@
package com.yovinchen.bookkeeping.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// Set of Material typography styles to start with
val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
)
/* Other default text styles to override
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
*/
)

View File

@@ -0,0 +1,184 @@
package com.yovinchen.bookkeeping.viewmodel
import android.app.Application
import android.util.Log
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
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.flow.*
import kotlinx.coroutines.launch
import java.time.LocalDateTime
import java.time.ZoneId
import java.util.Date
import java.util.Calendar
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<Double> = _totalIncome.asStateFlow()
private val _totalExpense = MutableStateFlow(0.0)
val totalExpense: StateFlow<Double> = _totalExpense.asStateFlow()
private val _selectedCategoryType = MutableStateFlow(TransactionType.EXPENSE)
val selectedCategoryType: StateFlow<TransactionType> = _selectedCategoryType.asStateFlow()
private val _selectedRecordType = MutableStateFlow<TransactionType?>(null)
val selectedRecordType: StateFlow<TransactionType?> = _selectedRecordType.asStateFlow()
private val _selectedDateTime = MutableStateFlow(LocalDateTime.now())
val selectedDateTime: StateFlow<LocalDateTime> = _selectedDateTime.asStateFlow()
val categories: StateFlow<List<Category>> = _selectedCategoryType
.flatMapLatest { type ->
dao.getCategoriesByType(type)
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
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 }
}
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = emptyList()
)
private val _uiState = MutableStateFlow(UiState())
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
init {
viewModelScope.launch {
records.collect { recordsList ->
updateTotals(recordsList)
}
}
}
private fun updateTotals(records: List<BookkeepingRecord>) {
_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(
amount = amount,
type = type,
category = category,
description = description,
date = Date.from(_selectedDateTime.value.atZone(ZoneId.systemDefault()).toInstant())
)
dao.insertRecord(record)
resetSelectedDateTime()
}
}
fun setSelectedDateTime(dateTime: LocalDateTime) {
_selectedDateTime.value = dateTime
}
fun setSelectedCategoryType(type: TransactionType) {
_selectedCategoryType.value = type
}
fun setSelectedRecordType(type: TransactionType?) {
_selectedRecordType.value = type
}
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)
}
}
fun deleteRecord(record: BookkeepingRecord) {
viewModelScope.launch {
dao.deleteRecord(record)
}
}
// 获取指定日期的记录
fun getRecordsByDate(date: LocalDateTime): Flow<List<BookkeepingRecord>> {
val calendar = Calendar.getInstance().apply {
time = Date.from(date.atZone(ZoneId.systemDefault()).toInstant())
set(Calendar.HOUR_OF_DAY, 0)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}
val startOfDay = calendar.time
calendar.add(Calendar.DAY_OF_MONTH, 1)
val endOfDay = calendar.time
return dao.getRecordsByDateRange(startOfDay, endOfDay)
}
// 获取指定日期范围的记录
fun getRecordsByDateRange(startDate: LocalDateTime, endDate: LocalDateTime): Flow<List<BookkeepingRecord>> {
val start = Date.from(startDate.atZone(ZoneId.systemDefault()).toInstant())
val end = Date.from(endDate.atZone(ZoneId.systemDefault()).toInstant())
return dao.getRecordsByDateRange(start, end)
}
// 获取指定类别的记录
fun getRecordsByCategory(category: String): Flow<List<BookkeepingRecord>> {
return dao.getRecordsByCategory(category)
}
// 获取指定类型的记录
fun getRecordsByType(type: TransactionType): Flow<List<BookkeepingRecord>> {
return dao.getRecordsByType(type)
}
}
data class UiState(
val isAddingRecord: Boolean = false,
val isManagingCategories: Boolean = false
)

View File

@@ -0,0 +1,43 @@
package com.yovinchen.bookkeeping.viewmodel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.yovinchen.bookkeeping.data.AppDatabase
import com.yovinchen.bookkeeping.data.Record
import com.yovinchen.bookkeeping.data.RecordRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import java.time.LocalDateTime
class RecordViewModel(application: Application) : AndroidViewModel(application) {
private val repository: RecordRepository
val allRecords: Flow<List<Record>>
init {
val recordDao = AppDatabase.getDatabase(application).recordDao()
repository = RecordRepository(recordDao)
allRecords = repository.getAllRecords()
}
fun getRecordsByDateRange(startDate: LocalDateTime, endDate: LocalDateTime): Flow<List<Record>> =
repository.getRecordsByDateRange(startDate, endDate)
fun insertRecord(record: Record) = viewModelScope.launch {
repository.insertRecord(record)
}
fun updateRecord(record: Record) = viewModelScope.launch {
repository.updateRecord(record)
}
fun deleteRecord(record: Record) = viewModelScope.launch {
repository.deleteRecord(record)
}
fun getTotalAmountByType(isExpense: Boolean, startDate: LocalDateTime, endDate: LocalDateTime): Flow<Double?> =
repository.getTotalAmountByType(isExpense, startDate, endDate)
fun getRecordsByCategory(category: String): Flow<List<Record>> =
repository.getRecordsByCategory(category)
}

View File

@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>

View File

@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>

View File

@@ -0,0 +1,3 @@
<resources>
<string name="app_name">bookkeeping</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Bookkeeping" parent="android:Theme.Material.Light.NoActionBar" />
</resources>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older that API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>

View File

@@ -0,0 +1,17 @@
package com.yovinchen.bookkeeping
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}