commit bb619bed78180646a72610aac3adbd805a977b90 Author: yovinchen Date: Tue Nov 26 22:47:37 2024 +0800 基础构建 diff --git a/.idea/appInsightsSettings.xml b/.idea/appInsightsSettings.xml new file mode 100644 index 0000000..371f2e2 --- /dev/null +++ b/.idea/appInsightsSettings.xml @@ -0,0 +1,26 @@ + + + + + + \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..b86273d --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml new file mode 100644 index 0000000..b268ef3 --- /dev/null +++ b/.idea/deploymentTargetSelector.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..ae733f1 --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,19 @@ + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..cde3e19 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,57 @@ + + + + \ No newline at end of file diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml new file mode 100644 index 0000000..fdf8d99 --- /dev/null +++ b/.idea/kotlinc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/migrations.xml b/.idea/migrations.xml new file mode 100644 index 0000000..f8051a6 --- /dev/null +++ b/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..74dd639 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml new file mode 100644 index 0000000..16660f1 --- /dev/null +++ b/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..6ba06c7 --- /dev/null +++ b/app/build.gradle.kts @@ -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") +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -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 \ No newline at end of file diff --git a/app/src/androidTest/java/com/yovinchen/bookkeeping/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/yovinchen/bookkeeping/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..69da562 --- /dev/null +++ b/app/src/androidTest/java/com/yovinchen/bookkeeping/ExampleInstrumentedTest.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..b9dd079 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/yovinchen/bookkeeping/MainActivity.kt b/app/src/main/java/com/yovinchen/bookkeeping/MainActivity.kt new file mode 100644 index 0000000..a03c616 --- /dev/null +++ b/app/src/main/java/com/yovinchen/bookkeeping/MainActivity.kt @@ -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.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() +} \ No newline at end of file diff --git a/app/src/main/java/com/yovinchen/bookkeeping/data/AppDatabase.kt b/app/src/main/java/com/yovinchen/bookkeeping/data/AppDatabase.kt new file mode 100644 index 0000000..f76f85a --- /dev/null +++ b/app/src/main/java/com/yovinchen/bookkeeping/data/AppDatabase.kt @@ -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 + } + } + } +} diff --git a/app/src/main/java/com/yovinchen/bookkeeping/data/BookkeepingDao.kt b/app/src/main/java/com/yovinchen/bookkeeping/data/BookkeepingDao.kt new file mode 100644 index 0000000..9bd65f1 --- /dev/null +++ b/app/src/main/java/com/yovinchen/bookkeeping/data/BookkeepingDao.kt @@ -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> + + @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> + + @Query("SELECT * FROM bookkeeping_records WHERE type = 'EXPENSE'") + fun getAllExpense(): Flow> + + // 按日期查询 + @Query("SELECT * FROM bookkeeping_records WHERE date >= :startOfDay AND date < :endOfDay ORDER BY date DESC") + fun getRecordsByDate(startOfDay: Date, endOfDay: Date): Flow> + + // 按日期范围查询 + @Query("SELECT * FROM bookkeeping_records WHERE date BETWEEN :startDate AND :endDate ORDER BY date DESC") + fun getRecordsByDateRange(startDate: Date, endDate: Date): Flow> + + // 按类别查询 + @Query("SELECT * FROM bookkeeping_records WHERE category = :category ORDER BY date DESC") + fun getRecordsByCategory(category: String): Flow> + + // 按类型查询 + @Query("SELECT * FROM bookkeeping_records WHERE type = :type ORDER BY date DESC") + fun getRecordsByType(type: TransactionType): Flow> + + // Category related queries + @Query("SELECT * FROM categories WHERE type = :type ORDER BY name ASC") + fun getCategoriesByType(type: TransactionType): Flow> + + @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) +} diff --git a/app/src/main/java/com/yovinchen/bookkeeping/data/BookkeepingDatabase.kt b/app/src/main/java/com/yovinchen/bookkeeping/data/BookkeepingDatabase.kt new file mode 100644 index 0000000..e84b134 --- /dev/null +++ b/app/src/main/java/com/yovinchen/bookkeeping/data/BookkeepingDatabase.kt @@ -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 + } + } + } + } +} diff --git a/app/src/main/java/com/yovinchen/bookkeeping/data/Converters.kt b/app/src/main/java/com/yovinchen/bookkeeping/data/Converters.kt new file mode 100644 index 0000000..84c5bea --- /dev/null +++ b/app/src/main/java/com/yovinchen/bookkeeping/data/Converters.kt @@ -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) + } +} diff --git a/app/src/main/java/com/yovinchen/bookkeeping/data/Record.kt b/app/src/main/java/com/yovinchen/bookkeeping/data/Record.kt new file mode 100644 index 0000000..d37049d --- /dev/null +++ b/app/src/main/java/com/yovinchen/bookkeeping/data/Record.kt @@ -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 +) diff --git a/app/src/main/java/com/yovinchen/bookkeeping/data/RecordDao.kt b/app/src/main/java/com/yovinchen/bookkeeping/data/RecordDao.kt new file mode 100644 index 0000000..58ba7a8 --- /dev/null +++ b/app/src/main/java/com/yovinchen/bookkeeping/data/RecordDao.kt @@ -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> + + @Query("SELECT * FROM records WHERE dateTime BETWEEN :startDate AND :endDate ORDER BY dateTime DESC") + fun getRecordsByDateRange(startDate: LocalDateTime, endDate: LocalDateTime): Flow> + + @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 + + @Query("SELECT * FROM records WHERE category = :category ORDER BY dateTime DESC") + fun getRecordsByCategory(category: String): Flow> +} diff --git a/app/src/main/java/com/yovinchen/bookkeeping/data/RecordRepository.kt b/app/src/main/java/com/yovinchen/bookkeeping/data/RecordRepository.kt new file mode 100644 index 0000000..ec945a0 --- /dev/null +++ b/app/src/main/java/com/yovinchen/bookkeeping/data/RecordRepository.kt @@ -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> = recordDao.getAllRecords() + + fun getRecordsByDateRange(startDate: LocalDateTime, endDate: LocalDateTime): Flow> = + 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 = + recordDao.getTotalAmountByType(isExpense, startDate, endDate) + + fun getRecordsByCategory(category: String): Flow> = + recordDao.getRecordsByCategory(category) +} diff --git a/app/src/main/java/com/yovinchen/bookkeeping/model/BookkeepingRecord.kt b/app/src/main/java/com/yovinchen/bookkeeping/model/BookkeepingRecord.kt new file mode 100644 index 0000000..43b5e86 --- /dev/null +++ b/app/src/main/java/com/yovinchen/bookkeeping/model/BookkeepingRecord.kt @@ -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(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 +) diff --git a/app/src/main/java/com/yovinchen/bookkeeping/model/Category.kt b/app/src/main/java/com/yovinchen/bookkeeping/model/Category.kt new file mode 100644 index 0000000..0c53f7a --- /dev/null +++ b/app/src/main/java/com/yovinchen/bookkeeping/model/Category.kt @@ -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 +) diff --git a/app/src/main/java/com/yovinchen/bookkeeping/model/ThemeMode.kt b/app/src/main/java/com/yovinchen/bookkeeping/model/ThemeMode.kt new file mode 100644 index 0000000..c3aeb5b --- /dev/null +++ b/app/src/main/java/com/yovinchen/bookkeeping/model/ThemeMode.kt @@ -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() +} diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/components/ColorPicker.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/components/ColorPicker.kt new file mode 100644 index 0000000..63ea8ec --- /dev/null +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/components/ColorPicker.kt @@ -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) + ) + } +} diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/components/DateTimePicker.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/components/DateTimePicker.kt new file mode 100644 index 0000000..55be6f3 --- /dev/null +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/components/DateTimePicker.kt @@ -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() } + ) +} diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/dialog/AddRecordDialog.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/dialog/AddRecordDialog.kt new file mode 100644 index 0000000..895b540 --- /dev/null +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/dialog/AddRecordDialog.kt @@ -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, + selectedType: TransactionType, + onTypeChange: (TransactionType) -> Unit, + selectedDateTime: LocalDateTime, + onDateTimeSelected: (LocalDateTime) -> Unit +) { + var amount by remember { mutableStateOf("") } + var selectedCategory by remember { mutableStateOf(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("确定") + } + } + } + } + } +} diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/dialog/CategoryManagementDialog.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/dialog/CategoryManagementDialog.kt new file mode 100644 index 0000000..5a3f5d3 --- /dev/null +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/dialog/CategoryManagementDialog.kt @@ -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, + 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("取消") + } + } + ) + } +} diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/dialog/RecordEditDialog.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/dialog/RecordEditDialog.kt new file mode 100644 index 0000000..0094fd7 --- /dev/null +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/dialog/RecordEditDialog.kt @@ -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, + 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("取消") + } + } + ) +} diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/navigation/MainNavigation.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/navigation/MainNavigation.kt new file mode 100644 index 0000000..6278042 --- /dev/null +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/navigation/MainNavigation.kt @@ -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 + ) + } + } + } +} diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/HomeScreen.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/HomeScreen.kt new file mode 100644 index 0000000..6c80531 --- /dev/null +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/HomeScreen.kt @@ -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(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 + ) + } + } + } + } +} diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/SettingsScreen.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/SettingsScreen.kt new file mode 100644 index 0000000..4ede4b0 --- /dev/null +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/SettingsScreen.kt @@ -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)) + } +} diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/theme/Color.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/theme/Color.kt new file mode 100644 index 0000000..dcab4be --- /dev/null +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/theme/Color.kt @@ -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) // 红色 \ No newline at end of file diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/theme/Theme.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/theme/Theme.kt new file mode 100644 index 0000000..f734c3b --- /dev/null +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/theme/Theme.kt @@ -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 + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/theme/Type.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/theme/Type.kt new file mode 100644 index 0000000..995d114 --- /dev/null +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/theme/Type.kt @@ -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 + ) + */ +) \ No newline at end of file diff --git a/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/HomeViewModel.kt b/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/HomeViewModel.kt new file mode 100644 index 0000000..781f4d2 --- /dev/null +++ b/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/HomeViewModel.kt @@ -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 = _totalIncome.asStateFlow() + + private val _totalExpense = MutableStateFlow(0.0) + val totalExpense: StateFlow = _totalExpense.asStateFlow() + + private val _selectedCategoryType = MutableStateFlow(TransactionType.EXPENSE) + val selectedCategoryType: StateFlow = _selectedCategoryType.asStateFlow() + + private val _selectedRecordType = MutableStateFlow(null) + val selectedRecordType: StateFlow = _selectedRecordType.asStateFlow() + + private val _selectedDateTime = MutableStateFlow(LocalDateTime.now()) + val selectedDateTime: StateFlow = _selectedDateTime.asStateFlow() + + val categories: StateFlow> = _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.asStateFlow() + + init { + viewModelScope.launch { + records.collect { recordsList -> + updateTotals(recordsList) + } + } + } + + private fun updateTotals(records: List) { + _totalIncome.value = records + .filter { it.type == TransactionType.INCOME } + .sumOf { it.amount } + + _totalExpense.value = records + .filter { it.type == TransactionType.EXPENSE } + .sumOf { it.amount } + } + + fun addRecord(type: TransactionType, amount: Double, category: String, description: String) { + viewModelScope.launch { + val record = BookkeepingRecord( + 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> { + 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> { + 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> { + return dao.getRecordsByCategory(category) + } + + // 获取指定类型的记录 + fun getRecordsByType(type: TransactionType): Flow> { + return dao.getRecordsByType(type) + } +} + +data class UiState( + val isAddingRecord: Boolean = false, + val isManagingCategories: Boolean = false +) diff --git a/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/RecordViewModel.kt b/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/RecordViewModel.kt new file mode 100644 index 0000000..dce1fa3 --- /dev/null +++ b/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/RecordViewModel.kt @@ -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> + + init { + val recordDao = AppDatabase.getDatabase(application).recordDao() + repository = RecordRepository(recordDao) + allRecords = repository.getAllRecords() + } + + fun getRecordsByDateRange(startDate: LocalDateTime, endDate: LocalDateTime): Flow> = + 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 = + repository.getTotalAmountByType(isExpense, startDate, endDate) + + fun getRecordsByCategory(category: String): Flow> = + repository.getRecordsByCategory(category) +} diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/app/src/main/res/mipmap-anydpi/ic_launcher.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..c209e78 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b2dfe3d Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..4f0f1d6 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..62b611d Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..948a307 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1b9a695 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..28d4b77 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9287f50 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..aa7d642 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9126ae3 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..087abc2 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + bookkeeping + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..b3a2f57 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +