基础构建

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

View File

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AppInsightsSettings">
<option name="tabSettings">
<map>
<entry key="Firebase Crashlytics">
<value>
<InsightsFilterSettings>
<option name="connection">
<ConnectionSetting>
<option name="appId" value="PLACEHOLDER" />
<option name="mobileSdkAppId" value="" />
<option name="projectId" value="" />
<option name="projectNumber" value="" />
</ConnectionSetting>
</option>
<option name="signal" value="SIGNAL_UNSPECIFIED" />
<option name="timeIntervalDays" value="THIRTY_DAYS" />
<option name="visibilityType" value="ALL" />
</InsightsFilterSettings>
</value>
</entry>
</map>
</option>
</component>
</project>

6
.idea/compiler.xml Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" />
</component>
</project>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
</SelectionState>
</selectionStates>
</component>
</project>

19
.idea/gradle.xml Normal file
View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
</set>
</option>
<option name="resolveExternalAnnotations" value="false" />
</GradleProjectSettings>
</option>
</component>
</project>

View File

@ -0,0 +1,57 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="ComposePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewDeviceShouldUseNewSpec" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
</profile>
</component>

6
.idea/kotlinc.xml Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="version" value="1.9.0" />
</component>
</project>

10
.idea/migrations.xml Normal file
View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectMigrations">
<option name="MigrateToGradleLocalJavaHome">
<set>
<option value="$PROJECT_DIR$" />
</set>
</option>
</component>
</project>

10
.idea/misc.xml Normal file
View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RunConfigurationProducerService">
<option name="ignoredProducers">
<set>
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
</set>
</option>
</component>
</project>

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)
}
}

7
build.gradle.kts Normal file
View File

@ -0,0 +1,7 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
id("com.android.application") version "8.2.2" apply false
id("com.android.library") version "8.2.2" apply false
id("org.jetbrains.kotlin.android") version "1.9.0" apply false
id("com.google.devtools.ksp") version "1.9.0-1.0.13" apply false
}

23
gradle.properties Normal file
View File

@ -0,0 +1,23 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. For more details, visit
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true

38
gradle/libs.versions.toml Normal file
View File

@ -0,0 +1,38 @@
[versions]
agp = "8.7.2"
kotlin = "2.0.0"
coreKtx = "1.10.1"
junit = "4.13.2"
junitVersion = "1.1.5"
espressoCore = "3.5.1"
lifecycleRuntimeKtx = "2.6.1"
activityCompose = "1.8.0"
composeBom = "2024.04.01"
roomCommon = "2.6.1"
navigationCommonKtx = "2.8.4"
navigationCompose = "2.8.4"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-room-common = { group = "androidx.room", name = "room-common", version.ref = "roomCommon" }
androidx-navigation-common-ktx = { group = "androidx.navigation", name = "navigation-common-ktx", version.ref = "navigationCommonKtx" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@ -0,0 +1,6 @@
#Tue Nov 26 17:20:57 CST 2024
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

185
gradlew vendored Executable file
View File

@ -0,0 +1,185 @@
#!/usr/bin/env sh
#
# Copyright 2015 the original author or authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=`expr $i + 1`
done
case $i in
0) set -- ;;
1) set -- "$args0" ;;
2) set -- "$args0" "$args1" ;;
3) set -- "$args0" "$args1" "$args2" ;;
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=`save "$@"`
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
exec "$JAVACMD" "$@"

89
gradlew.bat vendored Normal file
View File

@ -0,0 +1,89 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

23
settings.gradle.kts Normal file
View File

@ -0,0 +1,23 @@
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "bookkeeping"
include(":app")