├── .fleet
└── receipt.json
├── .gitignore
├── README.md
├── build.gradle.kts
├── composeApp
├── build.gradle.kts
└── src
│ ├── androidMain
│ ├── AndroidManifest.xml
│ ├── kotlin
│ │ ├── Platform.android.kt
│ │ └── com
│ │ │ └── tangping
│ │ │ └── zhoujiang
│ │ │ └── MainActivity.kt
│ └── res
│ │ ├── drawable-v24
│ │ └── ic_launcher_foreground.webp
│ │ ├── drawable
│ │ └── ic_launcher_background.xml
│ │ ├── mipmap-anydpi-v26
│ │ ├── ic_launcher.xml
│ │ └── ic_launcher_round.xml
│ │ ├── values-v27
│ │ └── styles.xml
│ │ ├── values-v29
│ │ └── styles.xml
│ │ ├── values
│ │ ├── strings.xml
│ │ └── styles.xml
│ │ └── xml
│ │ └── network_security_config.xml
│ ├── commonMain
│ ├── composeResources
│ │ ├── drawable
│ │ │ ├── compose-multiplatform.xml
│ │ │ ├── ic_add.xml
│ │ │ ├── ic_arrow_down.xml
│ │ │ ├── ic_auto.xml
│ │ │ ├── ic_back.xml
│ │ │ ├── ic_close.xml
│ │ │ ├── ic_copy.xml
│ │ │ ├── ic_countdown.xml
│ │ │ ├── ic_deposit.xml
│ │ │ ├── ic_deposit_goal.xml
│ │ │ ├── ic_details.xml
│ │ │ ├── ic_empty.xml
│ │ │ ├── ic_export.xml
│ │ │ ├── ic_history_overtime.xml
│ │ │ ├── ic_history_run.xml
│ │ │ ├── ic_logout.xml
│ │ │ ├── ic_memo.xml
│ │ │ ├── ic_milestone.xml
│ │ │ ├── ic_next.xml
│ │ │ ├── ic_overtime.xml
│ │ │ ├── ic_pin.xml
│ │ │ ├── ic_prev.xml
│ │ │ ├── ic_schedule.xml
│ │ │ ├── ic_server.xml
│ │ │ ├── ic_settings.xml
│ │ │ ├── ic_sync.xml
│ │ │ ├── ic_tick.xml
│ │ │ ├── ic_time_card.xml
│ │ │ ├── ic_todo.xml
│ │ │ ├── ic_todo_finished.xml
│ │ │ ├── ic_work_enough.xml
│ │ │ ├── ic_working.xml
│ │ │ └── ic_zhou.xml
│ │ └── values
│ │ │ └── strings.xml
│ └── kotlin
│ │ ├── App.kt
│ │ ├── Platform.kt
│ │ ├── constant
│ │ ├── RouteConstants.kt
│ │ ├── TabConstants.kt
│ │ └── TimeConstants.kt
│ │ ├── extension
│ │ ├── ModifierExt.kt
│ │ ├── NumberExt.kt
│ │ ├── StringExtension.kt
│ │ └── TimeExt.kt
│ │ ├── global
│ │ ├── AppColors.kt
│ │ └── AppTheme.kt
│ │ ├── helper
│ │ ├── DepositHelper.kt
│ │ ├── MemoHelper.kt
│ │ ├── NetworkHelper.kt
│ │ ├── ScheduleHelper.kt
│ │ ├── SyncHelper.kt
│ │ ├── TimeCardHelper.kt
│ │ ├── WorkHoursHelper.kt
│ │ └── effect
│ │ │ ├── BaseEffectObserver.kt
│ │ │ ├── EffectHelper.kt
│ │ │ └── EffectObservers.kt
│ │ ├── model
│ │ ├── DoubleToLongSerializer.kt
│ │ ├── calendar
│ │ │ └── MonthDay.kt
│ │ ├── display
│ │ │ └── MemoDisplayItem.kt
│ │ ├── records
│ │ │ ├── DepositRecords.kt
│ │ │ ├── Goals.kt
│ │ │ ├── MemoRecords.kt
│ │ │ ├── ScheduleRecords.kt
│ │ │ └── TimeCardRecords.kt
│ │ └── request
│ │ │ ├── DepositSyncRequest.kt
│ │ │ ├── LoginRequest.kt
│ │ │ ├── MemoSyncRequest.kt
│ │ │ ├── RegisterRequest.kt
│ │ │ ├── ScheduleSyncRequest.kt
│ │ │ └── TimeCardSyncRequest.kt
│ │ ├── store
│ │ ├── AppFlowStore.kt
│ │ ├── AppStore.kt
│ │ └── CurrentProcessStore.kt
│ │ ├── ui
│ │ ├── dialog
│ │ │ ├── CloudServerDialog.kt
│ │ │ ├── ConfirmDialog.kt
│ │ │ └── DepositGoalDialog.kt
│ │ ├── fragment
│ │ │ ├── DepositFragment.kt
│ │ │ ├── DepositPresenter.kt
│ │ │ ├── MemoFragment.kt
│ │ │ ├── MemoPresenter.kt
│ │ │ ├── ScheduleFragment.kt
│ │ │ ├── SchedulePresenter.kt
│ │ │ ├── SettingsFragment.kt
│ │ │ ├── TimeCardFragment.kt
│ │ │ └── TimeCardPresenter.kt
│ │ ├── scene
│ │ │ ├── AddSchedulePresenter.kt
│ │ │ ├── AddScheduleScene.kt
│ │ │ ├── DepositStatsPresenter.kt
│ │ │ ├── DepositStatsScene.kt
│ │ │ ├── ExportDataScene.kt
│ │ │ ├── HomeScene.kt
│ │ │ ├── LoginScene.kt
│ │ │ ├── SignUpScene.kt
│ │ │ ├── SyncScene.kt
│ │ │ ├── WriteMemoPresenter.kt
│ │ │ ├── WriteMemoScene.kt
│ │ │ └── detail
│ │ │ │ ├── DetailPresenter.kt
│ │ │ │ ├── DetailScene.kt
│ │ │ │ ├── HistoryFragment.kt
│ │ │ │ └── TodayFragment.kt
│ │ └── widget
│ │ │ ├── AutoSyncIndicator.kt
│ │ │ ├── BarChart.kt
│ │ │ ├── BaseImmersiveScene.kt
│ │ │ ├── BottomBar.kt
│ │ │ ├── Divider.kt
│ │ │ ├── EmptyLayout.kt
│ │ │ ├── FragmentHeader.kt
│ │ │ ├── HorizontalSeekBar.kt
│ │ │ ├── LineChart.kt
│ │ │ ├── ShimmerProgressBar.kt
│ │ │ └── TitleBar.kt
│ │ └── util
│ │ ├── CalendarUtil.kt
│ │ └── TimeUtil.kt
│ └── iosMain
│ └── kotlin
│ ├── MainViewController.kt
│ └── Platform.ios.kt
├── gradle.properties
├── gradle
├── libs.versions.toml
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
├── iosApp
├── Configuration
│ └── Config.xcconfig
├── iosApp.xcodeproj
│ ├── project.pbxproj
│ └── project.xcworkspace
│ │ ├── contents.xcworkspacedata
│ │ └── xcshareddata
│ │ └── IDEWorkspaceChecks.plist
└── iosApp
│ ├── Assets.xcassets
│ ├── AccentColor.colorset
│ │ └── Contents.json
│ ├── AppIcon.appiconset
│ │ ├── Contents.json
│ │ └── app-icon-1024.png
│ └── Contents.json
│ ├── ContentView.swift
│ ├── Info.plist
│ ├── Preview Content
│ └── Preview Assets.xcassets
│ │ └── Contents.json
│ └── iOSApp.swift
├── kotStore
├── build.gradle.kts
└── src
│ ├── androidMain
│ └── kotlin
│ │ └── com
│ │ └── tangping
│ │ └── kotstore
│ │ └── Platform.android.kt
│ ├── commonMain
│ └── kotlin
│ │ └── com
│ │ └── tangping
│ │ └── kotstore
│ │ ├── Platform.kt
│ │ ├── flow
│ │ ├── FlowDelegate.kt
│ │ └── FlowStore.kt
│ │ ├── model
│ │ ├── KotStoreFlowModel.kt
│ │ └── KotStoreModel.kt
│ │ └── store
│ │ ├── AbstractStore.kt
│ │ ├── BooleanStore.kt
│ │ ├── DoubleStore.kt
│ │ ├── FloatStore.kt
│ │ ├── IntStore.kt
│ │ ├── LongStore.kt
│ │ └── StringStore.kt
│ └── iosMain
│ └── kotlin
│ └── com
│ └── tangping
│ └── kotstore
│ └── Platform.ios.kt
└── settings.gradle.kts
/.fleet/receipt.json:
--------------------------------------------------------------------------------
1 | // Project generated by Kotlin Multiplatform Wizard
2 | {
3 | "spec": {
4 | "template_id": "kmt",
5 | "targets": {
6 | "android": {
7 | "ui": [
8 | "compose"
9 | ]
10 | },
11 | "ios": {
12 | "ui": [
13 | "compose"
14 | ]
15 | }
16 | }
17 | },
18 | "timestamp": "2024-04-12T08:22:48.385227794Z"
19 | }
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.iml
2 | .gradle
3 | **/build/
4 | xcuserdata
5 | !src/**/build/
6 | local.properties
7 | .idea
8 | .DS_Store
9 | captures
10 | .externalNativeBuild
11 | .cxx
12 | *.xcodeproj/*
13 | !*.xcodeproj/project.pbxproj
14 | !*.xcodeproj/xcshareddata/
15 | !*.xcodeproj/project.xcworkspace/
16 | !*.xcworkspace/contents.xcworkspacedata
17 | **/xcshareddata/WorkspaceSettings.xcsettings
18 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # ZhouTools
2 |
3 | > A work-life integration tool for modern workers.
4 |
5 | ## Information
6 |
7 | | | |
8 | | - |--------------------------------------------------|
9 | | **Supported Platforms** | Android 7.0+ / iOS (building from source needed) |
10 | | **Latest Version** | 1.4.0 |
11 | | **UI Language** | English |
12 |
13 | ## Update Notes
14 |
15 | ### Version 1.4.0
16 |
17 | - New statistics feature in `Time Card > Details > History`: Summarizing working hours by week/month/quarter/year.
18 |
19 | - New grouping functionality in `Memo` : Grouping notes and TODO tasks.
20 |
21 | - Android and iOS keyboard experience optimizations for the Memo feature.
22 |
23 | ### Version 1.3.0
24 |
25 | - New "Savings Statistics" feature page
26 |
27 | - Supports viewing total deposit amount changes by month as bar chart
28 |
29 | - Supports viewing monthly income trends as line chart
30 |
31 | - Provides multiple value filtering modes
32 |
33 | - New holiday display function in Schedule tab
34 |
35 | - Immersive experience optimization
36 |
37 | ### Version 1.2.0
38 |
39 | - Deposit Goal: It's now possible to set a total deposit goal and track your progress.
40 |
41 | - Milestone Goal: You can now set a milestone goal and see how many days remain until the target date.
42 |
43 | - `Memo > Goals` Section: Visualizes your deposit and milestone goals in one place.
44 |
45 | - Improved Data Synchronization: Redundant data synchronization operations are reduced to improve efficiency.
46 |
47 | - Improved slide-back gesture for iOS devices.
48 |
49 | ### Version 1.1.0
50 |
51 | - Login tokens are refreshed on launch to prevent expiration during data synchronization.
52 |
53 | - `Time Card > History` now displays historical data in reverse order, making it easier to view.
54 |
55 | - Introduce `Auto Sync` switch, enabling automatic data synchronization with the cloud, eliminating the need for manual operation (manual sync feature is still retained).
56 |
57 | ## Introduction
58 |
59 | This software is a mobile cross-platform work-life integration tool developed using the latest version of Compose Multiplatform. It aims to provide modern workers with a one-stop platform for managing **work hours, schedules and countdown days, memos, and monthly savings**. Data can be synchronized to the cloud via the official account system or a self-built server, ensuring 100% recovery of original records when switching devices. The UI is based on Google's Material Design 3, making the interface both beautiful and user-friendly.
60 |
61 | ### 1. Working Hours
62 |
63 | 
64 |
65 | Record clock-in and clock-out times, summarize them monthly, and calculate the number of overtime days and the average daily working hours.
66 |
67 | ### 2. Schedules and Countdown Days
68 |
69 | 
70 |
71 | Easily edit daily schedules in the calendar view, or create milestones to see how much time remains until a specific date or how much time has passed since that date.
72 |
73 | ### 3. Memos
74 |
75 | 
76 |
77 | Add memos, pin important items to the top, or create TO-DO tasks and mark them as completed once done.
78 |
79 | ### 4. Monthly Savings
80 |
81 | 
82 |
83 | Record the balance of your savings regularly each month, track your expenses by comparison, and always stay aware of how far you are from reaching your savings goal.
84 |
85 | ### 5. Settings
86 |
87 | 
88 |
89 | Adjust the target values for working hours and overtime hours according to the specific regulations of your company. Data can also be synchronized with the official cloud service or a self-built server.
--------------------------------------------------------------------------------
/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | // this is necessary to avoid the plugins to be loaded multiple times
3 | // in each subproject's classloader
4 | alias(libs.plugins.androidApplication) apply false
5 | alias(libs.plugins.androidLibrary) apply false
6 | alias(libs.plugins.jetbrainsCompose) apply false
7 | alias(libs.plugins.compose.compiler) apply false
8 | alias(libs.plugins.kotlinMultiplatform) apply false
9 | }
--------------------------------------------------------------------------------
/composeApp/build.gradle.kts:
--------------------------------------------------------------------------------
1 | import com.android.build.gradle.internal.api.ApkVariantOutputImpl
2 |
3 | plugins {
4 | alias(libs.plugins.kotlinMultiplatform)
5 | alias(libs.plugins.androidApplication)
6 | alias(libs.plugins.jetbrainsCompose)
7 | alias(libs.plugins.compose.compiler)
8 | kotlin(libs.plugins.serialization.get().pluginId).version(libs.versions.serialization)
9 | }
10 |
11 | compose.resources {
12 | publicResClass = false
13 | generateResClass = always
14 | }
15 |
16 | kotlin {
17 | androidTarget {
18 | compilations.all {
19 | kotlinOptions {
20 | jvmTarget = "11"
21 | }
22 | }
23 | }
24 |
25 | listOf(
26 | iosX64(),
27 | iosArm64(),
28 | iosSimulatorArm64()
29 | ).forEach { iosTarget ->
30 | iosTarget.binaries.framework {
31 | baseName = "ComposeApp"
32 | isStatic = true
33 | }
34 | }
35 |
36 | sourceSets {
37 |
38 | androidMain.dependencies {
39 | implementation(libs.compose.ui.tooling.preview)
40 | implementation(libs.androidx.activity.compose)
41 | implementation(libs.ktor.client.android)
42 | }
43 | iosMain.dependencies {
44 | implementation(libs.ktor.client.darwin)
45 | }
46 | commonMain.dependencies {
47 | implementation(compose.runtime)
48 | implementation(compose.foundation)
49 | implementation(compose.material)
50 | implementation(compose.ui)
51 | implementation(compose.components.resources)
52 | implementation(compose.components.uiToolingPreview)
53 | // Molecule
54 | implementation(libs.molecule.runtime)
55 | // PreCompose
56 | implementation(libs.precompose)
57 | implementation(libs.precompose.molecule)
58 | // Ktor
59 | implementation(libs.ktor.client.core)
60 | implementation(libs.ktor.client.content.negotiation)
61 | implementation(libs.ktor.serialization.kotlinx.json)
62 | // DataStore
63 | implementation(libs.androidx.datastore.core)
64 | // DateTime
65 | implementation(libs.kotlinx.datetime)
66 | // Logging
67 | api(libs.km.logging)
68 | // Material3
69 | implementation(libs.material3)
70 | // KotStore
71 | implementation(project(":kotStore"))
72 | }
73 | }
74 | }
75 |
76 | android {
77 | namespace = "com.tangping.zhoujiang"
78 | compileSdk = libs.versions.android.compileSdk.get().toInt()
79 |
80 | sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml")
81 | sourceSets["main"].res.srcDirs("src/androidMain/res")
82 | sourceSets["main"].resources.srcDirs("src/commonMain/resources")
83 |
84 | defaultConfig {
85 | applicationId = "com.tangping.zhoujiang"
86 | minSdk = libs.versions.android.minSdk.get().toInt()
87 | targetSdk = libs.versions.android.targetSdk.get().toInt()
88 | versionCode = 5
89 | versionName = "1.4.0"
90 | }
91 | packaging {
92 | resources {
93 | excludes += "/META-INF/{AL2.0,LGPL2.1}"
94 | }
95 | }
96 | buildTypes {
97 | getByName("release") {
98 | isMinifyEnabled = false
99 | }
100 | }
101 | compileOptions {
102 | sourceCompatibility = JavaVersion.VERSION_11
103 | targetCompatibility = JavaVersion.VERSION_11
104 | }
105 | dependencies {
106 | debugImplementation(libs.compose.ui.tooling)
107 | }
108 | applicationVariants.all {
109 | outputs.all { output ->
110 | if (output is ApkVariantOutputImpl) {
111 | output.outputFileName = "ZhouTools-v${versionName}.apk"
112 | }
113 | true
114 | }
115 | }
116 | }
117 |
118 |
--------------------------------------------------------------------------------
/composeApp/src/androidMain/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
13 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/composeApp/src/androidMain/kotlin/Platform.android.kt:
--------------------------------------------------------------------------------
1 | import android.app.Activity
2 | import android.content.ClipData
3 | import android.content.ClipboardManager
4 | import android.content.Context
5 | import android.graphics.Color
6 | import android.os.Build
7 | import android.view.View
8 | import android.view.inputmethod.InputMethodManager
9 | import androidx.compose.foundation.layout.WindowInsets
10 | import androidx.compose.foundation.layout.ime
11 | import androidx.compose.runtime.Composable
12 | import androidx.compose.runtime.State
13 | import androidx.compose.runtime.derivedStateOf
14 | import androidx.compose.runtime.remember
15 | import androidx.compose.ui.platform.LocalDensity
16 | import com.tangping.zhoujiang.MainActivity
17 |
18 | actual fun isIOS(): Boolean = false
19 |
20 | actual fun getAppVersion(): String {
21 | val context = MainActivity.context ?: return ""
22 | return context.packageManager.getPackageInfo(context.packageName, 0).versionName
23 | }
24 |
25 | actual fun setClipboardContent(text: String) {
26 | val context = MainActivity.context ?: return
27 | val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
28 | val clip = ClipData.newPlainText("label", text)
29 | clipboard.setPrimaryClip(clip)
30 | }
31 |
32 | actual fun hideSoftwareKeyboard() {
33 | val context = MainActivity.context ?: return
34 | val imeManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
35 | val currentFocus = (context as? Activity)?.currentFocus
36 | val windowToken = currentFocus?.windowToken
37 | windowToken?.let {
38 | imeManager.hideSoftInputFromWindow(windowToken, 0)
39 | }
40 | }
41 |
42 | actual fun setStatusBarColor(colorStr: String, isLight: Boolean) {
43 | val window = MainActivity.window ?: return
44 | window.statusBarColor = Color.parseColor(colorStr)
45 | if (isLight) {
46 | window.decorView.systemUiVisibility = window.decorView.systemUiVisibility or View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR
47 | } else {
48 | window.decorView.systemUiVisibility = window.decorView.systemUiVisibility and View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR.inv()
49 | }
50 | }
51 |
52 | actual fun setNavigationBarColor(colorStr: String, isLight: Boolean) {
53 | val window = MainActivity.window ?: return
54 | window.navigationBarColor = Color.parseColor(colorStr)
55 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
56 | if (isLight) {
57 | window.decorView.systemUiVisibility = window.decorView.systemUiVisibility or View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR
58 | } else {
59 | window.decorView.systemUiVisibility = window.decorView.systemUiVisibility and View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR.inv()
60 | }
61 | }
62 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
63 | window.navigationBarDividerColor = Color.parseColor(colorStr)
64 | }
65 | }
66 |
67 | @Composable
68 | actual fun rememberKeyboardVisibilityState(): State {
69 | val density = LocalDensity.current
70 | val imeInsets = WindowInsets.ime
71 | val isImeVisible = remember {
72 | derivedStateOf {
73 | imeInsets.getBottom(density) > 0
74 | }
75 | }
76 | return isImeVisible
77 | }
--------------------------------------------------------------------------------
/composeApp/src/androidMain/kotlin/com/tangping/zhoujiang/MainActivity.kt:
--------------------------------------------------------------------------------
1 | package com.tangping.zhoujiang
2 |
3 | import App
4 | import android.annotation.SuppressLint
5 | import android.content.Context
6 | import android.os.Bundle
7 | import android.view.Window
8 | import androidx.activity.ComponentActivity
9 | import androidx.activity.compose.setContent
10 | import androidx.compose.runtime.Composable
11 | import androidx.compose.ui.tooling.preview.Preview
12 | import androidx.core.view.WindowCompat
13 | import com.tangping.kotstore.KotStoreAndroidBase
14 |
15 | class MainActivity : ComponentActivity() {
16 | companion object {
17 | @SuppressLint("StaticFieldLeak")
18 | var context: Context? = null
19 | var window: Window? = null
20 | }
21 |
22 | override fun onCreate(savedInstanceState: Bundle?) {
23 | super.onCreate(savedInstanceState)
24 | context = this
25 | setTheme(R.style.TransparentSystemBars)
26 | WindowCompat.setDecorFitsSystemWindows(window, false)
27 | MainActivity.window = window
28 | KotStoreAndroidBase.init(this)
29 |
30 | setContent {
31 | App()
32 | }
33 | }
34 | }
35 |
36 | @Preview
37 | @Composable
38 | fun AppAndroidPreview() {
39 | App()
40 | }
--------------------------------------------------------------------------------
/composeApp/src/androidMain/res/drawable-v24/ic_launcher_foreground.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AkatsukiRika/ZhouTools/d847ee5dd74e0c661cc153f9cc75550ec7a238b5/composeApp/src/androidMain/res/drawable-v24/ic_launcher_foreground.webp
--------------------------------------------------------------------------------
/composeApp/src/androidMain/res/drawable/ic_launcher_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/composeApp/src/androidMain/res/values-v27/styles.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
11 |
--------------------------------------------------------------------------------
/composeApp/src/androidMain/res/values-v29/styles.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
12 |
--------------------------------------------------------------------------------
/composeApp/src/androidMain/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | Zhou Tools
3 |
--------------------------------------------------------------------------------
/composeApp/src/androidMain/res/values/styles.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
9 |
--------------------------------------------------------------------------------
/composeApp/src/androidMain/res/xml/network_security_config.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/composeResources/drawable/compose-multiplatform.xml:
--------------------------------------------------------------------------------
1 |
6 |
10 |
14 |
18 |
24 |
30 |
36 |
37 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/composeResources/drawable/ic_add.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
10 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/composeResources/drawable/ic_arrow_down.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
10 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/composeResources/drawable/ic_auto.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
10 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/composeResources/drawable/ic_back.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
10 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/composeResources/drawable/ic_close.xml:
--------------------------------------------------------------------------------
1 |
6 |
10 |
13 |
14 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/composeResources/drawable/ic_copy.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
10 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/composeResources/drawable/ic_countdown.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
10 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/composeResources/drawable/ic_deposit.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
10 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/composeResources/drawable/ic_deposit_goal.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
10 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/composeResources/drawable/ic_details.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
10 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/composeResources/drawable/ic_empty.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
10 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/composeResources/drawable/ic_export.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
12 |
13 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/composeResources/drawable/ic_history_overtime.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
12 |
13 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/composeResources/drawable/ic_history_run.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
12 |
13 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/composeResources/drawable/ic_logout.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
12 |
13 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/composeResources/drawable/ic_memo.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
10 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/composeResources/drawable/ic_milestone.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
12 |
13 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/composeResources/drawable/ic_next.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
10 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/composeResources/drawable/ic_overtime.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
10 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/composeResources/drawable/ic_pin.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
10 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/composeResources/drawable/ic_prev.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
10 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/composeResources/drawable/ic_schedule.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
10 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/composeResources/drawable/ic_server.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
12 |
13 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/composeResources/drawable/ic_settings.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
10 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/composeResources/drawable/ic_sync.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
10 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/composeResources/drawable/ic_tick.xml:
--------------------------------------------------------------------------------
1 |
6 |
13 |
14 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/composeResources/drawable/ic_time_card.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
10 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/composeResources/drawable/ic_todo.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
10 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/composeResources/drawable/ic_todo_finished.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
12 |
13 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/composeResources/drawable/ic_work_enough.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
12 |
13 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/composeResources/drawable/ic_working.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
12 |
13 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/composeResources/drawable/ic_zhou.xml:
--------------------------------------------------------------------------------
1 |
6 |
10 |
11 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/App.kt:
--------------------------------------------------------------------------------
1 | import androidx.compose.animation.core.LinearEasing
2 | import androidx.compose.animation.core.tween
3 | import androidx.compose.animation.slideInHorizontally
4 | import androidx.compose.animation.slideOutHorizontally
5 | import androidx.compose.runtime.*
6 | import global.AppTheme
7 | import constant.RouteConstants
8 | import helper.NetworkHelper
9 | import model.request.LoginRequest
10 | import moe.tlaster.precompose.PreComposeApp
11 | import moe.tlaster.precompose.navigation.NavHost
12 | import moe.tlaster.precompose.navigation.SwipeProperties
13 | import moe.tlaster.precompose.navigation.path
14 | import moe.tlaster.precompose.navigation.rememberNavigator
15 | import moe.tlaster.precompose.navigation.transition.NavTransition
16 | import org.jetbrains.compose.ui.tooling.preview.Preview
17 | import org.lighthousegames.logging.logging
18 | import ui.scene.HomeScene
19 | import ui.scene.LoginScene
20 | import store.AppStore
21 | import ui.scene.AddScheduleScene
22 | import ui.scene.DepositStatsScene
23 | import ui.scene.ExportDataScene
24 | import ui.scene.SignUpScene
25 | import ui.scene.SyncScene
26 | import ui.scene.WriteMemoScene
27 | import ui.scene.detail.DetailScene
28 |
29 | val logger = logging("App")
30 |
31 | @Composable
32 | @Preview
33 | fun App() {
34 | PreComposeApp {
35 | val navigator = rememberNavigator()
36 | val currentEntry = navigator.currentEntry.collectAsState(initial = null).value
37 | var swipeProperties: SwipeProperties? = remember { SwipeProperties() }
38 | val isLogin = AppStore.loginToken.isNotBlank()
39 | val navTransition = remember {
40 | NavTransition(
41 | createTransition = slideInHorizontally(animationSpec = tween(easing = LinearEasing)) { it },
42 | destroyTransition = slideOutHorizontally(animationSpec = tween(easing = LinearEasing)) { it },
43 | pauseTransition = slideOutHorizontally { -it / 4 },
44 | resumeTransition = slideInHorizontally { -it / 4 },
45 | exitTargetContentZIndex = 1f
46 | )
47 | }
48 |
49 | LaunchedEffect(currentEntry) {
50 | val currentRoute = currentEntry?.route?.route
51 | swipeProperties = if (currentRoute == RouteConstants.ROUTE_HOME) {
52 | null
53 | } else {
54 | SwipeProperties()
55 | }
56 | }
57 |
58 | NavHost(
59 | navigator = navigator,
60 | swipeProperties = swipeProperties,
61 | navTransition = navTransition,
62 | initialRoute = if (isLogin) RouteConstants.ROUTE_HOME else RouteConstants.ROUTE_LOGIN
63 | ) {
64 | scene(
65 | route = RouteConstants.ROUTE_LOGIN,
66 | navTransition = navTransition
67 | ) {
68 | AppTheme {
69 | LoginScene(navigator)
70 | }
71 | }
72 |
73 | scene(
74 | route = RouteConstants.ROUTE_SIGN_UP,
75 | navTransition = navTransition
76 | ) {
77 | AppTheme {
78 | SignUpScene(navigator)
79 | }
80 | }
81 |
82 | scene(
83 | route = RouteConstants.ROUTE_HOME,
84 | navTransition = navTransition
85 | ) {
86 | AppTheme {
87 | HomeScene(navigator)
88 | }
89 | }
90 |
91 | scene(
92 | route = RouteConstants.ROUTE_DETAILS,
93 | navTransition = navTransition
94 | ) {
95 | AppTheme {
96 | DetailScene(navigator)
97 | }
98 | }
99 |
100 | scene(
101 | route = RouteConstants.ROUTE_WRITE_MEMO,
102 | navTransition = navTransition
103 | ) {
104 | val isEdit = it.path("edit") ?: false
105 |
106 | AppTheme {
107 | WriteMemoScene(navigator, isEdit)
108 | }
109 | }
110 |
111 | scene(
112 | route = RouteConstants.ROUTE_ADD_SCHEDULE,
113 | navTransition = navTransition
114 | ) {
115 | AppTheme {
116 | AddScheduleScene(navigator)
117 | }
118 | }
119 |
120 | scene(
121 | route = RouteConstants.ROUTE_SYNC,
122 | navTransition = navTransition
123 | ) {
124 | val mode = it.path("mode") ?: ""
125 |
126 | AppTheme {
127 | SyncScene(navigator, mode)
128 | }
129 | }
130 |
131 | scene(
132 | route = RouteConstants.ROUTE_EXPORT,
133 | navTransition = navTransition
134 | ) {
135 | AppTheme {
136 | ExportDataScene(navigator)
137 | }
138 | }
139 |
140 | scene(
141 | route = RouteConstants.ROUTE_DEPOSIT_STATS,
142 | navTransition = navTransition
143 | ) {
144 | AppTheme {
145 | DepositStatsScene(navigator)
146 | }
147 | }
148 | }
149 |
150 | LaunchedEffect(Unit) {
151 | logger.i { "Welcome to Zhou Tools!" }
152 | checkLoginValidity()
153 | }
154 | }
155 | }
156 |
157 | /**
158 | * Check login token validity and login again when token is invalid.
159 | */
160 | private suspend fun checkLoginValidity() {
161 | logger.i { "checkLoginValidity: loginToken=${AppStore.loginToken}, loginUsername=${AppStore.loginUsername}" }
162 | if (AppStore.loginToken.isNotEmpty() && AppStore.loginUsername.isNotEmpty() && AppStore.loginPassword.isNotEmpty()) {
163 | val isValid = NetworkHelper.checkTokenValidity(AppStore.loginToken)
164 | logger.i { "checkLoginValidity: isValid=$isValid" }
165 | if (!isValid) {
166 | // login again
167 | val loginRequest = LoginRequest(username = AppStore.loginUsername, password = AppStore.loginPassword)
168 | val loginResponse = NetworkHelper.login(loginRequest)
169 | val isSuccess = loginResponse.first
170 | logger.i { "checkLoginValidity: login isSuccess=$isSuccess" }
171 | if (isSuccess) {
172 | val token = loginResponse.second
173 | logger.i { "checkLoginValidity: new token=$token" }
174 | if (token != null) {
175 | AppStore.loginToken = token
176 | }
177 | }
178 | }
179 | }
180 | }
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/Platform.kt:
--------------------------------------------------------------------------------
1 | import androidx.compose.runtime.Composable
2 | import androidx.compose.runtime.State
3 |
4 | internal const val PREFERENCES_NAME = "zhoutools.preferences_pb"
5 | internal const val FLOW_PREFERENCES_NAME = "zhoutools_flow.preferences_pb"
6 |
7 | expect fun isIOS(): Boolean
8 |
9 | expect fun getAppVersion(): String
10 |
11 | expect fun setClipboardContent(text: String)
12 |
13 | expect fun hideSoftwareKeyboard()
14 |
15 | expect fun setStatusBarColor(colorStr: String, isLight: Boolean)
16 |
17 | expect fun setNavigationBarColor(colorStr: String, isLight: Boolean)
18 |
19 | @Composable
20 | expect fun rememberKeyboardVisibilityState(): State
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/constant/RouteConstants.kt:
--------------------------------------------------------------------------------
1 | package constant
2 |
3 | object RouteConstants {
4 | const val PARAM_EDIT = "{edit}"
5 | const val PARAM_MODE = "{mode}"
6 |
7 | const val ROUTE_LOGIN = "/login"
8 | const val ROUTE_SIGN_UP = "/signUp"
9 | const val ROUTE_HOME = "/home"
10 | const val ROUTE_DETAILS = "/details"
11 | const val ROUTE_WRITE_MEMO = "/writeMemo/$PARAM_EDIT"
12 | const val ROUTE_ADD_SCHEDULE = "/addSchedule"
13 | const val ROUTE_SYNC = "/sync/$PARAM_MODE"
14 | const val ROUTE_EXPORT = "/export"
15 | const val ROUTE_DEPOSIT_STATS = "/deposit/stats"
16 | }
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/constant/TabConstants.kt:
--------------------------------------------------------------------------------
1 | package constant
2 |
3 | object TabConstants {
4 | const val TAB_TIME_CARD = 0
5 | const val TAB_SCHEDULE = 1
6 | const val TAB_SETTINGS = 2
7 | const val TAB_MEMO = 3
8 | const val TAB_DEPOSIT = 4
9 | const val TAB_COUNT = 5
10 | }
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/constant/TimeConstants.kt:
--------------------------------------------------------------------------------
1 | package constant
2 |
3 | object TimeConstants {
4 | const val DAY_MILLIS = 24 * 60 * 60 * 1000L
5 | const val WEEK_MILLIS = 7 * DAY_MILLIS
6 | }
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/extension/ModifierExt.kt:
--------------------------------------------------------------------------------
1 | package extension
2 |
3 | import androidx.compose.foundation.clickable
4 | import androidx.compose.foundation.interaction.MutableInteractionSource
5 | import androidx.compose.runtime.Composable
6 | import androidx.compose.runtime.remember
7 | import androidx.compose.ui.Modifier
8 |
9 | @Composable
10 | fun Modifier.clickableNoRipple(onClick: () -> Unit) = Modifier.clickable(
11 | onClick = onClick,
12 | interactionSource = remember { MutableInteractionSource() },
13 | indication = null
14 | )
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/extension/NumberExt.kt:
--------------------------------------------------------------------------------
1 | package extension
2 |
3 | import kotlin.math.pow
4 | import kotlin.math.round
5 |
6 | fun Float.roundToDecimalPlaces(n: Int): String {
7 | val multiplier = 10.0.pow(n.toDouble())
8 | val roundedNumber = round(this * multiplier) / multiplier
9 | val numberString = roundedNumber.toString()
10 |
11 | val decimalPointIndex = numberString.indexOf('.')
12 | return if (decimalPointIndex == -1) {
13 | "${numberString}.${"0".repeat(n)}"
14 | } else {
15 | val existingDecimalCount = numberString.length - decimalPointIndex - 1
16 | if (existingDecimalCount < n) {
17 | numberString + "0".repeat(n - existingDecimalCount)
18 | } else {
19 | numberString
20 | }
21 | }
22 | }
23 |
24 | fun Long.toMoneyDisplayStr() = when {
25 | this < 10 -> {
26 | "0.0${this}"
27 | }
28 |
29 | this < 100 -> {
30 | "0.${this}"
31 | }
32 |
33 | else -> {
34 | val beforePoint = this / 100
35 | val afterPoint = this % 100
36 | val afterPointStr = if (afterPoint < 10) {
37 | "0$afterPoint"
38 | } else afterPoint.toString()
39 | "${beforePoint}.${afterPointStr}"
40 | }
41 | }
42 |
43 | fun Int.toTwoDigits() = if (this < 10) {
44 | "0$this"
45 | } else {
46 | this.toString()
47 | }
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/extension/StringExtension.kt:
--------------------------------------------------------------------------------
1 | package extension
2 |
3 | fun String.firstCharToCapital(): String {
4 | if (this.isBlank()) {
5 | return this
6 | }
7 | return this.first().uppercase() + this.substring(1)
8 | }
9 |
10 | fun String.isBlankJson(): Boolean {
11 | return this == "{}"
12 | }
13 |
14 | fun String.isValidUrl(): Boolean {
15 | return this.startsWith("http://") || this.startsWith("https://")
16 | }
17 |
18 | fun String.isValidEmail(): Boolean {
19 | val emailRegex = "^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}\$"
20 | return this.matches(emailRegex.toRegex())
21 | }
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/global/AppColors.kt:
--------------------------------------------------------------------------------
1 | package global
2 |
3 | import androidx.compose.material3.FilterChipDefaults
4 | import androidx.compose.runtime.Composable
5 | import androidx.compose.ui.graphics.Color
6 |
7 | object AppColors {
8 | val Background = Color(0xFFF4F4F4)
9 | val DarkBackground = Color(0xFF202124)
10 | val Theme = Color(0xFFD685A9)
11 | val LightTheme = Color(0xFFE9A6B3)
12 | val SlightTheme = Color(0xFFFFEAE3)
13 | val Divider = Color(0x26333333)
14 | val LightGreen = Color(0xFF8DECB4)
15 | val DarkGreen = Color(0xFF41B06E)
16 | val Red = Color(0xFFC40C0C)
17 | val LightGold = Color(0xFFE1B84C)
18 | val Yellow = Color(0xFFFFD95F)
19 |
20 | @Composable
21 | fun getChipColors() = FilterChipDefaults.elevatedFilterChipColors(
22 | containerColor = SlightTheme,
23 | selectedContainerColor = Theme,
24 | selectedLabelColor = Color.White
25 | )
26 | }
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/global/AppTheme.kt:
--------------------------------------------------------------------------------
1 | package global
2 |
3 | import androidx.compose.material.MaterialTheme
4 | import androidx.compose.runtime.Composable
5 |
6 | @Composable
7 | fun AppTheme(content: @Composable () -> Unit) {
8 | MaterialTheme(
9 | colors = MaterialTheme.colors.copy(
10 | primary = AppColors.Theme,
11 | background = AppColors.Background
12 | )
13 | ) {
14 | content()
15 | }
16 | }
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/helper/DepositHelper.kt:
--------------------------------------------------------------------------------
1 | package helper
2 |
3 | import kotlinx.serialization.encodeToString
4 | import kotlinx.serialization.json.Json
5 | import model.records.DepositMonth
6 | import model.records.DepositRecords
7 | import model.request.DepositSyncRequest
8 | import store.AppStore
9 |
10 | object DepositHelper {
11 | fun buildSyncRequest(): DepositSyncRequest? {
12 | val months = getMonths()
13 | return try {
14 | if (AppStore.loginUsername.isEmpty()) {
15 | null
16 | } else {
17 | DepositSyncRequest(username = AppStore.loginUsername, depositMonths = months)
18 | }
19 | } catch (e: Exception) {
20 | null
21 | }
22 | }
23 |
24 | fun getMonths() = try {
25 | val depositRecords = Json.decodeFromString(AppStore.depositMonths)
26 | depositRecords.months
27 | } catch (e: Exception) {
28 | e.printStackTrace()
29 | mutableListOf()
30 | }
31 |
32 | fun addMonth(month: DepositMonth) {
33 | val months = getMonths().toMutableList()
34 | val alreadyMonth = months.find { it.monthStartTime == month.monthStartTime }
35 | if (alreadyMonth != null) {
36 | months.remove(alreadyMonth)
37 | }
38 | months.add(month)
39 | saveMonths(months)
40 | }
41 |
42 | fun removeMonth(month: DepositMonth) {
43 | val months = getMonths().toMutableList()
44 | months.remove(month)
45 | saveMonths(months)
46 | }
47 |
48 | private fun saveMonths(months: List) {
49 | val depositRecords = DepositRecords(months)
50 | AppStore.depositMonths = Json.encodeToString(depositRecords)
51 | }
52 | }
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/helper/MemoHelper.kt:
--------------------------------------------------------------------------------
1 | package helper
2 |
3 | import kotlinx.coroutines.runBlocking
4 | import kotlinx.serialization.encodeToString
5 | import kotlinx.serialization.json.Json
6 | import model.display.GroupDisplayItem
7 | import model.display.IMemoDisplayItem
8 | import model.display.MemoDisplayItem
9 | import model.records.Memo
10 | import model.records.MemoRecords
11 | import model.request.MemoSyncRequest
12 | import org.jetbrains.compose.resources.getString
13 | import store.AppStore
14 | import util.TimeUtil
15 | import zhoutools.composeapp.generated.resources.Res
16 | import zhoutools.composeapp.generated.resources.unsorted
17 |
18 | object MemoHelper {
19 | fun addMemo(text: String, todo: Boolean, pin: Boolean, group: String? = null) {
20 | val time = TimeUtil.currentTimeMillis()
21 | val memo = Memo(
22 | text = text,
23 | isTodo = todo,
24 | isPin = pin,
25 | createTime = time,
26 | modifyTime = time,
27 | group = group
28 | )
29 | val memos = getMemos()
30 | memos.add(memo)
31 | saveMemos(memos)
32 | }
33 |
34 | fun modifyMemo(memo: Memo, text: String, todo: Boolean, pin: Boolean, group: String? = null) {
35 | val memos = getMemos()
36 | val findResult = memos.find {
37 | it.text == memo.text
38 | && it.isTodo == memo.isTodo
39 | && it.isPin == memo.isPin
40 | && it.createTime == memo.createTime
41 | && it.modifyTime == memo.modifyTime
42 | && it.group == memo.group
43 | }
44 | if (findResult != null) {
45 | findResult.text = text
46 | findResult.isTodo = todo
47 | findResult.isPin = pin
48 | findResult.modifyTime = TimeUtil.currentTimeMillis()
49 | findResult.group = group
50 | } else {
51 | addMemo(text, todo, pin, group)
52 | }
53 | saveMemos(memos)
54 | }
55 |
56 | fun markDone(memo: Memo, done: Boolean) {
57 | val memos = getMemos()
58 | val findResult = memos.find {
59 | it.text == memo.text
60 | && it.isTodo == memo.isTodo
61 | && it.isPin == memo.isPin
62 | && it.createTime == memo.createTime
63 | && it.modifyTime == memo.modifyTime
64 | && it.group == memo.group
65 | }
66 | if (findResult != null) {
67 | findResult.isTodoFinished = done
68 | saveMemos(memos)
69 | }
70 | }
71 |
72 | fun deleteMemo(memo: Memo) {
73 | val memos = getMemos()
74 | memos.remove(memo)
75 | saveMemos(memos)
76 | }
77 |
78 | fun getDisplayList(): List {
79 | val memos = getMemos()
80 | val displayList = mutableListOf()
81 | val groupSet = getGroupSet().sorted()
82 | groupSet.forEach { group ->
83 | val groupDisplayItem = GroupDisplayItem(group)
84 | displayList.add(groupDisplayItem)
85 | val groupMemos = memos.filter { it.group == group }
86 | val groupPinMemos = groupMemos.filter { it.isPin }
87 | val groupNotPinMemos = groupMemos.filterNot { it.isPin }
88 | groupPinMemos.forEach { memo ->
89 | displayList.add(MemoDisplayItem(memo))
90 | }
91 | groupNotPinMemos.forEach { memo ->
92 | displayList.add(MemoDisplayItem(memo))
93 | }
94 | }
95 | val unsortedMemos = memos.filter { it.group == null }
96 | val unsortedPinMemos = unsortedMemos.filter { it.isPin }
97 | val unsortedNotPinMemos = unsortedMemos.filterNot { it.isPin }
98 | val unsorted = runBlocking { getString(Res.string.unsorted) }
99 | displayList.add(GroupDisplayItem(unsorted))
100 | unsortedPinMemos.forEach { memo ->
101 | displayList.add(MemoDisplayItem(memo))
102 | }
103 | unsortedNotPinMemos.forEach { memo ->
104 | displayList.add(MemoDisplayItem(memo))
105 | }
106 | return displayList
107 | }
108 |
109 | fun getGroupSet(): Set {
110 | val memos = getMemos()
111 | val groupSet = mutableSetOf()
112 | memos.forEach {
113 | it.group?.let { group ->
114 | groupSet.add(group)
115 | }
116 | }
117 | return groupSet
118 | }
119 |
120 | fun buildSyncRequest(): MemoSyncRequest? {
121 | val memos = getMemos()
122 | return try {
123 | if (AppStore.loginUsername.isEmpty()) {
124 | null
125 | } else {
126 | MemoSyncRequest(username = AppStore.loginUsername, memos = memos)
127 | }
128 | } catch (e: Exception) {
129 | null
130 | }
131 | }
132 |
133 | private fun getMemos() = try {
134 | val memoRecords = Json.decodeFromString(AppStore.memos)
135 | memoRecords.memos
136 | } catch (e: Exception) {
137 | e.printStackTrace()
138 | mutableListOf()
139 | }
140 |
141 | private fun saveMemos(memos: MutableList) {
142 | val records = MemoRecords(memos)
143 | AppStore.memos = Json.encodeToString(records)
144 | }
145 | }
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/helper/ScheduleHelper.kt:
--------------------------------------------------------------------------------
1 | package helper
2 |
3 | import kotlinx.serialization.encodeToString
4 | import kotlinx.serialization.json.Json
5 | import model.records.Schedule
6 | import model.records.ScheduleRecords
7 | import model.request.ScheduleSyncRequest
8 | import store.AppStore
9 |
10 | object ScheduleHelper {
11 | fun getDisplayList(): List {
12 | val schedules = getSchedules()
13 | val displayList = mutableListOf()
14 | val milestoneList = schedules.filter { it.isMilestone }
15 | val othersList = schedules.filterNot { it.isMilestone }
16 | displayList.addAll(milestoneList)
17 | displayList.addAll(othersList)
18 | return displayList
19 | }
20 |
21 | fun addSchedule(schedule: Schedule) {
22 | val schedules = getSchedules()
23 | schedules.add(schedule)
24 | saveSchedules(schedules)
25 | }
26 |
27 | fun deleteSchedule(schedule: Schedule) {
28 | val schedules = getSchedules()
29 | schedules.remove(schedule)
30 | saveSchedules(schedules)
31 | }
32 |
33 | fun modifySchedule(
34 | schedule: Schedule,
35 | text: String,
36 | startingTime: Long,
37 | endingTime: Long,
38 | isAllDay: Boolean,
39 | isMilestone: Boolean,
40 | milestoneGoal: Long
41 | ) {
42 | val schedules = getSchedules()
43 | val match = schedules.find { it == schedule }
44 | match?.let {
45 | it.text = text
46 | it.startingTime = startingTime
47 | it.endingTime = endingTime
48 | it.isAllDay = isAllDay
49 | it.isMilestone = isMilestone
50 | it.milestoneGoal = milestoneGoal
51 | }
52 | saveSchedules(schedules)
53 | }
54 |
55 | fun buildSyncRequest(): ScheduleSyncRequest? {
56 | val schedules = getSchedules()
57 | return try {
58 | if (AppStore.loginUsername.isEmpty()) {
59 | null
60 | } else {
61 | ScheduleSyncRequest(username = AppStore.loginUsername, schedules = schedules)
62 | }
63 | } catch (e: Exception) {
64 | null
65 | }
66 | }
67 |
68 | private fun getSchedules() = try {
69 | val scheduleRecords = Json.decodeFromString(AppStore.schedules)
70 | scheduleRecords.schedules
71 | } catch (e: Exception) {
72 | e.printStackTrace()
73 | mutableListOf()
74 | }
75 |
76 | private fun saveSchedules(schedules: MutableList) {
77 | val records = ScheduleRecords(schedules)
78 | AppStore.schedules = Json.encodeToString(records)
79 | }
80 | }
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/helper/TimeCardHelper.kt:
--------------------------------------------------------------------------------
1 | package helper
2 |
3 | import extension.dayStartTime
4 | import extension.isBlankJson
5 | import kotlinx.coroutines.flow.first
6 | import kotlinx.coroutines.runBlocking
7 | import kotlinx.serialization.encodeToString
8 | import kotlinx.serialization.json.Json
9 | import model.records.TimeCardDay
10 | import model.records.TimeCardRecords
11 | import model.request.TimeCardSyncRequest
12 | import store.AppFlowStore
13 | import store.AppStore
14 | import util.TimeUtil
15 |
16 | object TimeCardHelper {
17 | fun getMinWorkingTimeMillis() = runBlocking {
18 | val hours = AppFlowStore.minWorkingHoursFlow.first()
19 | val millis = hours * 60 * 60 * 1000
20 | millis.toLong()
21 | }
22 |
23 | fun getMinOvertimeMillis() = runBlocking {
24 | val hours = AppFlowStore.minWorkingHoursFlow.first() + AppFlowStore.minOvertimeHoursFlow.first()
25 | val millis = hours * 60 * 60 * 1000
26 | millis.toLong()
27 | }
28 |
29 | fun pressTimeCard() {
30 | val curTime = TimeUtil.currentTimeMillis()
31 | val dayStartTime = curTime.dayStartTime()
32 | val timeCardDay = TimeCardDay(
33 | dayStartTime = dayStartTime,
34 | latestTimeCard = curTime
35 | )
36 | try {
37 | val records: TimeCardRecords = Json.decodeFromString(AppStore.timeCards)
38 | val days = records.days
39 | val curDay = days.find { it.dayStartTime == dayStartTime }
40 | if (curDay != null) {
41 | curDay.latestTimeCard = curTime
42 | } else {
43 | days.add(timeCardDay)
44 | }
45 | AppStore.timeCards = Json.encodeToString(records)
46 | } catch (e: Exception) {
47 | e.printStackTrace()
48 | val days = mutableListOf()
49 | days.add(timeCardDay)
50 | val records = TimeCardRecords(days = days)
51 | AppStore.timeCards = Json.encodeToString(records)
52 | }
53 | }
54 |
55 | /**
56 | * @return isSuccess
57 | */
58 | fun run(): Boolean {
59 | val curTime = TimeUtil.currentTimeMillis()
60 | val dayStartTime = curTime.dayStartTime()
61 | return try {
62 | val records: TimeCardRecords = Json.decodeFromString(AppStore.timeCards)
63 | val days = records.days
64 | val curDay = days.find { it.dayStartTime == dayStartTime }
65 | if (curDay != null) {
66 | curDay.latestTimeRun = curTime
67 | AppStore.timeCards = Json.encodeToString(records)
68 | true
69 | } else {
70 | false
71 | }
72 | } catch (e: Exception) {
73 | e.printStackTrace()
74 | false
75 | }
76 | }
77 |
78 | private fun hasTodayTimeCard(): Boolean {
79 | if (AppStore.timeCards.isBlankJson()) {
80 | return false
81 | }
82 | val curTime = TimeUtil.currentTimeMillis()
83 | val dayStartTime = curTime.dayStartTime()
84 | return try {
85 | val records: TimeCardRecords = Json.decodeFromString(AppStore.timeCards)
86 | val days = records.days
87 | val curDay = days.find { it.dayStartTime == dayStartTime }
88 | curDay != null
89 | } catch (e: Exception) {
90 | e.printStackTrace()
91 | false
92 | }
93 | }
94 |
95 | fun todayTimeRun(): Long? {
96 | if (AppStore.timeCards.isBlankJson()) {
97 | return null
98 | }
99 | val curTime = TimeUtil.currentTimeMillis()
100 | val dayStartTime = curTime.dayStartTime()
101 | return try {
102 | val records: TimeCardRecords = Json.decodeFromString(AppStore.timeCards)
103 | val days = records.days
104 | val curDay = days.find { it.dayStartTime == dayStartTime }
105 | curDay?.latestTimeRun
106 | } catch (e: Exception) {
107 | e.printStackTrace()
108 | null
109 | }
110 | }
111 |
112 | fun todayTimeCard(): Long? {
113 | if (!hasTodayTimeCard()) {
114 | return null
115 | }
116 | val curTime = TimeUtil.currentTimeMillis()
117 | val dayStartTime = curTime.dayStartTime()
118 | return try {
119 | val records: TimeCardRecords = Json.decodeFromString(AppStore.timeCards)
120 | val days = records.days
121 | val curDay = days.find { it.dayStartTime == dayStartTime }
122 | return curDay?.latestTimeCard
123 | } catch (e: Exception) {
124 | e.printStackTrace()
125 | null
126 | }
127 | }
128 |
129 | fun buildSyncRequest(): TimeCardSyncRequest? {
130 | return try {
131 | val records: TimeCardRecords = Json.decodeFromString(AppStore.timeCards)
132 | val username = AppStore.loginUsername
133 | if (username.isBlank()) {
134 | null
135 | } else {
136 | TimeCardSyncRequest(username, records)
137 | }
138 | } catch (e: Exception) {
139 | null
140 | }
141 | }
142 | }
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/helper/WorkHoursHelper.kt:
--------------------------------------------------------------------------------
1 | package helper
2 |
3 | object WorkHoursHelper {
4 | val workingHoursMap = mutableMapOf(
5 | "4h" to 4f,
6 | "4.5h" to 4.5f,
7 | "5h" to 5f,
8 | "5.5h" to 5.5f,
9 | "6h" to 6f,
10 | "6.5h" to 6.5f,
11 | "7h" to 7f,
12 | "7.5h" to 7.5f,
13 | "8h" to 8f,
14 | "8.5h" to 8.5f,
15 | "9h" to 9f,
16 | "9.5h" to 9.5f,
17 | "10h" to 10f,
18 | "10.5h" to 10.5f,
19 | "11h" to 11f,
20 | "11.5h" to 11.5f,
21 | "12h" to 12f,
22 | "12.5h" to 12.5f,
23 | "13h" to 13f,
24 | "13.5h" to 13.5f,
25 | "14h" to 14f,
26 | "14.5h" to 14.5f,
27 | "15h" to 15f,
28 | "15.5h" to 15.5f,
29 | "16h" to 16f
30 | )
31 |
32 | val overtimeHoursMap = mutableMapOf(
33 | "0.5h" to 0.5f,
34 | "1h" to 1f,
35 | "1.5h" to 1.5f,
36 | "2h" to 2f,
37 | "2.5h" to 2.5f,
38 | "3h" to 3f,
39 | "3.5h" to 3.5f,
40 | "4h" to 4f
41 | )
42 |
43 | fun getWorkingHourString(hours: Float): String? {
44 | return workingHoursMap.entries.firstOrNull { it.value == hours }?.key
45 | }
46 |
47 | fun getOvertimeHourString(hours: Float): String? {
48 | return overtimeHoursMap.entries.firstOrNull { it.value == hours }?.key
49 | }
50 | }
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/helper/effect/BaseEffectObserver.kt:
--------------------------------------------------------------------------------
1 | package helper.effect
2 |
3 | import androidx.compose.runtime.Composable
4 | import androidx.compose.runtime.LaunchedEffect
5 | import kotlinx.coroutines.flow.MutableSharedFlow
6 | import kotlinx.coroutines.runBlocking
7 | import moe.tlaster.precompose.flow.collectAsStateWithLifecycle
8 |
9 | open class BaseEffectObserver {
10 | private val effectFlow = MutableSharedFlow(replay = 1)
11 |
12 | fun emitSync(effect: T?) {
13 | runBlocking {
14 | effectFlow.emit(effect)
15 | }
16 | }
17 |
18 | suspend fun emit(effect: T?) {
19 | effectFlow.emit(effect)
20 | }
21 |
22 | @Composable
23 | fun observeComposable(onEffect: (T) -> Unit) {
24 | val effect = effectFlow.collectAsStateWithLifecycle(null).value
25 |
26 | LaunchedEffect(effect) {
27 | if (effect != null) {
28 | onEffect(effect)
29 | emit(null)
30 | }
31 | }
32 | }
33 | }
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/helper/effect/EffectHelper.kt:
--------------------------------------------------------------------------------
1 | package helper.effect
2 |
3 | import androidx.compose.runtime.Composable
4 | import kotlinx.coroutines.CoroutineScope
5 | import kotlinx.coroutines.launch
6 |
7 | object EffectHelper {
8 | private val timeCardEffectObserver by lazy {
9 | TimeCardEffectObserver()
10 | }
11 |
12 | private val memoEffectObserver by lazy {
13 | MemoEffectObserver()
14 | }
15 |
16 | private val writeMemoEffectObserver by lazy {
17 | WriteMemoEffectObserver()
18 | }
19 |
20 | private val addScheduleEffectObserver by lazy {
21 | AddScheduleEffectObserver()
22 | }
23 |
24 | private val scheduleEffectObserver by lazy {
25 | ScheduleEffectObserver()
26 | }
27 |
28 | private val depositEffectObserver by lazy {
29 | DepositEffectObserver()
30 | }
31 |
32 | fun emitTimeCardEffect(effect: TimeCardEffect, scope: CoroutineScope? = null) {
33 | if (scope == null) {
34 | timeCardEffectObserver.emitSync(effect)
35 | } else {
36 | scope.launch {
37 | timeCardEffectObserver.emit(effect)
38 | }
39 | }
40 | }
41 |
42 | fun emitMemoEffect(effect: MemoEffect, scope: CoroutineScope? = null) {
43 | if (scope == null) {
44 | memoEffectObserver.emitSync(effect)
45 | } else {
46 | scope.launch {
47 | memoEffectObserver.emit(effect)
48 | }
49 | }
50 | }
51 |
52 | fun emitWriteMemoEffect(effect: WriteMemoEffect, scope: CoroutineScope? = null) {
53 | if (scope == null) {
54 | writeMemoEffectObserver.emitSync(effect)
55 | } else {
56 | scope.launch {
57 | writeMemoEffectObserver.emit(effect)
58 | }
59 | }
60 | }
61 |
62 | fun emitAddScheduleEffect(effect: AddScheduleEffect, scope: CoroutineScope? = null) {
63 | if (scope == null) {
64 | addScheduleEffectObserver.emitSync(effect)
65 | } else {
66 | scope.launch {
67 | addScheduleEffectObserver.emit(effect)
68 | }
69 | }
70 | }
71 |
72 | fun emitScheduleEffect(effect: ScheduleEffect, scope: CoroutineScope? = null) {
73 | if (scope == null) {
74 | scheduleEffectObserver.emitSync(effect)
75 | } else {
76 | scope.launch {
77 | scheduleEffectObserver.emit(effect)
78 | }
79 | }
80 | }
81 |
82 | fun emitDepositEffect(effect: DepositEffect, scope: CoroutineScope? = null) {
83 | if (scope == null) {
84 | depositEffectObserver.emitSync(effect)
85 | } else {
86 | scope.launch {
87 | depositEffectObserver.emit(effect)
88 | }
89 | }
90 | }
91 |
92 | @Composable
93 | fun observeTimeCardEffect(onEffect: (TimeCardEffect) -> Unit) {
94 | timeCardEffectObserver.observeComposable(onEffect)
95 | }
96 |
97 | @Composable
98 | fun observeMemoEffect(onEffect: (MemoEffect) -> Unit) {
99 | memoEffectObserver.observeComposable(onEffect)
100 | }
101 |
102 | @Composable
103 | fun observeWriteMemoEffect(onEffect: (WriteMemoEffect) -> Unit) {
104 | writeMemoEffectObserver.observeComposable(onEffect)
105 | }
106 |
107 | @Composable
108 | fun observeAddScheduleEffect(onEffect: (AddScheduleEffect) -> Unit) {
109 | addScheduleEffectObserver.observeComposable(onEffect)
110 | }
111 |
112 | @Composable
113 | fun observeScheduleEffect(onEffect: (ScheduleEffect) -> Unit) {
114 | scheduleEffectObserver.observeComposable(onEffect)
115 | }
116 |
117 | @Composable
118 | fun observeDepositEffect(onEffect: (DepositEffect) -> Unit) {
119 | depositEffectObserver.observeComposable(onEffect)
120 | }
121 | }
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/helper/effect/EffectObservers.kt:
--------------------------------------------------------------------------------
1 | package helper.effect
2 |
3 | import model.records.Memo
4 | import model.records.Schedule
5 |
6 | class TimeCardEffectObserver : BaseEffectObserver()
7 |
8 | class MemoEffectObserver : BaseEffectObserver()
9 |
10 | class WriteMemoEffectObserver : BaseEffectObserver()
11 |
12 | class AddScheduleEffectObserver : BaseEffectObserver()
13 |
14 | class ScheduleEffectObserver : BaseEffectObserver()
15 |
16 | class DepositEffectObserver : BaseEffectObserver()
17 |
18 | sealed interface TimeCardEffect {
19 | data object RefreshTodayState : TimeCardEffect
20 | }
21 |
22 | sealed interface MemoEffect {
23 | data object RefreshData : MemoEffect
24 | }
25 |
26 | sealed interface WriteMemoEffect {
27 | data class BeginEdit(val memo: Memo) : WriteMemoEffect
28 | }
29 |
30 | sealed interface AddScheduleEffect {
31 | data class SetDate(val year: Int, val month: Int, val day: Int) : AddScheduleEffect
32 | data class BeginEdit(val schedule: Schedule) : AddScheduleEffect
33 | }
34 |
35 | sealed interface ScheduleEffect {
36 | data object RefreshData : ScheduleEffect
37 | }
38 |
39 | sealed interface DepositEffect {
40 | data object RefreshData : DepositEffect
41 | }
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/model/DoubleToLongSerializer.kt:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import kotlinx.serialization.KSerializer
4 | import kotlinx.serialization.descriptors.PrimitiveKind
5 | import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
6 | import kotlinx.serialization.descriptors.SerialDescriptor
7 | import kotlinx.serialization.encoding.Decoder
8 | import kotlinx.serialization.encoding.Encoder
9 |
10 | object DoubleToLongSerializer : KSerializer {
11 | override val descriptor: SerialDescriptor
12 | get() = PrimitiveSerialDescriptor("DoubleToLong", PrimitiveKind.LONG)
13 |
14 | override fun deserialize(decoder: Decoder): Long {
15 | return decoder.decodeDouble().toLong()
16 | }
17 |
18 | override fun serialize(encoder: Encoder, value: Long) {
19 | encoder.encodeLong(value)
20 | }
21 | }
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/model/calendar/MonthDay.kt:
--------------------------------------------------------------------------------
1 | package model.calendar
2 |
3 | import kotlinx.datetime.DayOfWeek
4 | import util.CalendarUtil
5 |
6 | data class MonthDay(
7 | val day: Int, // 1..31
8 | val dayOfWeek: DayOfWeek,
9 | val isHoliday: Int = CalendarUtil.NOT_HOLIDAY
10 | )
11 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/model/display/MemoDisplayItem.kt:
--------------------------------------------------------------------------------
1 | package model.display
2 |
3 | import model.records.Memo
4 |
5 | interface IMemoDisplayItem
6 |
7 | data class GroupDisplayItem(val name: String) : IMemoDisplayItem
8 |
9 | data class MemoDisplayItem(val memo: Memo) : IMemoDisplayItem
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/model/records/DepositRecords.kt:
--------------------------------------------------------------------------------
1 | package model.records
2 |
3 | import kotlinx.serialization.SerialName
4 | import kotlinx.serialization.Serializable
5 | import model.DoubleToLongSerializer
6 |
7 | @Serializable
8 | data class DepositRecords(
9 | val months: List
10 | )
11 |
12 | @Serializable
13 | data class DepositMonth(
14 | @Serializable(with = DoubleToLongSerializer::class)
15 | @SerialName("month_start_time")
16 | val monthStartTime: Long = 0L,
17 | @Serializable(with = DoubleToLongSerializer::class)
18 | @SerialName("current_amount")
19 | val currentAmount: Long = 0L,
20 | @Serializable(with = DoubleToLongSerializer::class)
21 | @SerialName("monthly_income")
22 | val monthlyIncome: Long = 0L,
23 | @Serializable(with = DoubleToLongSerializer::class)
24 | @SerialName("extra_deposit")
25 | val extraDeposit: Long = 0L
26 | )
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/model/records/Goals.kt:
--------------------------------------------------------------------------------
1 | package model.records
2 |
3 | const val GOAL_TYPE_TIME = 0
4 |
5 | const val GOAL_TYPE_DEPOSIT = 1
6 |
7 | data class Goal(
8 | val type: Int,
9 | val currentValue: Long = 0L,
10 | val goalValue: Long = 0L
11 | ) {
12 | fun getProgress(): Float {
13 | return currentValue.toFloat() / goalValue
14 | }
15 | }
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/model/records/MemoRecords.kt:
--------------------------------------------------------------------------------
1 | package model.records
2 |
3 | import kotlinx.serialization.SerialName
4 | import kotlinx.serialization.Serializable
5 | import model.DoubleToLongSerializer
6 |
7 | @Serializable
8 | data class MemoRecords(
9 | val memos: MutableList
10 | )
11 |
12 | @Serializable
13 | data class Memo(
14 | var text: String = "",
15 | @SerialName("is_todo")
16 | var isTodo: Boolean = false,
17 | @SerialName("is_todo_finished")
18 | var isTodoFinished: Boolean = false,
19 | @SerialName("is_pin")
20 | var isPin: Boolean = false,
21 | @Serializable(with = DoubleToLongSerializer::class)
22 | @SerialName("create_time")
23 | val createTime: Long = 0L,
24 | @Serializable(with = DoubleToLongSerializer::class)
25 | @SerialName("modify_time")
26 | var modifyTime: Long = 0L,
27 | @SerialName("group")
28 | var group: String? = null
29 | )
30 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/model/records/ScheduleRecords.kt:
--------------------------------------------------------------------------------
1 | package model.records
2 |
3 | import kotlinx.serialization.SerialName
4 | import kotlinx.serialization.Serializable
5 | import model.DoubleToLongSerializer
6 |
7 | @Serializable
8 | data class ScheduleRecords(
9 | val schedules: MutableList
10 | )
11 |
12 | @Serializable
13 | data class Schedule(
14 | var text: String = "",
15 | @Serializable(with = DoubleToLongSerializer::class)
16 | @SerialName("day_start_time")
17 | var dayStartTime: Long = 0L,
18 | @Serializable(with = DoubleToLongSerializer::class)
19 | @SerialName("starting_time")
20 | var startingTime: Long = 0L,
21 | @Serializable(with = DoubleToLongSerializer::class)
22 | @SerialName("ending_time")
23 | var endingTime: Long = 0L,
24 | @SerialName("is_all_day")
25 | var isAllDay: Boolean = false,
26 | @SerialName("is_milestone")
27 | var isMilestone: Boolean = false,
28 | @Serializable(with = DoubleToLongSerializer::class)
29 | @SerialName("milestone_goal")
30 | var milestoneGoal: Long = 0L
31 | )
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/model/records/TimeCardRecords.kt:
--------------------------------------------------------------------------------
1 | package model.records
2 |
3 | import kotlinx.serialization.SerialName
4 | import kotlinx.serialization.Serializable
5 | import model.DoubleToLongSerializer
6 |
7 | @Serializable
8 | data class TimeCardRecords(
9 | val days: MutableList
10 | )
11 |
12 | @Serializable
13 | data class TimeCardDay(
14 | @Serializable(with = DoubleToLongSerializer::class)
15 | @SerialName("day_start_time")
16 | val dayStartTime: Long,
17 | @Serializable(with = DoubleToLongSerializer::class)
18 | @SerialName("latest_time_card")
19 | var latestTimeCard: Long,
20 | @Serializable(with = DoubleToLongSerializer::class)
21 | @SerialName("latest_time_run")
22 | var latestTimeRun: Long? = null
23 | )
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/model/request/DepositSyncRequest.kt:
--------------------------------------------------------------------------------
1 | package model.request
2 |
3 | import kotlinx.serialization.Serializable
4 | import model.records.DepositMonth
5 |
6 | @Serializable
7 | data class DepositSyncRequest(
8 | val username: String,
9 | val depositMonths: List
10 | )
11 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/model/request/LoginRequest.kt:
--------------------------------------------------------------------------------
1 | package model.request
2 |
3 | import kotlinx.serialization.Serializable
4 |
5 | @Serializable
6 | data class LoginRequest(
7 | val username: String,
8 | val password: String
9 | )
10 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/model/request/MemoSyncRequest.kt:
--------------------------------------------------------------------------------
1 | package model.request
2 |
3 | import kotlinx.serialization.Serializable
4 | import model.records.Memo
5 |
6 | @Serializable
7 | data class MemoSyncRequest(
8 | val username: String,
9 | val memos: List
10 | )
11 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/model/request/RegisterRequest.kt:
--------------------------------------------------------------------------------
1 | package model.request
2 |
3 | import kotlinx.serialization.Serializable
4 |
5 | @Serializable
6 | data class RegisterRequest(
7 | val username: String,
8 | val email: String,
9 | val password: String
10 | )
11 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/model/request/ScheduleSyncRequest.kt:
--------------------------------------------------------------------------------
1 | package model.request
2 |
3 | import kotlinx.serialization.Serializable
4 | import model.records.Schedule
5 |
6 | @Serializable
7 | data class ScheduleSyncRequest(
8 | val username: String,
9 | val schedules: List
10 | )
11 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/model/request/TimeCardSyncRequest.kt:
--------------------------------------------------------------------------------
1 | package model.request
2 |
3 | import kotlinx.serialization.Serializable
4 | import model.records.TimeCardRecords
5 |
6 | @Serializable
7 | data class TimeCardSyncRequest(
8 | val username: String,
9 | val timeCard: TimeCardRecords
10 | )
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/store/AppFlowStore.kt:
--------------------------------------------------------------------------------
1 | package store
2 |
3 | import FLOW_PREFERENCES_NAME
4 | import com.tangping.kotstore.model.KotStoreFlowModel
5 |
6 | object AppFlowStore : KotStoreFlowModel(storeName = FLOW_PREFERENCES_NAME) {
7 | val minWorkingHoursFlow by floatFlowStore(key = "min_working_hours_flow", default = 9.5f)
8 | val minOvertimeHoursFlow by floatFlowStore(key = "min_overtime_hours_flow", default = 1f)
9 | val autoSyncFlow by booleanFlowStore(key = "auto_sync_flow", default = false)
10 | val totalDepositGoalFlow by longFlowStore(key = "total_deposit_goal_flow", default = 0L)
11 | }
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/store/AppStore.kt:
--------------------------------------------------------------------------------
1 | package store
2 |
3 | import PREFERENCES_NAME
4 | import com.tangping.kotstore.model.KotStoreModel
5 | import kotlinx.coroutines.runBlocking
6 |
7 | object AppStore : KotStoreModel(storeName = PREFERENCES_NAME) {
8 | var loginToken by stringStore(key = "login_token", default = "")
9 | var loginUsername by stringStore(key = "login_username", default = "")
10 | var loginPassword by stringStore(key = "login_password", default = "")
11 | var customServerUrl by stringStore(key = "custom_server_url", default = "")
12 | var timeCards by stringStore(key = "time_cards", default = "{}", syncSave = true)
13 | var memos by stringStore(key = "memos", default = "{}", syncSave = true)
14 | var schedules by stringStore(key = "schedules", default = "{}", syncSave = true)
15 | var depositMonths by stringStore(key = "deposit_months", default = "{}", syncSave = true)
16 | var lastSync by longStore(key = "last_sync", default = 0L)
17 | var minWorkingHours by floatStore(key = "min_working_hours", default = 9.5f)
18 | var minOvertimeHours by floatStore(key = "min_overtime_hours", default = 1f)
19 | var totalDepositGoal by longStore(key = "total_deposit_goal", default = 0L)
20 |
21 | fun clearCache() {
22 | customServerUrl = ""
23 | timeCards = "{}"
24 | memos = "{}"
25 | schedules = "{}"
26 | depositMonths = "{}"
27 | lastSync = 0L
28 | }
29 |
30 | fun setMinWorkingHoursWithFlow(hours: Float) {
31 | minWorkingHours = hours
32 | runBlocking {
33 | AppFlowStore.minWorkingHoursFlow.emit(hours)
34 | }
35 | }
36 |
37 | fun setMinOvertimeHoursWithFlow(hours: Float) {
38 | minOvertimeHours = hours
39 | runBlocking {
40 | AppFlowStore.minOvertimeHoursFlow.emit(hours)
41 | }
42 | }
43 |
44 | fun setTotalDepositGoalWithFlow(goal: Long) {
45 | totalDepositGoal = goal
46 | runBlocking {
47 | AppFlowStore.totalDepositGoalFlow.emit(goal)
48 | }
49 | }
50 | }
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/store/CurrentProcessStore.kt:
--------------------------------------------------------------------------------
1 | package store
2 |
3 | import kotlinx.coroutines.flow.MutableStateFlow
4 |
5 | /**
6 | * Data stored only for the current process.
7 | */
8 | object CurrentProcessStore {
9 | var screenWidthPixels = MutableStateFlow(0)
10 | }
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/ui/dialog/CloudServerDialog.kt:
--------------------------------------------------------------------------------
1 | package ui.dialog
2 |
3 | import androidx.compose.foundation.background
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.padding
9 | import androidx.compose.foundation.layout.width
10 | import androidx.compose.foundation.shape.RoundedCornerShape
11 | import androidx.compose.material.Button
12 | import androidx.compose.material.OutlinedButton
13 | import androidx.compose.material.Text
14 | import androidx.compose.material.TextField
15 | import androidx.compose.material.TextFieldDefaults
16 | import androidx.compose.runtime.Composable
17 | import androidx.compose.runtime.derivedStateOf
18 | import androidx.compose.runtime.getValue
19 | import androidx.compose.runtime.mutableStateOf
20 | import androidx.compose.runtime.remember
21 | import androidx.compose.runtime.setValue
22 | import androidx.compose.ui.Alignment
23 | import androidx.compose.ui.Modifier
24 | import androidx.compose.ui.graphics.Color
25 | import androidx.compose.ui.text.font.FontWeight
26 | import androidx.compose.ui.unit.dp
27 | import androidx.compose.ui.unit.sp
28 | import androidx.compose.ui.window.Dialog
29 | import extension.isValidUrl
30 | import global.AppColors
31 | import org.jetbrains.compose.resources.stringResource
32 | import store.AppStore
33 | import zhoutools.composeapp.generated.resources.Res
34 | import zhoutools.composeapp.generated.resources.cancel
35 | import zhoutools.composeapp.generated.resources.confirm
36 | import zhoutools.composeapp.generated.resources.server_settings
37 | import zhoutools.composeapp.generated.resources.server_settings_hint
38 |
39 | @Composable
40 | fun CloudServerDialog(onCancel: () -> Unit, onConfirm: (String) -> Unit) {
41 | var inputUrl by remember { mutableStateOf(AppStore.customServerUrl) }
42 | val isValidUrl by remember(inputUrl) {
43 | derivedStateOf { inputUrl.isEmpty() || inputUrl.isValidUrl() }
44 | }
45 |
46 | Dialog(onDismissRequest = {}) {
47 | Column(modifier = Modifier
48 | .fillMaxWidth()
49 | .background(Color.White, shape = RoundedCornerShape(8.dp))
50 | ) {
51 | Row(
52 | modifier = Modifier
53 | .padding(all = 16.dp)
54 | .fillMaxWidth(),
55 | verticalAlignment = Alignment.CenterVertically
56 | ) {
57 | Text(
58 | text = stringResource(Res.string.server_settings),
59 | fontWeight = FontWeight.Bold,
60 | fontSize = 20.sp
61 | )
62 |
63 | Spacer(modifier = Modifier.weight(1f))
64 | }
65 |
66 | Text(
67 | text = stringResource(Res.string.server_settings_hint),
68 | modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp)
69 | )
70 |
71 | TextField(
72 | value = inputUrl,
73 | onValueChange = {
74 | inputUrl = it
75 | },
76 | modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
77 | colors = TextFieldDefaults.textFieldColors(
78 | focusedIndicatorColor = Color.Transparent,
79 | unfocusedIndicatorColor = Color.Transparent,
80 | disabledIndicatorColor = Color.Transparent,
81 | textColor = if (isValidUrl) AppColors.DarkGreen else AppColors.Red
82 | )
83 | )
84 |
85 | Row(
86 | modifier = Modifier
87 | .padding(start = 16.dp, top = 16.dp, bottom = 16.dp, end = 16.dp)
88 | .fillMaxWidth(),
89 | verticalAlignment = Alignment.CenterVertically
90 | ) {
91 | OutlinedButton(
92 | onClick = onCancel,
93 | modifier = Modifier.weight(1f)
94 | ) {
95 | Text(
96 | text = stringResource(Res.string.cancel).uppercase(),
97 | fontSize = 16.sp
98 | )
99 | }
100 |
101 | Spacer(modifier = Modifier.width(16.dp))
102 |
103 | Button(
104 | onClick = {
105 | onConfirm(inputUrl)
106 | },
107 | modifier = Modifier.weight(1f)
108 | ) {
109 | Text(
110 | text = stringResource(Res.string.confirm).uppercase(),
111 | fontSize = 16.sp
112 | )
113 | }
114 | }
115 | }
116 | }
117 | }
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/ui/dialog/ConfirmDialog.kt:
--------------------------------------------------------------------------------
1 | package ui.dialog
2 |
3 | import androidx.compose.foundation.background
4 | import androidx.compose.foundation.clickable
5 | import androidx.compose.foundation.layout.Box
6 | import androidx.compose.foundation.layout.Column
7 | import androidx.compose.foundation.layout.Row
8 | import androidx.compose.foundation.layout.Spacer
9 | import androidx.compose.foundation.layout.fillMaxWidth
10 | import androidx.compose.foundation.layout.padding
11 | import androidx.compose.foundation.layout.size
12 | import androidx.compose.foundation.layout.width
13 | import androidx.compose.foundation.shape.CircleShape
14 | import androidx.compose.foundation.shape.RoundedCornerShape
15 | import androidx.compose.material.Button
16 | import androidx.compose.material.Icon
17 | import androidx.compose.material.OutlinedButton
18 | import androidx.compose.material.Text
19 | import androidx.compose.runtime.Composable
20 | import androidx.compose.ui.Alignment
21 | import androidx.compose.ui.Modifier
22 | import androidx.compose.ui.draw.clip
23 | import androidx.compose.ui.graphics.Color
24 | import androidx.compose.ui.text.font.FontWeight
25 | import androidx.compose.ui.unit.dp
26 | import androidx.compose.ui.unit.sp
27 | import androidx.compose.ui.window.Dialog
28 | import org.jetbrains.compose.resources.painterResource
29 | import org.jetbrains.compose.resources.stringResource
30 | import zhoutools.composeapp.generated.resources.Res
31 | import zhoutools.composeapp.generated.resources.cancel
32 | import zhoutools.composeapp.generated.resources.confirm
33 | import zhoutools.composeapp.generated.resources.ic_close
34 |
35 | @Composable
36 | fun ConfirmDialog(
37 | title: String,
38 | content: String,
39 | cancel: String? = null,
40 | confirm: String? = null,
41 | onCancel: () -> Unit,
42 | onConfirm: () -> Unit,
43 | onDismiss: (() -> Unit)? = null
44 | ) {
45 | Dialog(onDismissRequest = {}) {
46 | Column(modifier = Modifier
47 | .fillMaxWidth()
48 | .background(Color.White, shape = RoundedCornerShape(8.dp))
49 | ) {
50 | Row(
51 | modifier = Modifier
52 | .padding(all = 16.dp)
53 | .fillMaxWidth(),
54 | verticalAlignment = Alignment.CenterVertically
55 | ) {
56 | Text(
57 | text = title,
58 | fontWeight = FontWeight.Bold,
59 | fontSize = 20.sp
60 | )
61 |
62 | Spacer(modifier = Modifier.weight(1f))
63 |
64 | if (onDismiss != null) {
65 | DismissButton(onDismiss)
66 | }
67 | }
68 |
69 | Text(
70 | text = content,
71 | fontSize = 16.sp,
72 | modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp)
73 | )
74 |
75 | Row(
76 | modifier = Modifier
77 | .padding(start = 16.dp, top = 8.dp, bottom = 16.dp, end = 16.dp)
78 | .fillMaxWidth(),
79 | verticalAlignment = Alignment.CenterVertically
80 | ) {
81 | OutlinedButton(
82 | onClick = onCancel,
83 | modifier = Modifier.weight(1f)
84 | ) {
85 | Text(
86 | text = (cancel ?: stringResource(Res.string.cancel)).uppercase(),
87 | fontSize = 16.sp
88 | )
89 | }
90 |
91 | Spacer(modifier = Modifier.width(16.dp))
92 |
93 | Button(
94 | onClick = onConfirm,
95 | modifier = Modifier.weight(1f)
96 | ) {
97 | Text(
98 | text = (confirm ?: stringResource(Res.string.confirm)).uppercase(),
99 | fontSize = 16.sp
100 | )
101 | }
102 | }
103 | }
104 | }
105 | }
106 |
107 | @Composable
108 | fun DismissButton(onDismiss: () -> Unit) {
109 | Box(
110 | modifier = Modifier
111 | .size(28.dp)
112 | .clip(CircleShape)
113 | .clickable {
114 | onDismiss()
115 | },
116 | contentAlignment = Alignment.Center
117 | ) {
118 | Icon(
119 | painter = painterResource(Res.drawable.ic_close),
120 | contentDescription = null,
121 | tint = Color.Unspecified,
122 | modifier = Modifier.size(20.dp)
123 | )
124 | }
125 | }
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/ui/dialog/DepositGoalDialog.kt:
--------------------------------------------------------------------------------
1 | package ui.dialog
2 |
3 | import androidx.compose.foundation.background
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.padding
9 | import androidx.compose.foundation.layout.width
10 | import androidx.compose.foundation.shape.RoundedCornerShape
11 | import androidx.compose.foundation.text.KeyboardOptions
12 | import androidx.compose.material.Button
13 | import androidx.compose.material.OutlinedButton
14 | import androidx.compose.material.Text
15 | import androidx.compose.material.TextField
16 | import androidx.compose.material.TextFieldDefaults
17 | import androidx.compose.runtime.Composable
18 | import androidx.compose.runtime.derivedStateOf
19 | import androidx.compose.runtime.getValue
20 | import androidx.compose.runtime.mutableStateOf
21 | import androidx.compose.runtime.remember
22 | import androidx.compose.runtime.setValue
23 | import androidx.compose.ui.Alignment
24 | import androidx.compose.ui.Modifier
25 | import androidx.compose.ui.graphics.Color
26 | import androidx.compose.ui.text.SpanStyle
27 | import androidx.compose.ui.text.buildAnnotatedString
28 | import androidx.compose.ui.text.font.FontWeight
29 | import androidx.compose.ui.text.input.KeyboardType
30 | import androidx.compose.ui.text.withStyle
31 | import androidx.compose.ui.unit.dp
32 | import androidx.compose.ui.unit.sp
33 | import androidx.compose.ui.window.Dialog
34 | import global.AppColors
35 | import org.jetbrains.compose.resources.stringResource
36 | import store.AppStore
37 | import zhoutools.composeapp.generated.resources.Res
38 | import zhoutools.composeapp.generated.resources.cancel
39 | import zhoutools.composeapp.generated.resources.confirm
40 | import zhoutools.composeapp.generated.resources.enter_your_x_below
41 | import zhoutools.composeapp.generated.resources.total_deposit_goal
42 |
43 | @Composable
44 | fun DepositGoalDialog(onCancel: () -> Unit, onConfirm: (Long?) -> Unit) {
45 | var totalDepositGoal by remember { mutableStateOf(AppStore.totalDepositGoal.toString()) }
46 | val isValidGoal by remember(totalDepositGoal) {
47 | derivedStateOf { totalDepositGoal.isNotEmpty() && totalDepositGoal.toLongOrNull() != null }
48 | }
49 |
50 | Dialog(onDismissRequest = {}) {
51 | Column(modifier = Modifier
52 | .fillMaxWidth()
53 | .background(Color.White, shape = RoundedCornerShape(8.dp))
54 | ) {
55 | val annotatedString = buildAnnotatedString {
56 | append(stringResource(Res.string.enter_your_x_below).substringBefore("%s"))
57 |
58 | withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
59 | append(stringResource(Res.string.total_deposit_goal))
60 | }
61 |
62 | append(stringResource(Res.string.enter_your_x_below).substringAfter("%s"))
63 | }
64 |
65 | Text(
66 | text = annotatedString,
67 | modifier = Modifier
68 | .align(Alignment.CenterHorizontally)
69 | .padding(start = 16.dp, end = 16.dp, bottom = 16.dp, top = 16.dp),
70 | )
71 |
72 | TextField(
73 | value = totalDepositGoal,
74 | onValueChange = {
75 | totalDepositGoal = it
76 | },
77 | modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
78 | colors = TextFieldDefaults.textFieldColors(
79 | focusedIndicatorColor = Color.Transparent,
80 | unfocusedIndicatorColor = Color.Transparent,
81 | disabledIndicatorColor = Color.Transparent,
82 | textColor = if (isValidGoal) AppColors.DarkGreen else AppColors.Red
83 | ),
84 | maxLines = 1,
85 | keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
86 | )
87 |
88 | Row(
89 | modifier = Modifier
90 | .padding(start = 16.dp, top = 16.dp, bottom = 16.dp, end = 16.dp)
91 | .fillMaxWidth(),
92 | verticalAlignment = Alignment.CenterVertically
93 | ) {
94 | OutlinedButton(
95 | onClick = onCancel,
96 | modifier = Modifier.weight(1f)
97 | ) {
98 | Text(
99 | text = stringResource(Res.string.cancel).uppercase(),
100 | fontSize = 16.sp
101 | )
102 | }
103 |
104 | Spacer(modifier = Modifier.width(16.dp))
105 |
106 | Button(
107 | onClick = {
108 | if (totalDepositGoal.isEmpty()) {
109 | onConfirm(0)
110 | } else {
111 | onConfirm(totalDepositGoal.toLongOrNull())
112 | }
113 | },
114 | modifier = Modifier.weight(1f)
115 | ) {
116 | Text(
117 | text = stringResource(Res.string.confirm).uppercase(),
118 | fontSize = 16.sp
119 | )
120 | }
121 | }
122 | }
123 | }
124 | }
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/ui/fragment/DepositPresenter.kt:
--------------------------------------------------------------------------------
1 | package ui.fragment
2 |
3 | import androidx.compose.runtime.Composable
4 | import androidx.compose.runtime.LaunchedEffect
5 | import androidx.compose.runtime.collectAsState
6 | import androidx.compose.runtime.getValue
7 | import androidx.compose.runtime.mutableFloatStateOf
8 | import androidx.compose.runtime.mutableLongStateOf
9 | import androidx.compose.runtime.mutableStateOf
10 | import androidx.compose.runtime.remember
11 | import androidx.compose.runtime.setValue
12 | import extension.toMonthYearString
13 | import helper.DepositHelper
14 | import helper.SyncHelper
15 | import kotlinx.coroutines.flow.Flow
16 | import model.records.DepositMonth
17 | import model.records.DepositRecords
18 | import moe.tlaster.precompose.molecule.collectAction
19 | import store.AppFlowStore
20 | import ui.fragment.DepositState.Companion.toDeque
21 | import util.TimeUtil
22 |
23 | @Composable
24 | fun DepositPresenter(actionFlow: Flow): DepositState {
25 | var currentAmount by remember { mutableLongStateOf(0L) }
26 | var progress by remember { mutableFloatStateOf(0f) }
27 | var displayDeque by remember { mutableStateOf(ArrayDeque()) }
28 | val depositGoal = AppFlowStore.totalDepositGoalFlow.collectAsState(initial = 0L).value
29 |
30 | fun refreshData() {
31 | val depositMonths = DepositHelper.getMonths()
32 | val deque = DepositRecords(months = depositMonths).toDeque()
33 | displayDeque = deque
34 | displayDeque.firstOrNull()?.let {
35 | currentAmount = it.currentAmount + it.extraDeposit
36 | }
37 | if (displayDeque.isEmpty()) {
38 | currentAmount = 0L
39 | }
40 | }
41 |
42 | LaunchedEffect(Unit) {
43 | refreshData()
44 | }
45 |
46 | LaunchedEffect(depositGoal, currentAmount) {
47 | progress = if (depositGoal == 0L) {
48 | 0f
49 | } else {
50 | currentAmount.toFloat() / (depositGoal.toFloat() * 100)
51 | }
52 | }
53 |
54 | actionFlow.collectAction {
55 | when (this) {
56 | is DepositAction.AddMonth -> {
57 | DepositHelper.addMonth(month)
58 | SyncHelper.autoPushDeposit()
59 | refreshData()
60 | }
61 |
62 | is DepositAction.RemoveMonth -> {
63 | DepositHelper.removeMonth(month)
64 | SyncHelper.autoPushDeposit()
65 | refreshData()
66 | }
67 |
68 | is DepositAction.RefreshData -> {
69 | refreshData()
70 | }
71 | }
72 | }
73 |
74 | return DepositState(currentAmount, progress, displayDeque)
75 | }
76 |
77 | data class DepositState(
78 | val currentAmount: Long = 0L,
79 | val progress: Float = 0f,
80 | val displayDeque: ArrayDeque = ArrayDeque()
81 | ) {
82 | companion object {
83 | fun DepositRecords.toDeque(): ArrayDeque {
84 | val sortedMonths = this.months.sortedByDescending { it.monthStartTime }
85 | val deque = ArrayDeque()
86 | sortedMonths.forEachIndexed { index, month ->
87 | val monthStr = month.monthStartTime.toMonthYearString()
88 | val currAmount = month.currentAmount
89 | val monthIncome = month.monthlyIncome
90 | val balance = currAmount - monthIncome
91 | val extraAmount = month.extraDeposit
92 | var balanceDiff: Long? = null
93 | if (index < sortedMonths.lastIndex) {
94 | val nextMonth = sortedMonths[index + 1]
95 | val nextMonthBalance = nextMonth.currentAmount - nextMonth.monthlyIncome
96 | balanceDiff = balance - nextMonthBalance
97 | }
98 | deque.add(DepositDisplayRecord(monthStr, currAmount, monthIncome, balance, extraAmount, balanceDiff))
99 | }
100 | return deque
101 | }
102 | }
103 | }
104 |
105 | data class DepositDisplayRecord(
106 | val monthStr: String = "",
107 | val currentAmount: Long = 0L,
108 | val monthlyIncome: Long = 0L,
109 | val balance: Long = 0L,
110 | val extraDeposit: Long = 0L,
111 | val balanceDiff: Long? = null
112 | ) {
113 | fun toDepositMonth(): DepositMonth? {
114 | val monthStartTime = TimeUtil.monthYearStringToMonthStartTime(monthStr)
115 | return if (monthStartTime != null) {
116 | DepositMonth(monthStartTime, currentAmount, monthlyIncome, extraDeposit)
117 | } else {
118 | null
119 | }
120 | }
121 | }
122 |
123 | sealed interface DepositAction {
124 | data class AddMonth(val month: DepositMonth) : DepositAction
125 | data class RemoveMonth(val month: DepositMonth) : DepositAction
126 | data object RefreshData : DepositAction
127 | }
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/ui/fragment/MemoPresenter.kt:
--------------------------------------------------------------------------------
1 | package ui.fragment
2 |
3 | import androidx.compose.runtime.Composable
4 | import androidx.compose.runtime.LaunchedEffect
5 | import androidx.compose.runtime.getValue
6 | import androidx.compose.runtime.mutableIntStateOf
7 | import androidx.compose.runtime.mutableStateOf
8 | import androidx.compose.runtime.remember
9 | import androidx.compose.runtime.setValue
10 | import extension.dayStartTime
11 | import helper.DepositHelper
12 | import helper.MemoHelper
13 | import helper.ScheduleHelper
14 | import helper.SyncHelper
15 | import helper.effect.EffectHelper
16 | import helper.effect.WriteMemoEffect
17 | import kotlinx.coroutines.flow.Flow
18 | import logger
19 | import model.display.IMemoDisplayItem
20 | import model.records.DepositRecords
21 | import model.records.GOAL_TYPE_DEPOSIT
22 | import model.records.GOAL_TYPE_TIME
23 | import model.records.Goal
24 | import model.records.Memo
25 | import moe.tlaster.precompose.molecule.collectAction
26 | import store.AppStore
27 | import ui.fragment.DepositState.Companion.toDeque
28 | import util.TimeUtil
29 |
30 | const val MODE_MEMO = 0
31 |
32 | const val MODE_GOALS = 1
33 |
34 | @Composable
35 | fun MemoPresenter(actionFlow: Flow, onGoEdit: () -> Unit): MemoState {
36 | var displayList by remember { mutableStateOf(MemoHelper.getDisplayList()) }
37 | var curMemo by remember { mutableStateOf(null) }
38 | var showBottomSheet by remember { mutableStateOf(false) }
39 | var mode by remember { mutableIntStateOf(MODE_MEMO) }
40 | var goalList by remember { mutableStateOf(emptyList()) }
41 |
42 | fun initGoalList() {
43 | val tempGoalList = mutableListOf()
44 | tempGoalList.clear()
45 |
46 | // Deposit Goal
47 | val depositMonths = DepositHelper.getMonths()
48 | val deque = DepositRecords(months = depositMonths).toDeque()
49 | deque.firstOrNull()?.let {
50 | val currentDeposit = it.currentAmount + it.extraDeposit
51 | val goalDeposit = AppStore.totalDepositGoal * 100L
52 | if (goalDeposit > 0) {
53 | tempGoalList.add(Goal(GOAL_TYPE_DEPOSIT, currentDeposit, goalDeposit))
54 | }
55 | }
56 |
57 | // Time Goals
58 | val scheduleList = ScheduleHelper.getDisplayList()
59 | scheduleList.forEach { schedule ->
60 | val todayStartTime = TimeUtil.currentTimeMillis().dayStartTime()
61 | val diffTime = todayStartTime - schedule.dayStartTime
62 | if (diffTime > 0 && schedule.milestoneGoal > 0) {
63 | tempGoalList.add(Goal(GOAL_TYPE_TIME, diffTime, schedule.milestoneGoal))
64 | }
65 | }
66 |
67 | goalList = tempGoalList
68 | logger.i { "goalList: $goalList" }
69 | }
70 |
71 | fun clickMemoItem(memo: Memo) {
72 | if (memo.isTodo) {
73 | curMemo = memo
74 | showBottomSheet = true
75 | } else {
76 | EffectHelper.emitWriteMemoEffect(WriteMemoEffect.BeginEdit(memo))
77 | onGoEdit()
78 | }
79 | }
80 |
81 | fun clickEdit() {
82 | curMemo?.let {
83 | EffectHelper.emitWriteMemoEffect(WriteMemoEffect.BeginEdit(it))
84 | onGoEdit()
85 | }
86 | }
87 |
88 | LaunchedEffect(Unit) {
89 | initGoalList()
90 | }
91 |
92 | actionFlow.collectAction {
93 | when (this) {
94 | is MemoAction.ClickMemoItem -> {
95 | clickMemoItem(memo)
96 | }
97 |
98 | is MemoAction.ClickEdit -> {
99 | clickEdit()
100 | showBottomSheet = false
101 | }
102 |
103 | is MemoAction.MarkDone -> {
104 | curMemo?.let {
105 | MemoHelper.markDone(it, !it.isTodoFinished)
106 | SyncHelper.autoPushMemo()
107 | curMemo = it.copy(isTodoFinished = !it.isTodoFinished)
108 | displayList = MemoHelper.getDisplayList()
109 | }
110 | showBottomSheet = false
111 | }
112 |
113 | is MemoAction.HideBottomSheet -> {
114 | showBottomSheet = false
115 | }
116 |
117 | is MemoAction.RefreshDisplayList -> {
118 | displayList = MemoHelper.getDisplayList()
119 | }
120 |
121 | is MemoAction.SwitchMode -> {
122 | if (newMode != mode) {
123 | showBottomSheet = false
124 | }
125 | mode = newMode
126 | }
127 | }
128 | }
129 |
130 | return MemoState(displayList, curMemo, showBottomSheet, mode, goalList.toList())
131 | }
132 |
133 | data class MemoState(
134 | val displayList: List = emptyList(),
135 | val curMemo: Memo? = null,
136 | val showBottomSheet: Boolean = false,
137 | val mode: Int = MODE_MEMO,
138 | val goalList: List = emptyList()
139 | )
140 |
141 | sealed interface MemoAction {
142 | data class ClickMemoItem(val memo: Memo) : MemoAction
143 | data object ClickEdit : MemoAction
144 | data object MarkDone : MemoAction
145 | data object HideBottomSheet : MemoAction
146 | data object RefreshDisplayList : MemoAction
147 | data class SwitchMode(val newMode: Int) : MemoAction
148 | }
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/ui/fragment/SchedulePresenter.kt:
--------------------------------------------------------------------------------
1 | package ui.fragment
2 |
3 | import androidx.compose.runtime.Composable
4 | import androidx.compose.runtime.LaunchedEffect
5 | import androidx.compose.runtime.collectAsState
6 | import androidx.compose.runtime.getValue
7 | import androidx.compose.runtime.mutableIntStateOf
8 | import androidx.compose.runtime.mutableStateOf
9 | import androidx.compose.runtime.remember
10 | import androidx.compose.runtime.rememberCoroutineScope
11 | import androidx.compose.runtime.setValue
12 | import kotlinx.coroutines.Dispatchers
13 | import kotlinx.coroutines.IO
14 | import kotlinx.coroutines.flow.Flow
15 | import kotlinx.coroutines.launch
16 | import kotlinx.datetime.Clock
17 | import kotlinx.datetime.TimeZone
18 | import kotlinx.datetime.toLocalDateTime
19 | import model.calendar.MonthDay
20 | import moe.tlaster.precompose.molecule.collectAction
21 | import util.CalendarUtil
22 |
23 | @Composable
24 | fun SchedulePresenter(actionFlow: Flow): ScheduleState {
25 | var currYear by remember { mutableIntStateOf(0) }
26 | var currMonthOfYear by remember { mutableIntStateOf(0) }
27 | var currMonthDays by remember { mutableStateOf>(emptyList()) }
28 | var prevMonthDays by remember { mutableStateOf>(emptyList()) }
29 | var selectDate by remember { mutableStateOf(Triple(0, 1, 1)) }
30 | val holidayMap = CalendarUtil.getHolidayMap().collectAsState().value
31 | val scope = rememberCoroutineScope()
32 |
33 | fun refreshMonthDays() {
34 | if (!holidayMap.containsKey(currYear)) {
35 | scope.launch(Dispatchers.IO) {
36 | CalendarUtil.fetchHolidayMap(currYear)
37 | }
38 | }
39 |
40 | val monthDays = CalendarUtil.getMonthDays(currYear, currMonthOfYear)
41 | val monthDayList = mutableListOf()
42 | monthDays.forEach {
43 | val isHoliday = CalendarUtil.isHoliday(currYear, currMonthOfYear, it.first)
44 | monthDayList.add(MonthDay(it.first, it.second, isHoliday))
45 | }
46 | val prevMonth = if (currMonthOfYear == 1) {
47 | CalendarUtil.getMonthDays(currYear - 1, 12)
48 | } else {
49 | CalendarUtil.getMonthDays(currYear, currMonthOfYear - 1)
50 | }
51 | val prevMonthList = mutableListOf()
52 | prevMonth.forEach {
53 | val isHoliday = CalendarUtil.isHoliday(
54 | if (currMonthOfYear == 1) currYear - 1 else currYear,
55 | if (currMonthOfYear == 1) 12 else currMonthOfYear - 1,
56 | it.first
57 | )
58 | prevMonthList.add(MonthDay(it.first, it.second, isHoliday))
59 | }
60 | currMonthDays = monthDayList
61 | prevMonthDays = prevMonthList
62 | }
63 |
64 | fun init() {
65 | val currentDate = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
66 | currYear = currentDate.year
67 | currMonthOfYear = currentDate.monthNumber
68 | selectDate = Triple(currYear, currMonthOfYear, currentDate.dayOfMonth)
69 | refreshMonthDays()
70 | }
71 |
72 | fun goPrevMonth() {
73 | if (currMonthOfYear == 1) {
74 | currYear--
75 | currMonthOfYear = 12
76 | } else {
77 | currMonthOfYear--
78 | }
79 | refreshMonthDays()
80 | }
81 |
82 | fun goNextMonth() {
83 | if (currMonthOfYear == 12) {
84 | currYear++
85 | currMonthOfYear = 1
86 | } else {
87 | currMonthOfYear++
88 | }
89 | refreshMonthDays()
90 | }
91 |
92 | LaunchedEffect(Unit) {
93 | init()
94 | }
95 |
96 | LaunchedEffect(holidayMap) {
97 | if (holidayMap.isNotEmpty()) {
98 | refreshMonthDays()
99 | }
100 | }
101 |
102 | actionFlow.collectAction {
103 | when (this) {
104 | is ScheduleAction.GoPrevMonth -> {
105 | goPrevMonth()
106 | }
107 | is ScheduleAction.GoNextMonth -> {
108 | goNextMonth()
109 | }
110 | is ScheduleAction.SelectDay -> {
111 | selectDate = date
112 | }
113 | }
114 | }
115 |
116 | return ScheduleState(currYear, currMonthOfYear, currMonthDays, prevMonthDays, selectDate)
117 | }
118 |
119 | data class ScheduleState(
120 | val currYear: Int,
121 | val currMonthOfYear: Int, // 1..12
122 | val currMonthDays: List = emptyList(),
123 | val prevMonthDays: List = emptyList(),
124 | val selectDate: Triple // year, month (1..12), day (1..31)
125 | ) {
126 | @Composable
127 | fun getCurrMonthName(): String {
128 | val index = currMonthOfYear - 1
129 | val monthNames = CalendarUtil.getMonthNames()
130 | return if (index in monthNames.indices) {
131 | monthNames[index]
132 | } else ""
133 | }
134 |
135 | fun isToday(dayOfMonth: Int): Boolean {
136 | val todayDate = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
137 | return currYear == todayDate.year && currMonthOfYear == todayDate.monthNumber && dayOfMonth == todayDate.dayOfMonth
138 | }
139 |
140 | fun isSelect(dayOfMonth: Int): Boolean {
141 | return selectDate.first == currYear && selectDate.second == currMonthOfYear && selectDate.third == dayOfMonth
142 | }
143 | }
144 |
145 | sealed interface ScheduleAction {
146 | data object GoPrevMonth : ScheduleAction
147 | data object GoNextMonth : ScheduleAction
148 | data class SelectDay(val date: Triple) : ScheduleAction
149 | }
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/ui/fragment/TimeCardPresenter.kt:
--------------------------------------------------------------------------------
1 | package ui.fragment
2 |
3 | import androidx.compose.runtime.Composable
4 | import androidx.compose.runtime.LaunchedEffect
5 | import androidx.compose.runtime.getValue
6 | import androidx.compose.runtime.mutableLongStateOf
7 | import androidx.compose.runtime.remember
8 | import androidx.compose.runtime.setValue
9 | import helper.SyncHelper
10 | import kotlinx.coroutines.delay
11 | import kotlinx.coroutines.flow.Flow
12 | import logger
13 | import moe.tlaster.precompose.molecule.collectAction
14 | import helper.TimeCardHelper
15 | import util.TimeUtil
16 |
17 | @Composable
18 | fun TimeCardPresenter(actionFlow: Flow): TimeCardState {
19 | var currentTime by remember { mutableLongStateOf(0L) }
20 | var todayTimeCard by remember { mutableLongStateOf(0L) }
21 | var todayWorkTime by remember { mutableLongStateOf(0L) }
22 | var todayRunTime by remember { mutableLongStateOf(0L) }
23 |
24 | fun refreshTodayState() {
25 | todayTimeCard = TimeCardHelper.todayTimeCard() ?: 0L
26 | todayRunTime = TimeCardHelper.todayTimeRun() ?: 0L
27 | logger.i { "todayTimeCard=$todayTimeCard, todayRunTime=$todayRunTime" }
28 | }
29 |
30 | LaunchedEffect(Unit) {
31 | // init
32 | refreshTodayState()
33 | }
34 |
35 | LaunchedEffect(Unit) {
36 | // update time
37 | while (true) {
38 | delay(500)
39 | currentTime = TimeUtil.currentTimeMillis()
40 | if (todayTimeCard != 0L) {
41 | todayWorkTime = if (todayRunTime == 0L) {
42 | currentTime - todayTimeCard
43 | } else {
44 | todayRunTime - todayTimeCard
45 | }
46 | }
47 | }
48 | }
49 |
50 | actionFlow.collectAction {
51 | when (this) {
52 | is TimeCardAction.PressTimeCard -> {
53 | TimeCardHelper.pressTimeCard()
54 | refreshTodayState()
55 | SyncHelper.autoPushTimeCard()
56 | }
57 |
58 | is TimeCardAction.Run -> {
59 | if (TimeCardHelper.run()) {
60 | refreshTodayState()
61 | SyncHelper.autoPushTimeCard()
62 | }
63 | }
64 |
65 | is TimeCardAction.RefreshTodayState -> {
66 | refreshTodayState()
67 | }
68 | }
69 | }
70 |
71 | return TimeCardState(currentTime, todayTimeCard, todayWorkTime, todayRunTime)
72 | }
73 |
74 | data class TimeCardState(
75 | val currentTime: Long = 0L,
76 | val todayTimeCard: Long = 0L,
77 | val todayWorkTime: Long = 0L,
78 | val todayRunTime: Long = 0L
79 | )
80 |
81 | sealed interface TimeCardAction {
82 | data object PressTimeCard : TimeCardAction
83 | data object Run : TimeCardAction
84 | data object RefreshTodayState : TimeCardAction
85 | }
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/ui/scene/AddSchedulePresenter.kt:
--------------------------------------------------------------------------------
1 | package ui.scene
2 |
3 | import androidx.compose.runtime.Composable
4 | import androidx.compose.runtime.getValue
5 | import androidx.compose.runtime.mutableIntStateOf
6 | import androidx.compose.runtime.mutableLongStateOf
7 | import androidx.compose.runtime.mutableStateOf
8 | import androidx.compose.runtime.remember
9 | import androidx.compose.runtime.setValue
10 | import helper.effect.ScheduleEffect
11 | import extension.dayStartTime
12 | import extension.getDayOfMonth
13 | import extension.getHour
14 | import extension.getMinute
15 | import extension.getMonthOfYear
16 | import extension.getYear
17 | import helper.ScheduleHelper
18 | import helper.SyncHelper
19 | import helper.effect.EffectHelper
20 | import kotlinx.coroutines.flow.Flow
21 | import model.records.Schedule
22 | import moe.tlaster.precompose.molecule.collectAction
23 | import util.CalendarUtil
24 | import util.TimeUtil
25 |
26 | enum class TimeEditType {
27 | START_TIME, END_TIME
28 | }
29 |
30 | @Composable
31 | fun AddSchedulePresenter(actionFlow: Flow): AddScheduleState {
32 | var year by remember { mutableIntStateOf(0) }
33 | var monthOfYear by remember { mutableIntStateOf(0) }
34 | var dayOfMonth by remember { mutableIntStateOf(0) }
35 | var text by remember { mutableStateOf("") }
36 | var startTime by remember { mutableLongStateOf(TimeUtil.currentTimeMillis()) }
37 | var endTime by remember { mutableLongStateOf(TimeUtil.currentTimeMillis()) }
38 | var isAllDay by remember { mutableStateOf(false) }
39 | var isMilestone by remember { mutableStateOf(false) }
40 | var timeEditType by remember { mutableStateOf(null) }
41 | var isEdit by remember { mutableStateOf(false) }
42 | var milestoneGoalMillis by remember { mutableLongStateOf(0L) }
43 | var editItem by remember { mutableStateOf(null) }
44 |
45 | fun setDate(dateTriple: Triple) {
46 | year = dateTriple.first
47 | monthOfYear = dateTriple.second
48 | dayOfMonth = dateTriple.third
49 | startTime = TimeUtil.toEpochMillis(year, monthOfYear, dayOfMonth, startTime.getHour(), startTime.getMinute())
50 | endTime = TimeUtil.toEpochMillis(year, monthOfYear, dayOfMonth, endTime.getHour(), endTime.getMinute())
51 | }
52 |
53 | fun initEditData(schedule: Schedule) {
54 | editItem = schedule
55 | year = schedule.dayStartTime.getYear()
56 | monthOfYear = schedule.dayStartTime.getMonthOfYear()
57 | dayOfMonth = schedule.dayStartTime.getDayOfMonth()
58 | text = schedule.text
59 | startTime = schedule.startingTime
60 | endTime = schedule.endingTime
61 | isAllDay = schedule.isAllDay
62 | isMilestone = schedule.isMilestone
63 | milestoneGoalMillis = schedule.milestoneGoal
64 | }
65 |
66 | fun addSchedule() {
67 | val schedule = Schedule(
68 | text = text,
69 | dayStartTime = startTime.dayStartTime(),
70 | startingTime = startTime,
71 | endingTime = endTime,
72 | isAllDay = isAllDay,
73 | isMilestone = isMilestone,
74 | milestoneGoal = milestoneGoalMillis
75 | )
76 | ScheduleHelper.addSchedule(schedule)
77 | }
78 |
79 | fun editSchedule() {
80 | editItem?.let {
81 | ScheduleHelper.modifySchedule(it, text, startTime, endTime, isAllDay, isMilestone, milestoneGoalMillis)
82 | }
83 | EffectHelper.emitScheduleEffect(ScheduleEffect.RefreshData)
84 | }
85 |
86 | actionFlow.collectAction {
87 | when (this) {
88 | is AddScheduleAction.SetDate -> {
89 | setDate(dateTriple)
90 | }
91 |
92 | is AddScheduleAction.SetAllDay -> {
93 | isAllDay = newValue
94 | }
95 |
96 | is AddScheduleAction.SetMilestone -> {
97 | isMilestone = newValue
98 | }
99 |
100 | is AddScheduleAction.SetStartTime -> {
101 | val millis = TimeUtil.toEpochMillis(year, monthOfYear, dayOfMonth, hour, minute)
102 | startTime = millis
103 | }
104 |
105 | is AddScheduleAction.SetEndTime -> {
106 | val millis = TimeUtil.toEpochMillis(year, monthOfYear, dayOfMonth, hour, minute)
107 | endTime = millis
108 | }
109 |
110 | is AddScheduleAction.SetTimeEditType -> {
111 | timeEditType = editType
112 | }
113 |
114 | is AddScheduleAction.Confirm -> {
115 | text = this.text
116 | milestoneGoalMillis = this.milestoneGoalMillis
117 | if (isEdit) {
118 | editSchedule()
119 | } else {
120 | addSchedule()
121 | }
122 | SyncHelper.autoPushSchedule()
123 | }
124 |
125 | is AddScheduleAction.BeginEdit -> {
126 | isEdit = true
127 | initEditData(schedule)
128 | }
129 | }
130 | }
131 |
132 | return AddScheduleState(year, monthOfYear, dayOfMonth, text, startTime, endTime, isAllDay, isMilestone, timeEditType, isEdit, milestoneGoalMillis)
133 | }
134 |
135 | data class AddScheduleState(
136 | val year: Int,
137 | val monthOfYear: Int,
138 | val dayOfMonth: Int,
139 | val text: String,
140 | val startTime: Long,
141 | val endTime: Long,
142 | val isAllDay: Boolean,
143 | val isMilestone: Boolean,
144 | val timeEditType: TimeEditType?,
145 | val isEdit: Boolean,
146 | val milestoneGoalMillis: Long
147 | ) {
148 | suspend fun getDateString(): String {
149 | if (monthOfYear - 1 in CalendarUtil.getMonthNamesNonComposable().indices) {
150 | val monthName = CalendarUtil.getMonthNamesNonComposable()[monthOfYear - 1]
151 | return "$monthName $dayOfMonth, $year"
152 | }
153 | return ""
154 | }
155 | }
156 |
157 | sealed interface AddScheduleAction {
158 | data class SetDate(val dateTriple: Triple) : AddScheduleAction
159 | data class SetAllDay(val newValue: Boolean) : AddScheduleAction
160 | data class SetMilestone(val newValue: Boolean) : AddScheduleAction
161 | data class SetStartTime(val hour: Int, val minute: Int) : AddScheduleAction
162 | data class SetEndTime(val hour: Int, val minute: Int) : AddScheduleAction
163 | data class SetTimeEditType(val editType: TimeEditType) : AddScheduleAction
164 | data class Confirm(val text: String, val milestoneGoalMillis: Long) : AddScheduleAction
165 | data class BeginEdit(val schedule: Schedule) : AddScheduleAction
166 | }
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/ui/scene/HomeScene.kt:
--------------------------------------------------------------------------------
1 | package ui.scene
2 |
3 | import androidx.compose.foundation.ExperimentalFoundationApi
4 | import androidx.compose.foundation.background
5 | import androidx.compose.foundation.layout.Column
6 | import androidx.compose.foundation.layout.fillMaxSize
7 | import androidx.compose.foundation.layout.fillMaxWidth
8 | import androidx.compose.foundation.layout.imePadding
9 | import androidx.compose.foundation.pager.HorizontalPager
10 | import androidx.compose.foundation.pager.rememberPagerState
11 | import androidx.compose.material.Scaffold
12 | import androidx.compose.material.SnackbarHost
13 | import androidx.compose.material.SnackbarHostState
14 | import androidx.compose.runtime.Composable
15 | import androidx.compose.runtime.remember
16 | import androidx.compose.runtime.rememberCoroutineScope
17 | import androidx.compose.ui.Modifier
18 | import androidx.compose.ui.layout.onSizeChanged
19 | import constant.TabConstants
20 | import ui.fragment.SettingsFragment
21 | import ui.fragment.TimeCardFragment
22 | import global.AppColors
23 | import kotlinx.coroutines.launch
24 | import moe.tlaster.precompose.navigation.BackHandler
25 | import moe.tlaster.precompose.navigation.Navigator
26 | import store.CurrentProcessStore
27 | import ui.fragment.DepositFragment
28 | import ui.fragment.MemoFragment
29 | import ui.fragment.ScheduleFragment
30 | import ui.widget.BaseImmersiveScene
31 | import ui.widget.BottomBar
32 |
33 | @OptIn(ExperimentalFoundationApi::class)
34 | @Composable
35 | fun HomeScene(navigator: Navigator) {
36 | val scope = rememberCoroutineScope()
37 | val snackbarHostState = remember { SnackbarHostState() }
38 |
39 | fun showSnackbar(message: String) {
40 | scope.launch {
41 | snackbarHostState.showSnackbar(message)
42 | }
43 | }
44 |
45 | BaseImmersiveScene(
46 | statusBarColorStr = "#F4F4F4",
47 | navigationBarColorStr = "#FFFFFF",
48 | statusBarPadding = true,
49 | navigationBarPadding = false,
50 | modifier = Modifier
51 | .imePadding()
52 | .fillMaxSize()
53 | .background(AppColors.Background)
54 | ) {
55 | Scaffold(snackbarHost = { SnackbarHost(hostState = snackbarHostState) }) {
56 | val pagerState = rememberPagerState(initialPage = TabConstants.TAB_TIME_CARD, pageCount = { TabConstants.TAB_COUNT })
57 |
58 | Column(modifier = Modifier.fillMaxSize()) {
59 | HorizontalPager(
60 | state = pagerState,
61 | userScrollEnabled = false,
62 | modifier = Modifier
63 | .weight(1f)
64 | .fillMaxWidth()
65 | .onSizeChanged {
66 | CurrentProcessStore.screenWidthPixels.value = it.width
67 | }
68 | ) {
69 | when (it) {
70 | TabConstants.TAB_TIME_CARD -> TimeCardFragment(
71 | modifier = Modifier.fillMaxSize(),
72 | navigator = navigator
73 | )
74 | TabConstants.TAB_SETTINGS -> SettingsFragment(
75 | modifier = Modifier.fillMaxSize(),
76 | navigator = navigator,
77 | showSnackbar = ::showSnackbar
78 | )
79 | TabConstants.TAB_MEMO -> MemoFragment(navigator = navigator)
80 | TabConstants.TAB_SCHEDULE -> ScheduleFragment(navigator = navigator)
81 | TabConstants.TAB_DEPOSIT -> DepositFragment(navigator = navigator)
82 | else -> TimeCardFragment(
83 | modifier = Modifier.fillMaxSize(),
84 | navigator = navigator
85 | )
86 | }
87 | }
88 |
89 | BottomBar(
90 | selectIndex = pagerState.currentPage,
91 | onSelect = {
92 | scope.launch {
93 | pagerState.scrollToPage(page = it)
94 | }
95 | }
96 | )
97 | }
98 |
99 | BackHandler {
100 | // Do nothing.
101 | }
102 | }
103 | }
104 | }
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/ui/scene/WriteMemoPresenter.kt:
--------------------------------------------------------------------------------
1 | package ui.scene
2 |
3 | import androidx.compose.runtime.Composable
4 | import androidx.compose.runtime.getValue
5 | import androidx.compose.runtime.mutableStateOf
6 | import androidx.compose.runtime.remember
7 | import androidx.compose.runtime.setValue
8 | import helper.MemoHelper
9 | import helper.SyncHelper
10 | import helper.effect.EffectHelper
11 | import helper.effect.MemoEffect
12 | import kotlinx.coroutines.flow.Flow
13 | import model.records.Memo
14 | import moe.tlaster.precompose.molecule.collectAction
15 | import moe.tlaster.precompose.navigation.Navigator
16 |
17 | @Composable
18 | fun WriteMemoPresenter(actionFlow: Flow): WriteMemoState {
19 | var memo by remember { mutableStateOf(null) }
20 | var text by remember { mutableStateOf("") }
21 | var isTodo by remember { mutableStateOf(false) }
22 | var isPin by remember { mutableStateOf(false) }
23 | var allGroups by remember { mutableStateOf(MemoHelper.getGroupSet()) }
24 | var group by remember { mutableStateOf(null) }
25 |
26 | actionFlow.collectAction {
27 | when (this) {
28 | is WriteMemoAction.BeginEdit -> {
29 | memo = editMemo
30 | text = editMemo.text
31 | isTodo = editMemo.isTodo
32 | isPin = editMemo.isPin
33 | group = editMemo.group
34 | }
35 |
36 | is WriteMemoAction.SetTodo -> {
37 | isTodo = this.newTodo
38 | }
39 |
40 | is WriteMemoAction.SetPin -> {
41 | isPin = this.newPin
42 | }
43 |
44 | is WriteMemoAction.SetGroup -> {
45 | group = this.group
46 | group?.let {
47 | val newAllGroups = allGroups.toMutableSet()
48 | newAllGroups.add(it)
49 | allGroups = newAllGroups
50 | }
51 | }
52 |
53 | is WriteMemoAction.Delete -> {
54 | memo?.let {
55 | MemoHelper.deleteMemo(it)
56 | }
57 | SyncHelper.autoPushMemo()
58 | EffectHelper.emitMemoEffect(MemoEffect.RefreshData)
59 | navigator.goBack()
60 | }
61 |
62 | is WriteMemoAction.Confirm -> {
63 | text = this.text
64 | if (memo == null) {
65 | MemoHelper.addMemo(text, isTodo, isPin, group)
66 | } else {
67 | memo?.let {
68 | MemoHelper.modifyMemo(it, text, isTodo, isPin, group)
69 | }
70 | }
71 | SyncHelper.autoPushMemo()
72 | EffectHelper.emitMemoEffect(MemoEffect.RefreshData)
73 | navigator.goBack()
74 | }
75 | }
76 | }
77 |
78 | return WriteMemoState(memo, text, isTodo, isPin, allGroups, group)
79 | }
80 |
81 | data class WriteMemoState(
82 | val memo: Memo? = null,
83 | val text: String = "",
84 | val isTodo: Boolean = false,
85 | val isPin: Boolean = false,
86 | val allGroups: Set = emptySet(),
87 | val group: String? = null
88 | )
89 |
90 | sealed interface WriteMemoAction {
91 | data class BeginEdit(val editMemo: Memo) : WriteMemoAction
92 | data class SetTodo(val newTodo: Boolean) : WriteMemoAction
93 | data class SetPin(val newPin: Boolean) : WriteMemoAction
94 | data class SetGroup(val group: String?) : WriteMemoAction
95 | data class Delete(val navigator: Navigator) : WriteMemoAction
96 | data class Confirm(val text: String, val navigator: Navigator) : WriteMemoAction
97 | }
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/ui/scene/detail/DetailScene.kt:
--------------------------------------------------------------------------------
1 | package ui.scene.detail
2 |
3 | import androidx.compose.foundation.background
4 | import androidx.compose.foundation.layout.Column
5 | import androidx.compose.foundation.layout.fillMaxSize
6 | import androidx.compose.material.Tab
7 | import androidx.compose.material.TabRow
8 | import androidx.compose.material.Text
9 | import androidx.compose.runtime.Composable
10 | import androidx.compose.ui.Modifier
11 | import androidx.compose.ui.graphics.Color
12 | import global.AppColors
13 | import moe.tlaster.precompose.molecule.rememberPresenter
14 | import moe.tlaster.precompose.navigation.Navigator
15 | import org.jetbrains.compose.resources.stringResource
16 | import ui.widget.BaseImmersiveScene
17 | import ui.widget.TitleBar
18 | import zhoutools.composeapp.generated.resources.Res
19 | import zhoutools.composeapp.generated.resources.details
20 | import zhoutools.composeapp.generated.resources.history
21 | import zhoutools.composeapp.generated.resources.today
22 |
23 | @Composable
24 | fun DetailScene(navigator: Navigator) {
25 | val (state, channel) = rememberPresenter { DetailPresenter(it) }
26 | val tabs = mapOf(
27 | DETAIL_TAB_TODAY to stringResource(Res.string.today),
28 | DETAIL_TAB_HISTORY to stringResource(Res.string.history)
29 | )
30 |
31 | BaseImmersiveScene(modifier = Modifier
32 | .fillMaxSize()
33 | .background(AppColors.Background)
34 | ) {
35 | Column {
36 | TitleBar(
37 | navigator = navigator,
38 | title = stringResource(Res.string.details)
39 | )
40 |
41 | TabRow(
42 | selectedTabIndex = state.tab,
43 | backgroundColor = Color.White,
44 | contentColor = AppColors.Theme
45 | ) {
46 | tabs.forEach {
47 | val index = it.key
48 | val title = it.value
49 | Tab(
50 | selected = state.tab == index,
51 | onClick = {
52 | channel.trySend(DetailAction.ChangeTab(index))
53 | },
54 | text = {
55 | Text(text = title)
56 | }
57 | )
58 | }
59 | }
60 |
61 | if (state.tab == DETAIL_TAB_HISTORY) {
62 | HistoryFragment(state.historyState, channel)
63 | } else {
64 | TodayFragment(state.todayState)
65 | }
66 | }
67 | }
68 | }
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/ui/widget/AutoSyncIndicator.kt:
--------------------------------------------------------------------------------
1 | package ui.widget
2 |
3 | import androidx.compose.animation.core.LinearEasing
4 | import androidx.compose.animation.core.animate
5 | import androidx.compose.animation.core.tween
6 | import androidx.compose.foundation.layout.Row
7 | import androidx.compose.foundation.layout.padding
8 | import androidx.compose.foundation.layout.size
9 | import androidx.compose.material.CircularProgressIndicator
10 | import androidx.compose.material.Text
11 | import androidx.compose.runtime.Composable
12 | import androidx.compose.runtime.LaunchedEffect
13 | import androidx.compose.runtime.collectAsState
14 | import androidx.compose.runtime.getValue
15 | import androidx.compose.runtime.mutableFloatStateOf
16 | import androidx.compose.runtime.remember
17 | import androidx.compose.runtime.setValue
18 | import androidx.compose.ui.Alignment
19 | import androidx.compose.ui.Modifier
20 | import androidx.compose.ui.draw.alpha
21 | import androidx.compose.ui.graphics.Color
22 | import androidx.compose.ui.unit.dp
23 | import androidx.compose.ui.unit.sp
24 | import helper.SyncHelper
25 | import org.jetbrains.compose.resources.stringResource
26 | import zhoutools.composeapp.generated.resources.Res
27 | import zhoutools.composeapp.generated.resources.downloading
28 | import zhoutools.composeapp.generated.resources.uploading
29 |
30 | @Composable
31 | fun AutoSyncIndicator() {
32 | var textAlpha by remember { mutableFloatStateOf(1f) }
33 | val isPulling = SyncHelper.isAutoPulling.collectAsState(initial = false).value
34 | val isPushing = SyncHelper.isAutoPushing.collectAsState(initial = false).value
35 |
36 | if (isPulling || isPushing) {
37 | Row(verticalAlignment = Alignment.CenterVertically) {
38 | CircularProgressIndicator(
39 | modifier = Modifier
40 | .padding(start = 8.dp)
41 | .size(20.dp),
42 | color = Color.LightGray,
43 | strokeWidth = 2.dp
44 | )
45 |
46 | Text(
47 | text = stringResource(
48 | if (isPulling) Res.string.downloading else Res.string.uploading
49 | ),
50 | color = Color.Gray,
51 | modifier = Modifier
52 | .padding(start = 8.dp)
53 | .alpha(textAlpha),
54 | fontSize = 14.sp
55 | )
56 | }
57 |
58 | LaunchedEffect(Unit) {
59 | // Text blinking effect
60 | while (true) {
61 | animate(1f, 0f, animationSpec = tween(500, delayMillis = 500, easing = LinearEasing)) { value, _ ->
62 | textAlpha = value
63 | }
64 | animate(0f, 1f, animationSpec = tween(500, easing = LinearEasing)) { value, _ ->
65 | textAlpha = value
66 | }
67 | }
68 | }
69 | }
70 | }
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/ui/widget/BaseImmersiveScene.kt:
--------------------------------------------------------------------------------
1 | package ui.widget
2 |
3 | import androidx.compose.foundation.layout.Box
4 | import androidx.compose.foundation.layout.navigationBarsPadding
5 | import androidx.compose.foundation.layout.statusBarsPadding
6 | import androidx.compose.runtime.Composable
7 | import androidx.compose.ui.Modifier
8 |
9 | /**
10 | * Scene for independent pages
11 | * Immersive status & navigation bar support for Android and iOS.
12 | */
13 | @Composable
14 | fun BaseImmersiveScene(
15 | modifier: Modifier = Modifier,
16 | statusBarColorStr: String = "#FFFFFF",
17 | navigationBarColorStr: String = "#F4F4F4",
18 | statusBarPadding: Boolean = false,
19 | navigationBarPadding: Boolean = true,
20 | content: @Composable () -> Unit
21 | ) {
22 | var rootModifier = modifier
23 | if (statusBarPadding) {
24 | rootModifier = rootModifier.statusBarsPadding()
25 | }
26 | if (navigationBarPadding) {
27 | rootModifier = rootModifier.navigationBarsPadding()
28 | }
29 |
30 | Box(modifier = rootModifier) {
31 | content()
32 | }
33 | }
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/ui/widget/BottomBar.kt:
--------------------------------------------------------------------------------
1 | package ui.widget
2 |
3 | import androidx.compose.foundation.background
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.navigationBarsPadding
10 | import androidx.compose.foundation.layout.offset
11 | import androidx.compose.foundation.layout.padding
12 | import androidx.compose.material.Icon
13 | import androidx.compose.material.Text
14 | import androidx.compose.runtime.Composable
15 | import androidx.compose.ui.Alignment
16 | import androidx.compose.ui.Modifier
17 | import androidx.compose.ui.graphics.Color
18 | import androidx.compose.ui.unit.dp
19 | import androidx.compose.ui.unit.sp
20 | import constant.TabConstants
21 | import extension.clickableNoRipple
22 | import global.AppColors
23 | import org.jetbrains.compose.resources.DrawableResource
24 | import org.jetbrains.compose.resources.StringResource
25 | import org.jetbrains.compose.resources.painterResource
26 | import org.jetbrains.compose.resources.stringResource
27 | import zhoutools.composeapp.generated.resources.Res
28 | import zhoutools.composeapp.generated.resources.deposit
29 | import zhoutools.composeapp.generated.resources.ic_deposit
30 | import zhoutools.composeapp.generated.resources.ic_memo
31 | import zhoutools.composeapp.generated.resources.ic_schedule
32 | import zhoutools.composeapp.generated.resources.ic_settings
33 | import zhoutools.composeapp.generated.resources.ic_time_card
34 | import zhoutools.composeapp.generated.resources.memo
35 | import zhoutools.composeapp.generated.resources.schedule
36 | import zhoutools.composeapp.generated.resources.settings
37 | import zhoutools.composeapp.generated.resources.time_card
38 |
39 | @Composable
40 | fun BottomBar(
41 | modifier: Modifier = Modifier,
42 | selectIndex: Int,
43 | onSelect: (Int) -> Unit
44 | ) {
45 | val rootModifier = modifier
46 | .background(Color.White)
47 | .navigationBarsPadding()
48 | .fillMaxWidth()
49 | .height(58.dp)
50 |
51 | Row(
52 | modifier = rootModifier,
53 | verticalAlignment = Alignment.CenterVertically
54 | ) {
55 | Spacer(modifier = Modifier.weight(1f))
56 |
57 | BottomBarItem(
58 | index = TabConstants.TAB_TIME_CARD,
59 | selectIndex = selectIndex,
60 | icon = Res.drawable.ic_time_card,
61 | name = Res.string.time_card
62 | ) {
63 | onSelect(it)
64 | }
65 |
66 | Spacer(modifier = Modifier.weight(1f))
67 |
68 | BottomBarItem(
69 | index = TabConstants.TAB_SCHEDULE,
70 | selectIndex = selectIndex,
71 | icon = Res.drawable.ic_schedule,
72 | name = Res.string.schedule
73 | ) {
74 | onSelect(it)
75 | }
76 |
77 | Spacer(modifier = Modifier.weight(1f))
78 |
79 | BottomBarItem(
80 | index = TabConstants.TAB_MEMO,
81 | selectIndex = selectIndex,
82 | icon = Res.drawable.ic_memo,
83 | name = Res.string.memo
84 | ) {
85 | onSelect(it)
86 | }
87 |
88 | Spacer(modifier = Modifier.weight(1f))
89 |
90 | BottomBarItem(
91 | index = TabConstants.TAB_DEPOSIT,
92 | selectIndex = selectIndex,
93 | icon = Res.drawable.ic_deposit,
94 | name = Res.string.deposit
95 | ) {
96 | onSelect(it)
97 | }
98 |
99 | Spacer(modifier = Modifier.weight(1f))
100 |
101 | BottomBarItem(
102 | index = TabConstants.TAB_SETTINGS,
103 | selectIndex = selectIndex,
104 | icon = Res.drawable.ic_settings,
105 | name = Res.string.settings
106 | ) {
107 | onSelect(it)
108 | }
109 |
110 | Spacer(modifier = Modifier.weight(1f))
111 | }
112 | }
113 |
114 | @Composable
115 | private fun BottomBarItem(index: Int, selectIndex: Int, icon: DrawableResource, name: StringResource, onSelect: (Int) -> Unit) {
116 | Column(
117 | horizontalAlignment = Alignment.CenterHorizontally,
118 | modifier = Modifier
119 | .clickableNoRipple {
120 | onSelect(index)
121 | }
122 | .padding(horizontal = 4.dp)
123 | ) {
124 | Icon(
125 | painter = painterResource(icon),
126 | modifier = Modifier.height(32.dp).offset(y = 4.dp),
127 | contentDescription = null,
128 | tint = if (selectIndex == index) AppColors.Theme else Color.Unspecified
129 | )
130 |
131 | Text(
132 | text = stringResource(name),
133 | fontSize = 11.sp,
134 | color = if (selectIndex == index) AppColors.Theme else Color.Black
135 | )
136 | }
137 | }
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/ui/widget/Divider.kt:
--------------------------------------------------------------------------------
1 | package ui.widget
2 |
3 | import androidx.compose.foundation.background
4 | import androidx.compose.foundation.layout.Box
5 | import androidx.compose.foundation.layout.fillMaxWidth
6 | import androidx.compose.foundation.layout.height
7 | import androidx.compose.runtime.Composable
8 | import androidx.compose.ui.Modifier
9 | import androidx.compose.ui.unit.dp
10 | import global.AppColors
11 |
12 | @Composable
13 | fun VerticalDivider() {
14 | Box(modifier = Modifier
15 | .fillMaxWidth()
16 | .height(1.dp)
17 | .background(AppColors.Divider)
18 | )
19 | }
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/ui/widget/EmptyLayout.kt:
--------------------------------------------------------------------------------
1 | package ui.widget
2 |
3 | import androidx.compose.foundation.layout.Column
4 | import androidx.compose.foundation.layout.Spacer
5 | import androidx.compose.foundation.layout.fillMaxWidth
6 | import androidx.compose.foundation.layout.height
7 | import androidx.compose.foundation.layout.padding
8 | import androidx.compose.foundation.layout.size
9 | import androidx.compose.material.Icon
10 | import androidx.compose.material.Text
11 | import androidx.compose.runtime.Composable
12 | import androidx.compose.ui.Alignment
13 | import androidx.compose.ui.Modifier
14 | import androidx.compose.ui.draw.alpha
15 | import androidx.compose.ui.graphics.Color
16 | import androidx.compose.ui.unit.dp
17 | import androidx.compose.ui.unit.sp
18 | import org.jetbrains.compose.resources.painterResource
19 | import zhoutools.composeapp.generated.resources.Res
20 | import zhoutools.composeapp.generated.resources.ic_empty
21 |
22 | @Composable
23 | fun EmptyLayout(modifier: Modifier = Modifier, description: String) {
24 | Column(
25 | modifier = modifier
26 | .fillMaxWidth()
27 | .padding(top = 64.dp)
28 | .alpha(0.5f),
29 | horizontalAlignment = Alignment.CenterHorizontally
30 | ) {
31 | Icon(
32 | painter = painterResource(Res.drawable.ic_empty),
33 | contentDescription = null,
34 | tint = Color.Unspecified,
35 | modifier = Modifier.size(64.dp)
36 | )
37 |
38 | Spacer(modifier = Modifier.height(8.dp))
39 |
40 | Text(
41 | text = description,
42 | fontSize = 16.sp
43 | )
44 | }
45 | }
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/ui/widget/FragmentHeader.kt:
--------------------------------------------------------------------------------
1 | package ui.widget
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.padding
7 | import androidx.compose.material.Text
8 | import androidx.compose.runtime.Composable
9 | import androidx.compose.ui.Alignment
10 | import androidx.compose.ui.Modifier
11 | import androidx.compose.ui.text.font.FontWeight
12 | import androidx.compose.ui.unit.dp
13 | import androidx.compose.ui.unit.sp
14 |
15 | @Composable
16 | fun FragmentHeader(title: String, endingSlot: @Composable (() -> Unit)? = null) {
17 | Row(
18 | modifier = Modifier
19 | .fillMaxWidth()
20 | .padding(start = 16.dp, top = 12.dp, end = 16.dp, bottom = 12.dp),
21 | verticalAlignment = Alignment.CenterVertically
22 | ) {
23 | Text(
24 | text = title.uppercase(),
25 | fontSize = 24.sp,
26 | fontWeight = FontWeight.ExtraBold
27 | )
28 |
29 | AutoSyncIndicator()
30 |
31 | Spacer(modifier = Modifier.weight(1f))
32 |
33 | endingSlot?.invoke()
34 | }
35 | }
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/ui/widget/HorizontalSeekBar.kt:
--------------------------------------------------------------------------------
1 | package ui.widget
2 |
3 | import androidx.compose.foundation.layout.Column
4 | import androidx.compose.foundation.layout.PaddingValues
5 | import androidx.compose.foundation.layout.fillMaxWidth
6 | import androidx.compose.foundation.layout.padding
7 | import androidx.compose.foundation.layout.size
8 | import androidx.compose.foundation.layout.width
9 | import androidx.compose.foundation.lazy.LazyRow
10 | import androidx.compose.foundation.lazy.itemsIndexed
11 | import androidx.compose.foundation.lazy.rememberLazyListState
12 | import androidx.compose.material3.Icon
13 | import androidx.compose.material3.Text
14 | import androidx.compose.runtime.Composable
15 | import androidx.compose.runtime.LaunchedEffect
16 | import androidx.compose.runtime.getValue
17 | import androidx.compose.runtime.mutableIntStateOf
18 | import androidx.compose.runtime.mutableStateOf
19 | import androidx.compose.runtime.remember
20 | import androidx.compose.runtime.setValue
21 | import androidx.compose.runtime.snapshotFlow
22 | import androidx.compose.ui.Alignment
23 | import androidx.compose.ui.Modifier
24 | import androidx.compose.ui.layout.onSizeChanged
25 | import androidx.compose.ui.platform.LocalDensity
26 | import androidx.compose.ui.text.font.FontWeight
27 | import androidx.compose.ui.text.style.TextAlign
28 | import androidx.compose.ui.unit.Dp
29 | import androidx.compose.ui.unit.dp
30 | import androidx.compose.ui.unit.sp
31 | import global.AppColors
32 | import kotlinx.coroutines.delay
33 | import org.jetbrains.compose.resources.painterResource
34 | import zhoutools.composeapp.generated.resources.Res
35 | import zhoutools.composeapp.generated.resources.ic_arrow_down
36 | import kotlin.math.roundToInt
37 |
38 | @Composable
39 | fun HorizontalSeekBar(
40 | modifier: Modifier = Modifier,
41 | itemList: List,
42 | itemWidth: Dp,
43 | defaultSelectItem: String? = null,
44 | onSelectItem: ((String) -> Unit)? = null
45 | ) {
46 | fun getDefaultSelectIndex(): Int {
47 | val index = itemList.indexOf(defaultSelectItem)
48 | return if (index >= 0) index else 0
49 | }
50 |
51 | val density = LocalDensity.current
52 | val toPx = { dp: Dp ->
53 | density.run { dp.toPx() }
54 | }
55 | val toDp = { px: Float ->
56 | density.run { px.toDp() }
57 | }
58 | var thisWidth by remember { mutableIntStateOf(0) }
59 | val lazyListState = rememberLazyListState()
60 | val paddingHorizontal = thisWidth / 2 - toPx(itemWidth) / 2
61 | var selectIndex by remember { mutableIntStateOf(getDefaultSelectIndex()) }
62 | var isInitialScroll by remember { mutableStateOf(true) }
63 |
64 | fun selectItem(index: Int) {
65 | if (index in itemList.indices) {
66 | selectIndex = index
67 | onSelectItem?.invoke(itemList[index])
68 | }
69 | }
70 |
71 | LaunchedEffect(Unit) {
72 | lazyListState.animateScrollToItem(selectIndex)
73 | isInitialScroll = false
74 | }
75 |
76 | LaunchedEffect(lazyListState.firstVisibleItemIndex, lazyListState.firstVisibleItemScrollOffset) {
77 | if (!isInitialScroll) {
78 | lazyListState.apply {
79 | val totalOffset = firstVisibleItemIndex * toPx(itemWidth) + firstVisibleItemScrollOffset
80 | selectItem((totalOffset / toPx(itemWidth)).roundToInt())
81 | }
82 | }
83 | }
84 |
85 | LaunchedEffect(lazyListState) {
86 | snapshotFlow { lazyListState.isScrollInProgress }.collect { isScrolling ->
87 | if (!isScrolling) {
88 | delay(100)
89 | if (!lazyListState.isScrollInProgress) {
90 | lazyListState.animateScrollToItem(selectIndex)
91 | }
92 | }
93 | }
94 | }
95 |
96 | Column(
97 | horizontalAlignment = Alignment.CenterHorizontally,
98 | modifier = modifier
99 | .fillMaxWidth()
100 | .onSizeChanged {
101 | thisWidth = it.width
102 | }
103 | ) {
104 | Icon(
105 | painter = painterResource(Res.drawable.ic_arrow_down),
106 | tint = AppColors.Theme,
107 | contentDescription = null,
108 | modifier = Modifier.size(24.dp)
109 | )
110 |
111 | LazyRow(
112 | modifier = Modifier.fillMaxWidth(),
113 | verticalAlignment = Alignment.CenterVertically,
114 | state = lazyListState,
115 | contentPadding = PaddingValues(horizontal = if (paddingHorizontal > 0) toDp(paddingHorizontal) else 0.dp)
116 | ) {
117 | itemsIndexed(itemList) { index, it ->
118 | Text(
119 | text = it,
120 | fontSize = if (index == selectIndex) 20.sp else 16.sp,
121 | textAlign = TextAlign.Center,
122 | fontWeight = if (index == selectIndex) FontWeight.ExtraBold else FontWeight.Normal,
123 | modifier = Modifier
124 | .width(itemWidth)
125 | .padding(top = 4.dp)
126 | )
127 | }
128 | }
129 | }
130 | }
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/ui/widget/ShimmerProgressBar.kt:
--------------------------------------------------------------------------------
1 | package ui.widget
2 |
3 | import androidx.annotation.FloatRange
4 | import androidx.compose.animation.core.RepeatMode
5 | import androidx.compose.animation.core.animateFloat
6 | import androidx.compose.animation.core.infiniteRepeatable
7 | import androidx.compose.animation.core.rememberInfiniteTransition
8 | import androidx.compose.animation.core.tween
9 | import androidx.compose.foundation.background
10 | import androidx.compose.foundation.layout.Box
11 | import androidx.compose.foundation.layout.fillMaxHeight
12 | import androidx.compose.foundation.layout.fillMaxSize
13 | import androidx.compose.foundation.layout.fillMaxWidth
14 | import androidx.compose.foundation.layout.offset
15 | import androidx.compose.material.LinearProgressIndicator
16 | import androidx.compose.material.MaterialTheme
17 | import androidx.compose.material.ProgressIndicatorDefaults
18 | import androidx.compose.runtime.Composable
19 | import androidx.compose.runtime.getValue
20 | import androidx.compose.runtime.mutableIntStateOf
21 | import androidx.compose.runtime.remember
22 | import androidx.compose.runtime.setValue
23 | import androidx.compose.ui.Modifier
24 | import androidx.compose.ui.graphics.Brush
25 | import androidx.compose.ui.graphics.Color
26 | import androidx.compose.ui.layout.onSizeChanged
27 | import androidx.compose.ui.platform.LocalDensity
28 |
29 | @Composable
30 | fun ShimmerProgressBar(
31 | @FloatRange(from = 0.0, to = 1.0)
32 | progress: Float,
33 | modifier: Modifier = Modifier,
34 | color: Color = MaterialTheme.colors.primary,
35 | backgroundColor: Color = color.copy(alpha = ProgressIndicatorDefaults.IndicatorBackgroundOpacity),
36 | durationMillis: Int = 3000
37 | ) {
38 | var width by remember { mutableIntStateOf(0) }
39 | val infiniteTransition = rememberInfiniteTransition()
40 | val animatedOffset by infiniteTransition.animateFloat(
41 | initialValue = -1f,
42 | targetValue = 2f,
43 | animationSpec = infiniteRepeatable(
44 | animation = tween(durationMillis),
45 | repeatMode = RepeatMode.Restart
46 | )
47 | )
48 |
49 | Box(modifier = modifier.onSizeChanged {
50 | width = it.width
51 | }) {
52 | LinearProgressIndicator(
53 | progress = progress,
54 | color = color,
55 | backgroundColor = backgroundColor,
56 | modifier = Modifier.fillMaxSize()
57 | )
58 |
59 | // Infinite transition shimmer effect
60 | Box(modifier = Modifier
61 | .fillMaxWidth(0.3f)
62 | .fillMaxHeight()
63 | .offset(x = with(LocalDensity.current) { (animatedOffset * width).toDp() })
64 | .background(
65 | brush = Brush.horizontalGradient(
66 | colors = listOf(
67 | Color.White.copy(alpha = 0f),
68 | Color.White.copy(alpha = 0.3f),
69 | Color.White.copy(alpha = 0f)
70 | )
71 | )
72 | )
73 | )
74 | }
75 | }
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/ui/widget/TitleBar.kt:
--------------------------------------------------------------------------------
1 | package ui.widget
2 |
3 | import androidx.compose.foundation.background
4 | import androidx.compose.foundation.clickable
5 | import androidx.compose.foundation.layout.Box
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.padding
10 | import androidx.compose.foundation.layout.size
11 | import androidx.compose.foundation.layout.statusBarsPadding
12 | import androidx.compose.foundation.layout.width
13 | import androidx.compose.foundation.shape.CircleShape
14 | import androidx.compose.material.Icon
15 | import androidx.compose.material.Text
16 | import androidx.compose.runtime.Composable
17 | import androidx.compose.ui.Alignment
18 | import androidx.compose.ui.Modifier
19 | import androidx.compose.ui.draw.clip
20 | import androidx.compose.ui.graphics.Color
21 | import androidx.compose.ui.text.font.FontWeight
22 | import androidx.compose.ui.unit.dp
23 | import androidx.compose.ui.unit.sp
24 | import moe.tlaster.precompose.navigation.Navigator
25 | import org.jetbrains.compose.resources.painterResource
26 | import zhoutools.composeapp.generated.resources.Res
27 | import zhoutools.composeapp.generated.resources.ic_back
28 |
29 | @Composable
30 | fun TitleBar(navigator: Navigator, title: String) {
31 | val rootModifier = Modifier
32 | .background(Color.White)
33 | .statusBarsPadding()
34 | .fillMaxWidth()
35 |
36 | Row(
37 | modifier = rootModifier,
38 | verticalAlignment = Alignment.CenterVertically
39 | ) {
40 | Spacer(modifier = Modifier.width(12.dp))
41 |
42 | Box(modifier = Modifier
43 | .size(36.dp)
44 | .clip(CircleShape)
45 | .clickable {
46 | navigator.goBack()
47 | }
48 | ) {
49 | Icon(
50 | painter = painterResource(Res.drawable.ic_back),
51 | contentDescription = null,
52 | tint = Color.Unspecified,
53 | modifier = Modifier
54 | .align(Alignment.Center)
55 | .size(24.dp)
56 | )
57 | }
58 |
59 | Spacer(modifier = Modifier.width(12.dp))
60 |
61 | Text(
62 | text = title,
63 | fontSize = 20.sp,
64 | fontWeight = FontWeight.Bold,
65 | modifier = Modifier.padding(vertical = 12.dp)
66 | )
67 | }
68 | }
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/util/CalendarUtil.kt:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import androidx.compose.runtime.Composable
4 | import extension.getYear
5 | import extension.toTwoDigits
6 | import helper.NetworkHelper
7 | import kotlinx.coroutines.CoroutineScope
8 | import kotlinx.coroutines.Dispatchers
9 | import kotlinx.coroutines.IO
10 | import kotlinx.coroutines.SupervisorJob
11 | import kotlinx.coroutines.flow.MutableStateFlow
12 | import kotlinx.coroutines.flow.StateFlow
13 | import kotlinx.coroutines.flow.update
14 | import kotlinx.coroutines.launch
15 | import kotlinx.datetime.DateTimeUnit
16 | import kotlinx.datetime.DayOfWeek
17 | import kotlinx.datetime.LocalDate
18 | import kotlinx.datetime.daysUntil
19 | import kotlinx.datetime.plus
20 | import kotlinx.serialization.json.JsonObject
21 | import kotlinx.serialization.json.booleanOrNull
22 | import kotlinx.serialization.json.jsonObject
23 | import kotlinx.serialization.json.jsonPrimitive
24 | import org.jetbrains.compose.resources.getString
25 | import org.jetbrains.compose.resources.stringResource
26 | import zhoutools.composeapp.generated.resources.Res
27 | import zhoutools.composeapp.generated.resources.april
28 | import zhoutools.composeapp.generated.resources.august
29 | import zhoutools.composeapp.generated.resources.december
30 | import zhoutools.composeapp.generated.resources.february
31 | import zhoutools.composeapp.generated.resources.sunday
32 | import zhoutools.composeapp.generated.resources.monday
33 | import zhoutools.composeapp.generated.resources.tuesday
34 | import zhoutools.composeapp.generated.resources.wednesday
35 | import zhoutools.composeapp.generated.resources.thursday
36 | import zhoutools.composeapp.generated.resources.friday
37 | import zhoutools.composeapp.generated.resources.january
38 | import zhoutools.composeapp.generated.resources.july
39 | import zhoutools.composeapp.generated.resources.june
40 | import zhoutools.composeapp.generated.resources.march
41 | import zhoutools.composeapp.generated.resources.may
42 | import zhoutools.composeapp.generated.resources.november
43 | import zhoutools.composeapp.generated.resources.october
44 | import zhoutools.composeapp.generated.resources.saturday
45 | import zhoutools.composeapp.generated.resources.september
46 | import kotlin.math.abs
47 |
48 | object CalendarUtil {
49 | const val NOT_HOLIDAY = 0
50 | const val DAY_OFF = 1
51 | const val WORK_DAY = 2
52 |
53 | const val KEY_IS_OFF_DAY = "isOffDay"
54 |
55 | private val coroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
56 |
57 | private val holidayMap = MutableStateFlow(mapOf())
58 |
59 | init {
60 | coroutineScope.launch(Dispatchers.IO) {
61 | fetchHolidayMap()
62 | }
63 | }
64 |
65 | /**
66 | * @param year null for current year
67 | */
68 | suspend fun fetchHolidayMap(year: Int? = null) {
69 | val currentYear = TimeUtil.currentTimeMillis().getYear()
70 | val useYear = year ?: currentYear
71 | val holidays = NetworkHelper.getHolidays(useYear)
72 | if (holidays != null) {
73 | holidayMap.update {
74 | it + (useYear to holidays)
75 | }
76 | }
77 | }
78 |
79 | @Composable
80 | fun getWeekDays(): List = listOf(
81 | stringResource(Res.string.sunday),
82 | stringResource(Res.string.monday),
83 | stringResource(Res.string.tuesday),
84 | stringResource(Res.string.wednesday),
85 | stringResource(Res.string.thursday),
86 | stringResource(Res.string.friday),
87 | stringResource(Res.string.saturday)
88 | )
89 |
90 | @Composable
91 | fun getMonthNames(): List = listOf(
92 | stringResource(Res.string.january),
93 | stringResource(Res.string.february),
94 | stringResource(Res.string.march),
95 | stringResource(Res.string.april),
96 | stringResource(Res.string.may),
97 | stringResource(Res.string.june),
98 | stringResource(Res.string.july),
99 | stringResource(Res.string.august),
100 | stringResource(Res.string.september),
101 | stringResource(Res.string.october),
102 | stringResource(Res.string.november),
103 | stringResource(Res.string.december),
104 | )
105 |
106 | suspend fun getMonthNamesNonComposable(): List = listOf(
107 | getString(Res.string.january),
108 | getString(Res.string.february),
109 | getString(Res.string.march),
110 | getString(Res.string.april),
111 | getString(Res.string.may),
112 | getString(Res.string.june),
113 | getString(Res.string.july),
114 | getString(Res.string.august),
115 | getString(Res.string.september),
116 | getString(Res.string.october),
117 | getString(Res.string.november),
118 | getString(Res.string.december),
119 | )
120 |
121 | /**
122 | * @return pair.first: day of month (1..31); pair.second: day of week
123 | */
124 | fun getMonthDays(year: Int, month: Int): List> {
125 | val monthStart = LocalDate(year, month, 1)
126 | val nextMonthStart = monthStart.plus(1, DateTimeUnit.MonthBased(1))
127 | val daysInMonth = abs(monthStart.daysUntil(nextMonthStart))
128 | val resultList = mutableListOf>()
129 | for (day in 1..daysInMonth) {
130 | val date = LocalDate(year, month, day)
131 | resultList.add(Pair(date.dayOfMonth, date.dayOfWeek))
132 | }
133 | return resultList
134 | }
135 |
136 | fun isHoliday(year: Int, month: Int, day: Int): Int {
137 | runCatching {
138 | val dateStr = "${year}-${month.toTwoDigits()}-${day.toTwoDigits()}"
139 | val holidays = holidayMap.value[year] ?: return NOT_HOLIDAY
140 | val holiday = holidays[dateStr]?.jsonObject ?: return NOT_HOLIDAY
141 | val isOffDay = holiday[KEY_IS_OFF_DAY]?.jsonPrimitive?.booleanOrNull
142 | return if (isOffDay == true) DAY_OFF else WORK_DAY
143 | }.onFailure {
144 | it.printStackTrace()
145 | }
146 | return NOT_HOLIDAY
147 | }
148 |
149 | fun getHolidayMap(): StateFlow