├── .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 | 
4 |
5 | ## 鸣谢
6 |
7 | 
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