├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── TODO.md ├── app ├── .gitignore ├── build.gradle.kts ├── proguard-rules.pro ├── schemas │ └── me.konyaco.keeptally.storage.database.AppDatabase │ │ └── 1.json └── src │ ├── androidTest │ └── java │ │ └── me │ │ └── konyaco │ │ └── keeptally │ │ ├── ExampleInstrumentedTest.kt │ │ └── database │ │ └── AppDatabaseTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── assets │ │ └── fonts │ │ │ └── roboto_slab_variable.ttf │ ├── ic_launcher-playstore.png │ ├── kotlin │ │ └── me │ │ │ └── konyaco │ │ │ └── keeptally │ │ │ ├── KeepTallyApplication.kt │ │ │ ├── MainActivity.kt │ │ │ ├── service │ │ │ └── KeepTally.kt.bak │ │ │ ├── storage │ │ │ ├── dao │ │ │ │ ├── RecordDao.kt │ │ │ │ └── RecordTypeDao.kt │ │ │ ├── database │ │ │ │ └── AppDatabase.kt │ │ │ └── entity │ │ │ │ ├── Record.kt │ │ │ │ └── RecordType.kt │ │ │ ├── ui │ │ │ ├── App.kt │ │ │ ├── LocalNavController.kt │ │ │ ├── component │ │ │ │ ├── HomeTopBar.kt │ │ │ │ ├── MoneyIndicator.kt │ │ │ │ ├── MoneyString.kt │ │ │ │ ├── RecordItem.kt │ │ │ │ └── addrecord │ │ │ │ │ ├── AddRecord.kt │ │ │ │ │ ├── Util.kt │ │ │ │ │ └── component │ │ │ │ │ ├── AddLabel.kt │ │ │ │ │ ├── AddLabelDialog.kt │ │ │ │ │ ├── DateChooser.kt │ │ │ │ │ ├── EditArea.kt │ │ │ │ │ ├── EditDescription.kt │ │ │ │ │ ├── LabelContainer.kt │ │ │ │ │ ├── LabelItem.kt │ │ │ │ │ └── LabelList.kt │ │ │ ├── detail │ │ │ │ ├── DetailScreen.kt │ │ │ │ └── component │ │ │ │ │ ├── DailyRecord.kt │ │ │ │ │ ├── LineChart.kt │ │ │ │ │ ├── MoreInfo.kt │ │ │ │ │ └── TotalExpenditure.kt │ │ │ ├── filter │ │ │ │ └── FilterScreen.kt │ │ │ ├── other │ │ │ │ ├── OtherScreen.kt │ │ │ │ └── component │ │ │ │ │ ├── OptionList.kt │ │ │ │ │ └── UserProfile.kt │ │ │ ├── rememberVariableFont.kt │ │ │ ├── statistic │ │ │ │ ├── StatisticScreen.kt │ │ │ │ ├── component │ │ │ │ │ ├── CircleLineChart.kt │ │ │ │ │ ├── EmptyScreen.kt │ │ │ │ │ ├── Graph.kt │ │ │ │ │ ├── RecordItem.kt │ │ │ │ │ └── Tab.kt │ │ │ │ ├── expenditure │ │ │ │ │ ├── ExpenditureScreen.kt │ │ │ │ │ └── RecordList.kt │ │ │ │ ├── income │ │ │ │ │ ├── IncomeScreen.kt │ │ │ │ │ └── RecordList.kt │ │ │ │ └── summary │ │ │ │ │ ├── DetailCard.kt │ │ │ │ │ └── SummaryScreen.kt │ │ │ ├── theme │ │ │ │ ├── Color.kt │ │ │ │ ├── Theme.kt │ │ │ │ └── Typo.kt │ │ │ └── utils.kt │ │ │ └── viewmodel │ │ │ ├── DetailViewModel.kt │ │ │ ├── MainViewModel.kt │ │ │ ├── SharedViewModel.kt │ │ │ ├── StatisticViewModel.kt │ │ │ ├── Utils.kt │ │ │ └── model │ │ │ ├── Colors.kt │ │ │ ├── DateRange.kt │ │ │ ├── Money.kt │ │ │ └── RecordSign.kt │ └── res │ │ ├── drawable │ │ ├── ic_alipay.xml │ │ ├── ic_forward.xml │ │ ├── ic_label.xml │ │ ├── ic_launcher_foreground.xml │ │ ├── ic_launcher_monochrome.xml │ │ ├── ic_wechatpay.xml │ │ └── woman_and_pen.xml │ │ ├── font │ │ ├── roboto_slab_black.ttf │ │ ├── roboto_slab_bold.ttf │ │ ├── roboto_slab_extra_bold.ttf │ │ ├── roboto_slab_extra_light.ttf │ │ ├── roboto_slab_light.ttf │ │ ├── roboto_slab_medium.ttf │ │ ├── roboto_slab_regular.ttf │ │ ├── roboto_slab_semi_bold.ttf │ │ └── roboto_slab_thin.ttf │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ └── values │ │ ├── ic_launcher_background.xml │ │ ├── strings.xml │ │ └── themes.xml │ └── test │ └── java │ └── me │ └── konyaco │ └── keeptally │ └── ExampleUnitTest.kt ├── assets └── banner.png ├── build.gradle.kts ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle.kts /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | local.properties 3 | /.gradle 4 | /.idea 5 | /build 6 | /logic 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 持记 KeepTally 2 | 一个轻便美观的记账应用 3 | ![Banner](assets/banner.png) 4 | 5 | ## 鸣谢 6 | 7 | ![JetBrains Logo (Main) logo](https://resources.jetbrains.com/storage/products/company/brand/logos/jb_beam.svg) 8 | 9 | 感谢 [Jetbrains's Licenses for Open Source Development](https://jb.gg/OpenSourceSupport) 计划对本项目的支持 10 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # TODO 2 | 3 | - 添加记录的描述 4 | - 记录的日期: 5 | - 同年不显示年,同年同月不显示年月 6 | - 记录顶部的统计条 7 | - 编辑记录数据 8 | 9 | - 筛查页面 10 | - 统计页面总计的环形统计图的实际用途 11 | 12 | - 添加、编辑标签,设置的父标签 13 | - 考虑删除标签时旧数据如何处理(可以做标签伪删除?) 14 | 15 | - 考虑“预算”功能的实际业务 16 | 17 | - 用户登录与云同步 18 | - 导出、导入数据 19 | 20 | - i18n 支持 -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /.gradle 2 | /build -------------------------------------------------------------------------------- /app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("com.android.application") 3 | kotlin("android") 4 | kotlin("kapt") 5 | id("com.google.devtools.ksp") version "1.7.20-1.0.6" 6 | id("com.google.dagger.hilt.android") 7 | } 8 | 9 | android { 10 | namespace = "me.konyaco.keeptally" 11 | compileSdk = 33 12 | 13 | defaultConfig { 14 | applicationId = "me.konyaco.keeptally" 15 | minSdk = 21 16 | targetSdk = 33 17 | versionCode = 1 18 | versionName = "1.0" 19 | 20 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 21 | vectorDrawables { 22 | useSupportLibrary = true 23 | } 24 | // For Room 25 | ksp { 26 | arg("room.schemaLocation", "$projectDir/schemas") 27 | } 28 | } 29 | buildTypes { 30 | release { 31 | isMinifyEnabled = true 32 | proguardFiles.apply { 33 | add(getDefaultProguardFile("proguard-android-optimize.txt")) 34 | add(file("proguard-rules.pro")) 35 | } 36 | } 37 | debug { 38 | isMinifyEnabled = false 39 | proguardFiles.apply { 40 | add(getDefaultProguardFile("proguard-android-optimize.txt")) 41 | add(file("proguard-rules.pro")) 42 | } 43 | } 44 | } 45 | compileOptions { 46 | isCoreLibraryDesugaringEnabled = true 47 | sourceCompatibility = JavaVersion.VERSION_11 48 | targetCompatibility = JavaVersion.VERSION_11 49 | } 50 | kotlinOptions { 51 | jvmTarget = "11" 52 | } 53 | buildFeatures { 54 | compose = true 55 | } 56 | composeOptions { 57 | // kotlinCompilerExtensionVersion = rootProject.extra["compose_version"] as String 58 | kotlinCompilerExtensionVersion = "1.3.2" 59 | } 60 | packagingOptions { 61 | resources { 62 | excludes += "/META-INF/{AL2.0,LGPL2.1}" 63 | } 64 | } 65 | } 66 | 67 | kapt { 68 | correctErrorTypes = true 69 | } 70 | 71 | dependencies { 72 | coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.0") 73 | 74 | val hiltVersion = "2.44" 75 | implementation("com.google.dagger:hilt-android:$hiltVersion") 76 | kapt("com.google.dagger:hilt-compiler:$hiltVersion") 77 | implementation("androidx.hilt:hilt-navigation-compose:1.0.0") 78 | 79 | testImplementation("com.google.dagger:hilt-android-testing:$hiltVersion") 80 | androidTestImplementation("com.google.dagger:hilt-android-testing:$hiltVersion") 81 | kaptTest("com.google.dagger:hilt-compiler:$hiltVersion") 82 | kaptAndroidTest("com.google.dagger:hilt-compiler:$hiltVersion") 83 | 84 | implementation("androidx.core:core-ktx:1.9.0") 85 | implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.5.1") 86 | implementation("androidx.activity:activity-compose:1.6.1") 87 | 88 | val composeVersion = rootProject.extra["compose_version"]!!.toString() 89 | implementation("androidx.compose.ui:ui:$composeVersion") 90 | implementation("androidx.compose.ui:ui-tooling-preview:$composeVersion") 91 | implementation("androidx.compose.foundation:foundation:$composeVersion") 92 | implementation("androidx.compose.animation:animation:$composeVersion") 93 | implementation("androidx.compose.material:material:$composeVersion") 94 | implementation("androidx.compose.material:material-icons-extended:$composeVersion") 95 | implementation("androidx.compose.material3:material3:1.0.0") 96 | 97 | implementation("androidx.constraintlayout:constraintlayout-compose:1.0.1") 98 | 99 | androidTestImplementation("androidx.compose.ui:ui-test-junit4:$composeVersion") 100 | debugImplementation("androidx.compose.ui:ui-tooling:$composeVersion") 101 | 102 | implementation("androidx.navigation:navigation-compose:2.5.3") 103 | 104 | val accompanistVersion = "0.27.0" 105 | implementation("com.google.accompanist:accompanist-pager:$accompanistVersion") 106 | implementation("com.google.accompanist:accompanist-systemuicontroller:$accompanistVersion") 107 | 108 | val roomVersion = "2.4.3" 109 | implementation("androidx.room:room-runtime:$roomVersion") 110 | ksp("androidx.room:room-compiler:$roomVersion") 111 | implementation("androidx.room:room-ktx:$roomVersion") 112 | 113 | testImplementation("junit:junit:4.13.2") 114 | 115 | androidTestImplementation("androidx.test.ext:junit:1.1.3") 116 | androidTestImplementation("androidx.test.espresso:espresso-core:3.4.0") 117 | } -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | -dontwarn com.google.auto.value.AutoValue 23 | -dontwarn com.google.errorprone.annotations.InlineMe -------------------------------------------------------------------------------- /app/schemas/me.konyaco.keeptally.storage.database.AppDatabase/1.json: -------------------------------------------------------------------------------- 1 | { 2 | "formatVersion": 1, 3 | "database": { 4 | "version": 1, 5 | "identityHash": "7b7f9c2bae44eff2fea922c16fb9f2f4", 6 | "entities": [ 7 | { 8 | "tableName": "Record", 9 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `money` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `typeId` INTEGER NOT NULL, `description` TEXT)", 10 | "fields": [ 11 | { 12 | "fieldPath": "id", 13 | "columnName": "id", 14 | "affinity": "INTEGER", 15 | "notNull": true 16 | }, 17 | { 18 | "fieldPath": "money", 19 | "columnName": "money", 20 | "affinity": "INTEGER", 21 | "notNull": true 22 | }, 23 | { 24 | "fieldPath": "timestamp", 25 | "columnName": "timestamp", 26 | "affinity": "INTEGER", 27 | "notNull": true 28 | }, 29 | { 30 | "fieldPath": "typeId", 31 | "columnName": "typeId", 32 | "affinity": "INTEGER", 33 | "notNull": true 34 | }, 35 | { 36 | "fieldPath": "description", 37 | "columnName": "description", 38 | "affinity": "TEXT", 39 | "notNull": false 40 | } 41 | ], 42 | "primaryKey": { 43 | "columnNames": [ 44 | "id" 45 | ], 46 | "autoGenerate": true 47 | }, 48 | "indices": [], 49 | "foreignKeys": [] 50 | }, 51 | { 52 | "tableName": "RecordType", 53 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `label` TEXT NOT NULL, `parentId` INTEGER, `isIncome` INTEGER NOT NULL)", 54 | "fields": [ 55 | { 56 | "fieldPath": "id", 57 | "columnName": "id", 58 | "affinity": "INTEGER", 59 | "notNull": true 60 | }, 61 | { 62 | "fieldPath": "label", 63 | "columnName": "label", 64 | "affinity": "TEXT", 65 | "notNull": true 66 | }, 67 | { 68 | "fieldPath": "parentId", 69 | "columnName": "parentId", 70 | "affinity": "INTEGER", 71 | "notNull": false 72 | }, 73 | { 74 | "fieldPath": "isIncome", 75 | "columnName": "isIncome", 76 | "affinity": "INTEGER", 77 | "notNull": true 78 | } 79 | ], 80 | "primaryKey": { 81 | "columnNames": [ 82 | "id" 83 | ], 84 | "autoGenerate": true 85 | }, 86 | "indices": [], 87 | "foreignKeys": [] 88 | } 89 | ], 90 | "views": [], 91 | "setupQueries": [ 92 | "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", 93 | "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7b7f9c2bae44eff2fea922c16fb9f2f4')" 94 | ] 95 | } 96 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/me/konyaco/keeptally/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package me.konyaco.keeptally 2 | 3 | import androidx.test.ext.junit.runners.AndroidJUnit4 4 | import androidx.test.platform.app.InstrumentationRegistry 5 | import org.junit.Assert.assertEquals 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | /** 10 | * Instrumented test, which will execute on an Android device. 11 | * 12 | * See [testing documentation](http://d.android.com/tools/testing). 13 | */ 14 | @RunWith(AndroidJUnit4::class) 15 | class ExampleInstrumentedTest { 16 | @Test 17 | fun useAppContext() { 18 | // Context of the app under test. 19 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 20 | assertEquals("me.konyaco.keeptally", appContext.packageName) 21 | } 22 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/me/konyaco/keeptally/database/AppDatabaseTest.kt: -------------------------------------------------------------------------------- 1 | package me.konyaco.keeptally.database 2 | 3 | internal class AppDatabaseTest -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 12 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /app/src/main/assets/fonts/roboto_slab_variable.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/123Duo3/KeepTally/378a3563958159ecf5f36b6bed388648b48176d5/app/src/main/assets/fonts/roboto_slab_variable.ttf -------------------------------------------------------------------------------- /app/src/main/ic_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/123Duo3/KeepTally/378a3563958159ecf5f36b6bed388648b48176d5/app/src/main/ic_launcher-playstore.png -------------------------------------------------------------------------------- /app/src/main/kotlin/me/konyaco/keeptally/KeepTallyApplication.kt: -------------------------------------------------------------------------------- 1 | package me.konyaco.keeptally 2 | 3 | import android.app.Application 4 | import android.content.Context 5 | import androidx.room.Room 6 | import dagger.Module 7 | import dagger.Provides 8 | import dagger.hilt.InstallIn 9 | import dagger.hilt.android.HiltAndroidApp 10 | import dagger.hilt.android.qualifiers.ApplicationContext 11 | import dagger.hilt.components.SingletonComponent 12 | import me.konyaco.keeptally.storage.database.AppDatabase 13 | import javax.inject.Inject 14 | 15 | @HiltAndroidApp 16 | class KeepTallyApplication : Application() { 17 | @Inject 18 | lateinit var database: AppDatabase 19 | } 20 | 21 | @Module 22 | @InstallIn(SingletonComponent::class) 23 | internal object InjectionModule { 24 | @Provides 25 | fun provideDatabase(@ApplicationContext context: Context): AppDatabase { 26 | return Room.databaseBuilder( 27 | context, 28 | AppDatabase::class.java, "database" 29 | ).build() 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/konyaco/keeptally/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package me.konyaco.keeptally 2 | 3 | import android.os.Bundle 4 | import androidx.activity.ComponentActivity 5 | import androidx.activity.compose.setContent 6 | import androidx.compose.runtime.CompositionLocalProvider 7 | import androidx.core.view.WindowCompat 8 | import androidx.hilt.navigation.compose.hiltViewModel 9 | import androidx.navigation.compose.NavHost 10 | import androidx.navigation.compose.composable 11 | import androidx.navigation.compose.rememberNavController 12 | import dagger.hilt.android.AndroidEntryPoint 13 | import me.konyaco.keeptally.ui.App 14 | import me.konyaco.keeptally.ui.LocalNavController 15 | 16 | private const val TAG = "MainActivity" 17 | 18 | @AndroidEntryPoint 19 | class MainActivity : ComponentActivity() { 20 | 21 | override fun onCreate(savedInstanceState: Bundle?) { 22 | WindowCompat.setDecorFitsSystemWindows(window, false); 23 | super.onCreate(savedInstanceState) 24 | 25 | setContent { 26 | App() 27 | /*val navController = rememberNavController() 28 | CompositionLocalProvider(LocalNavController provides navController) { 29 | NavHost( 30 | navController = navController, 31 | startDestination = "home", 32 | builder = { 33 | composable("home") { 34 | App() 35 | } 36 | } 37 | ) 38 | }*/ 39 | } 40 | } 41 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/me/konyaco/keeptally/service/KeepTally.kt.bak: -------------------------------------------------------------------------------- 1 | package me.konyaco.keeptally.service 2 | 3 | import java.time.Instant 4 | 5 | interface KeepTally { 6 | } 7 | 8 | interface RecordManager { 9 | fun record(money: Int, billType: BillType?) 10 | fun getRecords() 11 | fun find(scope: FindScope.() -> Unit): List 12 | 13 | interface FindScope { 14 | fun setStartDate(instant: Instant) 15 | fun setEndDate(instant: Instant) 16 | fun findByTypes(types: List) 17 | fun findByLabel() 18 | fun limit(count: Int) 19 | } 20 | 21 | fun findRecordByTypes() 22 | fun findRecordByLabel() 23 | } 24 | 25 | interface BillRecord { 26 | 27 | } 28 | 29 | interface BillType { 30 | val label: String 31 | } 32 | 33 | interface BillTypeManager { 34 | fun addType(label: String) 35 | fun getTypes(): List 36 | } 37 | 38 | interface Statistics { 39 | 40 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/me/konyaco/keeptally/storage/dao/RecordDao.kt: -------------------------------------------------------------------------------- 1 | package me.konyaco.keeptally.storage.dao 2 | 3 | import androidx.room.Dao 4 | import androidx.room.Delete 5 | import androidx.room.Insert 6 | import androidx.room.Query 7 | import me.konyaco.keeptally.storage.entity.Record 8 | 9 | @Dao 10 | interface RecordDao { 11 | @Query("SELECT * FROM record") 12 | suspend fun getAll(): List 13 | 14 | @Query("SELECT * FROM record WHERE id IN (:ids)") 15 | suspend fun loadAllByIds(ids: IntArray): List 16 | 17 | @Query("SELECT * FROM record WHERE timestamp >= :start AND timestamp < :end ORDER BY timestamp") 18 | suspend fun loadAllByDate(start: Long, end: Long): List 19 | 20 | @Query("SELECT * FROM record WHERE timestamp >= :start AND timestamp < :end ORDER BY timestamp DESC") 21 | suspend fun loadAllByDateDesc(start: Long, end: Long): List 22 | 23 | @Query("""SELECT * FROM record WHERE description LIKE :description LIMIT 1""") 24 | suspend fun findByDescription(description: String): Record 25 | 26 | @Query("""SELECT * FROM record WHERE typeId IN (:types)""") 27 | suspend fun loadAllByTypes(types: IntArray): List 28 | 29 | @Insert 30 | suspend fun insertAll(vararg records: Record) 31 | 32 | @Delete 33 | suspend fun delete(record: Record) 34 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/me/konyaco/keeptally/storage/dao/RecordTypeDao.kt: -------------------------------------------------------------------------------- 1 | package me.konyaco.keeptally.storage.dao 2 | 3 | import androidx.room.Dao 4 | import androidx.room.Delete 5 | import androidx.room.Insert 6 | import androidx.room.Query 7 | import me.konyaco.keeptally.storage.entity.RecordType 8 | 9 | @Dao 10 | interface RecordTypeDao { 11 | @Query("SELECT count(*) FROM recordtype") 12 | suspend fun count(): Long 13 | 14 | @Query("SELECT * FROM recordtype") 15 | suspend fun getAll(): List 16 | 17 | @Query("SELECT * FROM recordtype WHERE isIncome = 1") 18 | suspend fun getAllIncome(): List 19 | 20 | @Query("SELECT * FROM recordtype WHERE isIncome = 0") 21 | suspend fun getAllExpenditure(): List 22 | 23 | @Query("SELECT * FROM recordtype WHERE id IN (:id)") 24 | suspend fun loadAllByIds(vararg id: Int): List 25 | 26 | @Query("SELECT * FROM recordtype WHERE parentId IS NULL") 27 | suspend fun getAllRoot(): List 28 | 29 | @Query("SELECT * FROM recordtype WHERE label LIKE :label") 30 | suspend fun getByLabel(label: String): List 31 | 32 | @Query("SELECT * FROM recordtype WHERE label LIKE :label AND parentId IS NULL") 33 | suspend fun getRootByLabel(label: String): RecordType? 34 | 35 | @Query("SELECT * FROM recordtype WHERE parentId = :parentId") 36 | suspend fun getSubTypes(parentId: Int): List 37 | 38 | @Insert 39 | suspend fun insertAll(vararg types: RecordType): List 40 | 41 | @Delete 42 | suspend fun delete(type: RecordType) 43 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/me/konyaco/keeptally/storage/database/AppDatabase.kt: -------------------------------------------------------------------------------- 1 | package me.konyaco.keeptally.storage.database 2 | 3 | import androidx.room.Database 4 | import androidx.room.RoomDatabase 5 | //import me.konyaco.keeptally.storage.dao.BudgetDao 6 | import me.konyaco.keeptally.storage.dao.RecordDao 7 | import me.konyaco.keeptally.storage.dao.RecordTypeDao 8 | import me.konyaco.keeptally.storage.entity.Record 9 | import me.konyaco.keeptally.storage.entity.RecordType 10 | 11 | @Database(entities = [Record::class, RecordType::class], version = 1) 12 | abstract class AppDatabase : RoomDatabase() { 13 | abstract fun recordDao(): RecordDao 14 | abstract fun recordTypeDao(): RecordTypeDao 15 | // abstract fun budgetDao(): BudgetDao 16 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/me/konyaco/keeptally/storage/entity/Record.kt: -------------------------------------------------------------------------------- 1 | package me.konyaco.keeptally.storage.entity 2 | 3 | import androidx.room.Entity 4 | import androidx.room.PrimaryKey 5 | import java.time.Instant 6 | 7 | /** 8 | * @param money: If the record is income, money >= 0, else, money < 0 9 | */ 10 | @Entity 11 | data class Record( 12 | @PrimaryKey(autoGenerate = true) val id: Int, 13 | val money: Int, 14 | val timestamp: Long = Instant.now().epochSecond, 15 | val typeId: Int, 16 | val description: String? 17 | ) -------------------------------------------------------------------------------- /app/src/main/kotlin/me/konyaco/keeptally/storage/entity/RecordType.kt: -------------------------------------------------------------------------------- 1 | package me.konyaco.keeptally.storage.entity 2 | 3 | import androidx.room.Entity 4 | import androidx.room.PrimaryKey 5 | 6 | @Entity 7 | data class RecordType( 8 | @PrimaryKey(autoGenerate = true) val id: Int, 9 | val label: String, 10 | val parentId: Int?, 11 | val isIncome: Boolean 12 | ) -------------------------------------------------------------------------------- /app/src/main/kotlin/me/konyaco/keeptally/ui/App.kt: -------------------------------------------------------------------------------- 1 | package me.konyaco.keeptally.ui 2 | 3 | import androidx.compose.animation.AnimatedContent 4 | import androidx.compose.animation.AnimatedContentScope.SlideDirection.Companion.End 5 | import androidx.compose.animation.AnimatedContentScope.SlideDirection.Companion.Start 6 | import androidx.compose.animation.ContentTransform 7 | import androidx.compose.animation.ExperimentalAnimationApi 8 | import androidx.compose.foundation.layout.* 9 | import androidx.compose.material.* 10 | import androidx.compose.material3.MaterialTheme 11 | import androidx.compose.material3.Surface 12 | import androidx.compose.runtime.* 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.graphics.RectangleShape 15 | import androidx.compose.ui.platform.LocalFocusManager 16 | import androidx.hilt.navigation.compose.hiltViewModel 17 | import com.google.accompanist.pager.ExperimentalPagerApi 18 | import com.google.accompanist.pager.HorizontalPager 19 | import com.google.accompanist.pager.rememberPagerState 20 | import kotlinx.coroutines.launch 21 | import me.konyaco.keeptally.ui.component.HomeTopBar 22 | import me.konyaco.keeptally.ui.component.HomeTopBarState 23 | import me.konyaco.keeptally.ui.component.rememberHomeTopBarState 24 | import me.konyaco.keeptally.ui.detail.DetailScreen 25 | import me.konyaco.keeptally.ui.component.addrecord.AddRecord 26 | import me.konyaco.keeptally.ui.filter.FilterScreen 27 | import me.konyaco.keeptally.ui.other.OtherScreen 28 | import me.konyaco.keeptally.ui.statistic.StatisticScreen 29 | import me.konyaco.keeptally.ui.theme.AndroidKeepTallyTheme 30 | import me.konyaco.keeptally.viewmodel.MainViewModel 31 | import me.konyaco.keeptally.viewmodel.model.DateRange 32 | 33 | @Composable 34 | fun App(viewModel: MainViewModel = hiltViewModel()) { 35 | AndroidKeepTallyTheme { 36 | Surface( 37 | modifier = Modifier 38 | .fillMaxSize(), 39 | color = MaterialTheme.colorScheme.inverseOnSurface, 40 | contentColor = MaterialTheme.colorScheme.onBackground 41 | ) { 42 | Main(viewModel) 43 | } 44 | } 45 | } 46 | 47 | @OptIn(ExperimentalMaterialApi::class) 48 | @Composable 49 | private fun Main(viewModel: MainViewModel) { 50 | val localFocus = LocalFocusManager.current 51 | val scope = rememberCoroutineScope() 52 | val sheet = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden, 53 | confirmStateChange = { 54 | if (it == ModalBottomSheetValue.Hidden) 55 | localFocus.clearFocus() 56 | true 57 | } 58 | ) 59 | ModalBottomSheetLayout( 60 | sheetState = sheet, 61 | sheetContent = { 62 | AddRecord( 63 | modifier = Modifier 64 | .fillMaxWidth() 65 | .imePadding(), 66 | onCloseRequest = { 67 | localFocus.clearFocus() 68 | scope.launch { sheet.hide() } 69 | } 70 | ) 71 | }, 72 | sheetShape = RectangleShape 73 | ) { 74 | Content(viewModel, sheet) 75 | } 76 | } 77 | 78 | @Composable 79 | @OptIn(ExperimentalMaterialApi::class) 80 | private fun Content( 81 | viewModel: MainViewModel, 82 | sheet: ModalBottomSheetState 83 | ) { 84 | ContentAnimatedContent(viewModel, sheet) 85 | } 86 | 87 | @Composable 88 | @OptIn(ExperimentalAnimationApi::class, ExperimentalMaterialApi::class) 89 | private fun ContentAnimatedContent( 90 | viewModel: MainViewModel, 91 | sheet: ModalBottomSheetState 92 | ) { 93 | Column( 94 | Modifier 95 | .fillMaxSize() 96 | .statusBarsPadding() 97 | ) { 98 | val scope = rememberCoroutineScope() 99 | val homeTopBarState = rememberHomeTopBarState() 100 | 101 | HomeTopBar( 102 | Modifier.fillMaxWidth(), 103 | homeTopBarState, 104 | onDateChosen = { year, month -> 105 | viewModel.setDateRange(DateRange.Month(year, month)) 106 | }, 107 | onTabSelect = { 108 | homeTopBarState.selectTab(it) 109 | } 110 | ) 111 | 112 | AnimatedContent( 113 | modifier = Modifier 114 | .fillMaxWidth() 115 | .weight(1f), 116 | targetState = homeTopBarState.selectedTab, 117 | transitionSpec = { 118 | if (initialState < targetState) { 119 | ContentTransform( 120 | targetContentEnter = slideIntoContainer(Start), 121 | initialContentExit = slideOutOfContainer(Start) 122 | ) 123 | } else { 124 | ContentTransform( 125 | targetContentEnter = slideIntoContainer(End), 126 | initialContentExit = slideOutOfContainer(End) 127 | ) 128 | } 129 | } 130 | ) { 131 | when (it) { 132 | HomeTopBarState.TabItem.Detail -> DetailScreen(onAddClick = { scope.launch { sheet.show() } }) 133 | HomeTopBarState.TabItem.Filter -> FilterScreen() 134 | HomeTopBarState.TabItem.Statistics -> StatisticScreen() 135 | HomeTopBarState.TabItem.Other -> OtherScreen() 136 | } 137 | } 138 | } 139 | } 140 | 141 | 142 | @Composable 143 | @OptIn(ExperimentalPagerApi::class, ExperimentalMaterialApi::class) 144 | private fun ContentPager( 145 | viewModel: MainViewModel, 146 | sheet: ModalBottomSheetState 147 | ) { 148 | Column( 149 | Modifier 150 | .fillMaxSize() 151 | .statusBarsPadding() 152 | ) { 153 | val scope = rememberCoroutineScope() 154 | val homeTopBarState = rememberHomeTopBarState() 155 | val pagerState = rememberPagerState() 156 | var isScrolling by remember { mutableStateOf(false) } 157 | 158 | 159 | /*LaunchedEffect(pagerState.currentPage, isScrolling) { 160 | if (!isScrolling) { 161 | val tab = HomeTopBarState.TabItem.values()[pagerState.currentPage] 162 | homeTopBarState.selectTab(tab) 163 | } 164 | }*/ 165 | 166 | // Change tab state according to user scrolling. 167 | LaunchedEffect(pagerState.currentPageOffset, pagerState.currentPage, isScrolling) { 168 | if (!isScrolling) { 169 | val offset = pagerState.currentPageOffset 170 | val current = pagerState.currentPage 171 | val index = if (offset > 0.50f) { 172 | current + 1 173 | } else if (offset < -0.50f) { 174 | current - 1 175 | } else { 176 | current 177 | } 178 | val tab = HomeTopBarState.TabItem.values()[index] 179 | homeTopBarState.selectTab(tab) 180 | } 181 | } 182 | 183 | HomeTopBar( 184 | Modifier.fillMaxWidth(), 185 | homeTopBarState, 186 | onDateChosen = { year, month -> 187 | viewModel.setDateRange(DateRange.Month(year, month)) 188 | }, 189 | onTabSelect = { 190 | scope.launch { 191 | homeTopBarState.selectTab(it) 192 | val index = HomeTopBarState.TabItem.values 193 | .indexOf(homeTopBarState.selectedTab) 194 | isScrolling = true 195 | pagerState.animateScrollToPage(index) 196 | isScrolling = false 197 | } 198 | } 199 | ) 200 | 201 | 202 | HorizontalPager( 203 | modifier = Modifier 204 | .fillMaxWidth() 205 | .weight(1f), 206 | count = remember { HomeTopBarState.TabItem.values.size }, 207 | state = pagerState 208 | ) { 209 | when (it) { 210 | 0 -> DetailScreen(onAddClick = { scope.launch { sheet.show() } }) // FIXME(2022/9/17): This will cause frequent recomposition 211 | 1 -> FilterScreen() 212 | 2 -> StatisticScreen() 213 | 3 -> OtherScreen() 214 | } 215 | } 216 | } 217 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/me/konyaco/keeptally/ui/LocalNavController.kt: -------------------------------------------------------------------------------- 1 | package me.konyaco.keeptally.ui 2 | 3 | import androidx.compose.runtime.compositionLocalOf 4 | import androidx.navigation.NavHostController 5 | 6 | val LocalNavController = compositionLocalOf { error("Not available") } -------------------------------------------------------------------------------- /app/src/main/kotlin/me/konyaco/keeptally/ui/component/MoneyIndicator.kt: -------------------------------------------------------------------------------- 1 | package me.konyaco.keeptally.ui.component 2 | 3 | import androidx.compose.foundation.Canvas 4 | import androidx.compose.foundation.background 5 | import androidx.compose.foundation.layout.size 6 | import androidx.compose.material3.MaterialTheme 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.runtime.remember 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.geometry.Offset 11 | import androidx.compose.ui.graphics.Color 12 | import androidx.compose.ui.tooling.preview.Preview 13 | import androidx.compose.ui.unit.dp 14 | import me.konyaco.keeptally.ui.theme.KeepTallyTheme 15 | 16 | @Composable 17 | fun MoneyIndicator( 18 | money: Int, 19 | budget: Int, 20 | fillColor: Color, 21 | gapColor: Color = MaterialTheme.colorScheme.inverseOnSurface, 22 | overflowColor: Color = MaterialTheme.colorScheme.error 23 | ) { 24 | val progress = remember(budget, money) { 25 | if (budget == 0) 1f else (money.toDouble() / budget).toFloat().coerceAtMost(2f) 26 | } 27 | Canvas( 28 | Modifier 29 | .size(4.dp, 32.dp) 30 | .background(gapColor) 31 | ) { 32 | val height = size.height * progress.coerceAtMost(1f) 33 | drawRect( 34 | color = fillColor, 35 | topLeft = Offset(x = 0f, y = size.height - height), 36 | size = size.copy(height = height) 37 | ) 38 | if (progress > 1f) { 39 | drawRect( 40 | color = overflowColor, 41 | size = size.copy(height = size.height * (progress - 1f)) 42 | ) 43 | } 44 | } 45 | } 46 | 47 | @Preview 48 | @Composable 49 | private fun Preview1() { 50 | KeepTallyTheme { 51 | MoneyIndicator(money = 100, budget = 120, fillColor = MaterialTheme.colorScheme.primary) 52 | } 53 | } 54 | 55 | @Preview 56 | @Composable 57 | private fun Preview2() { 58 | KeepTallyTheme { 59 | MoneyIndicator(money = 120, budget = 100, fillColor = MaterialTheme.colorScheme.primary) 60 | } 61 | } 62 | 63 | @Preview 64 | @Composable 65 | private fun Preview3() { 66 | KeepTallyTheme { 67 | MoneyIndicator(money = 50, budget = 100, fillColor = MaterialTheme.colorScheme.primary) 68 | } 69 | } 70 | 71 | @Preview 72 | @Composable 73 | private fun Preview4() { 74 | KeepTallyTheme { 75 | MoneyIndicator(money = 50, budget = 0, fillColor = MaterialTheme.colorScheme.primary) 76 | } 77 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/me/konyaco/keeptally/ui/component/MoneyString.kt: -------------------------------------------------------------------------------- 1 | package me.konyaco.keeptally.ui.component 2 | 3 | import androidx.compose.foundation.layout.Row 4 | import androidx.compose.material3.LocalContentColor 5 | import androidx.compose.material3.LocalTextStyle 6 | import androidx.compose.material3.MaterialTheme 7 | import androidx.compose.material3.Text 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.runtime.CompositionLocalProvider 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.graphics.Color 12 | import androidx.compose.ui.text.font.FontFamily 13 | import androidx.compose.ui.tooling.preview.Preview 14 | import me.konyaco.keeptally.viewmodel.model.RecordSign 15 | import me.konyaco.keeptally.ui.theme.KeepTallyTheme 16 | import me.konyaco.keeptally.ui.theme.RobotoSlab 17 | 18 | @Composable 19 | fun MoneyString( 20 | moneyStr: String, 21 | isIncome: Boolean, 22 | budget: String? = null, 23 | positiveColor: Color = MaterialTheme.colorScheme.tertiary, 24 | negativeColor: Color = MaterialTheme.colorScheme.primary 25 | ) { 26 | CompositionLocalProvider( 27 | LocalContentColor provides if (isIncome) positiveColor else negativeColor, 28 | LocalTextStyle provides MaterialTheme.typography.headlineMedium.copy(fontFamily = FontFamily.RobotoSlab) 29 | ) { 30 | Row { 31 | Text( 32 | modifier = Modifier.alignByBaseline(), 33 | text = if (isIncome) RecordSign.POSITIVE else RecordSign.NEGATIVE 34 | ) 35 | Text( 36 | modifier = Modifier.alignByBaseline(), 37 | text = moneyStr 38 | ) 39 | Text( 40 | modifier = Modifier.alignByBaseline(), 41 | text = RecordSign.RMB, 42 | style = MaterialTheme.typography.titleLarge, 43 | fontFamily = FontFamily.RobotoSlab 44 | ) 45 | budget?.let { 46 | Text( 47 | modifier = Modifier.alignByBaseline(), 48 | text = "/$it", 49 | style = MaterialTheme.typography.titleLarge, 50 | fontFamily = FontFamily.RobotoSlab 51 | ) 52 | } 53 | } 54 | } 55 | } 56 | 57 | @Preview 58 | @Composable 59 | private fun PositivePreview() { 60 | KeepTallyTheme { 61 | MoneyString("1.00", true) 62 | } 63 | } 64 | 65 | @Preview 66 | @Composable 67 | private fun NegativePreview() { 68 | KeepTallyTheme { 69 | MoneyString("1.00", false) 70 | } 71 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/me/konyaco/keeptally/ui/component/RecordItem.kt: -------------------------------------------------------------------------------- 1 | package me.konyaco.keeptally.ui.component 2 | 3 | import androidx.compose.foundation.ExperimentalFoundationApi 4 | import androidx.compose.foundation.combinedClickable 5 | import androidx.compose.foundation.layout.* 6 | import androidx.compose.material3.LocalContentColor 7 | import androidx.compose.material3.MaterialTheme 8 | import androidx.compose.material3.Text 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.runtime.remember 11 | import androidx.compose.ui.Alignment 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.graphics.Color 14 | import androidx.compose.ui.text.style.TextOverflow 15 | import androidx.compose.ui.tooling.preview.Preview 16 | import androidx.compose.ui.unit.dp 17 | import me.konyaco.keeptally.ui.theme.KeepTallyTheme 18 | 19 | @OptIn(ExperimentalFoundationApi::class) 20 | @Composable 21 | fun RecordItem( 22 | color: Color, 23 | title: String, 24 | time: String, 25 | category: String, 26 | money: Int, 27 | moneyStr: String, 28 | description: String?, 29 | onClick: () -> Unit, 30 | onLongClick: () -> Unit, 31 | modifier: Modifier = Modifier 32 | ) { 33 | Row( 34 | modifier 35 | .combinedClickable(onClick = onClick, onLongClick = onLongClick) 36 | .padding(horizontal = 16.dp, vertical = 8.dp), 37 | verticalAlignment = Alignment.CenterVertically 38 | ) { 39 | MoneyIndicator(money = money, budget = 0, fillColor = color) 40 | Spacer(Modifier.width(16.dp)) 41 | Column(Modifier.weight(1f)) { 42 | Row( 43 | modifier = Modifier.fillMaxWidth(), 44 | verticalAlignment = Alignment.CenterVertically, 45 | horizontalArrangement = Arrangement.spacedBy(4.dp) 46 | ) { 47 | Text(text = title, style = MaterialTheme.typography.titleMedium) 48 | if (description != null) 49 | Text( 50 | modifier = Modifier.weight(1f), 51 | text = "· $description", 52 | style = MaterialTheme.typography.bodyMedium, 53 | color = LocalContentColor.current.copy(0.9f), 54 | overflow = TextOverflow.Ellipsis, 55 | maxLines = 1 56 | ) 57 | } 58 | Text( 59 | text = remember { "$time $category" }, 60 | style = MaterialTheme.typography.bodyMedium, 61 | color = MaterialTheme.colorScheme.secondary 62 | ) 63 | } 64 | MoneyString(moneyStr, money > 0) 65 | } 66 | } 67 | 68 | @Preview(showBackground = true) 69 | @Composable 70 | private fun OutcomeRecordItemPreview() { 71 | KeepTallyTheme { 72 | RecordItem( 73 | modifier = Modifier.fillMaxWidth(), 74 | color = MaterialTheme.colorScheme.primary, 75 | title = "消费", 76 | time = "12:30", 77 | category = "分类", 78 | money = -1100, 79 | moneyStr = "11.00", 80 | description = "备注", 81 | onClick = {}, 82 | onLongClick = {} 83 | ) 84 | } 85 | } 86 | 87 | @Preview(showBackground = true) 88 | @Composable 89 | private fun IncomeRecordItemPreview() { 90 | KeepTallyTheme { 91 | RecordItem( 92 | modifier = Modifier.fillMaxWidth(), 93 | color = MaterialTheme.colorScheme.primary, 94 | title = "工资", 95 | time = "12:30", 96 | category = "分类", 97 | money = 1000000, 98 | moneyStr = "10,000.00", 99 | description = "备注", 100 | onClick = {}, 101 | onLongClick = {} 102 | ) 103 | } 104 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/me/konyaco/keeptally/ui/component/addrecord/AddRecord.kt: -------------------------------------------------------------------------------- 1 | package me.konyaco.keeptally.ui.component.addrecord 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.Row 6 | import androidx.compose.foundation.layout.Spacer 7 | import androidx.compose.foundation.layout.fillMaxWidth 8 | import androidx.compose.foundation.layout.height 9 | import androidx.compose.foundation.layout.padding 10 | import androidx.compose.foundation.layout.width 11 | import androidx.compose.foundation.layout.wrapContentHeight 12 | import androidx.compose.material.icons.Icons 13 | import androidx.compose.material.icons.filled.Add 14 | import androidx.compose.material.icons.filled.Close 15 | import androidx.compose.material3.Button 16 | import androidx.compose.material3.ButtonDefaults 17 | import androidx.compose.material3.Divider 18 | import androidx.compose.material3.Icon 19 | import androidx.compose.material3.IconButton 20 | import androidx.compose.material3.MaterialTheme 21 | import androidx.compose.material3.Surface 22 | import androidx.compose.material3.Text 23 | import androidx.compose.runtime.Composable 24 | import androidx.compose.runtime.collectAsState 25 | import androidx.compose.runtime.getValue 26 | import androidx.compose.runtime.mutableStateOf 27 | import androidx.compose.runtime.remember 28 | import androidx.compose.runtime.setValue 29 | import androidx.compose.ui.Alignment 30 | import androidx.compose.ui.Modifier 31 | import androidx.compose.ui.text.input.TextFieldValue 32 | import androidx.compose.ui.tooling.preview.Preview 33 | import androidx.compose.ui.unit.dp 34 | import androidx.hilt.navigation.compose.hiltViewModel 35 | import me.konyaco.keeptally.ui.component.addrecord.component.AddLabelDialog 36 | import me.konyaco.keeptally.ui.component.addrecord.component.DateChooser 37 | import me.konyaco.keeptally.ui.component.addrecord.component.DateChooserState 38 | import me.konyaco.keeptally.ui.component.addrecord.component.EditArea 39 | import me.konyaco.keeptally.ui.component.addrecord.component.EditDescription 40 | import me.konyaco.keeptally.ui.component.addrecord.component.LabelList 41 | import me.konyaco.keeptally.ui.parseMoneyToCent 42 | import me.konyaco.keeptally.ui.theme.KeepTallyTheme 43 | import me.konyaco.keeptally.viewmodel.MainViewModel 44 | import java.time.LocalDateTime 45 | 46 | data class AddDialogState( 47 | val isIncomeLabel: Boolean, 48 | val parentLabel: String? 49 | ) 50 | 51 | @Composable 52 | fun AddRecord( 53 | modifier: Modifier, 54 | viewModel: MainViewModel = hiltViewModel(), 55 | onCloseRequest: () -> Unit 56 | ) { 57 | var showDialog by remember { mutableStateOf(null) } 58 | 59 | var isIncome by remember { mutableStateOf(false) } 60 | val expenditureLabels by viewModel.expenditureLabels.collectAsState() 61 | val incomeLabels by viewModel.incomeLabels.collectAsState() 62 | 63 | val labels = if (isIncome) incomeLabels else expenditureLabels 64 | val primaryLabels = remember(labels) { labels.keys.map { it.label } } 65 | var selectedPrimaryLabel by remember(isIncome, primaryLabels) { mutableStateOf(0) } 66 | 67 | val secondaryLabels = remember(labels, selectedPrimaryLabel) { 68 | labels.keys.elementAtOrNull(selectedPrimaryLabel) 69 | ?.let { primary -> 70 | labels[primary]?.map { secondary -> 71 | secondary.label 72 | } 73 | } 74 | ?: emptyList() 75 | } 76 | 77 | var selectedSecondaryLabel by remember(isIncome, selectedPrimaryLabel) { 78 | mutableStateOf(null) 79 | } 80 | AddRecord( 81 | onCloseClick = onCloseRequest, 82 | modifier = modifier, 83 | isIncome = isIncome, 84 | onIncomeChange = { isIncome = !isIncome }, 85 | primaryLabels = primaryLabels, 86 | secondaryLabels = secondaryLabels, 87 | checkedPrimaryLabel = selectedPrimaryLabel, 88 | onPrimaryLabelSelect = { selectedPrimaryLabel = it }, 89 | checkedSecondaryLabel = selectedSecondaryLabel, 90 | onSecondaryLabelSelect = { 91 | selectedSecondaryLabel = 92 | if (selectedSecondaryLabel == it) null 93 | else it 94 | }, 95 | onAddLabelClick = { isIncomeLabel, parentLabel -> 96 | val parentLabel = parentLabel?.let { 97 | labels.keys.elementAtOrNull(it)?.label 98 | } 99 | showDialog = AddDialogState(isIncomeLabel, parentLabel) 100 | }, 101 | onAddRecordClick = { income, money, desc, date -> 102 | val primaryLabel = primaryLabels[selectedPrimaryLabel] 103 | val secondaryLabel = selectedSecondaryLabel?.let { secondaryLabels[it] } 104 | when (date) { 105 | is DateChooserState.Custom -> { 106 | val date = 107 | LocalDateTime.of(date.year, date.month, date.day, date.hour, date.minute) 108 | viewModel.addRecord(income, money, primaryLabel, secondaryLabel, desc, date) 109 | } 110 | 111 | DateChooserState.Now -> { 112 | viewModel.addRecord(income, money, primaryLabel, secondaryLabel, desc) 113 | } 114 | } 115 | onCloseRequest() 116 | }, 117 | ) 118 | 119 | showDialog?.let { 120 | AddLabelDialog( 121 | onDismissRequest = { showDialog = null }, 122 | onConfirm = { labelName -> 123 | val parent = it.parentLabel 124 | if (parent == null) { 125 | viewModel.addPrimaryLabel(labelName, it.isIncomeLabel) 126 | } else { 127 | viewModel.addSecondaryLabel(parent, labelName, it.isIncomeLabel) 128 | } 129 | showDialog = null 130 | } 131 | ) 132 | } 133 | } 134 | 135 | 136 | @Composable 137 | fun AddRecord( 138 | onCloseClick: () -> Unit, 139 | modifier: Modifier = Modifier, 140 | isIncome: Boolean, 141 | onIncomeChange: (Boolean) -> Unit, 142 | primaryLabels: List, 143 | checkedPrimaryLabel: Int, 144 | onPrimaryLabelSelect: (Int) -> Unit, 145 | secondaryLabels: List, 146 | checkedSecondaryLabel: Int?, 147 | onSecondaryLabelSelect: (Int) -> Unit, 148 | onAddLabelClick: (isIncomeLabel: Boolean, parentLabel: Int?) -> Unit, 149 | onAddRecordClick: (isIncome: Boolean, money: Int, description: String?, date: DateChooserState) -> Unit 150 | ) { 151 | Surface(modifier) { 152 | Column( 153 | Modifier 154 | .fillMaxWidth() 155 | .wrapContentHeight() 156 | ) { 157 | var moneyStr by remember { mutableStateOf(TextFieldValue("")) } 158 | 159 | val labelColor = if (isIncome) MaterialTheme.colorScheme.tertiaryContainer 160 | else MaterialTheme.colorScheme.primaryContainer 161 | val buttonColor = if (isIncome) MaterialTheme.colorScheme.tertiary 162 | else MaterialTheme.colorScheme.primary 163 | 164 | var dateState by remember { mutableStateOf(DateChooserState.Now) } 165 | Row( 166 | modifier = Modifier.fillMaxWidth(), 167 | horizontalArrangement = Arrangement.SpaceBetween, 168 | verticalAlignment = Alignment.CenterVertically 169 | ) { 170 | IconButton(onClick = onCloseClick) { 171 | Icon(Icons.Default.Close, contentDescription = "Close") 172 | } 173 | DateChooser( 174 | modifier = Modifier.padding(horizontal = 16.dp), 175 | dateState, 176 | { dateState = it } 177 | ) 178 | } 179 | 180 | EditArea( 181 | modifier = Modifier 182 | .fillMaxWidth() 183 | .padding(horizontal = 16.dp), 184 | income = isIncome, 185 | onIncomeChange = onIncomeChange, 186 | moneyStr = moneyStr, 187 | onMoneyStrChange = { moneyStr = it } 188 | ) 189 | Divider( 190 | Modifier 191 | .padding(horizontal = 16.dp, vertical = 8.dp) 192 | .fillMaxWidth() 193 | ) 194 | LabelList( 195 | Modifier.fillMaxWidth(), 196 | primaryLabels, 197 | checkedPrimaryLabel, 198 | onLabelClick = onPrimaryLabelSelect, 199 | labelColor = labelColor, 200 | onAddLabelClick = { onAddLabelClick(isIncome, null) } 201 | ) 202 | Divider( 203 | Modifier 204 | .fillMaxWidth() 205 | .padding(horizontal = 16.dp, vertical = 8.dp) 206 | ) 207 | LabelList( 208 | Modifier.fillMaxWidth(), 209 | secondaryLabels, 210 | checkedSecondaryLabel, 211 | onLabelClick = onSecondaryLabelSelect, 212 | labelColor = labelColor, 213 | onAddLabelClick = { onAddLabelClick(isIncome, checkedPrimaryLabel) } 214 | ) 215 | Divider( 216 | Modifier 217 | .fillMaxWidth() 218 | .padding(horizontal = 16.dp, vertical = 8.dp) 219 | ) 220 | var input by remember { mutableStateOf(TextFieldValue()) } 221 | 222 | EditDescription( 223 | modifier = Modifier 224 | .fillMaxWidth() 225 | .padding(horizontal = 16.dp), 226 | value = input, 227 | onValueChange = { input = it } 228 | ) 229 | 230 | Button( 231 | modifier = Modifier 232 | .fillMaxWidth() 233 | .padding(horizontal = 16.dp), 234 | onClick = { 235 | onAddRecordClick( 236 | isIncome, 237 | parseMoneyToCent(moneyStr.text), 238 | input.text.takeIf { it.isNotEmpty() }, 239 | dateState 240 | ) 241 | // Reset state 242 | moneyStr = TextFieldValue() 243 | input = TextFieldValue() 244 | dateState = DateChooserState.Now 245 | }, 246 | enabled = moneyStr.text.isNotBlank(), 247 | colors = ButtonDefaults.buttonColors(buttonColor) 248 | ) { 249 | Icon(Icons.Default.Add, contentDescription = "Add") 250 | Spacer(Modifier.width(12.dp)) 251 | Text("记录") 252 | } 253 | Spacer(Modifier.height(58.dp)) 254 | } 255 | } 256 | } 257 | 258 | 259 | @Preview 260 | @Composable 261 | private fun AddRecordPreview() { 262 | KeepTallyTheme { 263 | AddRecord( 264 | onCloseClick = {}, 265 | isIncome = false, 266 | onIncomeChange = {}, 267 | onAddRecordClick = { _, _, _, _ -> }, 268 | primaryLabels = remember { 269 | listOf("购物", "餐饮", "洗浴") 270 | }, 271 | secondaryLabels = remember { 272 | listOf("早餐", "午餐", "晚餐") 273 | }, 274 | checkedPrimaryLabel = 0, 275 | checkedSecondaryLabel = 0, 276 | modifier = Modifier.fillMaxWidth(), 277 | onPrimaryLabelSelect = {}, 278 | onSecondaryLabelSelect = {}, 279 | onAddLabelClick = { _, _ -> } 280 | ) 281 | } 282 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/me/konyaco/keeptally/ui/component/addrecord/Util.kt: -------------------------------------------------------------------------------- 1 | package me.konyaco.keeptally.ui.component.addrecord 2 | 3 | import androidx.compose.ui.text.TextRange 4 | import androidx.compose.ui.text.input.TextFieldValue 5 | 6 | internal fun validateNumberString(str: String): Boolean { 7 | var hasDot = false 8 | var decimalLength = 0 9 | 10 | for (c in str) { 11 | if (c == '.') { 12 | if (hasDot) return false 13 | else hasDot = true 14 | } else if (c in '0'..'9') { 15 | if (hasDot) { 16 | decimalLength += 1 17 | if (decimalLength > 2) { 18 | return false 19 | } 20 | } 21 | } else { 22 | return false 23 | } 24 | } 25 | return true 26 | } 27 | 28 | /** 29 | * Trim '0' 30 | * 0043.1 31 | * 43.1 32 | */ 33 | internal fun normalizeNumberString(value: TextFieldValue): TextFieldValue { 34 | val text = value.text 35 | return if (text == "0") { 36 | value 37 | } else if (text.getOrNull(text.indexOf('.') - 1) == '0') { 38 | value 39 | } else { 40 | var zeros = 0 41 | for (c in text) { 42 | if (c == '0') zeros++ 43 | else break 44 | } 45 | value.copy( 46 | text = text.substring(zeros until text.length), 47 | selection = TextRange(value.selection.start - zeros, value.selection.end - zeros) 48 | ) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/konyaco/keeptally/ui/component/addrecord/component/AddLabel.kt: -------------------------------------------------------------------------------- 1 | package me.konyaco.keeptally.ui.component.addrecord.component 2 | 3 | import androidx.compose.foundation.interaction.MutableInteractionSource 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.foundation.layout.wrapContentSize 7 | import androidx.compose.foundation.text.BasicTextField 8 | import androidx.compose.foundation.text.KeyboardActions 9 | import androidx.compose.foundation.text.KeyboardOptions 10 | import androidx.compose.material.icons.Icons 11 | import androidx.compose.material.icons.sharp.Add 12 | import androidx.compose.material3.Icon 13 | import androidx.compose.material3.LocalContentColor 14 | import androidx.compose.material3.MaterialTheme 15 | import androidx.compose.material3.contentColorFor 16 | import androidx.compose.runtime.Composable 17 | import androidx.compose.runtime.getValue 18 | import androidx.compose.runtime.mutableStateOf 19 | import androidx.compose.runtime.remember 20 | import androidx.compose.runtime.setValue 21 | import androidx.compose.ui.Alignment 22 | import androidx.compose.ui.Modifier 23 | import androidx.compose.ui.focus.FocusRequester 24 | import androidx.compose.ui.focus.focusRequester 25 | import androidx.compose.ui.focus.onFocusChanged 26 | import androidx.compose.ui.graphics.Color 27 | import androidx.compose.ui.graphics.SolidColor 28 | import androidx.compose.ui.text.input.ImeAction 29 | import androidx.compose.ui.tooling.preview.Preview 30 | import androidx.compose.ui.unit.dp 31 | import me.konyaco.keeptally.ui.theme.KeepTallyTheme 32 | 33 | @Composable 34 | internal fun AddLabel( 35 | activeColor: Color = MaterialTheme.colorScheme.tertiaryContainer, 36 | activeContentColor: Color = contentColorFor(activeColor) 37 | ) { 38 | var selected by remember { mutableStateOf(false) } 39 | 40 | val focusRequester = remember { FocusRequester() } 41 | val interactionSource = remember { MutableInteractionSource() } 42 | 43 | LabelContainer( 44 | modifier = Modifier.wrapContentSize(), 45 | selected = selected, 46 | onSelectChange = { focusRequester.requestFocus() }, 47 | activeColor = activeColor, 48 | activeContentColor = activeContentColor, 49 | interactionSource = interactionSource 50 | ) { 51 | Box(Modifier.padding(horizontal = 12.dp), Alignment.Center) { 52 | var value by remember { mutableStateOf("") } 53 | BasicTextField( 54 | modifier = Modifier 55 | .wrapContentSize() 56 | .padding(vertical = 6.dp) 57 | .focusRequester(focusRequester) 58 | .onFocusChanged { selected = it.isFocused }, 59 | value = value, 60 | interactionSource = interactionSource, 61 | onValueChange = { value = it }, 62 | textStyle = MaterialTheme.typography.labelLarge.copy( 63 | color = LocalContentColor.current 64 | ), 65 | singleLine = true, 66 | keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), 67 | keyboardActions = KeyboardActions(onDone = { 68 | focusRequester.freeFocus() 69 | // TODO: On add label 70 | }), 71 | cursorBrush = SolidColor(LocalContentColor.current) 72 | ) 73 | if (!selected) Icon(Icons.Sharp.Add, contentDescription = "Add label") 74 | } 75 | } 76 | } 77 | 78 | 79 | @Preview 80 | @Composable 81 | private fun AddLabelPreview() { 82 | KeepTallyTheme { 83 | AddLabel() 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/konyaco/keeptally/ui/component/addrecord/component/AddLabelDialog.kt: -------------------------------------------------------------------------------- 1 | package me.konyaco.keeptally.ui.component.addrecord.component 2 | 3 | import androidx.compose.material3.AlertDialog 4 | import androidx.compose.material3.ExperimentalMaterial3Api 5 | import androidx.compose.material3.OutlinedTextField 6 | import androidx.compose.material3.Text 7 | import androidx.compose.material3.TextButton 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.runtime.getValue 10 | import androidx.compose.runtime.mutableStateOf 11 | import androidx.compose.runtime.remember 12 | import androidx.compose.runtime.setValue 13 | 14 | @OptIn(ExperimentalMaterial3Api::class) 15 | @Composable 16 | internal fun AddLabelDialog(onDismissRequest: () -> Unit, onConfirm: (String) -> Unit) { 17 | var value by remember { mutableStateOf("") } 18 | AlertDialog( 19 | title = { Text(text = "新建标签") }, 20 | text = { 21 | OutlinedTextField( 22 | value = value, 23 | onValueChange = { value = it }, 24 | ) 25 | }, 26 | onDismissRequest = onDismissRequest, 27 | confirmButton = { 28 | TextButton(onClick = { onConfirm(value) }) { 29 | Text("确定") 30 | } 31 | }, dismissButton = { 32 | TextButton(onClick = onDismissRequest) { 33 | Text("取消") 34 | } 35 | } 36 | ) 37 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/me/konyaco/keeptally/ui/component/addrecord/component/DateChooser.kt: -------------------------------------------------------------------------------- 1 | package me.konyaco.keeptally.ui.component.addrecord.component 2 | 3 | import android.app.DatePickerDialog 4 | import android.app.TimePickerDialog 5 | import android.content.Context 6 | import androidx.compose.animation.Crossfade 7 | import androidx.compose.foundation.clickable 8 | import androidx.compose.foundation.interaction.MutableInteractionSource 9 | import androidx.compose.foundation.layout.Arrangement 10 | import androidx.compose.foundation.layout.Box 11 | import androidx.compose.foundation.layout.Row 12 | import androidx.compose.foundation.layout.fillMaxWidth 13 | import androidx.compose.foundation.layout.padding 14 | import androidx.compose.foundation.layout.wrapContentSize 15 | import androidx.compose.material3.MaterialTheme 16 | import androidx.compose.material3.Text 17 | import androidx.compose.runtime.Composable 18 | import androidx.compose.runtime.Stable 19 | import androidx.compose.runtime.getValue 20 | import androidx.compose.runtime.mutableStateOf 21 | import androidx.compose.runtime.remember 22 | import androidx.compose.runtime.rememberCoroutineScope 23 | import androidx.compose.runtime.setValue 24 | import androidx.compose.ui.Alignment 25 | import androidx.compose.ui.Modifier 26 | import androidx.compose.ui.platform.LocalContext 27 | import androidx.compose.ui.tooling.preview.Preview 28 | import androidx.compose.ui.unit.dp 29 | import kotlinx.coroutines.launch 30 | import kotlinx.coroutines.suspendCancellableCoroutine 31 | import me.konyaco.keeptally.ui.theme.KeepTallyTheme 32 | import java.time.LocalDate 33 | import java.time.LocalDateTime 34 | import java.time.LocalTime 35 | import java.time.format.DateTimeFormatter 36 | import java.time.format.FormatStyle 37 | import kotlin.coroutines.resume 38 | 39 | @Composable 40 | internal fun DateChooser( 41 | modifier: Modifier, 42 | state: DateChooserState, 43 | onStateChange: (DateChooserState) -> Unit 44 | ) { 45 | val scope = rememberCoroutineScope() 46 | val context = LocalContext.current 47 | 48 | Box(modifier) { 49 | Crossfade( 50 | modifier = Modifier.fillMaxWidth(), 51 | targetState = state 52 | ) { state -> 53 | Row( 54 | modifier = Modifier.fillMaxWidth(), 55 | verticalAlignment = Alignment.CenterVertically, 56 | horizontalArrangement = Arrangement.End 57 | ) { 58 | when (state) { 59 | DateChooserState.Now -> { 60 | Text( 61 | modifier = Modifier 62 | .clickable( 63 | interactionSource = remember { MutableInteractionSource() }, 64 | indication = null 65 | ) { 66 | scope.launch { 67 | val state = with(getUserSelection(context)) { 68 | DateChooserState.Custom( 69 | year, monthValue, 70 | dayOfMonth, hour, minute 71 | ) 72 | } 73 | onStateChange(state) 74 | } 75 | } 76 | .padding(horizontal = 4.dp), 77 | text = "现在", 78 | color = MaterialTheme.colorScheme.onSurfaceVariant, 79 | style = MaterialTheme.typography.labelLarge 80 | ) 81 | } 82 | 83 | is DateChooserState.Custom -> { 84 | val dateText = formatDate(state) 85 | val timeText = formatTime(state) 86 | 87 | Text( 88 | modifier = Modifier 89 | .clickable( 90 | interactionSource = remember { MutableInteractionSource() }, 91 | indication = null 92 | ) { 93 | scope.launch { 94 | val date = getDate( 95 | context, 96 | LocalDate.of(state.year, state.month, state.day) 97 | ) 98 | onStateChange( 99 | DateChooserState.Custom( 100 | date.year, date.monthValue, date.dayOfMonth, 101 | state.hour, state.minute 102 | ) 103 | ) 104 | } 105 | } 106 | .padding(4.dp), 107 | text = dateText, 108 | style = MaterialTheme.typography.labelLarge, 109 | color = MaterialTheme.colorScheme.onSurface 110 | ) 111 | Text( 112 | text = "•", 113 | style = MaterialTheme.typography.labelLarge, 114 | color = MaterialTheme.colorScheme.onSurface 115 | ) 116 | Text( 117 | modifier = Modifier 118 | .clickable( 119 | interactionSource = remember { MutableInteractionSource() }, 120 | indication = null 121 | ) { 122 | scope.launch { 123 | val time = 124 | getTime(context, LocalTime.of(state.hour, state.minute)) 125 | onStateChange( 126 | DateChooserState.Custom( 127 | state.year, state.month, state.day, 128 | time.hour, time.minute 129 | ) 130 | ) 131 | } 132 | } 133 | .padding(4.dp), 134 | text = timeText, 135 | style = MaterialTheme.typography.labelLarge, 136 | color = MaterialTheme.colorScheme.onSurface 137 | ) 138 | } 139 | } 140 | } 141 | } 142 | } 143 | } 144 | 145 | sealed class DateChooserState { 146 | object Now : DateChooserState() 147 | data class Custom( 148 | val year: Int, 149 | val month: Int, 150 | val day: Int, 151 | val hour: Int, 152 | val minute: Int, 153 | ) : DateChooserState() 154 | } 155 | 156 | @Stable 157 | @Composable 158 | private fun formatDate(state: DateChooserState.Custom): String = remember(state) { 159 | with(state) { 160 | DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) 161 | .format(LocalDate.of(year, month, day)) 162 | } 163 | } 164 | 165 | @Stable 166 | @Composable 167 | private fun formatTime(state: DateChooserState.Custom): String = remember(state) { 168 | with(state) { 169 | DateTimeFormatter.ofPattern("HH:mm").format(LocalTime.of(hour, minute)) 170 | } 171 | } 172 | 173 | private suspend fun getUserSelection(context: Context): LocalDateTime { 174 | return getDate(context).atTime(getTime(context)) 175 | } 176 | 177 | private suspend fun getDate(context: Context, date: LocalDate = LocalDate.now()): LocalDate { 178 | return suspendCancellableCoroutine { cont -> 179 | DatePickerDialog( 180 | context, { _, year, month, day -> 181 | cont.resume(LocalDate.of(year, month + 1, day)) 182 | }, date.year, date.monthValue - 1, date.dayOfMonth 183 | ).show() 184 | } 185 | } 186 | 187 | private suspend fun getTime( 188 | context: Context, 189 | time: LocalTime = LocalTime.now() 190 | ): LocalTime { 191 | return suspendCancellableCoroutine { cont -> 192 | TimePickerDialog( 193 | context, { _, hour, minute -> 194 | cont.resume(LocalTime.of(hour, minute)) 195 | }, time.hour, time.minute, true 196 | ).show() 197 | } 198 | } 199 | 200 | @Preview 201 | @Composable 202 | private fun Preview() { 203 | KeepTallyTheme { 204 | var state by remember { mutableStateOf(DateChooserState.Now) } 205 | DateChooser( 206 | modifier = Modifier.wrapContentSize(), 207 | state = state, 208 | onStateChange = { state = it } 209 | ) 210 | } 211 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/me/konyaco/keeptally/ui/component/addrecord/component/EditArea.kt: -------------------------------------------------------------------------------- 1 | package me.konyaco.keeptally.ui.component.addrecord.component 2 | 3 | import androidx.compose.foundation.layout.Row 4 | import androidx.compose.foundation.layout.Spacer 5 | import androidx.compose.foundation.layout.fillMaxWidth 6 | import androidx.compose.foundation.layout.offset 7 | import androidx.compose.foundation.layout.size 8 | import androidx.compose.foundation.layout.width 9 | import androidx.compose.foundation.text.BasicTextField 10 | import androidx.compose.foundation.text.KeyboardActions 11 | import androidx.compose.foundation.text.KeyboardOptions 12 | import androidx.compose.material3.Divider 13 | import androidx.compose.material3.IconButton 14 | import androidx.compose.material3.MaterialTheme 15 | import androidx.compose.material3.Text 16 | import androidx.compose.runtime.Composable 17 | import androidx.compose.runtime.remember 18 | import androidx.compose.ui.Alignment 19 | import androidx.compose.ui.Modifier 20 | import androidx.compose.ui.graphics.SolidColor 21 | import androidx.compose.ui.platform.LocalFocusManager 22 | import androidx.compose.ui.text.font.FontFamily 23 | import androidx.compose.ui.text.input.ImeAction 24 | import androidx.compose.ui.text.input.KeyboardType 25 | import androidx.compose.ui.text.input.TextFieldValue 26 | import androidx.compose.ui.text.style.TextAlign 27 | import androidx.compose.ui.tooling.preview.Preview 28 | import androidx.compose.ui.unit.dp 29 | import me.konyaco.keeptally.ui.component.addrecord.normalizeNumberString 30 | import me.konyaco.keeptally.ui.component.addrecord.validateNumberString 31 | import me.konyaco.keeptally.ui.theme.KeepTallyTheme 32 | import me.konyaco.keeptally.ui.theme.RobotoSlab 33 | import me.konyaco.keeptally.viewmodel.model.RecordSign 34 | 35 | @Composable 36 | internal fun EditArea( 37 | modifier: Modifier, 38 | income: Boolean, 39 | onIncomeChange: (Boolean) -> Unit, 40 | moneyStr: TextFieldValue, 41 | onMoneyStrChange: (TextFieldValue) -> Unit 42 | ) { 43 | val color = if (income) MaterialTheme.colorScheme.tertiary 44 | else MaterialTheme.colorScheme.primary 45 | Row(modifier, verticalAlignment = Alignment.CenterVertically) { 46 | IconButton(onClick = { onIncomeChange(!income) }) { 47 | Text( 48 | modifier = Modifier.offset(y = (-2).dp), 49 | text = if (income) RecordSign.POSITIVE else RecordSign.NEGATIVE, 50 | style = MaterialTheme.typography.displaySmall, 51 | color = color, 52 | fontFamily = FontFamily.RobotoSlab 53 | ) 54 | } 55 | Divider(Modifier.size(1.dp, 32.dp)) 56 | val focus = LocalFocusManager.current 57 | BasicTextField( 58 | modifier = Modifier 59 | .weight(1f) 60 | .alignByBaseline(), 61 | value = moneyStr, 62 | onValueChange = { 63 | if (validateNumberString(it.text)) { 64 | val text = normalizeNumberString(it) 65 | onMoneyStrChange(text) 66 | } 67 | }, 68 | textStyle = MaterialTheme.typography.displaySmall.copy( 69 | fontFamily = FontFamily.RobotoSlab, 70 | color = color, 71 | textAlign = TextAlign.End 72 | ), 73 | singleLine = true, 74 | keyboardOptions = KeyboardOptions( 75 | keyboardType = KeyboardType.Number, 76 | imeAction = ImeAction.Done 77 | ), 78 | keyboardActions = KeyboardActions(onDone = { 79 | focus.clearFocus() 80 | }), 81 | cursorBrush = SolidColor(MaterialTheme.colorScheme.primary) 82 | ) 83 | 84 | Spacer(Modifier.width(8.dp)) 85 | Text( 86 | modifier = Modifier.alignByBaseline(), 87 | text = RecordSign.RMB, 88 | style = MaterialTheme.typography.headlineLarge, 89 | fontFamily = FontFamily.RobotoSlab 90 | ) 91 | } 92 | } 93 | 94 | @Preview(showBackground = true) 95 | @Composable 96 | private fun EditAreaPreview() { 97 | KeepTallyTheme { 98 | EditArea( 99 | Modifier.fillMaxWidth(), 100 | true, 101 | {}, 102 | remember { TextFieldValue() }, 103 | onMoneyStrChange = {}) 104 | } 105 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/me/konyaco/keeptally/ui/component/addrecord/component/EditDescription.kt: -------------------------------------------------------------------------------- 1 | package me.konyaco.keeptally.ui.component.addrecord.component 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.fillMaxWidth 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.foundation.text.BasicTextField 7 | import androidx.compose.material3.LocalContentColor 8 | import androidx.compose.material3.LocalTextStyle 9 | import androidx.compose.material3.Text 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.graphics.SolidColor 13 | import androidx.compose.ui.text.input.TextFieldValue 14 | import androidx.compose.ui.unit.dp 15 | 16 | @Composable 17 | internal fun EditDescription( 18 | modifier: Modifier, 19 | value: TextFieldValue, 20 | onValueChange: (TextFieldValue) -> Unit 21 | ) { 22 | Box(modifier) { 23 | BasicTextField( 24 | modifier = Modifier 25 | .fillMaxWidth() 26 | .padding(vertical = 12.dp), 27 | value = value, 28 | onValueChange = onValueChange, 29 | decorationBox = { 30 | Box( 31 | Modifier 32 | .fillMaxWidth() 33 | ) { 34 | it() 35 | if (value.text.isEmpty()) { 36 | Text("添加备注", color = LocalContentColor.current.copy(0.7f)) 37 | } 38 | } 39 | }, 40 | textStyle = LocalTextStyle.current.copy(LocalContentColor.current), 41 | cursorBrush = SolidColor(LocalContentColor.current) 42 | ) 43 | } 44 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/me/konyaco/keeptally/ui/component/addrecord/component/LabelContainer.kt: -------------------------------------------------------------------------------- 1 | package me.konyaco.keeptally.ui.component.addrecord.component 2 | 3 | import androidx.compose.animation.animateColorAsState 4 | import androidx.compose.animation.core.animateDpAsState 5 | import androidx.compose.foundation.BorderStroke 6 | import androidx.compose.foundation.clickable 7 | import androidx.compose.foundation.interaction.MutableInteractionSource 8 | import androidx.compose.foundation.layout.defaultMinSize 9 | import androidx.compose.foundation.shape.RoundedCornerShape 10 | import androidx.compose.material3.LocalContentColor 11 | import androidx.compose.material3.MaterialTheme 12 | import androidx.compose.material3.Surface 13 | import androidx.compose.material3.contentColorFor 14 | import androidx.compose.runtime.Composable 15 | import androidx.compose.runtime.CompositionLocalProvider 16 | import androidx.compose.runtime.getValue 17 | import androidx.compose.runtime.remember 18 | import androidx.compose.ui.Modifier 19 | import androidx.compose.ui.graphics.Color 20 | import androidx.compose.ui.unit.dp 21 | 22 | @Composable 23 | internal fun LabelContainer( 24 | modifier: Modifier, 25 | selected: Boolean, 26 | onSelectChange: (Boolean) -> Unit, 27 | interactionSource: MutableInteractionSource = remember { 28 | MutableInteractionSource() 29 | }, 30 | activeColor: Color = MaterialTheme.colorScheme.tertiaryContainer, 31 | activeContentColor: Color = contentColorFor(activeColor), 32 | content: @Composable () -> Unit 33 | ) { 34 | val borderWidth by animateDpAsState(if (selected) 0.dp else 1.dp) 35 | Surface( 36 | modifier = modifier 37 | .defaultMinSize(minWidth = 48.dp) 38 | .clickable( 39 | interactionSource = interactionSource, 40 | indication = null, 41 | onClick = { onSelectChange(!selected) }, 42 | ), 43 | color = animateColorAsState( 44 | if (selected) activeColor 45 | else MaterialTheme.colorScheme.surface 46 | ).value, 47 | border = if (borderWidth == 0.dp) null else BorderStroke( 48 | borderWidth, 49 | Color(0xFF757876) /* TODO */ 50 | ), 51 | shape = RoundedCornerShape(8.dp) 52 | ) { 53 | CompositionLocalProvider( 54 | LocalContentColor provides animateColorAsState( 55 | if (selected) activeContentColor 56 | else MaterialTheme.colorScheme.onSurfaceVariant 57 | ).value 58 | ) { 59 | content() 60 | } 61 | } 62 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/me/konyaco/keeptally/ui/component/addrecord/component/LabelItem.kt: -------------------------------------------------------------------------------- 1 | package me.konyaco.keeptally.ui.component.addrecord.component 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.padding 5 | import androidx.compose.foundation.layout.wrapContentSize 6 | import androidx.compose.material3.MaterialTheme 7 | import androidx.compose.material3.Text 8 | import androidx.compose.material3.contentColorFor 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.runtime.getValue 11 | import androidx.compose.runtime.mutableStateOf 12 | import androidx.compose.runtime.remember 13 | import androidx.compose.runtime.setValue 14 | import androidx.compose.ui.Alignment 15 | import androidx.compose.ui.Modifier 16 | import androidx.compose.ui.graphics.Color 17 | import androidx.compose.ui.tooling.preview.Preview 18 | import androidx.compose.ui.unit.dp 19 | import me.konyaco.keeptally.ui.theme.KeepTallyTheme 20 | 21 | @Composable 22 | internal fun LabelItem( 23 | modifier: Modifier = Modifier, 24 | selected: Boolean, 25 | onSelectChange: (Boolean) -> Unit, 26 | text: String, 27 | activeColor: Color = MaterialTheme.colorScheme.tertiaryContainer, 28 | activeContentColor: Color = contentColorFor(activeColor) 29 | ) { 30 | LabelContainer( 31 | modifier = modifier, 32 | selected = selected, 33 | onSelectChange = onSelectChange, 34 | activeColor = activeColor, 35 | activeContentColor = activeContentColor 36 | ) { 37 | Box(Modifier.padding(12.dp, 6.dp), Alignment.Center) { 38 | Text( 39 | modifier = Modifier.wrapContentSize(), 40 | text = text, 41 | style = MaterialTheme.typography.labelLarge 42 | ) 43 | } 44 | } 45 | } 46 | 47 | @Preview 48 | @Composable 49 | private fun PreviewLabel() { 50 | KeepTallyTheme { 51 | var selected by remember { mutableStateOf(false) } 52 | LabelItem(selected = selected, onSelectChange = { selected = it }, text = "标签") 53 | } 54 | } 55 | 56 | 57 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/konyaco/keeptally/ui/component/addrecord/component/LabelList.kt: -------------------------------------------------------------------------------- 1 | package me.konyaco.keeptally.ui.component.addrecord.component 2 | 3 | import androidx.compose.foundation.ExperimentalFoundationApi 4 | import androidx.compose.foundation.layout.Arrangement 5 | import androidx.compose.foundation.layout.PaddingValues 6 | import androidx.compose.foundation.layout.Row 7 | import androidx.compose.foundation.layout.fillMaxWidth 8 | import androidx.compose.foundation.layout.height 9 | import androidx.compose.foundation.layout.width 10 | import androidx.compose.foundation.layout.wrapContentHeight 11 | import androidx.compose.foundation.lazy.LazyRow 12 | import androidx.compose.foundation.lazy.itemsIndexed 13 | import androidx.compose.material.icons.Icons 14 | import androidx.compose.material.icons.filled.ArrowDropDown 15 | import androidx.compose.material3.Divider 16 | import androidx.compose.material3.Icon 17 | import androidx.compose.material3.IconButton 18 | import androidx.compose.material3.MaterialTheme 19 | import androidx.compose.runtime.Composable 20 | import androidx.compose.runtime.getValue 21 | import androidx.compose.runtime.mutableStateOf 22 | import androidx.compose.runtime.remember 23 | import androidx.compose.runtime.setValue 24 | import androidx.compose.ui.Alignment 25 | import androidx.compose.ui.Modifier 26 | import androidx.compose.ui.graphics.Color 27 | import androidx.compose.ui.tooling.preview.Preview 28 | import androidx.compose.ui.unit.dp 29 | import me.konyaco.keeptally.ui.theme.KeepTallyTheme 30 | 31 | @OptIn(ExperimentalFoundationApi::class) 32 | @Composable 33 | internal fun LabelList( 34 | modifier: Modifier, 35 | labels: List, 36 | checkedLabel: Int?, 37 | onLabelClick: (Int) -> Unit, 38 | onAddLabelClick: () -> Unit, 39 | labelColor: Color 40 | ) { 41 | Row( 42 | modifier = modifier.wrapContentHeight(), 43 | verticalAlignment = Alignment.CenterVertically 44 | ) { 45 | LazyRow( 46 | modifier = Modifier 47 | .wrapContentHeight() 48 | .weight(1f), 49 | contentPadding = PaddingValues(horizontal = 16.dp), 50 | horizontalArrangement = Arrangement.spacedBy(16.dp) 51 | ) { 52 | itemsIndexed(labels) { index, item -> 53 | LabelItem( 54 | modifier = Modifier.animateItemPlacement(), 55 | selected = checkedLabel == index, 56 | onSelectChange = { onLabelClick(index) }, 57 | text = item, 58 | activeColor = labelColor 59 | ) 60 | } 61 | item { 62 | LabelItem( 63 | modifier = Modifier.animateItemPlacement(), 64 | selected = false, 65 | onSelectChange = { onAddLabelClick() }, 66 | text = "+", 67 | activeColor = labelColor 68 | ) 69 | } 70 | } 71 | Divider( 72 | Modifier 73 | .height(48.dp) 74 | .width(1.dp) 75 | ) 76 | IconButton(onClick = { /*TODO*/ }) { 77 | Icon(Icons.Default.ArrowDropDown, contentDescription = "Dropdown") 78 | } 79 | } 80 | } 81 | 82 | @Composable 83 | @Preview 84 | private fun PreviewLabelList() { 85 | KeepTallyTheme { 86 | val primaryLabels = remember { 87 | listOf("购物", "餐饮", "洗浴") 88 | } 89 | var enabledLabel by remember { mutableStateOf(0) } 90 | LabelList( 91 | modifier = Modifier.fillMaxWidth(), 92 | primaryLabels, 93 | enabledLabel, 94 | onLabelClick = { 95 | enabledLabel = it 96 | }, 97 | labelColor = MaterialTheme.colorScheme.tertiary, 98 | onAddLabelClick = {}) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/konyaco/keeptally/ui/detail/DetailScreen.kt: -------------------------------------------------------------------------------- 1 | package me.konyaco.keeptally.ui.detail 2 | 3 | import androidx.compose.animation.Crossfade 4 | import androidx.compose.foundation.Image 5 | import androidx.compose.foundation.layout.Arrangement 6 | import androidx.compose.foundation.layout.Box 7 | import androidx.compose.foundation.layout.Column 8 | import androidx.compose.foundation.layout.PaddingValues 9 | import androidx.compose.foundation.layout.Spacer 10 | import androidx.compose.foundation.layout.WindowInsets 11 | import androidx.compose.foundation.layout.asPaddingValues 12 | import androidx.compose.foundation.layout.fillMaxSize 13 | import androidx.compose.foundation.layout.fillMaxWidth 14 | import androidx.compose.foundation.layout.height 15 | import androidx.compose.foundation.layout.navigationBars 16 | import androidx.compose.foundation.layout.padding 17 | import androidx.compose.foundation.layout.sizeIn 18 | import androidx.compose.foundation.layout.wrapContentHeight 19 | import androidx.compose.foundation.lazy.LazyColumn 20 | import androidx.compose.material.icons.Icons 21 | import androidx.compose.material.icons.sharp.Add 22 | import androidx.compose.material3.CircularProgressIndicator 23 | import androidx.compose.material3.Divider 24 | import androidx.compose.material3.ExtendedFloatingActionButton 25 | import androidx.compose.material3.Icon 26 | import androidx.compose.material3.MaterialTheme 27 | import androidx.compose.material3.Surface 28 | import androidx.compose.material3.Text 29 | import androidx.compose.runtime.Composable 30 | import androidx.compose.runtime.Stable 31 | import androidx.compose.runtime.collectAsState 32 | import androidx.compose.runtime.getValue 33 | import androidx.compose.runtime.remember 34 | import androidx.compose.ui.Alignment 35 | import androidx.compose.ui.Modifier 36 | import androidx.compose.ui.graphics.ColorFilter 37 | import androidx.compose.ui.layout.ContentScale 38 | import androidx.compose.ui.res.painterResource 39 | import androidx.compose.ui.unit.dp 40 | import androidx.hilt.navigation.compose.hiltViewModel 41 | import me.konyaco.keeptally.R 42 | import me.konyaco.keeptally.ui.detail.component.DailyRecord 43 | import me.konyaco.keeptally.ui.detail.component.LineChart 44 | import me.konyaco.keeptally.ui.detail.component.MoreInfo 45 | import me.konyaco.keeptally.ui.detail.component.TotalExpenditure 46 | import me.konyaco.keeptally.viewmodel.MainViewModel 47 | import me.konyaco.keeptally.viewmodel.MainViewModel.State.Done 48 | import me.konyaco.keeptally.viewmodel.MainViewModel.State.Initializing 49 | import me.konyaco.keeptally.viewmodel.MainViewModel.State.Loading 50 | 51 | @Composable 52 | fun DetailScreen( 53 | viewModel: MainViewModel = hiltViewModel(), 54 | onAddClick: () -> Unit 55 | ) { 56 | val state by viewModel.state.collectAsState() 57 | Crossfade(modifier = Modifier.fillMaxSize(), targetState = state) { 58 | Box(Modifier.fillMaxSize()) { 59 | when (it) { 60 | Initializing, Loading -> CircularProgressIndicator(Modifier.align(Alignment.Center)) 61 | Done -> { 62 | Column( 63 | Modifier 64 | .fillMaxSize() 65 | .padding(horizontal = 16.dp) 66 | ) { 67 | val statistics by viewModel.statistics.collectAsState() 68 | TotalExpenditure( 69 | Modifier 70 | .fillMaxWidth() 71 | .padding(horizontal = 8.dp), 72 | integer = statistics.expenditure.moneyStr.integer, 73 | decimal = statistics.expenditure.moneyStr.decimal 74 | ) 75 | Divider(Modifier.padding(vertical = 8.dp)) 76 | MoreInfo( 77 | modifier = Modifier 78 | .fillMaxWidth() 79 | .padding(horizontal = 8.dp), 80 | budget = statistics.budget.moneyStr.join, 81 | income = statistics.income.moneyStr.join 82 | ) 83 | Spacer(Modifier.height(12.dp)) 84 | 85 | Content( 86 | modifier = Modifier 87 | .fillMaxWidth() 88 | .weight(1f) 89 | ) 90 | } 91 | AddRecordButton(Modifier.align(Alignment.BottomEnd), onAddClick) 92 | } 93 | } 94 | } 95 | } 96 | } 97 | 98 | 99 | @Composable 100 | private fun AddRecordButton(modifier: Modifier, onAddClick: () -> Unit) { 101 | val insetPaddings = WindowInsets.navigationBars.asPaddingValues() 102 | val paddingBottom = remember(insetPaddings) { insetPaddings.calculateBottomPadding() } 103 | 104 | ExtendedFloatingActionButton( 105 | modifier = modifier.padding( 106 | end = 16.dp, 107 | bottom = 16.dp + paddingBottom 108 | ), 109 | icon = { Icon(Icons.Sharp.Add, "Add Record") }, 110 | text = { Text("添加记录") }, 111 | onClick = onAddClick 112 | ) 113 | } 114 | 115 | 116 | @Composable 117 | private fun Content( 118 | viewModel: MainViewModel = hiltViewModel(), 119 | modifier: Modifier 120 | ) { 121 | Column(modifier) { 122 | LineChart(Modifier.fillMaxWidth()) 123 | val records by viewModel.records.collectAsState() 124 | Surface( 125 | modifier = Modifier 126 | .fillMaxWidth() 127 | .weight(1f), 128 | color = MaterialTheme.colorScheme.surface 129 | ) { 130 | Crossfade(modifier = modifier, targetState = records) { 131 | if (it.isEmpty()) { 132 | EmptyContent(Modifier.fillMaxSize()) 133 | } else { 134 | Content( 135 | modifier = Modifier.fillMaxSize(), 136 | records = it, 137 | onDelete = { viewModel.deleteRecord(it.id) } 138 | ) 139 | } 140 | } 141 | } 142 | } 143 | } 144 | 145 | @Composable 146 | private fun EmptyContent(modifier: Modifier) { 147 | Column( 148 | modifier = modifier, 149 | horizontalAlignment = Alignment.CenterHorizontally, 150 | verticalArrangement = Arrangement.Center 151 | ) { 152 | Image( 153 | modifier = Modifier 154 | .fillMaxWidth() 155 | .wrapContentHeight() 156 | .sizeIn(maxWidth = 500.dp, maxHeight = 500.dp), 157 | painter = painterResource(id = R.drawable.woman_and_pen), 158 | contentDescription = "Empty", 159 | contentScale = ContentScale.Fit, 160 | colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onPrimaryContainer) 161 | ) 162 | Spacer(Modifier.height(8.dp)) 163 | Text( 164 | text = "点击「添加记录」来创建第一笔记账", 165 | color = MaterialTheme.colorScheme.onSurfaceVariant 166 | ) 167 | } 168 | } 169 | 170 | @Composable 171 | private fun Content( 172 | modifier: Modifier, 173 | records: List, 174 | onDelete: (MainViewModel.Record) -> Unit 175 | ) { 176 | LazyColumn( 177 | modifier = modifier, 178 | contentPadding = PaddingValues( 179 | top = 16.dp, 180 | bottom = 16.dp + WindowInsets.navigationBars.asPaddingValues() 181 | .calculateBottomPadding() 182 | ) 183 | ) { 184 | records.forEachIndexed { index, item -> 185 | DailyRecord( 186 | index == records.size - 1, 187 | item.date, 188 | item.expenditure.moneyStr.join, 189 | item.income.moneyStr.join, 190 | item.records, 191 | onDelete 192 | ) 193 | } 194 | 195 | /* itemsIndexed( 196 | items = records, 197 | contentType = { index, item -> 1 }, 198 | key = { index, item -> item.date.dateString } 199 | ) { index, item -> 200 | DailyRecord( 201 | Modifier.fillMaxWidth(), 202 | item.date.parseAsString(), 203 | item.expenditure.moneyStr.join, 204 | item.income.moneyStr.join, 205 | item.records, 206 | onDelete 207 | ) 208 | if (index < records.size - 1) Divider(Modifier.padding(vertical = 8.dp)) 209 | }*/ 210 | } 211 | } 212 | 213 | @Stable 214 | fun MainViewModel.Date.parseAsString(): String { 215 | return when (daysOffset) { 216 | -2 -> "后天" 217 | -1 -> "明天" 218 | 0 -> "今天" 219 | 1 -> "昨天" 220 | 2 -> "前天" 221 | else -> dateString 222 | } 223 | } 224 | 225 | /* 226 | @Preview(showBackground = true) 227 | @Composable 228 | private fun DetailPagePreview() { 229 | AndroidKeepTallyTheme { 230 | DetailScreen(onAddClick = {}) 231 | } 232 | }*/ 233 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/konyaco/keeptally/ui/detail/component/DailyRecord.kt: -------------------------------------------------------------------------------- 1 | package me.konyaco.keeptally.ui.detail.component 2 | 3 | import androidx.compose.foundation.isSystemInDarkTheme 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.Row 7 | import androidx.compose.foundation.layout.Spacer 8 | import androidx.compose.foundation.layout.fillMaxWidth 9 | import androidx.compose.foundation.layout.height 10 | import androidx.compose.foundation.layout.padding 11 | import androidx.compose.foundation.layout.width 12 | import androidx.compose.foundation.lazy.LazyListScope 13 | import androidx.compose.foundation.lazy.items 14 | import androidx.compose.material3.Divider 15 | import androidx.compose.material3.DropdownMenu 16 | import androidx.compose.material3.DropdownMenuItem 17 | import androidx.compose.material3.MaterialTheme 18 | import androidx.compose.material3.Text 19 | import androidx.compose.runtime.Composable 20 | import androidx.compose.runtime.getValue 21 | import androidx.compose.runtime.mutableStateOf 22 | import androidx.compose.runtime.remember 23 | import androidx.compose.runtime.setValue 24 | import androidx.compose.ui.Modifier 25 | import androidx.compose.ui.text.font.FontFamily 26 | import androidx.compose.ui.tooling.preview.Preview 27 | import androidx.compose.ui.unit.dp 28 | import me.konyaco.keeptally.ui.component.RecordItem 29 | import me.konyaco.keeptally.ui.detail.parseAsString 30 | import me.konyaco.keeptally.ui.getRecordColor 31 | import me.konyaco.keeptally.ui.theme.KeepTallyTheme 32 | import me.konyaco.keeptally.ui.theme.RobotoSlab 33 | import me.konyaco.keeptally.viewmodel.MainViewModel 34 | import me.konyaco.keeptally.viewmodel.model.Money 35 | import me.konyaco.keeptally.viewmodel.model.RecordSign 36 | 37 | @Composable 38 | fun DailyRecord( 39 | modifier: Modifier = Modifier, 40 | date: String, 41 | expenditure: String, 42 | income: String, 43 | records: List, 44 | onDeleteClick: (record: MainViewModel.Record) -> Unit 45 | ) { 46 | Column(modifier) { 47 | val dark = isSystemInDarkTheme() 48 | Total( 49 | Modifier 50 | .fillMaxWidth() 51 | .padding(horizontal = 16.dp), 52 | date, expenditure, income 53 | ) 54 | Spacer(Modifier.height(8.dp)) 55 | var dropdown by remember { mutableStateOf(null) } 56 | 57 | Column(Modifier.fillMaxWidth()) { 58 | for (record in records) { 59 | val color = remember(record, dark) { 60 | getRecordColor(record.type.colorIndex, record.type.income, dark) 61 | } 62 | RecordItem( 63 | modifier = Modifier.fillMaxWidth(), 64 | color = color, 65 | title = record.type.label, 66 | time = record.time, 67 | category = record.type.parent ?: "", 68 | money = record.money.money, 69 | onClick = { /* TODO */ }, 70 | onLongClick = { dropdown = record }, 71 | moneyStr = record.money.moneyStr.join, 72 | description = record.description 73 | ) 74 | } 75 | DropdownMenu(expanded = dropdown != null, onDismissRequest = { dropdown = null }) { 76 | DropdownMenuItem(text = { 77 | Text(text = "删除") 78 | }, onClick = { 79 | dropdown?.let(onDeleteClick) 80 | dropdown = null 81 | }) 82 | } 83 | } 84 | } 85 | } 86 | 87 | fun LazyListScope.DailyRecord( 88 | isLast: Boolean, 89 | date: MainViewModel.Date, 90 | expenditure: String, 91 | income: String, 92 | records: List, 93 | onDeleteClick: (record: MainViewModel.Record) -> Unit 94 | ) { 95 | // Header 96 | item(contentType = "header") { 97 | Total( 98 | Modifier 99 | .fillMaxWidth() 100 | .padding(horizontal = 16.dp), 101 | remember(date) { date.parseAsString() }, expenditure, income 102 | ) 103 | Spacer(Modifier.height(8.dp)) 104 | } 105 | 106 | // Records 107 | items( 108 | items = records, 109 | contentType = { "record" } 110 | ) { record -> 111 | var dropdown by remember { mutableStateOf(false) } 112 | val dark = isSystemInDarkTheme() 113 | val color = remember(record, dark) { 114 | getRecordColor(record.type.colorIndex, record.type.income, dark) 115 | } 116 | 117 | Box(modifier = Modifier.fillMaxWidth()) { 118 | RecordItem( 119 | modifier = Modifier.fillMaxWidth(), 120 | color = color, 121 | title = record.type.label, 122 | time = record.time, 123 | category = record.type.parent ?: "", 124 | money = record.money.money, 125 | onClick = { }, 126 | onLongClick = { dropdown = true }, 127 | moneyStr = record.money.moneyStr.join, 128 | description = record.description 129 | ) 130 | DropdownMenu(expanded = dropdown, onDismissRequest = { dropdown = false }) { 131 | DropdownMenuItem(text = { 132 | Text(text = "删除") 133 | }, onClick = { 134 | onDeleteClick(record) 135 | dropdown = false 136 | }) 137 | } 138 | } 139 | } 140 | 141 | // Divider 142 | if (!isLast) item(contentType = "divider") { 143 | Divider(Modifier.padding(vertical = 8.dp)) 144 | } 145 | } 146 | 147 | @Composable 148 | private fun Total( 149 | modifier: Modifier, 150 | date: String, 151 | expenditure: String, 152 | income: String 153 | ) { 154 | Row(modifier) { 155 | Text( 156 | text = date, 157 | modifier = Modifier.weight(1f), 158 | style = MaterialTheme.typography.titleSmall 159 | ) 160 | Text( 161 | text = moneyToString(expenditure, false), 162 | color = MaterialTheme.colorScheme.primary, 163 | fontFamily = FontFamily.RobotoSlab, 164 | style = MaterialTheme.typography.titleSmall 165 | ) 166 | Spacer(Modifier.width(4.dp)) 167 | Text( 168 | text = moneyToString(income, true), 169 | color = MaterialTheme.colorScheme.tertiary, 170 | fontFamily = FontFamily.RobotoSlab, 171 | style = MaterialTheme.typography.titleSmall 172 | ) 173 | } 174 | } 175 | 176 | @Composable 177 | private fun moneyToString(money: String, positive: Boolean): String { 178 | return remember(money, positive) { 179 | "${if (positive) RecordSign.POSITIVE else RecordSign.NEGATIVE}$money${RecordSign.RMB}" 180 | } 181 | } 182 | 183 | @Preview(showBackground = true) 184 | @Composable 185 | private fun DailyRecordPreview() { 186 | KeepTallyTheme { 187 | DailyRecord( 188 | modifier = Modifier.fillMaxWidth(), 189 | date = "今天", 190 | expenditure = "6,000", 191 | income = "0", 192 | records = listOf( 193 | MainViewModel.Record( 194 | time = "12:30", 195 | type = MainViewModel.RecordType("父分类", "分类", false, 0), 196 | money = Money(1100), 197 | date = MainViewModel.Date("12-20", 0), 198 | id = 0, 199 | isIncome = true, 200 | description = null 201 | ), 202 | MainViewModel.Record( 203 | time = "12:30", 204 | type = MainViewModel.RecordType("父分类", "分类", false, 0), 205 | money = Money(-1100), 206 | date = MainViewModel.Date("12-20", 0), 207 | id = 1, 208 | isIncome = false, 209 | description = "备注" 210 | ) 211 | ), 212 | onDeleteClick = {} 213 | ) 214 | } 215 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/me/konyaco/keeptally/ui/detail/component/LineChart.kt: -------------------------------------------------------------------------------- 1 | package me.konyaco.keeptally.ui.detail.component 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.Row 5 | import androidx.compose.foundation.layout.height 6 | import androidx.compose.material3.MaterialTheme 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.Modifier 9 | import androidx.compose.ui.layout.Layout 10 | import androidx.compose.ui.unit.dp 11 | 12 | @Composable 13 | fun LineChart(modifier: Modifier) { 14 | Row( 15 | modifier 16 | .height(4.dp) 17 | .background(MaterialTheme.colorScheme.primary) 18 | ) { 19 | // TODO: 20 | 21 | Layout(measurePolicy = { measurables, constraints -> 22 | val placeables = measurables.map { it.measure(constraints) } // 将父布局的 Constraints 向下传递 23 | layout(constraints.maxWidth, constraints.maxHeight) { 24 | placeables.forEach { it.place(0, 0) } // 把子组件都放置到 0, 0 25 | } 26 | }) 27 | } 28 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/me/konyaco/keeptally/ui/detail/component/MoreInfo.kt: -------------------------------------------------------------------------------- 1 | package me.konyaco.keeptally.ui.detail.component 2 | 3 | import androidx.compose.animation.Crossfade 4 | import androidx.compose.foundation.layout.* 5 | import androidx.compose.material3.MaterialTheme 6 | import androidx.compose.material3.Text 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.Modifier 9 | import androidx.compose.ui.graphics.Color 10 | import androidx.compose.ui.text.font.FontFamily 11 | import androidx.compose.ui.text.style.TextAlign 12 | import androidx.compose.ui.tooling.preview.Preview 13 | import androidx.compose.ui.unit.dp 14 | import me.konyaco.keeptally.viewmodel.model.RecordSign 15 | import me.konyaco.keeptally.ui.theme.KeepTallyTheme 16 | import me.konyaco.keeptally.ui.theme.RobotoSlab 17 | 18 | @Composable 19 | fun MoreInfo(modifier: Modifier, budget: String, income: String) { 20 | Column(modifier) { 21 | TextRow( 22 | text = "预算", 23 | money = budget, 24 | sign = RecordSign.NEGATIVE, 25 | color = MaterialTheme.colorScheme.primary 26 | ) 27 | Spacer(Modifier.height(8.dp)) 28 | TextRow( 29 | text = "收入", 30 | money = income, 31 | sign = RecordSign.POSITIVE, 32 | color = MaterialTheme.colorScheme.tertiary 33 | ) 34 | } 35 | } 36 | 37 | @Composable 38 | private fun TextRow(text: String, money: String, sign: String, color: Color) { 39 | Row(Modifier.fillMaxWidth()) { 40 | Text(text = text, style = MaterialTheme.typography.bodyLarge) 41 | Crossfade( 42 | modifier = Modifier.weight(1f), 43 | targetState = "$sign$money${RecordSign.RMB}") { 44 | Text( 45 | modifier = Modifier.fillMaxWidth(), 46 | text = it, 47 | textAlign = TextAlign.End, 48 | color = color, 49 | style = MaterialTheme.typography.bodyLarge, 50 | fontFamily = FontFamily.RobotoSlab 51 | ) 52 | } 53 | 54 | } 55 | } 56 | 57 | @Preview(showBackground = true) 58 | @Composable 59 | private fun MoreInfoPreview() { 60 | KeepTallyTheme { 61 | MoreInfo(Modifier.fillMaxWidth(), "1,234.56", "1,234.56") 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/konyaco/keeptally/ui/detail/component/TotalExpenditure.kt: -------------------------------------------------------------------------------- 1 | package me.konyaco.keeptally.ui.detail.component 2 | 3 | import androidx.compose.animation.Crossfade 4 | import androidx.compose.foundation.layout.Arrangement 5 | import androidx.compose.foundation.layout.Row 6 | import androidx.compose.foundation.layout.fillMaxWidth 7 | import androidx.compose.material3.MaterialTheme 8 | import androidx.compose.material3.Text 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.ui.Alignment 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.text.font.FontFamily 13 | import androidx.compose.ui.text.font.FontWeight 14 | import androidx.compose.ui.text.style.TextAlign 15 | import androidx.compose.ui.tooling.preview.Preview 16 | import me.konyaco.keeptally.viewmodel.model.RecordSign 17 | import me.konyaco.keeptally.ui.theme.KeepTallyTheme 18 | import me.konyaco.keeptally.ui.theme.RobotoSlab 19 | 20 | @Composable 21 | fun TotalExpenditure(modifier: Modifier, integer: String, decimal: String) { 22 | Row(modifier, verticalAlignment = Alignment.CenterVertically) { 23 | Text( 24 | text = RecordSign.RMB, 25 | color = MaterialTheme.colorScheme.primary, 26 | style = MaterialTheme.typography.displaySmall, 27 | fontFamily = FontFamily.RobotoSlab, 28 | ) 29 | Crossfade( 30 | modifier = Modifier.weight(1f), 31 | targetState = integer to decimal 32 | ) { (integer, decimal) -> 33 | Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { 34 | Text( 35 | modifier = Modifier.alignByBaseline(), 36 | text = "${RecordSign.NEGATIVE}$integer.", 37 | color = MaterialTheme.colorScheme.primary, 38 | style = MaterialTheme.typography.displaySmall, 39 | fontFamily = FontFamily.RobotoSlab, 40 | textAlign = TextAlign.End 41 | ) 42 | Text( 43 | modifier = Modifier.alignByBaseline(), 44 | text = decimal, 45 | color = MaterialTheme.colorScheme.primary, 46 | style = MaterialTheme.typography.headlineMedium, 47 | fontWeight = FontWeight.W800, 48 | fontFamily = FontFamily.RobotoSlab, 49 | textAlign = TextAlign.End 50 | ) 51 | } 52 | } 53 | 54 | } 55 | } 56 | 57 | @Preview(showBackground = true) 58 | @Composable 59 | fun TotalExpenditurePreview() { 60 | KeepTallyTheme { 61 | TotalExpenditure(Modifier.fillMaxWidth(), "123", "456") 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/konyaco/keeptally/ui/filter/FilterScreen.kt: -------------------------------------------------------------------------------- 1 | package me.konyaco.keeptally.ui.filter 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.fillMaxSize 5 | import androidx.compose.material3.Text 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.Modifier 8 | 9 | @Composable 10 | fun FilterScreen() { 11 | Box(Modifier.fillMaxSize()) { 12 | Text("TODO") 13 | } 14 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/me/konyaco/keeptally/ui/other/OtherScreen.kt: -------------------------------------------------------------------------------- 1 | package me.konyaco.keeptally.ui.other 2 | 3 | import androidx.compose.foundation.layout.* 4 | import androidx.compose.foundation.rememberScrollState 5 | import androidx.compose.foundation.verticalScroll 6 | import androidx.compose.material3.MaterialTheme 7 | import androidx.compose.material3.Surface 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.tooling.preview.Preview 11 | import androidx.compose.ui.unit.dp 12 | import me.konyaco.keeptally.ui.other.component.OptionList 13 | import me.konyaco.keeptally.ui.other.component.UserProfile 14 | import me.konyaco.keeptally.ui.theme.KeepTallyTheme 15 | 16 | @Composable 17 | fun OtherScreen() { 18 | Column( 19 | Modifier 20 | .fillMaxSize() 21 | .verticalScroll(rememberScrollState()) 22 | .padding(horizontal = 16.dp) 23 | ) { 24 | UserProfile( 25 | modifier = Modifier.fillMaxWidth(), 26 | userName = "示例用户", 27 | email = "user@example.com", 28 | alipayBalance = "123.45", 29 | wechatBalance = "123.45" 30 | ) 31 | Spacer(Modifier.height(16.dp)) 32 | OptionList(Modifier.fillMaxWidth()) 33 | } 34 | } 35 | 36 | @Preview 37 | @Composable 38 | private fun Preview() { 39 | KeepTallyTheme { 40 | Surface(color = MaterialTheme.colorScheme.inverseOnSurface) { 41 | OtherScreen() 42 | } 43 | } 44 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/me/konyaco/keeptally/ui/other/component/OptionList.kt: -------------------------------------------------------------------------------- 1 | package me.konyaco.keeptally.ui.other.component 2 | 3 | import androidx.compose.foundation.clickable 4 | import androidx.compose.foundation.layout.* 5 | import androidx.compose.material.icons.Icons 6 | import androidx.compose.material.icons.filled.FileDownload 7 | import androidx.compose.material.icons.filled.FileUpload 8 | import androidx.compose.material3.* 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.ui.Alignment 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.graphics.painter.Painter 13 | import androidx.compose.ui.graphics.vector.rememberVectorPainter 14 | import androidx.compose.ui.res.painterResource 15 | import androidx.compose.ui.tooling.preview.Preview 16 | import androidx.compose.ui.unit.dp 17 | import me.konyaco.keeptally.R 18 | import me.konyaco.keeptally.ui.theme.KeepTallyTheme 19 | 20 | @Composable 21 | fun OptionList(modifier: Modifier) { 22 | Column(modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) { 23 | OptionItem( 24 | icon = painterResource(id = R.drawable.ic_label), 25 | label = "自定义标签", 26 | description = "更改记录内的标签类别", 27 | onClick = {} 28 | ) 29 | OptionItem( 30 | icon = rememberVectorPainter(Icons.Default.FileUpload), 31 | label = "导入数据", 32 | description = "导入 JSON 数据", 33 | onClick = {} 34 | ) 35 | OptionItem( 36 | icon = rememberVectorPainter(Icons.Default.FileDownload), 37 | label = "导出数据", 38 | description = "导出数据为 JSON 文件", 39 | onClick = {} 40 | ) 41 | } 42 | } 43 | 44 | @Composable 45 | fun OptionItem( 46 | icon: Painter, 47 | label: String, 48 | description: String, 49 | onClick: () -> Unit, 50 | modifier: Modifier = Modifier 51 | ) { 52 | Surface( 53 | modifier = modifier 54 | .fillMaxWidth() 55 | .clickable(onClick = onClick), 56 | ) { 57 | Row( 58 | modifier = Modifier.padding(16.dp), 59 | verticalAlignment = Alignment.CenterVertically 60 | ) { 61 | Box(Modifier.size(26.dp), Alignment.Center) { 62 | Icon(painter = icon, contentDescription = null) 63 | } 64 | Spacer(modifier = Modifier.width(16.dp)) 65 | Column(Modifier.weight(1f)) { 66 | Text(text = label, style = MaterialTheme.typography.titleMedium) 67 | Text( 68 | text = description, style = MaterialTheme.typography.bodyMedium, 69 | color = LocalContentColor.current.copy(0.7f) 70 | ) 71 | } 72 | } 73 | } 74 | } 75 | 76 | @Preview 77 | @Composable 78 | private fun Preview() { 79 | KeepTallyTheme { 80 | Surface(color = MaterialTheme.colorScheme.inverseOnSurface) { 81 | OptionList(Modifier.fillMaxSize()) 82 | } 83 | } 84 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/me/konyaco/keeptally/ui/other/component/UserProfile.kt: -------------------------------------------------------------------------------- 1 | package me.konyaco.keeptally.ui.other.component 2 | 3 | import androidx.compose.foundation.Image 4 | import androidx.compose.foundation.layout.* 5 | import androidx.compose.foundation.shape.RoundedCornerShape 6 | import androidx.compose.material.icons.Icons 7 | import androidx.compose.material.icons.filled.ChevronRight 8 | import androidx.compose.material.icons.filled.CloudDone 9 | import androidx.compose.material.icons.filled.Person 10 | import androidx.compose.material3.* 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.ui.Alignment 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.graphics.Color 15 | import androidx.compose.ui.graphics.ColorFilter 16 | import androidx.compose.ui.graphics.painter.Painter 17 | import androidx.compose.ui.res.painterResource 18 | import androidx.compose.ui.text.font.FontFamily 19 | import androidx.compose.ui.text.style.TextAlign 20 | import androidx.compose.ui.tooling.preview.Preview 21 | import androidx.compose.ui.unit.dp 22 | import me.konyaco.keeptally.R 23 | import me.konyaco.keeptally.viewmodel.model.RecordSign 24 | import me.konyaco.keeptally.ui.theme.KeepTallyTheme 25 | import me.konyaco.keeptally.ui.theme.RobotoSlab 26 | 27 | @Composable 28 | fun UserProfile( 29 | modifier: Modifier, 30 | userName: String, 31 | email: String, 32 | alipayBalance: String, 33 | wechatBalance: String 34 | ) { 35 | Surface(modifier) { 36 | Column(Modifier.padding(horizontal = 16.dp, vertical = 24.dp)) { 37 | Row( 38 | modifier = Modifier.fillMaxWidth(), 39 | verticalAlignment = Alignment.CenterVertically, 40 | horizontalArrangement = Arrangement.spacedBy(16.dp) 41 | ) { 42 | Image( 43 | modifier = Modifier.size(32.dp), 44 | contentDescription = "Avatar", 45 | imageVector = Icons.Default.Person, 46 | colorFilter = ColorFilter.tint(LocalContentColor.current) 47 | ) 48 | Column(Modifier.weight(1f)) { 49 | Text(text = userName, style = MaterialTheme.typography.titleLarge) 50 | Text( 51 | text = email, 52 | style = MaterialTheme.typography.bodyMedium, 53 | color = MaterialTheme.colorScheme.onSurface.copy(0.7f) 54 | ) 55 | } 56 | Icon(imageVector = Icons.Default.CloudDone, contentDescription = "Backup") 57 | Icon( 58 | imageVector = Icons.Default.ChevronRight, 59 | contentDescription = "Forward" 60 | ) 61 | } 62 | Spacer(Modifier.height(16.dp)) 63 | Row(Modifier.fillMaxWidth()) { 64 | AlipayButton(money = alipayBalance, modifier = Modifier.weight(1f)) 65 | Spacer(modifier = Modifier.width(16.dp)) 66 | WechatPayButton(money = wechatBalance, modifier = Modifier.weight(1f)) 67 | } 68 | } 69 | } 70 | } 71 | 72 | @Composable 73 | private fun PaymentButton( 74 | icon: Painter, 75 | text: String, 76 | money: String, 77 | color: Color, 78 | modifier: Modifier = Modifier 79 | ) { 80 | OutlinedButton( 81 | modifier = modifier, 82 | onClick = { /*TODO*/ }, 83 | shape = RoundedCornerShape(8.dp), 84 | contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp), 85 | colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.onSurface) 86 | ) { 87 | Icon( 88 | icon, 89 | contentDescription = text, 90 | tint = color, 91 | ) 92 | Spacer( 93 | Modifier 94 | .width(8.dp) 95 | ) 96 | Text( 97 | text = text, 98 | style = MaterialTheme.typography.labelLarge 99 | ) 100 | Divider( 101 | Modifier 102 | .padding(horizontal = 4.dp) 103 | .height(12.dp) 104 | .width(1.dp) 105 | ) 106 | Text( 107 | modifier = Modifier.weight(1f), 108 | text = "$money${RecordSign.RMB}", 109 | color = LocalContentColor.current.copy(0.8f), 110 | style = MaterialTheme.typography.labelLarge, 111 | fontFamily = FontFamily.RobotoSlab, 112 | textAlign = TextAlign.Center, 113 | ) 114 | } 115 | } 116 | 117 | @Composable 118 | private fun AlipayButton(money: String, modifier: Modifier) { 119 | PaymentButton( 120 | icon = painterResource(id = R.drawable.ic_alipay), 121 | text = "支付宝", 122 | money = money, 123 | color = MaterialTheme.colorScheme.primary, 124 | modifier = modifier 125 | ) 126 | } 127 | 128 | @Composable 129 | private fun WechatPayButton(money: String, modifier: Modifier) { 130 | PaymentButton( 131 | icon = painterResource(id = R.drawable.ic_wechatpay), 132 | text = "微信", 133 | money = money, 134 | color = MaterialTheme.colorScheme.primary, 135 | modifier = modifier 136 | ) 137 | } 138 | 139 | 140 | @Preview 141 | @Composable 142 | private fun Preview() { 143 | KeepTallyTheme { 144 | UserProfile( 145 | modifier = Modifier.fillMaxWidth(), 146 | userName = "示例用户", 147 | email = "user@email.com", 148 | alipayBalance = "123.45", 149 | wechatBalance = "123.45" 150 | ) 151 | } 152 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/me/konyaco/keeptally/ui/rememberVariableFont.kt: -------------------------------------------------------------------------------- 1 | package me.konyaco.keeptally.ui 2 | 3 | import android.os.Build 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.getValue 6 | import androidx.compose.runtime.produceState 7 | import androidx.compose.runtime.remember 8 | import androidx.compose.ui.platform.LocalContext 9 | import androidx.compose.ui.text.ExperimentalTextApi 10 | import androidx.compose.ui.text.font.FontFamily 11 | import androidx.compose.ui.text.font.FontWeight 12 | import androidx.compose.ui.text.font.Typeface 13 | 14 | @OptIn(ExperimentalTextApi::class) 15 | @Composable 16 | fun rememberVariableFont( 17 | fontName: String, 18 | weight: FontWeight 19 | ): FontFamily { 20 | val context = LocalContext.current 21 | val assets = remember(context) { context.assets } 22 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { 23 | val builder = remember(fontName) { 24 | android.graphics.Typeface.Builder(assets, "fonts/${fontName}_variable.ttf") 25 | } 26 | val typeface by produceState( 27 | initialValue = builder.build(), 28 | key1 = weight, 29 | producer = { 30 | builder.setFontVariationSettings("'wght' ${weight.weight}") 31 | builder.setWeight(weight.weight) 32 | value = builder.build() 33 | } 34 | ) 35 | return FontFamily(Typeface(typeface)) 36 | } else { 37 | return FontFamily.Default 38 | } 39 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/me/konyaco/keeptally/ui/statistic/StatisticScreen.kt: -------------------------------------------------------------------------------- 1 | package me.konyaco.keeptally.ui.statistic 2 | 3 | import androidx.compose.animation.AnimatedContent 4 | import androidx.compose.animation.ExperimentalAnimationApi 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.fillMaxSize 7 | import androidx.compose.material3.MaterialTheme 8 | import androidx.compose.material3.Surface 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.runtime.getValue 11 | import androidx.compose.runtime.mutableStateOf 12 | import androidx.compose.runtime.saveable.rememberSaveable 13 | import androidx.compose.runtime.setValue 14 | import androidx.compose.ui.Modifier 15 | import androidx.compose.ui.input.nestedscroll.NestedScrollConnection 16 | import androidx.compose.ui.input.nestedscroll.nestedScroll 17 | import androidx.compose.ui.tooling.preview.Preview 18 | import androidx.hilt.navigation.compose.hiltViewModel 19 | import me.konyaco.keeptally.ui.statistic.component.Tab 20 | import me.konyaco.keeptally.ui.statistic.component.TabItem 21 | import me.konyaco.keeptally.ui.statistic.expenditure.ExpenditureScreen 22 | import me.konyaco.keeptally.ui.statistic.income.IncomeScreen 23 | import me.konyaco.keeptally.ui.statistic.summary.SummaryScreen 24 | import me.konyaco.keeptally.ui.theme.KeepTallyTheme 25 | import me.konyaco.keeptally.viewmodel.StatisticViewModel 26 | 27 | @OptIn(ExperimentalAnimationApi::class) 28 | @Composable 29 | fun StatisticScreen(viewModel: StatisticViewModel = hiltViewModel()) { 30 | Column(Modifier 31 | .fillMaxSize() 32 | .nestedScroll( 33 | object : NestedScrollConnection { 34 | 35 | } 36 | ) 37 | ) { 38 | var page by rememberSaveable { mutableStateOf(TabItem.TOTAL) } 39 | 40 | Tab(selected = page, onSelectedChange = { page = it }) 41 | 42 | AnimatedContent(targetState = page) { 43 | when (it) { 44 | TabItem.TOTAL -> SummaryScreen() 45 | TabItem.EXPENDITURE -> ExpenditureScreen() 46 | TabItem.INCOME -> IncomeScreen() 47 | } 48 | } 49 | } 50 | } 51 | 52 | 53 | @Preview 54 | @Composable 55 | private fun StatisticScreenPreview() { 56 | KeepTallyTheme { 57 | Surface(color = MaterialTheme.colorScheme.inverseOnSurface) { 58 | StatisticScreen() 59 | } 60 | } 61 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/me/konyaco/keeptally/ui/statistic/component/CircleLineChart.kt: -------------------------------------------------------------------------------- 1 | package me.konyaco.keeptally.ui.statistic.component 2 | 3 | import androidx.compose.foundation.Canvas 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.fillMaxSize 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.runtime.remember 8 | import androidx.compose.ui.Modifier 9 | import androidx.compose.ui.graphics.Color 10 | import androidx.compose.ui.graphics.drawscope.Stroke 11 | import androidx.compose.ui.platform.LocalDensity 12 | import androidx.compose.ui.unit.dp 13 | 14 | data class DataItem( 15 | val color: Color, 16 | val value: Int 17 | ) 18 | 19 | @Composable 20 | fun CircleLineChart( 21 | modifier: Modifier, 22 | data: List 23 | ) { 24 | Box(modifier) { 25 | val sum = remember(data) { data.sumOf { it.value } } 26 | val gap = remember(data) { if (data.size > 1) 1f else 0f } 27 | val width = with(LocalDensity.current) { 4.dp.toPx() } 28 | Canvas( 29 | modifier = Modifier.fillMaxSize(), 30 | onDraw = { 31 | var start = 0f 32 | data.forEach { 33 | val angle = 360f * it.value / sum 34 | drawArc( 35 | color = it.color, 36 | startAngle = start + gap, 37 | sweepAngle = angle - gap, 38 | useCenter = false, 39 | style = Stroke(width = width) 40 | ) 41 | start += angle 42 | } 43 | } 44 | ) 45 | } 46 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/me/konyaco/keeptally/ui/statistic/component/EmptyScreen.kt: -------------------------------------------------------------------------------- 1 | package me.konyaco.keeptally.ui.statistic.component 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.fillMaxSize 5 | import androidx.compose.material3.MaterialTheme 6 | import androidx.compose.material3.Text 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.Alignment 9 | import androidx.compose.ui.Modifier 10 | 11 | @Composable 12 | fun EmptyScreen() { 13 | Box(Modifier.fillMaxSize(), Alignment.Center) { 14 | Text(text = "暂无记录", color = MaterialTheme.colorScheme.onSurfaceVariant) 15 | } 16 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/me/konyaco/keeptally/ui/statistic/component/Graph.kt: -------------------------------------------------------------------------------- 1 | package me.konyaco.keeptally.ui.statistic.component 2 | 3 | import androidx.compose.foundation.layout.* 4 | import androidx.compose.material3.MaterialTheme 5 | import androidx.compose.material3.Text 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.Modifier 8 | import androidx.compose.ui.graphics.Color 9 | import androidx.compose.ui.text.font.FontFamily 10 | import androidx.compose.ui.text.font.FontWeight 11 | import androidx.compose.ui.unit.dp 12 | import androidx.constraintlayout.compose.ConstraintLayout 13 | import me.konyaco.keeptally.viewmodel.model.RecordSign 14 | import me.konyaco.keeptally.ui.theme.RobotoSlab 15 | 16 | @Composable 17 | fun Graph( 18 | modifier: Modifier, 19 | label: String, 20 | moneyStr: String, 21 | caption: String, 22 | color: Color, 23 | data: List 24 | ) { 25 | Box( 26 | modifier 27 | .requiredSizeIn(maxHeight = 360.dp, maxWidth = 360.dp) 28 | .fillMaxWidth() 29 | .aspectRatio(1f), 30 | ) { 31 | CircleLineChart( 32 | modifier = Modifier.fillMaxSize(), 33 | data = data 34 | ) 35 | ConstraintLayout(Modifier.fillMaxSize()) { 36 | val (labelRef, moneyRef, budgetRef) = createRefs() 37 | Text( 38 | modifier = Modifier.constrainAs(labelRef) { 39 | centerHorizontallyTo(parent) 40 | bottom.linkTo(moneyRef.top) 41 | }, 42 | text = label, 43 | style = MaterialTheme.typography.headlineSmall, 44 | color = MaterialTheme.colorScheme.onSurface 45 | ) 46 | 47 | Row(Modifier.constrainAs(moneyRef) { centerTo(parent) }) { 48 | Text( 49 | modifier = Modifier.alignByBaseline(), 50 | text = moneyStr, 51 | color = color, 52 | style = MaterialTheme.typography.displaySmall, 53 | fontFamily = FontFamily.RobotoSlab 54 | ) 55 | Text( 56 | modifier = Modifier.alignByBaseline(), 57 | text = RecordSign.RMB, 58 | color = color, 59 | style = MaterialTheme.typography.headlineSmall, 60 | fontFamily = FontFamily.RobotoSlab 61 | ) 62 | } 63 | 64 | Text( 65 | modifier = Modifier.constrainAs(budgetRef) { 66 | centerHorizontallyTo(moneyRef) 67 | top.linkTo(moneyRef.bottom) 68 | }, 69 | text = caption, 70 | color = color, 71 | style = MaterialTheme.typography.headlineSmall, 72 | fontWeight = FontWeight.W400 73 | ) 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/konyaco/keeptally/ui/statistic/component/RecordItem.kt: -------------------------------------------------------------------------------- 1 | package me.konyaco.keeptally.ui.statistic.component 2 | 3 | import androidx.compose.foundation.clickable 4 | import androidx.compose.foundation.layout.* 5 | import androidx.compose.material.icons.Icons 6 | import androidx.compose.material.icons.filled.ChevronRight 7 | import androidx.compose.material3.Icon 8 | import androidx.compose.material3.LocalContentColor 9 | import androidx.compose.material3.MaterialTheme 10 | import androidx.compose.material3.Text 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.ui.Alignment 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.graphics.Color 15 | import androidx.compose.ui.unit.dp 16 | import me.konyaco.keeptally.ui.component.MoneyIndicator 17 | import me.konyaco.keeptally.ui.component.MoneyString 18 | 19 | @Composable 20 | fun RecordItem( 21 | color: Color, 22 | title: String, 23 | money: Int, 24 | moneyStr: String, 25 | budget: Int?, 26 | budgetStr: String?, 27 | onClick: () -> Unit, 28 | modifier: Modifier = Modifier 29 | ) { 30 | Row( 31 | modifier 32 | .wrapContentHeight() 33 | .sizeIn(minHeight = 56.dp) 34 | .clickable(onClick = onClick) 35 | .padding(horizontal = 16.dp), 36 | verticalAlignment = Alignment.CenterVertically 37 | ) { 38 | MoneyIndicator(money = money, budget = budget ?: 0, fillColor = color) 39 | Spacer(Modifier.width(16.dp)) 40 | Text( 41 | modifier = Modifier.weight(1f), 42 | text = title, 43 | style = MaterialTheme.typography.titleMedium 44 | ) 45 | MoneyString( 46 | moneyStr = moneyStr, 47 | isIncome = money > 0, 48 | budget = budgetStr, 49 | positiveColor = MaterialTheme.colorScheme.primary, 50 | negativeColor = MaterialTheme.colorScheme.primary 51 | ) 52 | Spacer(Modifier.width(16.dp)) 53 | Icon( 54 | modifier = Modifier.size(12.dp), 55 | imageVector = Icons.Default.ChevronRight, 56 | contentDescription = "Forward", 57 | tint = LocalContentColor.current.copy(0.5f) 58 | ) 59 | } 60 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/me/konyaco/keeptally/ui/statistic/component/Tab.kt: -------------------------------------------------------------------------------- 1 | package me.konyaco.keeptally.ui.statistic.component 2 | 3 | import androidx.annotation.StringRes 4 | import androidx.compose.foundation.background 5 | import androidx.compose.foundation.layout.Box 6 | import androidx.compose.foundation.layout.fillMaxWidth 7 | import androidx.compose.foundation.layout.height 8 | import androidx.compose.foundation.layout.width 9 | import androidx.compose.material3.LocalContentColor 10 | import androidx.compose.material3.MaterialTheme 11 | import androidx.compose.material3.TabRow 12 | import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset 13 | import androidx.compose.material3.Text 14 | import androidx.compose.runtime.Composable 15 | import androidx.compose.runtime.Stable 16 | import androidx.compose.ui.Alignment 17 | import androidx.compose.ui.Modifier 18 | import androidx.compose.ui.graphics.Color 19 | import androidx.compose.ui.res.stringResource 20 | import androidx.compose.ui.unit.dp 21 | import me.konyaco.keeptally.R 22 | 23 | enum class TabItem( 24 | @StringRes 25 | val label: Int 26 | ) { 27 | TOTAL(R.string.statistic_tab_total), 28 | EXPENDITURE(R.string.statistic_tab_expenditure), 29 | INCOME(R.string.statistic_tab_income); 30 | 31 | @Stable 32 | fun index(): Int { 33 | return when (this) { 34 | TOTAL -> 0 35 | EXPENDITURE -> 1 36 | INCOME -> 2 37 | } 38 | } 39 | } 40 | 41 | @Composable 42 | fun Tab( 43 | selected: TabItem, 44 | onSelectedChange: (TabItem) -> Unit, 45 | modifier: Modifier = Modifier 46 | ) { 47 | TabRow( 48 | modifier = modifier, 49 | selectedTabIndex = selected.index(), 50 | containerColor = Color.Transparent, 51 | contentColor = MaterialTheme.colorScheme.onBackground, 52 | indicator = { 53 | Box( 54 | Modifier 55 | .tabIndicatorOffset(it[selected.index()]) 56 | .fillMaxWidth() 57 | .height(2.dp) 58 | ) { 59 | Box( 60 | Modifier 61 | .align(Alignment.BottomCenter) 62 | .width(30.dp) 63 | .height(2.dp) 64 | .background(LocalContentColor.current) 65 | ) 66 | } 67 | }, 68 | divider = {} 69 | ) { 70 | TabItem.values().forEach { 71 | androidx.compose.material3.Tab( 72 | modifier = Modifier.height(48.dp), 73 | selected = selected == it, 74 | onClick = { 75 | onSelectedChange(it) 76 | } 77 | ) { 78 | Text(text = stringResource(id = it.label)) 79 | } 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/konyaco/keeptally/ui/statistic/expenditure/ExpenditureScreen.kt: -------------------------------------------------------------------------------- 1 | package me.konyaco.keeptally.ui.statistic.expenditure 2 | 3 | import androidx.compose.foundation.isSystemInDarkTheme 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.Spacer 6 | import androidx.compose.foundation.layout.fillMaxSize 7 | import androidx.compose.foundation.layout.height 8 | import androidx.compose.foundation.rememberScrollState 9 | import androidx.compose.foundation.verticalScroll 10 | import androidx.compose.material3.MaterialTheme 11 | import androidx.compose.material3.Surface 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.runtime.collectAsState 14 | import androidx.compose.runtime.getValue 15 | import androidx.compose.runtime.remember 16 | import androidx.compose.ui.Alignment 17 | import androidx.compose.ui.Modifier 18 | import androidx.compose.ui.tooling.preview.Preview 19 | import androidx.compose.ui.unit.dp 20 | import androidx.hilt.navigation.compose.hiltViewModel 21 | import me.konyaco.keeptally.ui.getRecordColor 22 | import me.konyaco.keeptally.ui.statistic.component.DataItem 23 | import me.konyaco.keeptally.ui.statistic.component.EmptyScreen 24 | import me.konyaco.keeptally.ui.statistic.component.Graph 25 | import me.konyaco.keeptally.ui.theme.KeepTallyTheme 26 | import me.konyaco.keeptally.viewmodel.StatisticViewModel 27 | 28 | @Composable 29 | fun ExpenditureScreen( 30 | viewModel: StatisticViewModel = hiltViewModel() 31 | ) { 32 | val exp by viewModel.expenditures.collectAsState() 33 | val summary by viewModel.summary.collectAsState() 34 | val isDark = isSystemInDarkTheme() 35 | 36 | if (exp.isEmpty()) 37 | EmptyScreen() 38 | else Column( 39 | Modifier 40 | .fillMaxSize() 41 | .verticalScroll(state = rememberScrollState()) 42 | ) { 43 | Spacer(Modifier.height(32.dp)) 44 | Graph( 45 | Modifier.align(Alignment.CenterHorizontally), 46 | "支出", 47 | summary.expenditure.moneyStr.joinWithSign(false), 48 | "/${summary.budget.moneyStr.join}", 49 | MaterialTheme.colorScheme.primary, 50 | remember(exp) { 51 | exp.map { 52 | val color = getRecordColor(it.color, false, isDark) 53 | DataItem(color, it.money.money) 54 | } 55 | } 56 | ) 57 | Spacer(Modifier.height(32.dp)) 58 | RecordList(Modifier.fillMaxSize(), exp, onClick = { 59 | // TODO: Navigate to detail 60 | }) 61 | } 62 | } 63 | 64 | @Preview 65 | @Composable 66 | private fun Preview() { 67 | KeepTallyTheme { 68 | Surface(color = MaterialTheme.colorScheme.inverseOnSurface) { 69 | ExpenditureScreen() 70 | } 71 | } 72 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/me/konyaco/keeptally/ui/statistic/expenditure/RecordList.kt: -------------------------------------------------------------------------------- 1 | package me.konyaco.keeptally.ui.statistic.expenditure 2 | 3 | import androidx.compose.foundation.isSystemInDarkTheme 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.fillMaxWidth 6 | import androidx.compose.foundation.layout.padding 7 | import androidx.compose.material3.MaterialTheme 8 | import androidx.compose.material3.Surface 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.unit.dp 12 | import me.konyaco.keeptally.ui.getRecordColor 13 | import me.konyaco.keeptally.ui.statistic.component.RecordItem 14 | import me.konyaco.keeptally.viewmodel.StatisticViewModel 15 | 16 | @Composable 17 | fun RecordList( 18 | modifier: Modifier, 19 | data: List, 20 | onClick: (Int) -> Unit 21 | ) { 22 | Surface( 23 | modifier = modifier.padding(horizontal = 16.dp), 24 | color = MaterialTheme.colorScheme.surface 25 | ) { 26 | val isDark = isSystemInDarkTheme() 27 | Column(Modifier.fillMaxWidth()) { 28 | data.forEachIndexed { index, record -> 29 | RecordItem( 30 | modifier = Modifier.fillMaxWidth(), 31 | color = getRecordColor(record.color, false, isDark), 32 | title = record.label, 33 | money = record.money.money, 34 | budget = record.budget.money, 35 | onClick = { 36 | onClick(index) 37 | }, 38 | moneyStr = record.money.moneyStr.join, 39 | budgetStr = record.budget.moneyStr.integer 40 | ) 41 | } 42 | } 43 | } 44 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/me/konyaco/keeptally/ui/statistic/income/IncomeScreen.kt: -------------------------------------------------------------------------------- 1 | package me.konyaco.keeptally.ui.statistic.income 2 | 3 | import androidx.compose.foundation.isSystemInDarkTheme 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.Spacer 6 | import androidx.compose.foundation.layout.fillMaxSize 7 | import androidx.compose.foundation.layout.height 8 | import androidx.compose.foundation.rememberScrollState 9 | import androidx.compose.foundation.verticalScroll 10 | import androidx.compose.material3.MaterialTheme 11 | import androidx.compose.material3.Surface 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.runtime.collectAsState 14 | import androidx.compose.runtime.getValue 15 | import androidx.compose.runtime.remember 16 | import androidx.compose.ui.Alignment 17 | import androidx.compose.ui.Modifier 18 | import androidx.compose.ui.graphics.Color 19 | import androidx.compose.ui.tooling.preview.Preview 20 | import androidx.compose.ui.unit.dp 21 | import androidx.hilt.navigation.compose.hiltViewModel 22 | import me.konyaco.keeptally.ui.getRecordColor 23 | import me.konyaco.keeptally.ui.statistic.component.DataItem 24 | import me.konyaco.keeptally.ui.statistic.component.EmptyScreen 25 | import me.konyaco.keeptally.ui.statistic.component.Graph 26 | import me.konyaco.keeptally.ui.theme.KeepTallyTheme 27 | import me.konyaco.keeptally.viewmodel.StatisticViewModel 28 | 29 | private val testData = listOf( 30 | DataItem(Color(0xFF5EDBBC), 1500), 31 | DataItem(Color(0xFF416277), 1959), 32 | DataItem(Color(0xFFB6FFEA), 2958), 33 | ) 34 | 35 | @Composable 36 | fun IncomeScreen(viewModel: StatisticViewModel = hiltViewModel()) { 37 | val summary by viewModel.summary.collectAsState() 38 | val incomes by viewModel.incomes.collectAsState() 39 | val isDark = isSystemInDarkTheme() 40 | 41 | if (incomes.isEmpty()) 42 | EmptyScreen() 43 | else Column( 44 | Modifier 45 | .fillMaxSize() 46 | .verticalScroll(state = rememberScrollState()) 47 | ) { 48 | Spacer(Modifier.height(32.dp)) 49 | Graph( 50 | Modifier.align(Alignment.CenterHorizontally), 51 | "收入", 52 | summary.income.moneyStr.joinWithSign(true), 53 | "", 54 | MaterialTheme.colorScheme.tertiary, 55 | remember(incomes) { 56 | incomes.map { 57 | val color = getRecordColor(it.color, true, isDark) 58 | DataItem(color, it.money.money) 59 | } 60 | } 61 | ) 62 | Spacer(Modifier.height(32.dp)) 63 | RecordList(Modifier.fillMaxSize(), incomes, onClick = { 64 | // TODO: 65 | }) 66 | } 67 | } 68 | 69 | @Preview 70 | @Composable 71 | private fun Preview() { 72 | KeepTallyTheme { 73 | Surface(color = MaterialTheme.colorScheme.inverseOnSurface) { 74 | IncomeScreen() 75 | } 76 | } 77 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/me/konyaco/keeptally/ui/statistic/income/RecordList.kt: -------------------------------------------------------------------------------- 1 | package me.konyaco.keeptally.ui.statistic.income 2 | 3 | import androidx.compose.foundation.isSystemInDarkTheme 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.fillMaxWidth 6 | import androidx.compose.foundation.layout.padding 7 | import androidx.compose.material3.MaterialTheme 8 | import androidx.compose.material3.Surface 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.unit.dp 12 | import me.konyaco.keeptally.ui.getRecordColor 13 | import me.konyaco.keeptally.ui.statistic.component.RecordItem 14 | import me.konyaco.keeptally.viewmodel.StatisticViewModel 15 | 16 | @Composable 17 | fun RecordList( 18 | modifier: Modifier, 19 | data: List, 20 | onClick: (Int) -> Unit 21 | ) { 22 | Surface( 23 | modifier = modifier.padding(horizontal = 16.dp), 24 | color = MaterialTheme.colorScheme.surface 25 | ) { 26 | Column(Modifier.fillMaxWidth()) { 27 | data.forEachIndexed { index, record -> 28 | RecordItem( 29 | modifier = Modifier.fillMaxWidth(), 30 | color = getRecordColor(record.color, true, isSystemInDarkTheme()), 31 | title = record.label, 32 | money = record.money.money, 33 | budget = null, 34 | onClick = { 35 | onClick(index) 36 | }, 37 | moneyStr = record.money.moneyStr.join, 38 | budgetStr = null 39 | ) 40 | } 41 | } 42 | } 43 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/me/konyaco/keeptally/ui/statistic/summary/DetailCard.kt: -------------------------------------------------------------------------------- 1 | package me.konyaco.keeptally.ui.statistic.summary 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.clickable 5 | import androidx.compose.foundation.layout.* 6 | import androidx.compose.material.icons.Icons 7 | import androidx.compose.material.icons.filled.ChevronRight 8 | import androidx.compose.material3.* 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.ui.Alignment 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.graphics.Color 13 | import androidx.compose.ui.unit.dp 14 | import me.konyaco.keeptally.ui.component.MoneyString 15 | 16 | @Composable 17 | fun DetailCard( 18 | modifier: Modifier, 19 | expStr: String, 20 | incomeStr: String, 21 | balanceStr: String, 22 | isBalancePositive: Boolean 23 | ) { 24 | Surface( 25 | modifier = modifier 26 | .padding(horizontal = 16.dp), 27 | color = MaterialTheme.colorScheme.surface 28 | ) { 29 | Column( 30 | Modifier 31 | .fillMaxWidth() 32 | ) { 33 | RecordItem( 34 | modifier = Modifier.fillMaxWidth(), 35 | color = MaterialTheme.colorScheme.primary, 36 | title = "支出", 37 | money = expStr, 38 | onClick = {}, 39 | isIncome = false 40 | ) 41 | RecordItem( 42 | modifier = Modifier.fillMaxWidth(), 43 | color = MaterialTheme.colorScheme.tertiary, 44 | title = "收入", 45 | money = incomeStr, 46 | onClick = {}, 47 | isIncome = true 48 | ) 49 | Divider( 50 | Modifier.padding(vertical = 8.dp, horizontal = 16.dp), 51 | color = MaterialTheme.colorScheme.onSurface.copy(0.12f) 52 | ) 53 | RecordItem( 54 | modifier = Modifier.fillMaxWidth(), 55 | color = MaterialTheme.colorScheme.onSurface, 56 | title = "结余", 57 | money = balanceStr, 58 | onClick = {}, 59 | isIncome = isBalancePositive 60 | ) 61 | } 62 | } 63 | 64 | } 65 | 66 | @Composable 67 | fun RecordItem( 68 | color: Color, 69 | title: String, 70 | isIncome: Boolean, 71 | money: String, 72 | onClick: () -> Unit, 73 | modifier: Modifier = Modifier 74 | ) { 75 | Row( 76 | modifier 77 | .wrapContentHeight() 78 | .sizeIn(minHeight = 56.dp) 79 | .clickable(onClick = onClick) 80 | .padding(horizontal = 16.dp), 81 | verticalAlignment = Alignment.CenterVertically 82 | ) { 83 | Box( 84 | Modifier 85 | .size(4.dp, 32.dp) 86 | .background(color) 87 | ) 88 | Spacer(Modifier.width(16.dp)) 89 | Text( 90 | modifier = Modifier.weight(1f), 91 | text = title, 92 | style = MaterialTheme.typography.titleMedium 93 | ) 94 | MoneyString(money, isIncome, positiveColor = color, negativeColor = color) 95 | Spacer(Modifier.width(16.dp)) 96 | Icon( 97 | modifier = Modifier.size(12.dp), 98 | imageVector = Icons.Default.ChevronRight, 99 | contentDescription = "Forward", 100 | tint = LocalContentColor.current.copy(0.5f) 101 | ) 102 | } 103 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/me/konyaco/keeptally/ui/statistic/summary/SummaryScreen.kt: -------------------------------------------------------------------------------- 1 | package me.konyaco.keeptally.ui.statistic.summary 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.Spacer 5 | import androidx.compose.foundation.layout.fillMaxSize 6 | import androidx.compose.foundation.layout.height 7 | import androidx.compose.foundation.rememberScrollState 8 | import androidx.compose.foundation.verticalScroll 9 | import androidx.compose.material3.MaterialTheme 10 | import androidx.compose.material3.Surface 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.runtime.collectAsState 13 | import androidx.compose.runtime.getValue 14 | import androidx.compose.ui.Alignment 15 | import androidx.compose.ui.Modifier 16 | import androidx.compose.ui.graphics.Color 17 | import androidx.compose.ui.tooling.preview.Preview 18 | import androidx.compose.ui.unit.dp 19 | import androidx.hilt.navigation.compose.hiltViewModel 20 | import me.konyaco.keeptally.ui.statistic.component.DataItem 21 | import me.konyaco.keeptally.ui.statistic.component.Graph 22 | import me.konyaco.keeptally.ui.theme.KeepTallyTheme 23 | import me.konyaco.keeptally.viewmodel.StatisticViewModel 24 | import me.konyaco.keeptally.viewmodel.model.RecordSign 25 | 26 | private val testData = listOf( 27 | DataItem(Color(0xFF5EDBBC), 1500), 28 | DataItem(Color(0xFF416277), 1959), 29 | DataItem(Color(0xFFB6FFEA), 2958), 30 | ) 31 | 32 | @Composable 33 | fun SummaryScreen(viewModel: StatisticViewModel = hiltViewModel()) { 34 | val summary by viewModel.summary.collectAsState() 35 | SummaryScreen( 36 | summary.expenditure.moneyStr.join, 37 | summary.income.moneyStr.join, 38 | summary.balance.moneyStr.join, 39 | summary.balance.money >= 0 40 | ) 41 | } 42 | 43 | @Composable 44 | fun SummaryScreen( 45 | expenditure: String, 46 | income: String, 47 | balance: String, 48 | isBalancePositive: Boolean 49 | ) { 50 | Column( 51 | Modifier 52 | .fillMaxSize() 53 | .verticalScroll(state = rememberScrollState()) 54 | ) { 55 | Spacer(Modifier.height(32.dp)) 56 | Graph( 57 | Modifier.align(Alignment.CenterHorizontally), 58 | "结余", 59 | (if (isBalancePositive) RecordSign.POSITIVE else RecordSign.NEGATIVE) + balance, 60 | "", 61 | MaterialTheme.colorScheme.primary, 62 | testData 63 | ) 64 | Spacer(Modifier.height(32.dp)) 65 | DetailCard(Modifier.fillMaxSize(), expenditure, income, balance, isBalancePositive) 66 | } 67 | } 68 | 69 | @Preview 70 | @Composable 71 | private fun TotalScreenPreview() { 72 | KeepTallyTheme { 73 | Surface(color = MaterialTheme.colorScheme.inverseOnSurface) { 74 | SummaryScreen("23.33", "23.00", "0.33", false) 75 | } 76 | } 77 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/me/konyaco/keeptally/ui/theme/Color.kt: -------------------------------------------------------------------------------- 1 | package me.konyaco.keeptally.ui.theme 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | object KeepTallyColors { 6 | val md_theme_light_primary = Color(0xFF006b56) 7 | val md_theme_light_onPrimary = Color(0xFFffffff) 8 | val md_theme_light_primaryContainer = Color(0xFF7cf8d7) 9 | val md_theme_light_onPrimaryContainer = Color(0xFF002019) 10 | val md_theme_light_secondary = Color(0xFF4b635b) 11 | val md_theme_light_onSecondary = Color(0xFFffffff) 12 | val md_theme_light_secondaryContainer = Color(0xFFcde9de) 13 | val md_theme_light_onSecondaryContainer = Color(0xFF072019) 14 | val md_theme_light_tertiary = Color(0xFF416277) 15 | val md_theme_light_onTertiary = Color(0xFFffffff) 16 | val md_theme_light_tertiaryContainer = Color(0xFFc5e7ff) 17 | val md_theme_light_onTertiaryContainer = Color(0xFF001e2d) 18 | val md_theme_light_error = Color(0xFFba1b1b) 19 | val md_theme_light_errorContainer = Color(0xFFffdad4) 20 | val md_theme_light_onError = Color(0xFFffffff) 21 | val md_theme_light_onErrorContainer = Color(0xFF410001) 22 | val md_theme_light_background = Color(0xFFfafdfa) 23 | val md_theme_light_onBackground = Color(0xFF191c1b) 24 | val md_theme_light_surface = Color(0xFFfafdfa) 25 | val md_theme_light_onSurface = Color(0xFF191c1b) 26 | val md_theme_light_surfaceVariant = Color(0xFFdbe5e0) 27 | val md_theme_light_onSurfaceVariant = Color(0xFF3f4945) 28 | val md_theme_light_outline = Color(0xFF707975) 29 | val md_theme_light_inverseOnSurface = Color(0xFFeff2ef) 30 | val md_theme_light_inverseSurface = Color(0xFF2d312f) 31 | 32 | val md_theme_dark_primary = Color(0xFF5edbbc) 33 | val md_theme_dark_onPrimary = Color(0xFF00382c) 34 | val md_theme_dark_primaryContainer = Color(0xFF005141) 35 | val md_theme_dark_onPrimaryContainer = Color(0xFF7cf8d7) 36 | val md_theme_dark_secondary = Color(0xFFb1ccc2) 37 | val md_theme_dark_onSecondary = Color(0xFF1d352e) 38 | val md_theme_dark_secondaryContainer = Color(0xFF344b44) 39 | val md_theme_dark_onSecondaryContainer = Color(0xFFcde9de) 40 | val md_theme_dark_tertiary = Color(0xFFa9cbe3) 41 | val md_theme_dark_onTertiary = Color(0xFF0f3447) 42 | val md_theme_dark_tertiaryContainer = Color(0xFF294a5e) 43 | val md_theme_dark_onTertiaryContainer = Color(0xFFc5e7ff) 44 | val md_theme_dark_error = Color(0xFFffb4a9) 45 | val md_theme_dark_errorContainer = Color(0xFF930006) 46 | val md_theme_dark_onError = Color(0xFF680003) 47 | val md_theme_dark_onErrorContainer = Color(0xFFffdad4) 48 | val md_theme_dark_background = Color(0xFF191c1b) 49 | val md_theme_dark_onBackground = Color(0xFFe0e3e0) 50 | val md_theme_dark_surface = Color(0xFF191c1b) 51 | val md_theme_dark_onSurface = Color(0xFFe0e3e0) 52 | val md_theme_dark_surfaceVariant = Color(0xFF3f4945) 53 | val md_theme_dark_onSurfaceVariant = Color(0xFFbfc9c4) 54 | val md_theme_dark_outline = Color(0xFF89938e) 55 | val md_theme_dark_inverseOnSurface = Color(0xFF191c1b) 56 | val md_theme_dark_inverseSurface = Color(0xFFe0e3e0) 57 | 58 | val seed = Color(0xFF00876f) 59 | val error = Color(0xFFba1b1b) 60 | val Custom0 = Color(0xFF2196f3) 61 | val Custom1 = Color(0xFF3f51b5) 62 | val Custom2 = Color(0xFF6f43c0) 63 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/me/konyaco/keeptally/ui/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package me.konyaco.keeptally.ui.theme 2 | 3 | import android.os.Build 4 | import androidx.compose.foundation.isSystemInDarkTheme 5 | import androidx.compose.material.Colors 6 | import androidx.compose.material.Typography 7 | import androidx.compose.material3.* 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.runtime.LaunchedEffect 10 | import androidx.compose.ui.graphics.Color 11 | import androidx.compose.ui.graphics.luminance 12 | import androidx.compose.ui.platform.LocalContext 13 | import com.google.accompanist.systemuicontroller.rememberSystemUiController 14 | 15 | private val LightColorScheme = lightColorScheme( 16 | primary = KeepTallyColors.md_theme_light_primary, 17 | onPrimary = KeepTallyColors.md_theme_light_onPrimary, 18 | primaryContainer = KeepTallyColors.md_theme_light_primaryContainer, 19 | onPrimaryContainer = KeepTallyColors.md_theme_light_onPrimaryContainer, 20 | secondary = KeepTallyColors.md_theme_light_secondary, 21 | onSecondary = KeepTallyColors.md_theme_light_onSecondary, 22 | secondaryContainer = KeepTallyColors.md_theme_light_secondaryContainer, 23 | onSecondaryContainer = KeepTallyColors.md_theme_light_onSecondaryContainer, 24 | tertiary = KeepTallyColors.md_theme_light_tertiary, 25 | onTertiary = KeepTallyColors.md_theme_light_onTertiary, 26 | tertiaryContainer = KeepTallyColors.md_theme_light_tertiaryContainer, 27 | onTertiaryContainer = KeepTallyColors.md_theme_light_onTertiaryContainer, 28 | error = KeepTallyColors.md_theme_light_error, 29 | errorContainer = KeepTallyColors.md_theme_light_errorContainer, 30 | onError = KeepTallyColors.md_theme_light_onError, 31 | onErrorContainer = KeepTallyColors.md_theme_light_onErrorContainer, 32 | background = KeepTallyColors.md_theme_light_background, 33 | onBackground = KeepTallyColors.md_theme_light_onBackground, 34 | surface = KeepTallyColors.md_theme_light_surface, 35 | onSurface = KeepTallyColors.md_theme_light_onSurface, 36 | surfaceVariant = KeepTallyColors.md_theme_light_surfaceVariant, 37 | onSurfaceVariant = KeepTallyColors.md_theme_light_onSurfaceVariant, 38 | outline = KeepTallyColors.md_theme_light_outline, 39 | inverseOnSurface = KeepTallyColors.md_theme_light_inverseOnSurface, 40 | inverseSurface = KeepTallyColors.md_theme_light_inverseSurface, 41 | ) 42 | 43 | private val DarkColorScheme = darkColorScheme( 44 | primary = KeepTallyColors.md_theme_dark_primary, 45 | onPrimary = KeepTallyColors.md_theme_dark_onPrimary, 46 | primaryContainer = KeepTallyColors.md_theme_dark_primaryContainer, 47 | onPrimaryContainer = KeepTallyColors.md_theme_dark_onPrimaryContainer, 48 | secondary = KeepTallyColors.md_theme_dark_secondary, 49 | onSecondary = KeepTallyColors.md_theme_dark_onSecondary, 50 | secondaryContainer = KeepTallyColors.md_theme_dark_secondaryContainer, 51 | onSecondaryContainer = KeepTallyColors.md_theme_dark_onSecondaryContainer, 52 | tertiary = KeepTallyColors.md_theme_dark_tertiary, 53 | onTertiary = KeepTallyColors.md_theme_dark_onTertiary, 54 | tertiaryContainer = KeepTallyColors.md_theme_dark_tertiaryContainer, 55 | onTertiaryContainer = KeepTallyColors.md_theme_dark_onTertiaryContainer, 56 | error = KeepTallyColors.md_theme_dark_error, 57 | errorContainer = KeepTallyColors.md_theme_dark_errorContainer, 58 | onError = KeepTallyColors.md_theme_dark_onError, 59 | onErrorContainer = KeepTallyColors.md_theme_dark_onErrorContainer, 60 | background = KeepTallyColors.md_theme_dark_background, 61 | onBackground = KeepTallyColors.md_theme_dark_onBackground, 62 | surface = KeepTallyColors.md_theme_dark_surface, 63 | onSurface = KeepTallyColors.md_theme_dark_onSurface, 64 | surfaceVariant = KeepTallyColors.md_theme_dark_surfaceVariant, 65 | onSurfaceVariant = KeepTallyColors.md_theme_dark_onSurfaceVariant, 66 | outline = KeepTallyColors.md_theme_dark_outline, 67 | inverseOnSurface = KeepTallyColors.md_theme_dark_inverseOnSurface, 68 | inverseSurface = KeepTallyColors.md_theme_dark_inverseSurface, 69 | ) 70 | 71 | @Composable 72 | fun KeepTallyTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) { 73 | val dynamicColor = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S 74 | val colorScheme = when { 75 | dynamicColor && darkTheme -> dynamicDarkColorScheme(LocalContext.current) 76 | dynamicColor && !darkTheme -> dynamicLightColorScheme(LocalContext.current) 77 | darkTheme -> DarkColorScheme 78 | else -> LightColorScheme 79 | } 80 | 81 | val md2Color = Colors( 82 | primary = colorScheme.primary, 83 | primaryVariant = colorScheme.primary, 84 | onPrimary = colorScheme.onPrimary, 85 | secondary = colorScheme.secondary, 86 | secondaryVariant = colorScheme.secondary, 87 | onSecondary = colorScheme.onSecondary, 88 | surface = colorScheme.surface, 89 | onSurface = colorScheme.onSurface, 90 | background = colorScheme.background, 91 | onBackground = colorScheme.onBackground, 92 | error = colorScheme.error, 93 | onError = colorScheme.onError, 94 | isLight = !darkTheme 95 | ) 96 | 97 | val typography = KeepTallyTypography 98 | 99 | val md2Typo = Typography( 100 | h1 = typography.displayLarge, 101 | h2 = typography.displayMedium, 102 | h3 = typography.displaySmall, 103 | h4 = typography.headlineLarge, 104 | h5 = typography.headlineMedium, 105 | h6 = typography.headlineSmall, 106 | subtitle1 = typography.titleLarge, 107 | subtitle2 = typography.titleMedium, 108 | body1 = typography.bodyMedium, 109 | body2 = typography.bodySmall, 110 | caption = typography.labelLarge 111 | ) 112 | 113 | androidx.compose.material.MaterialTheme(md2Color) { 114 | MaterialTheme( 115 | colorScheme = colorScheme, 116 | typography = typography, 117 | content = content 118 | ) 119 | } 120 | } 121 | 122 | @Composable 123 | fun AndroidKeepTallyTheme(content: @Composable () -> Unit) { 124 | val systemUiController = rememberSystemUiController() 125 | KeepTallyTheme { 126 | val surfaceColor = MaterialTheme.colorScheme.surfaceVariant 127 | LaunchedEffect(systemUiController) { 128 | systemUiController.setSystemBarsColor( 129 | Color.Transparent, 130 | surfaceColor.luminance() > 0.5f 131 | ) 132 | } 133 | content() 134 | } 135 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/me/konyaco/keeptally/ui/theme/Typo.kt: -------------------------------------------------------------------------------- 1 | package me.konyaco.keeptally.ui.theme 2 | 3 | import androidx.compose.material3.Typography 4 | import androidx.compose.runtime.Stable 5 | import androidx.compose.ui.text.TextStyle 6 | import androidx.compose.ui.text.font.Font 7 | import androidx.compose.ui.text.font.FontFamily 8 | import androidx.compose.ui.text.font.FontStyle 9 | import androidx.compose.ui.text.font.FontWeight 10 | import androidx.compose.ui.unit.sp 11 | import me.konyaco.keeptally.R 12 | 13 | val Roboto = FontFamily.Default 14 | 15 | val RobotoSlab = FontFamily( 16 | listOf( 17 | Font(R.font.roboto_slab_black, FontWeight.Black, FontStyle.Normal), 18 | Font(R.font.roboto_slab_bold, FontWeight.Bold, FontStyle.Normal), 19 | Font(R.font.roboto_slab_extra_bold, FontWeight.ExtraBold, FontStyle.Normal), 20 | Font(R.font.roboto_slab_extra_light, FontWeight.ExtraLight, FontStyle.Normal), 21 | Font(R.font.roboto_slab_light, FontWeight.Light, FontStyle.Normal), 22 | Font(R.font.roboto_slab_medium, FontWeight.Medium, FontStyle.Normal), 23 | Font(R.font.roboto_slab_regular, FontWeight.Normal, FontStyle.Normal), 24 | Font(R.font.roboto_slab_semi_bold, FontWeight.SemiBold, FontStyle.Normal), 25 | Font(R.font.roboto_slab_thin, FontWeight.Thin, FontStyle.Normal), 26 | ) 27 | ) 28 | 29 | val FontFamily.Companion.RobotoSlab: FontFamily 30 | @Stable 31 | get() = me.konyaco.keeptally.ui.theme.RobotoSlab 32 | 33 | val KeepTallyTypography = Typography( 34 | displayLarge = TextStyle( 35 | fontWeight = FontWeight.W400, 36 | fontSize = 57.sp, 37 | lineHeight = 64.sp, 38 | letterSpacing = (-0.25).sp, 39 | ), 40 | displayMedium = TextStyle( 41 | fontWeight = FontWeight.W400, 42 | fontSize = 45.sp, 43 | lineHeight = 52.sp, 44 | letterSpacing = 0.sp, 45 | ), 46 | displaySmall = TextStyle( 47 | fontWeight = FontWeight.W600, 48 | fontSize = 36.sp, 49 | lineHeight = 44.sp, 50 | letterSpacing = 0.sp, 51 | ), 52 | headlineLarge = TextStyle( 53 | fontWeight = FontWeight.W600, 54 | fontSize = 32.sp, 55 | lineHeight = 40.sp, 56 | letterSpacing = 0.sp, 57 | ), 58 | headlineMedium = TextStyle( 59 | fontWeight = FontWeight.W400, 60 | fontSize = 28.sp, 61 | lineHeight = 36.sp, 62 | letterSpacing = 0.sp, 63 | ), 64 | headlineSmall = TextStyle( 65 | fontWeight = FontWeight.W800, 66 | fontSize = 24.sp, 67 | lineHeight = 32.sp, 68 | letterSpacing = 0.sp, 69 | ), 70 | titleLarge = TextStyle( 71 | fontWeight = FontWeight.W500, 72 | fontSize = 22.sp, 73 | lineHeight = 28.sp, 74 | letterSpacing = 0.sp, 75 | ), 76 | titleMedium = TextStyle( 77 | fontWeight = FontWeight.W500, 78 | fontSize = 16.sp, 79 | lineHeight = 24.sp, 80 | letterSpacing = 0.1.sp, 81 | ), 82 | titleSmall = TextStyle( 83 | fontWeight = FontWeight.W500, 84 | fontSize = 14.sp, 85 | lineHeight = 20.sp, 86 | letterSpacing = 0.1.sp, 87 | ), 88 | labelLarge = TextStyle( 89 | fontWeight = FontWeight.W500, 90 | fontSize = 14.sp, 91 | lineHeight = 20.sp, 92 | letterSpacing = 0.1.sp, 93 | ), 94 | labelMedium = TextStyle( 95 | fontWeight = FontWeight.W500, 96 | fontSize = 12.sp, 97 | lineHeight = 16.sp, 98 | letterSpacing = 0.5.sp, 99 | ), 100 | labelSmall = TextStyle( 101 | fontWeight = FontWeight.W500, 102 | fontSize = 11.sp, 103 | lineHeight = 16.sp, 104 | letterSpacing = 0.5.sp, 105 | ), 106 | bodyLarge = TextStyle( 107 | fontWeight = FontWeight.W400, 108 | fontSize = 16.sp, 109 | lineHeight = 24.sp, 110 | letterSpacing = 0.5.sp, 111 | ), 112 | bodyMedium = TextStyle( 113 | fontWeight = FontWeight.W400, 114 | fontSize = 14.sp, 115 | lineHeight = 20.sp, 116 | letterSpacing = 0.25.sp, 117 | ), 118 | bodySmall = TextStyle( 119 | fontWeight = FontWeight.W400, 120 | fontSize = 12.sp, 121 | lineHeight = 16.sp, 122 | letterSpacing = 0.4.sp, 123 | ) 124 | ) 125 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/konyaco/keeptally/ui/utils.kt: -------------------------------------------------------------------------------- 1 | package me.konyaco.keeptally.ui 2 | 3 | import androidx.compose.runtime.Stable 4 | import androidx.compose.ui.graphics.Color 5 | import me.konyaco.keeptally.viewmodel.model.Colors 6 | import java.math.BigDecimal 7 | 8 | @Stable 9 | fun parseMoneyToCent(str: String): Int { 10 | return BigDecimal(str).multiply(BigDecimal(100)).toInt() 11 | } 12 | 13 | @Stable 14 | fun getRecordColor( 15 | colorIndex: Int, 16 | isIncome: Boolean, 17 | isDark: Boolean 18 | ): Color { 19 | val (lightColor, darkColor) = 20 | if (isIncome) Colors.incomeColors[colorIndex] 21 | else Colors.expColors[colorIndex] 22 | return Color(if (isDark) darkColor else lightColor) 23 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/me/konyaco/keeptally/viewmodel/DetailViewModel.kt: -------------------------------------------------------------------------------- 1 | package me.konyaco.keeptally.viewmodel 2 | 3 | import androidx.lifecycle.ViewModel 4 | import dagger.hilt.android.lifecycle.HiltViewModel 5 | import me.konyaco.keeptally.storage.database.AppDatabase 6 | import javax.inject.Inject 7 | 8 | @HiltViewModel 9 | class DetailViewModel @Inject constructor( 10 | database: AppDatabase 11 | ) : ViewModel() { 12 | 13 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/me/konyaco/keeptally/viewmodel/MainViewModel.kt: -------------------------------------------------------------------------------- 1 | package me.konyaco.keeptally.viewmodel 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import dagger.hilt.android.lifecycle.HiltViewModel 6 | import kotlinx.coroutines.Dispatchers 7 | import kotlinx.coroutines.flow.MutableStateFlow 8 | import kotlinx.coroutines.launch 9 | import kotlinx.coroutines.withContext 10 | import me.konyaco.keeptally.storage.database.AppDatabase 11 | import me.konyaco.keeptally.viewmodel.model.DateRange 12 | import me.konyaco.keeptally.viewmodel.model.Money 13 | import java.time.Duration 14 | import java.time.Instant 15 | import java.time.LocalDateTime 16 | import java.time.ZoneId 17 | import java.time.ZonedDateTime 18 | import java.time.format.DateTimeFormatter 19 | import javax.inject.Inject 20 | import kotlin.math.abs 21 | import me.konyaco.keeptally.storage.entity.Record as EntityRecord 22 | import me.konyaco.keeptally.storage.entity.RecordType as EntityRecordType 23 | 24 | @HiltViewModel 25 | class MainViewModel @Inject constructor( 26 | private val appDatabase: AppDatabase, 27 | private val sharedViewModel: SharedViewModel 28 | ) : ViewModel() { 29 | private val recordDao = appDatabase.recordDao() 30 | private val recordTypeDao = appDatabase.recordTypeDao() 31 | private val localZoneId = ZoneId.systemDefault() 32 | 33 | val state = MutableStateFlow(State.Initializing) 34 | val records = MutableStateFlow>(emptyList()) 35 | val expenditureLabels = MutableStateFlow>>(emptyMap()) 36 | val incomeLabels = MutableStateFlow>>(emptyMap()) 37 | val dateRange = sharedViewModel.dateRange 38 | val statistics = MutableStateFlow(Statistics(Money(0), Money(0), Money(0))) 39 | 40 | sealed class State { 41 | object Initializing: State() 42 | object Loading: State() 43 | object Done: State() 44 | } 45 | 46 | data class DailyRecord( 47 | val date: Date, 48 | val expenditure: Money, 49 | val income: Money, 50 | val records: List, 51 | ) 52 | 53 | data class Record( 54 | val id: Int, 55 | val isIncome: Boolean, 56 | val money: Money, 57 | val date: Date, 58 | val time: String, 59 | val type: RecordType, 60 | val description: String? 61 | ) 62 | 63 | data class Date( 64 | val dateString: String, 65 | val daysOffset: Int 66 | ) 67 | 68 | data class RecordType( 69 | val label: String, 70 | val parent: String?, 71 | /** 72 | * Income label or expenditure label 73 | */ 74 | val income: Boolean, 75 | val colorIndex: Int 76 | ) 77 | 78 | data class Statistics( 79 | val expenditure: Money, 80 | val income: Money, 81 | val budget: Money, 82 | ) 83 | 84 | init { 85 | viewModelScope.launch(Dispatchers.IO) { 86 | sharedViewModel.isReady.collect { 87 | if (it) init() 88 | } 89 | } 90 | } 91 | 92 | private val hhmFormatter = DateTimeFormatter.ofPattern("HH:mm") 93 | private val dateFormatter = DateTimeFormatter.ofPattern("MMMd" + "日") 94 | 95 | private suspend fun init() { 96 | refreshLabels() 97 | refreshRecords() 98 | state.value = State.Done 99 | } 100 | 101 | private suspend fun refreshRecords() = withContext(Dispatchers.IO) { 102 | val range = dateRange.value 103 | val start = range.start.zonedEpoch() 104 | val end = range.end.zonedEpoch() 105 | 106 | var expenditure = 0 107 | var income = 0 108 | 109 | val result = recordDao.loadAllByDateDesc(start, end).map { 110 | it.mapToRecord().also { 111 | if (it.money.money < 0) { 112 | expenditure += abs(it.money.money) 113 | } else { 114 | income += it.money.money 115 | } 116 | } 117 | } 118 | statistics.emit(Statistics(Money(expenditure), Money(income), Money(0))) // TODO: Budget 119 | val dailyRecords = groupToDailyRecord(result) 120 | records.emit(dailyRecords) 121 | } 122 | 123 | private suspend fun EntityRecord.mapToRecord(): Record { 124 | val instant = Instant.ofEpochSecond(this.timestamp) 125 | val zoned = instant.atZone(localZoneId) 126 | val time = hhmFormatter.format(zoned) 127 | val date = Date(dateFormatter.format(zoned), calculateDayOffset(zoned)) 128 | val label = recordTypeDao.loadAllByIds(this.typeId).first().mapToRecordType() 129 | 130 | return Record( 131 | money = Money(this.money), 132 | isIncome = false, 133 | date = date, 134 | time = time, 135 | type = label, 136 | id = this.id, 137 | description = description 138 | ) 139 | } 140 | 141 | private fun calculateDayOffset(date: ZonedDateTime): Int { 142 | val today = with(ZonedDateTime.now()) { 143 | ZonedDateTime.of(year, monthValue, dayOfMonth, 0, 0, 0, 0, zone) 144 | } 145 | val target = with(date) { 146 | ZonedDateTime.of(year, monthValue, dayOfMonth, 0, 0, 0, 0, zone) 147 | } 148 | val offsetDay = Duration.between(target, today).toDays().toInt() 149 | return offsetDay 150 | } 151 | 152 | private suspend fun refreshLabels() = withContext(Dispatchers.IO) { 153 | val expMap = mutableMapOf>() 154 | val incomeMap = mutableMapOf>() 155 | 156 | recordTypeDao.getAllRoot().forEach { 157 | val key = it.mapToRecordType() 158 | val list = recordTypeDao.getSubTypes(it.id).map { it.mapToRecordType() } 159 | if (it.isIncome) { 160 | incomeMap[key] = list 161 | } else { 162 | expMap[key] = list 163 | } 164 | } 165 | 166 | expenditureLabels.emit(expMap) 167 | incomeLabels.emit(incomeMap) 168 | } 169 | 170 | fun setDateRange(dateRange: DateRange) { 171 | this.dateRange.value = dateRange 172 | viewModelScope.launch(Dispatchers.IO) { 173 | refreshRecords() 174 | } 175 | } 176 | 177 | fun addPrimaryLabel(name: String, isIncomeLabel: Boolean) { 178 | viewModelScope.launch(Dispatchers.IO) { 179 | // Check existence 180 | if (recordTypeDao.getRootByLabel(name) == null) { 181 | recordTypeDao.insertAll(EntityRecordType(0, name, null, isIncomeLabel)) 182 | sharedViewModel.refresh() 183 | refreshLabels() 184 | } else { 185 | // TODO: Show error message to user 186 | error("Duplicated label name") 187 | } 188 | } 189 | } 190 | 191 | fun addSecondaryLabel(primaryLabel: String, name: String, isIncomeLabel: Boolean) { 192 | viewModelScope.launch(Dispatchers.IO) { 193 | val primary = recordTypeDao.getRootByLabel(primaryLabel) 194 | 195 | // Check existence 196 | if (primary != null && primary.isIncome == isIncomeLabel) { 197 | val exist = 198 | recordTypeDao.getSubTypes(primary.id).firstOrNull { it.label == name } != null 199 | 200 | if (!exist) { 201 | recordTypeDao.insertAll(EntityRecordType(0, name, primary.id, isIncomeLabel)) 202 | refreshLabels() 203 | } else { 204 | // TODO: Show error message to user 205 | error("The label was existed") 206 | } 207 | } else { 208 | // TODO: Show error message to user 209 | error("Primary label was not found") 210 | } 211 | } 212 | } 213 | 214 | fun addRecord( 215 | isIncome: Boolean, 216 | money: Int, 217 | primaryLabel: String, 218 | secondaryLabel: String?, 219 | description: String? 220 | ) { 221 | addRecord(isIncome, money, primaryLabel, secondaryLabel, description, LocalDateTime.now()) 222 | } 223 | 224 | fun addRecord( 225 | isIncome: Boolean, 226 | money: Int, 227 | primaryLabel: String, 228 | secondaryLabel: String?, 229 | description: String?, 230 | date: LocalDateTime 231 | ) { 232 | viewModelScope.launch(Dispatchers.IO) { 233 | val primary = recordTypeDao.getRootByLabel(primaryLabel) 234 | ?: error("Primary label: $primaryLabel was not found") 235 | val label = if (secondaryLabel != null) { 236 | recordTypeDao.getSubTypes(primary.id) 237 | .firstOrNull { it.label == secondaryLabel } 238 | ?: error("Secondary label $secondaryLabel in $primaryLabel was not found") 239 | } else primary 240 | 241 | val money = if (isIncome) abs(money) else -abs(money) 242 | recordDao.insertAll( 243 | EntityRecord( 244 | 0, 245 | money, 246 | date.atZone(ZoneId.systemDefault()).toEpochSecond(), 247 | label.id, 248 | description 249 | ) 250 | ) 251 | refreshRecords() 252 | } 253 | } 254 | 255 | private fun groupToDailyRecord(records: List): List { 256 | return records.groupBy { 257 | it.date.dateString 258 | }.map { (date, records) -> 259 | var expenditure = 0 260 | var income = 0 261 | records.forEach { 262 | if (it.money.money < 0) { 263 | expenditure += abs(it.money.money) 264 | } else { 265 | income += it.money.money 266 | } 267 | } 268 | DailyRecord( 269 | date = records.first().date, 270 | expenditure = Money(expenditure), 271 | income = Money(income), 272 | records = records 273 | ) 274 | } 275 | } 276 | 277 | private suspend fun EntityRecordType.mapToRecordType(): RecordType { 278 | val parent = parentId?.let { recordTypeDao.loadAllByIds(it).firstOrNull() } 279 | return RecordType( 280 | label, 281 | parent?.label, 282 | isIncome, 283 | sharedViewModel.colors.value[parentId ?: id]!! 284 | ) 285 | } 286 | 287 | fun deleteRecord(id: Int) { 288 | viewModelScope.launch(Dispatchers.IO) { 289 | recordDao.delete(me.konyaco.keeptally.storage.entity.Record(id = id, 0, 0, 0, null)) 290 | refreshRecords() 291 | } 292 | } 293 | } 294 | 295 | private fun Pair.joinToString(): Triple { 296 | return Triple(first, second, "$first.$second") 297 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/me/konyaco/keeptally/viewmodel/SharedViewModel.kt: -------------------------------------------------------------------------------- 1 | package me.konyaco.keeptally.viewmodel 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import dagger.hilt.android.ViewModelLifecycle 6 | import kotlinx.coroutines.Dispatchers 7 | import kotlinx.coroutines.flow.MutableStateFlow 8 | import kotlinx.coroutines.launch 9 | import kotlinx.coroutines.sync.Mutex 10 | import kotlinx.coroutines.sync.withLock 11 | import me.konyaco.keeptally.storage.database.AppDatabase 12 | import me.konyaco.keeptally.storage.entity.RecordType 13 | import me.konyaco.keeptally.viewmodel.model.DateRange 14 | import javax.inject.Inject 15 | import javax.inject.Singleton 16 | 17 | @Singleton 18 | class SharedViewModel @Inject constructor( 19 | private val database: AppDatabase 20 | ) : ViewModel() { 21 | val dateRange = MutableStateFlow(DateRange.Month.now()) 22 | val colors: MutableStateFlow> = MutableStateFlow(emptyMap()) 23 | val isReady: MutableStateFlow = MutableStateFlow(false) 24 | 25 | private val lock = Mutex() 26 | 27 | init { 28 | viewModelScope.launch(Dispatchers.IO) { 29 | prepopulateData() 30 | loadColors() 31 | isReady.emit(true) 32 | } 33 | } 34 | 35 | private val preExp = mapOf( 36 | "餐饮" to listOf("早餐", "午餐", "晚餐", "小吃", "饮料"), 37 | "购物" to listOf("日用品"), 38 | "交通" to listOf("公交地铁", "骑行"), 39 | "生活" to listOf("电费", "水费", "房租"), 40 | "娱乐" to emptyList() 41 | ) 42 | 43 | private val preIncome = mapOf>( 44 | "工资" to emptyList(), 45 | "生活费" to emptyList() 46 | ) 47 | 48 | private suspend fun prepopulateData() { 49 | val recordTypeDao = database.recordTypeDao() 50 | if (recordTypeDao.count() == 0L) { 51 | 52 | preExp.forEach { (k, v) -> 53 | val id = recordTypeDao.insertAll(RecordType(0, k, null, false))[0].toInt() 54 | recordTypeDao.insertAll(*v.map { 55 | RecordType(0, it, id, false) 56 | }.toTypedArray()) 57 | } 58 | 59 | preIncome.forEach { (k, v) -> 60 | val id = recordTypeDao.insertAll(RecordType(0, k, null, true))[0].toInt() 61 | recordTypeDao.insertAll(*v.map { 62 | RecordType(0, it, id, true) 63 | }.toTypedArray()) 64 | } 65 | } 66 | } 67 | 68 | private suspend fun loadColors() = lock.withLock { 69 | val map = mutableMapOf() 70 | var incomeI = 0 71 | var expI = 0 72 | 73 | database.recordTypeDao().getAllRoot().forEach { recordType -> 74 | if (recordType.isIncome) { 75 | map[recordType.id] = incomeI++ 76 | } else { 77 | map[recordType.id] = expI++ 78 | } 79 | } 80 | colors.emit(map) 81 | } 82 | 83 | suspend fun refresh() { 84 | loadColors() 85 | } 86 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/me/konyaco/keeptally/viewmodel/StatisticViewModel.kt: -------------------------------------------------------------------------------- 1 | package me.konyaco.keeptally.viewmodel 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import dagger.hilt.android.ViewModelLifecycle 6 | import dagger.hilt.android.lifecycle.HiltViewModel 7 | import kotlinx.coroutines.Dispatchers 8 | import kotlinx.coroutines.flow.MutableStateFlow 9 | import kotlinx.coroutines.flow.collectLatest 10 | import kotlinx.coroutines.launch 11 | import me.konyaco.keeptally.storage.database.AppDatabase 12 | import me.konyaco.keeptally.viewmodel.model.DateRange 13 | import me.konyaco.keeptally.viewmodel.model.Money 14 | import javax.inject.Inject 15 | 16 | @HiltViewModel 17 | class StatisticViewModel @Inject constructor( 18 | private val sharedViewModel: SharedViewModel, 19 | private val database: AppDatabase 20 | ) : ViewModel() { 21 | private val recordDao = database.recordDao() 22 | private val recordTypeDao = database.recordTypeDao() 23 | 24 | // TODO: 2022/9/11 25 | data class Summary( 26 | val expenditure: Money, 27 | val income: Money, 28 | val balance: Money, 29 | val budget: Money 30 | ) 31 | 32 | data class Expenditure( 33 | val typeId: Int, 34 | val label: String, 35 | val money: Money, 36 | val budget: Money, 37 | val color: Int 38 | ) 39 | 40 | data class Income( 41 | val typeId: Int, 42 | val label: String, 43 | val money: Money, 44 | val color: Int 45 | ) 46 | 47 | private val defaultMoney = Money(0) 48 | 49 | val summary: MutableStateFlow = 50 | MutableStateFlow(Summary(defaultMoney, defaultMoney, defaultMoney, defaultMoney)) 51 | val expenditures: MutableStateFlow> = MutableStateFlow(emptyList()) 52 | val incomes: MutableStateFlow> = MutableStateFlow(emptyList()) 53 | 54 | init { 55 | viewModelScope.launch(Dispatchers.IO) { 56 | sharedViewModel.dateRange.collectLatest { 57 | refresh(it) 58 | } 59 | } 60 | } 61 | 62 | private suspend fun refresh(range: DateRange) { 63 | val records = recordDao.loadAllByDate( 64 | range.start.zonedEpoch(), 65 | range.end.zonedEpoch() 66 | ) 67 | val types = recordTypeDao.getAll().let { 68 | buildMap { 69 | it.forEach { put(it.id, it) } 70 | } 71 | } 72 | 73 | val incomesR = mutableMapOf() 74 | val expenditureR = mutableMapOf() 75 | 76 | // TODO(Optimize): Use flow 77 | records.forEach { record -> 78 | // TODO(Optimize): Add cache to optimize 79 | var type = types[record.typeId]!! 80 | // Get the root label 81 | while (type.parentId != null) { 82 | type = types[type.parentId!!]!! 83 | } 84 | if (type.isIncome) { 85 | val income = incomesR.getOrPut(type.id) { 86 | Income( 87 | type.id, type.label, Money(0), 88 | sharedViewModel.colors.value[type.id]!! 89 | ) 90 | } 91 | incomesR[type.id] = 92 | income.copy(money = Money(income.money.money + record.money)) 93 | } else { 94 | val exp = expenditureR.getOrPut(type.id) { 95 | Expenditure( 96 | type.id, type.label, Money(0), Money(0), 97 | sharedViewModel.colors.value[type.id]!! 98 | ) 99 | } 100 | expenditureR[type.id] = 101 | exp.copy(money = Money(exp.money.money + record.money)) 102 | } 103 | } 104 | 105 | val incomeSum = incomesR.values.sumOf { it.money.money } 106 | val expenditureSum = expenditureR.values.sumOf { it.money.money } 107 | 108 | expenditures.emit(expenditureR.values.toList()) 109 | incomes.emit(incomesR.values.toList()) 110 | summary.emit( 111 | Summary( 112 | Money(expenditureSum), 113 | Money(incomeSum), 114 | Money(incomeSum + expenditureSum), 115 | Money(0) // TODO 116 | ) 117 | ) 118 | } 119 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/me/konyaco/keeptally/viewmodel/Utils.kt: -------------------------------------------------------------------------------- 1 | package me.konyaco.keeptally.viewmodel 2 | 3 | import java.time.LocalDate 4 | import java.time.ZoneId 5 | import java.time.ZonedDateTime 6 | 7 | fun LocalDate.zonedEpoch(localZoneId: ZoneId = ZoneId.systemDefault()): Long { 8 | return ZonedDateTime.of( 9 | year, 10 | monthValue, 11 | dayOfMonth, 12 | 0, 13 | 0, 14 | 0, 15 | 0, 16 | localZoneId 17 | ).toEpochSecond() 18 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/me/konyaco/keeptally/viewmodel/model/Colors.kt: -------------------------------------------------------------------------------- 1 | package me.konyaco.keeptally.viewmodel.model 2 | 3 | object Colors { 4 | val deepPurple = longArrayOf( 5 | 0xFFEDE7F6, 6 | 0xFFD1C4E9, 7 | 0xFFB39DDB, 8 | 0xFF9575CD, 9 | 0xFF7E57C2, 10 | 0xFF673AB7, 11 | 0xFF5E35B1, 12 | 0xFF512DA8, 13 | 0xFF4527A0, 14 | 0xFF311B92 15 | ) 16 | val cyan = longArrayOf( 17 | 0xFFE0F7FA, 18 | 0xFFB2EBF2, 19 | 0xFF80DEEA, 20 | 0xFF4DD0E1, 21 | 0xFF26C6DA, 22 | 0xFF00BCD4, 23 | 0xFF00ACC1, 24 | 0xFF0097A7, 25 | 0xFF00838F, 26 | 0xFF006064 27 | ) 28 | 29 | val blue = longArrayOf( 30 | 0xFFE3F2FD, 31 | 0xFFBBDEFB, 32 | 0xFF90CAF9, 33 | 0xFF64B5F6, 34 | 0xFF42A5F5, 35 | 0xFF2196F3, 36 | 0xFF1E88E5, 37 | 0xFF1976D2, 38 | 0xFF1565C0, 39 | 0xFF0D47A1 40 | ) 41 | 42 | val green = longArrayOf( 43 | 0xFFE8F5E9, 44 | 0xFFC8E6C9, 45 | 0xFFA5D6A7, 46 | 0xFF81C784, 47 | 0xFF66BB6A, 48 | 0xFF4CAF50, 49 | 0xFF43A047, 50 | 0xFF388E3C, 51 | 0xFF2E7D32, 52 | 0xFF1B5E20 53 | ) 54 | 55 | val lightGreen = longArrayOf( 56 | 0xFFF1F8E9, 57 | 0xFFDCEDC8, 58 | 0xFFC5E1A5, 59 | 0xFFAED581, 60 | 0xFF9CCC65, 61 | 0xFF8BC34A, 62 | 0xFF7CB342, 63 | 0xFF689F38, 64 | 0xFF558B2F, 65 | 0xFF33691E 66 | ) 67 | 68 | val lime = longArrayOf( 69 | 0xFFF9FBE7, 70 | 0xFFF0F4C3, 71 | 0xFFE6EE9C, 72 | 0xFFDCE775, 73 | 0xFFD4E157, 74 | 0xFFCDDC39, 75 | 0xFFC0CA33, 76 | 0xFFAFB42B, 77 | 0xFF9E9D24, 78 | 0xFF827717 79 | ) 80 | 81 | 82 | val expColorSet = arrayOf(deepPurple, cyan, blue) 83 | val incomeColorSet = arrayOf(green, lightGreen, lime) 84 | 85 | val expLightColors: List = expColorSet.flatMap { it.slice(setOf(1, 3, 5, 7, 9)) } 86 | val expDarkColors: List = expColorSet.flatMap { it.slice(setOf(0, 2, 4, 6, 8)).reversed() } 87 | 88 | 89 | val incomeLightColors: List = incomeColorSet.flatMap { it.slice(setOf(1, 3, 5, 7, 9)) } 90 | val incomeDarkColors: List = incomeColorSet.flatMap { it.slice(setOf(0, 2, 4, 6, 8)).reversed() } 91 | 92 | val expColors: List> = expLightColors.zip(expDarkColors).shuffled() 93 | val incomeColors: List> = incomeLightColors.zip(incomeDarkColors).shuffled() 94 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/me/konyaco/keeptally/viewmodel/model/DateRange.kt: -------------------------------------------------------------------------------- 1 | package me.konyaco.keeptally.viewmodel.model 2 | 3 | import java.time.LocalDate 4 | import java.time.LocalDateTime 5 | 6 | sealed class DateRange(val start: LocalDate, val end: LocalDate) { 7 | data class Month(val year: Int, val month: Int) : 8 | DateRange( 9 | LocalDate.of(year, month, 1), 10 | LocalDate.of(year, month, 1).plusMonths(1) 11 | ) { 12 | companion object { 13 | fun now(): Month { 14 | val dateTime = LocalDateTime.now() 15 | return Month(dateTime.year, dateTime.monthValue) 16 | } 17 | } 18 | } 19 | 20 | data class Day(val year: Int, val month: Int, val day: Int) : DateRange( 21 | LocalDate.of(year, month, day), 22 | LocalDate.of(year, month, day).plusDays(1) 23 | ) { 24 | companion object { 25 | fun now(): Day { 26 | val dateTime = LocalDateTime.now() 27 | return Day(dateTime.year, dateTime.monthValue, dateTime.dayOfMonth) 28 | } 29 | } 30 | } 31 | 32 | class Custom(start: LocalDate, end: LocalDate) : DateRange(start, end) 33 | 34 | override fun equals(other: Any?): Boolean { 35 | return other is DateRange && other.start == start && other.end == end 36 | } 37 | 38 | override fun hashCode(): Int { 39 | var result = start.hashCode() 40 | result = 31 * result + end.hashCode() 41 | return result 42 | } 43 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/me/konyaco/keeptally/viewmodel/model/Money.kt: -------------------------------------------------------------------------------- 1 | package me.konyaco.keeptally.viewmodel.model 2 | 3 | import androidx.compose.runtime.Stable 4 | import me.konyaco.keeptally.viewmodel.model.RecordSign 5 | import java.text.DecimalFormat 6 | import kotlin.math.abs 7 | 8 | /** 9 | * @param money abs money 10 | */ 11 | data class Money( 12 | val money: Int, 13 | val moneyStr: MoneyString = MoneyString.fromMoney(money) 14 | ) { 15 | data class MoneyString( 16 | val integer: String, 17 | val decimal: String, 18 | val join: String = "$integer.$decimal", 19 | ) { 20 | @Stable 21 | fun joinWithSign(positive: Boolean): String { 22 | return if (positive) { 23 | RecordSign.POSITIVE + join 24 | } else { 25 | RecordSign.NEGATIVE + join 26 | } 27 | } 28 | 29 | companion object { 30 | fun fromMoney(money: Int): MoneyString { 31 | val format = formatMoneyCent(abs(money)) 32 | return MoneyString(format.first, format.second) 33 | } 34 | } 35 | } 36 | } 37 | 38 | private val decimalFormat = DecimalFormat.getInstance().apply { 39 | isGroupingUsed = true 40 | } 41 | 42 | @Stable 43 | fun formatMoneyCent(money: Int): Pair { 44 | val integer = money / 100 45 | val decimal = money % 100 46 | val integerStr = decimalFormat.format(integer) 47 | val decimalStr = "%02d".format(decimal) 48 | return integerStr to decimalStr 49 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/me/konyaco/keeptally/viewmodel/model/RecordSign.kt: -------------------------------------------------------------------------------- 1 | package me.konyaco.keeptally.viewmodel.model 2 | 3 | object RecordSign { 4 | const val POSITIVE = "+" 5 | const val NEGATIVE = "−" 6 | const val RMB = "¥" 7 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_alipay.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_forward.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_label.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 76 | 77 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_monochrome.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 12 | 15 | 18 | 19 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_wechatpay.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/woman_and_pen.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 12 | 15 | 18 | 21 | 24 | 27 | 30 | 33 | 36 | 39 | 42 | 45 | 48 | 51 | 54 | 57 | 60 | 63 | 66 | 69 | 72 | 75 | 78 | 81 | 84 | 87 | 90 | 93 | 96 | 99 | 102 | 105 | 108 | 111 | 114 | 117 | 118 | -------------------------------------------------------------------------------- /app/src/main/res/font/roboto_slab_black.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/123Duo3/KeepTally/378a3563958159ecf5f36b6bed388648b48176d5/app/src/main/res/font/roboto_slab_black.ttf -------------------------------------------------------------------------------- /app/src/main/res/font/roboto_slab_bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/123Duo3/KeepTally/378a3563958159ecf5f36b6bed388648b48176d5/app/src/main/res/font/roboto_slab_bold.ttf -------------------------------------------------------------------------------- /app/src/main/res/font/roboto_slab_extra_bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/123Duo3/KeepTally/378a3563958159ecf5f36b6bed388648b48176d5/app/src/main/res/font/roboto_slab_extra_bold.ttf -------------------------------------------------------------------------------- /app/src/main/res/font/roboto_slab_extra_light.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/123Duo3/KeepTally/378a3563958159ecf5f36b6bed388648b48176d5/app/src/main/res/font/roboto_slab_extra_light.ttf -------------------------------------------------------------------------------- /app/src/main/res/font/roboto_slab_light.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/123Duo3/KeepTally/378a3563958159ecf5f36b6bed388648b48176d5/app/src/main/res/font/roboto_slab_light.ttf -------------------------------------------------------------------------------- /app/src/main/res/font/roboto_slab_medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/123Duo3/KeepTally/378a3563958159ecf5f36b6bed388648b48176d5/app/src/main/res/font/roboto_slab_medium.ttf -------------------------------------------------------------------------------- /app/src/main/res/font/roboto_slab_regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/123Duo3/KeepTally/378a3563958159ecf5f36b6bed388648b48176d5/app/src/main/res/font/roboto_slab_regular.ttf -------------------------------------------------------------------------------- /app/src/main/res/font/roboto_slab_semi_bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/123Duo3/KeepTally/378a3563958159ecf5f36b6bed388648b48176d5/app/src/main/res/font/roboto_slab_semi_bold.ttf -------------------------------------------------------------------------------- /app/src/main/res/font/roboto_slab_thin.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/123Duo3/KeepTally/378a3563958159ecf5f36b6bed388648b48176d5/app/src/main/res/font/roboto_slab_thin.ttf -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/123Duo3/KeepTally/378a3563958159ecf5f36b6bed388648b48176d5/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/123Duo3/KeepTally/378a3563958159ecf5f36b6bed388648b48176d5/app/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/123Duo3/KeepTally/378a3563958159ecf5f36b6bed388648b48176d5/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/123Duo3/KeepTally/378a3563958159ecf5f36b6bed388648b48176d5/app/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/123Duo3/KeepTally/378a3563958159ecf5f36b6bed388648b48176d5/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/123Duo3/KeepTally/378a3563958159ecf5f36b6bed388648b48176d5/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/123Duo3/KeepTally/378a3563958159ecf5f36b6bed388648b48176d5/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/123Duo3/KeepTally/378a3563958159ecf5f36b6bed388648b48176d5/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/123Duo3/KeepTally/378a3563958159ecf5f36b6bed388648b48176d5/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/123Duo3/KeepTally/378a3563958159ecf5f36b6bed388648b48176d5/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/values/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FAFDFA 4 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Keep Tally 3 | 明细 4 | 筛查 5 | 统计 6 | 杂项 7 | 支出 8 | 收入 9 | 总计 10 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | -------------------------------------------------------------------------------- /app/src/test/java/me/konyaco/keeptally/ExampleUnitTest.kt: -------------------------------------------------------------------------------- 1 | package me.konyaco.keeptally 2 | 3 | import org.junit.Assert.assertEquals 4 | import org.junit.Test 5 | 6 | /** 7 | * Example local unit test, which will execute on the development machine (host). 8 | * 9 | * See [testing documentation](http://d.android.com/tools/testing). 10 | */ 11 | class ExampleUnitTest { 12 | @Test 13 | fun addition_isCorrect() { 14 | assertEquals(4, 2 + 2) 15 | } 16 | } -------------------------------------------------------------------------------- /assets/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/123Duo3/KeepTally/378a3563958159ecf5f36b6bed388648b48176d5/assets/banner.png -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | buildscript { 2 | extra["compose_version"] = "1.3.0" 3 | } 4 | 5 | plugins { 6 | id("com.android.application") version "8.0.0-alpha05" apply false 7 | id("com.android.library") version "8.0.0-alpha05" apply false 8 | kotlin("android") version "1.7.20" apply false 9 | id("com.google.dagger.hilt.android") version "2.44" apply false 10 | } 11 | 12 | tasks.withType { 13 | delete(rootProject.buildDir) 14 | } -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will override* 4 | # any settings specified in this file. 5 | # For more details on how to configure your build environment visit 6 | # http://www.gradle.org/docs/current/userguide/build_environment.html 7 | # Specifies the JVM arguments used for the daemon process. 8 | # The setting is particularly useful for tweaking memory settings. 9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 10 | # When configured, Gradle will run in incubating parallel mode. 11 | # This option should only be used with decoupled projects. More details, visit 12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 13 | # org.gradle.parallel=true 14 | # AndroidX package structure to make it clearer which packages are bundled with the 15 | # Android operating system, and which are packaged with your app"s APK 16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 17 | android.useAndroidX=true 18 | # Kotlin code style for this project: "official" or "obsolete": 19 | kotlin.code.style=official 20 | # Enables namespacing of each library's R class so that its R class includes only the 21 | # resources declared in the library itself and none from the library's dependencies, 22 | # thereby reducing the size of the R class for that library 23 | android.nonTransitiveRClass=true -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/123Duo3/KeepTally/378a3563958159ecf5f36b6bed388648b48176d5/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Sat Jun 18 16:27:28 CST 2022 2 | distributionBase=GRADLE_USER_HOME 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-bin.zip 4 | distributionPath=wrapper/dists 5 | zipStorePath=wrapper/dists 6 | zipStoreBase=GRADLE_USER_HOME 7 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # 4 | # Copyright 2015 the original author or authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | ## 21 | ## Gradle start up script for UN*X 22 | ## 23 | ############################################################################## 24 | 25 | # Attempt to set APP_HOME 26 | # Resolve links: $0 may be a link 27 | PRG="$0" 28 | # Need this for relative symlinks. 29 | while [ -h "$PRG" ] ; do 30 | ls=`ls -ld "$PRG"` 31 | link=`expr "$ls" : '.*-> \(.*\)$'` 32 | if expr "$link" : '/.*' > /dev/null; then 33 | PRG="$link" 34 | else 35 | PRG=`dirname "$PRG"`"/$link" 36 | fi 37 | done 38 | SAVED="`pwd`" 39 | cd "`dirname \"$PRG\"`/" >/dev/null 40 | APP_HOME="`pwd -P`" 41 | cd "$SAVED" >/dev/null 42 | 43 | APP_NAME="Gradle" 44 | APP_BASE_NAME=`basename "$0"` 45 | 46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 48 | 49 | # Use the maximum available, or set MAX_FD != -1 to use that value. 50 | MAX_FD="maximum" 51 | 52 | warn () { 53 | echo "$*" 54 | } 55 | 56 | die () { 57 | echo 58 | echo "$*" 59 | echo 60 | exit 1 61 | } 62 | 63 | # OS specific support (must be 'true' or 'false'). 64 | cygwin=false 65 | msys=false 66 | darwin=false 67 | nonstop=false 68 | case "`uname`" in 69 | CYGWIN* ) 70 | cygwin=true 71 | ;; 72 | Darwin* ) 73 | darwin=true 74 | ;; 75 | MINGW* ) 76 | msys=true 77 | ;; 78 | NONSTOP* ) 79 | nonstop=true 80 | ;; 81 | esac 82 | 83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 84 | 85 | 86 | # Determine the Java command to use to start the JVM. 87 | if [ -n "$JAVA_HOME" ] ; then 88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 89 | # IBM's JDK on AIX uses strange locations for the executables 90 | JAVACMD="$JAVA_HOME/jre/sh/java" 91 | else 92 | JAVACMD="$JAVA_HOME/bin/java" 93 | fi 94 | if [ ! -x "$JAVACMD" ] ; then 95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 96 | 97 | Please set the JAVA_HOME variable in your environment to match the 98 | location of your Java installation." 99 | fi 100 | else 101 | JAVACMD="java" 102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 103 | 104 | Please set the JAVA_HOME variable in your environment to match the 105 | location of your Java installation." 106 | fi 107 | 108 | # Increase the maximum file descriptors if we can. 109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 110 | MAX_FD_LIMIT=`ulimit -H -n` 111 | if [ $? -eq 0 ] ; then 112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 113 | MAX_FD="$MAX_FD_LIMIT" 114 | fi 115 | ulimit -n $MAX_FD 116 | if [ $? -ne 0 ] ; then 117 | warn "Could not set maximum file descriptor limit: $MAX_FD" 118 | fi 119 | else 120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 121 | fi 122 | fi 123 | 124 | # For Darwin, add options to specify how the application appears in the dock 125 | if $darwin; then 126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 127 | fi 128 | 129 | # For Cygwin or MSYS, switch paths to Windows format before running java 130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then 131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 133 | 134 | JAVACMD=`cygpath --unix "$JAVACMD"` 135 | 136 | # We build the pattern for arguments to be converted via cygpath 137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 138 | SEP="" 139 | for dir in $ROOTDIRSRAW ; do 140 | ROOTDIRS="$ROOTDIRS$SEP$dir" 141 | SEP="|" 142 | done 143 | OURCYGPATTERN="(^($ROOTDIRS))" 144 | # Add a user-defined pattern to the cygpath arguments 145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 147 | fi 148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 149 | i=0 150 | for arg in "$@" ; do 151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 153 | 154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 156 | else 157 | eval `echo args$i`="\"$arg\"" 158 | fi 159 | i=`expr $i + 1` 160 | done 161 | case $i in 162 | 0) set -- ;; 163 | 1) set -- "$args0" ;; 164 | 2) set -- "$args0" "$args1" ;; 165 | 3) set -- "$args0" "$args1" "$args2" ;; 166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 172 | esac 173 | fi 174 | 175 | # Escape application args 176 | save () { 177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 178 | echo " " 179 | } 180 | APP_ARGS=`save "$@"` 181 | 182 | # Collect all arguments for the java command, following the shell quoting and substitution rules 183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 184 | 185 | exec "$JAVACMD" "$@" 186 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | gradlePluginPortal() 4 | google() 5 | mavenCentral() 6 | } 7 | } 8 | dependencyResolutionManagement { 9 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 10 | repositories { 11 | google() 12 | mavenCentral() 13 | } 14 | } 15 | rootProject.name = "Keep Tally" 16 | include(":app") --------------------------------------------------------------------------------