diff --git a/.gitignore b/.gitignore
index 7d9c0e4..e8b01d4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,3 +16,4 @@ captures
!*.xcodeproj/project.xcworkspace/
!*.xcworkspace/contents.xcworkspacedata
**/xcshareddata/WorkspaceSettings.xcsettings
+node_modules/
\ No newline at end of file
diff --git a/build.gradle.kts b/build.gradle.kts
index cf780d5..feb6e8f 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -3,7 +3,14 @@ plugins {
// in each subproject's classloader
alias(libs.plugins.androidApplication) apply false
alias(libs.plugins.androidLibrary) apply false
+ alias(libs.plugins.composeHotReload) apply false
alias(libs.plugins.composeMultiplatform) apply false
alias(libs.plugins.composeCompiler) apply false
alias(libs.plugins.kotlinMultiplatform) apply false
+
+ alias(libs.plugins.jetbrains.kotlin.serialization) apply false
+ alias(libs.plugins.gms.google) apply false
+
+ alias(libs.plugins.ksp) apply false
+ alias(libs.plugins.androidx.room) apply false
}
\ No newline at end of file
diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts
index 0a9f7b7..f31dd33 100644
--- a/composeApp/build.gradle.kts
+++ b/composeApp/build.gradle.kts
@@ -1,19 +1,23 @@
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
-import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
-import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackConfig
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidApplication)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
+ alias(libs.plugins.composeHotReload)
+
+ alias(libs.plugins.jetbrains.kotlin.serialization)
+ alias(libs.plugins.gms.google)
+
+ alias(libs.plugins.ksp)
+ alias(libs.plugins.androidx.room)
}
kotlin {
androidTarget {
- @OptIn(ExperimentalKotlinGradlePluginApi::class)
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
@@ -30,48 +34,84 @@ kotlin {
}
}
- jvm("desktop")
-
+ jvm()
+
+ js {
+ browser()
+ binaries.executable()
+ }
+
@OptIn(ExperimentalWasmDsl::class)
wasmJs {
- moduleName = "composeApp"
- browser {
- val rootDirPath = project.rootDir.path
- val projectDirPath = project.projectDir.path
- commonWebpackConfig {
- outputFileName = "composeApp.js"
- devServer = (devServer ?: KotlinWebpackConfig.DevServer()).apply {
- static = (static ?: mutableListOf()).apply {
- // Serve sources to debug inside browser
- add(rootDirPath)
- add(projectDirPath)
- }
- }
- }
- }
+ browser()
binaries.executable()
}
sourceSets {
- val desktopMain by getting
-
androidMain.dependencies {
implementation(compose.preview)
implementation(libs.androidx.activity.compose)
+
+ // Koin依赖注入
+ implementation(libs.koin.android)
+
+ // firebase
+ implementation(project.dependencies.platform(libs.firebase.bom))
+ implementation(libs.firebase.analytics)
+ // facebook
+ implementation(libs.android.facebook.android.sdk)
+
+ // mmkv
+ implementation(libs.android.mmkv)
+
+ // sqlite
+ implementation(libs.androidx.room.sqlite.wrapper)
}
commonMain.dependencies {
implementation(compose.runtime)
implementation(compose.foundation)
- implementation(compose.material)
+ implementation(compose.material3)
implementation(compose.ui)
implementation(compose.components.resources)
implementation(compose.components.uiToolingPreview)
implementation(libs.androidx.lifecycle.viewmodel)
- implementation(libs.androidx.lifecycle.runtime.compose)
+ implementation(libs.androidx.lifecycle.runtimeCompose)
+
+
+ // 导航
+ implementation(libs.navigation.compose)
+
+ // Koin依赖注入
+ implementation(libs.koin.core)
+ implementation(libs.koin.compose)
+ implementation(libs.koin.viewmodel)
+
+ // coil
+ implementation(libs.coil3.compose)
+ implementation(libs.coil3.svg)
+ // implementation(libs.coil3.gif)
+ // implementation(libs.coil3.network.ktor3)
+
+ // 添加日期时间处理依赖
+ implementation(libs.kotlinx.datetime)
+
+ // ICON
+ implementation(libs.material.icons.core)
+ implementation(libs.material.icons.extended)
+
+ // JSON
+ implementation(libs.kotlinx.serialization.json)
+
+ // Room
+ implementation(libs.androidx.room.runtime)
+ implementation(libs.androidx.sqlite.bundled)
}
- desktopMain.dependencies {
+ jvmMain.dependencies {
implementation(compose.desktop.currentOs)
- implementation(libs.kotlinx.coroutines.swing)
+ implementation(libs.kotlinx.coroutinesSwing)
+ }
+ commonTest.dependencies {
+ implementation(libs.kotlin.test)
}
}
}
@@ -86,6 +126,9 @@ android {
targetSdk = libs.versions.android.targetSdk.get().toInt()
versionCode = 1
versionName = "1.0"
+
+ manifestPlaceholders["facebookAppId"] = libs.versions.android.facebookAppId.get()
+ manifestPlaceholders["facebookClientToken"] = libs.versions.android.facebookClientToken.get()
}
packaging {
resources {
@@ -101,10 +144,21 @@ android {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
+
+ buildFeatures {
+ buildConfig = true
+ }
}
dependencies {
debugImplementation(compose.uiTooling)
+ // add("kspCommonMainMetadata",libs.androidx.room.compiler)
+ // add("kspCommonMain",libs.androidx.room.compiler)
+ // add("kspWasmJs",libs.androidx.room.compiler)
+ add("kspAndroid", libs.androidx.room.compiler)
+ add("kspIosX64", libs.androidx.room.compiler)
+ add("kspIosArm64", libs.androidx.room.compiler)
+ add("kspIosSimulatorArm64", libs.androidx.room.compiler)
}
compose.desktop {
@@ -117,4 +171,12 @@ compose.desktop {
packageVersion = "1.0.0"
}
}
+
+ dependencies {
+ ksp(libs.androidx.room.compiler)
+ }
}
+
+room {
+ schemaDirectory("$projectDir/schemas")
+}
\ No newline at end of file
diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml
index c5db0b1..2e80973 100644
--- a/composeApp/src/androidMain/AndroidManifest.xml
+++ b/composeApp/src/androidMain/AndroidManifest.xml
@@ -1,7 +1,13 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/composeApp/src/androidMain/kotlin/com/taskttl/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/taskttl/MainActivity.kt
index 4d438fb..283c938 100644
--- a/composeApp/src/androidMain/kotlin/com/taskttl/MainActivity.kt
+++ b/composeApp/src/androidMain/kotlin/com/taskttl/MainActivity.kt
@@ -3,12 +3,14 @@ package com.taskttl
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
setContent {
App()
diff --git a/composeApp/src/androidMain/kotlin/com/taskttl/MainApplication.kt b/composeApp/src/androidMain/kotlin/com/taskttl/MainApplication.kt
new file mode 100644
index 0000000..86186d9
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/com/taskttl/MainApplication.kt
@@ -0,0 +1,61 @@
+package com.taskttl
+
+import android.app.Activity
+import android.app.Application
+import android.os.Bundle
+import com.google.firebase.FirebaseApp
+import com.taskttl.data.di.initKoin
+import com.tencent.mmkv.MMKV
+import org.koin.android.ext.koin.androidContext
+import org.koin.android.ext.koin.androidLogger
+
+class MainApplication : Application() {
+
+ companion object {
+ lateinit var instance: Application
+ }
+
+ @Volatile
+ var currentActivity: Activity? = null
+ private set
+
+
+
+
+ init {
+ instance = this
+ }
+
+
+ override fun onCreate() {
+ super.onCreate()
+
+ registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks {
+ override fun onActivityResumed(activity: Activity) {
+ currentActivity = activity
+ }
+
+ override fun onActivityPaused(activity: Activity) {
+ if (currentActivity == activity) {
+ currentActivity = null
+ }
+ }
+
+ // 其他生命周期方法可以留空
+ override fun onActivityCreated(a: Activity, b: Bundle?) {}
+ override fun onActivityStarted(a: Activity) {}
+ override fun onActivityStopped(a: Activity) {}
+ override fun onActivitySaveInstanceState(a: Activity, b: Bundle) {}
+ override fun onActivityDestroyed(a: Activity) {}
+ })
+
+ MMKV.initialize(this@MainApplication)
+ // 初始化 Firebase SDK
+ FirebaseApp.initializeApp(this@MainApplication)
+ // 初始化 Koin
+ initKoin() {
+ androidLogger()
+ androidContext(this@MainApplication)
+ }
+ }
+}
\ No newline at end of file
diff --git a/composeApp/src/androidMain/kotlin/com/taskttl/Platform.android.kt b/composeApp/src/androidMain/kotlin/com/taskttl/Platform.android.kt
deleted file mode 100644
index 0844afb..0000000
--- a/composeApp/src/androidMain/kotlin/com/taskttl/Platform.android.kt
+++ /dev/null
@@ -1,9 +0,0 @@
-package com.taskttl
-
-import android.os.Build
-
-class AndroidPlatform : Platform {
- override val name: String = "Android ${Build.VERSION.SDK_INT}"
-}
-
-actual fun getPlatform(): Platform = AndroidPlatform()
\ No newline at end of file
diff --git a/composeApp/src/androidMain/kotlin/com/taskttl/core/ui/DevTTLWebView.android.kt b/composeApp/src/androidMain/kotlin/com/taskttl/core/ui/DevTTLWebView.android.kt
new file mode 100644
index 0000000..4f04cd0
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/com/taskttl/core/ui/DevTTLWebView.android.kt
@@ -0,0 +1,176 @@
+package com.taskttl.core.ui
+
+import android.annotation.SuppressLint
+import android.graphics.Bitmap
+import android.view.ViewGroup
+import android.webkit.WebResourceError
+import android.webkit.WebResourceRequest
+import android.webkit.WebView
+import android.webkit.WebViewClient
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+import org.jetbrains.compose.resources.stringResource
+import taskttl.composeapp.generated.resources.Res
+import taskttl.composeapp.generated.resources.btn_retry
+import taskttl.composeapp.generated.resources.webview_loading_error
+
+@OptIn(ExperimentalMaterial3Api::class)
+@SuppressLint("SetJavaScriptEnabled")
+@Composable
+actual fun DevTTLWebView(modifier: Modifier, url: String) {
+ // 状态管理
+ var isLoading by remember { mutableStateOf(true) }
+ var hasError by remember { mutableStateOf(false) }
+ var errorMessage by remember { mutableStateOf("") }
+ var webView by remember { mutableStateOf(null) }
+
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ ) {
+ val loadingError = stringResource(Res.string.webview_loading_error)
+ // 使用AndroidView加载WebView
+ AndroidView(
+ factory = { ctx ->
+ WebView(ctx).apply {
+ layoutParams = ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.MATCH_PARENT
+ )
+
+ // 配置WebView设置
+ settings.apply {
+ javaScriptEnabled = true
+ domStorageEnabled = true
+ loadWithOverviewMode = true
+ useWideViewPort = true
+ // 启用缓存
+ cacheMode = android.webkit.WebSettings.LOAD_DEFAULT
+ // 启用混合内容(HTTP和HTTPS)
+ mixedContentMode =
+ android.webkit.WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
+ }
+
+
+ webViewClient = object : WebViewClient() {
+ override fun onPageStarted(
+ view: WebView?,
+ url: String?,
+ favicon: Bitmap?
+ ) {
+ super.onPageStarted(view, url, favicon)
+ isLoading = true
+ hasError = false
+ }
+
+ override fun onPageFinished(
+ view: WebView?,
+ url: String?
+ ) {
+ super.onPageFinished(view, url)
+ isLoading = false
+ }
+
+ // 处理加载错误
+ override fun onReceivedError(
+ view: WebView?,
+ request: WebResourceRequest?,
+ error: WebResourceError?
+ ) {
+ super.onReceivedError(view, request, error)
+ if (request?.isForMainFrame == true) {
+ isLoading = false
+ hasError = true
+ errorMessage = loadingError
+ }
+ }
+ }
+ loadUrl(url)
+ webView = this
+ }
+ },
+ modifier = Modifier.fillMaxSize(),
+ update = { view ->
+ // 更新WebView
+ webView = view
+ }
+ )
+
+ // 加载指示器
+ if (isLoading) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
+ }
+ }
+
+ // 错误显示
+ if (hasError) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = Modifier.padding(16.dp)
+ ) {
+ Text(
+ text = errorMessage,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.error
+ )
+ Button(
+ onClick = {
+ hasError = false
+ webView?.reload()
+ },
+ modifier = Modifier
+ .padding(top = 16.dp)
+ .fillMaxWidth(0.5f)
+ ) {
+ Icon(
+ imageVector = Icons.Default.Refresh,
+ contentDescription = null
+ )
+ Text(
+ text = stringResource(Res.string.btn_retry),
+ modifier = Modifier.padding(start = 8.dp)
+ )
+ }
+ }
+ }
+ }
+ }
+
+
+ // 清理资源
+ DisposableEffect(Unit) {
+ onDispose {
+ webView?.destroy()
+ }
+ }
+}
\ No newline at end of file
diff --git a/composeApp/src/androidMain/kotlin/com/taskttl/core/utils/LogUtils.android.kt b/composeApp/src/androidMain/kotlin/com/taskttl/core/utils/LogUtils.android.kt
new file mode 100644
index 0000000..eb8b1e5
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/com/taskttl/core/utils/LogUtils.android.kt
@@ -0,0 +1,26 @@
+package com.taskttl.core.utils
+
+import android.util.Log as AndroidLog
+
+/**
+ * 日志
+ * @author admin
+ * @date 2025/09/27
+ */
+actual object LogUtils {
+ actual fun d(tag: String, message: String) {
+ AndroidLog.d(tag, message)
+ }
+
+ actual fun i(tag: String, message: String) {
+ AndroidLog.i(tag, message)
+ }
+
+ actual fun w(tag: String, message: String) {
+ AndroidLog.w(tag, message)
+ }
+
+ actual fun e(tag: String, message: String, throwable: Throwable?) {
+ AndroidLog.e(tag, message, throwable)
+ }
+}
\ No newline at end of file
diff --git a/composeApp/src/androidMain/kotlin/com/taskttl/core/utils/StorageUtils.android.kt b/composeApp/src/androidMain/kotlin/com/taskttl/core/utils/StorageUtils.android.kt
new file mode 100644
index 0000000..8e29265
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/com/taskttl/core/utils/StorageUtils.android.kt
@@ -0,0 +1,80 @@
+package com.taskttl.core.utils
+
+import com.tencent.mmkv.MMKV
+import kotlinx.serialization.json.Json
+
+actual object StorageUtils {
+
+ val mmkv: MMKV
+ get() = MMKV.defaultMMKV()
+
+ val json = Json {
+ ignoreUnknownKeys = true
+ isLenient = true
+ prettyPrint = true
+ }
+
+ actual fun saveString(key: String, value: String) {
+ mmkv.encode(key, value)
+ }
+
+ actual fun getString(key: String, defaultValue: String): String {
+ val value = mmkv.decodeString(key)
+ if (value.isNullOrEmpty()) {
+ mmkv.encode(key, defaultValue)
+ return defaultValue
+ }
+ return value
+ }
+
+ actual fun saveInt(key: String, value: Int) {
+ mmkv.encode(key, value)
+ }
+
+ actual fun getInt(key: String, defaultValue: Int): Int {
+ return mmkv.decodeInt(key, defaultValue)
+ }
+
+ actual fun saveLong(key: String, value: Long) {
+ mmkv.encode(key, value)
+ }
+
+ actual fun getLong(key: String, defaultValue: Long): Long {
+ return mmkv.decodeLong(key, defaultValue)
+ }
+
+ actual fun saveBoolean(key: String, value: Boolean) {
+ mmkv.encode(key, value)
+ }
+
+ actual fun getBoolean(key: String, defaultValue: Boolean): Boolean {
+ return mmkv.decodeBool(key, defaultValue)
+ }
+
+ actual inline fun saveObject(key: String, value: T) {
+ val data = json.encodeToString(value)
+ mmkv.encode(key, data)
+ }
+
+ actual inline fun getObject(key: String): T? {
+ val data = mmkv.decodeString(key) ?: return null
+ return try {
+ json.decodeFromString(data)
+ } catch (e: Exception) {
+ null
+ }
+ }
+
+ actual fun contains(key: String): Boolean {
+ return mmkv.contains(key)
+ }
+
+ actual fun remove(key: String) {
+ mmkv.removeValueForKey(key)
+ }
+
+ actual fun clear() {
+ mmkv.clearAll()
+ }
+
+}
\ No newline at end of file
diff --git a/composeApp/src/androidMain/kotlin/com/taskttl/data/di/KoinModels.android.kt b/composeApp/src/androidMain/kotlin/com/taskttl/data/di/KoinModels.android.kt
new file mode 100644
index 0000000..1e8aba0
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/com/taskttl/data/di/KoinModels.android.kt
@@ -0,0 +1,10 @@
+package com.taskttl.data.di
+
+import com.taskttl.data.local.database.TaskTTLDatabase
+import com.taskttl.data.local.database.getDatabaseBuilder
+import org.koin.dsl.module
+
+actual fun platformModule() = module {
+ single { getDatabaseBuilder() }
+}
+
diff --git a/composeApp/src/androidMain/kotlin/com/taskttl/data/local/database/Database.android.kt b/composeApp/src/androidMain/kotlin/com/taskttl/data/local/database/Database.android.kt
new file mode 100644
index 0000000..268eb9b
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/com/taskttl/data/local/database/Database.android.kt
@@ -0,0 +1,17 @@
+package com.taskttl.data.local.database
+
+import androidx.room.Room
+import androidx.sqlite.driver.bundled.BundledSQLiteDriver
+import com.taskttl.MainApplication
+import kotlinx.coroutines.Dispatchers
+
+actual fun getDatabaseBuilder(): TaskTTLDatabase {
+ val context = MainApplication.instance.applicationContext
+ return Room.databaseBuilder(
+ context,
+ TaskTTLDatabase::class.java,
+ "taskttl_database"
+ ).setDriver(BundledSQLiteDriver())
+ .setQueryCoroutineContext(Dispatchers.IO)
+ .build()
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/composeResources/drawable/compose-multiplatform.xml b/composeApp/src/commonMain/composeResources/drawable/compose-multiplatform.xml
deleted file mode 100644
index c0bcfb2..0000000
--- a/composeApp/src/commonMain/composeResources/drawable/compose-multiplatform.xml
+++ /dev/null
@@ -1,36 +0,0 @@
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
new file mode 100644
index 0000000..011bb43
--- /dev/null
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -0,0 +1,313 @@
+
+ 待办
+ 倒数日
+ 统计
+ 设置
+
+ 我的任务
+ 任务详情
+ 添加任务
+ 编辑任务
+
+ 倒数日
+ 倒数日详情
+ 添加倒数日
+ 编辑倒数日
+
+ 统计
+
+ 关于
+
+ 分类管理
+ 添加分类
+ 编辑分类
+
+ 任务列表
+ 显示已完成
+ 暂无任务
+ 点击右下角按钮添加新任务
+ 已完成
+ 未完成
+
+ 任务标题
+ 任务描述
+ 选择分类
+ 优先级
+ 截止日期(可选)
+ 选择日期
+ 标签(用逗号分隔)
+ 例如:重要,紧急,工作
+
+ 任务不存在
+ 截止日期:
+ 无
+ 创建时间:
+ 任务描述
+
+ 倒数日列表
+ 天
+ 暂无倒数日
+ 点击右下角按钮添加新的倒数日
+ 添加倒数日
+
+ 倒数日标题
+ 倒数日描述
+ 选择分类
+ 目标日期
+ 通知设置
+
+ 总任务
+ 已完成
+ 完成率
+ 倒数日总数
+ 活跃中
+
+ 总览
+ 分类统计
+
+
+ 倒数日不存在
+
+
+ 天
+ 事件描述
+ 详细信息
+
+
+ 提醒
+ 创建时间
+
+
+ 取消
+ 确定
+ 删除
+ 导出
+ 导入
+ 选择文件
+ 返回
+ 操作
+ 全部
+ 搜索任务...
+ 搜索
+ 清除
+
+
+ 数据管理
+ 导出数据
+ 将所有任务和倒数日导出为文件
+ 导入数据
+ 从文件导入任务和倒数日
+ 自动备份
+ 定期自动备份数据到云端
+ 清除所有数据
+ 删除所有任务、倒数日和设置
+ 清理已完成任务
+ 删除所有已完成的任务
+ 清理过期倒数日
+ 删除所有已过期的倒数日
+ 此操作将删除所有任务、倒数日和设置,且无法恢复。
+
+ 选择要导入的文件
+ 进入
+ 数据清理
+ 备份与恢复
+
+
+ 选择文件
+ 选择导出格式
+ JSON格式
+ CSV格式
+
+ 暂无分类
+ 点击右下角按钮添加新分类
+ 编辑
+ %1$d 个任务
+ %1$d 个倒数日
+
+ 分类名字
+ 输入分类名称...
+ 分类类型
+ 选择颜色
+ 选择图标
+ 任务分类
+ 倒数日分类
+
+
+ 应用设置
+ 通用设置
+ 数据管理
+ 社交分享
+ 帮助与反馈
+
+
+ 推送通知
+ 接收任务和倒数日提醒
+ 深色模式
+ 使用深色主题
+ 语言设置
+ 简体中文
+
+
+ 分类管理
+ 管理分类
+ 数据管理
+ 备份和恢复数据
+
+
+ 分享成就
+ 分享任务完成成就
+ 推荐给朋友
+ 邀请朋友使用 TaskMaster
+
+
+ 意见反馈
+ 告诉我们您的想法
+ 隐私政策
+ 了解我们如何保护您的数据
+ 关于应用
+ 版本 1.0.0
+
+
+ TaskMaster 用户
+ 已使用 %1$d 天 · 完成 %2$d 个任务
+
+
+ 输入分类名称...
+
+
+
+ 版本
+ 构建版本
+
+
+ 应用介绍
+
+ TaskTTL 是一款现代化的任务管理与倒数日应用,帮助您高效管理日常任务与重要日期。
+ 支持分类管理、优先级设置与统计分析,让生活更有条理。
+
+
+
+ 技术栈
+ Kotlin Multiplatform(跨平台开发框架)
+ Jetpack Compose(现代化 UI 框架)
+ Room Database(本地存储)
+ Koin(依赖注入框架)
+ MVI Architecture(响应式架构模式)
+
+
+ 开发者
+ DevTTL 团队
+
+
+ 联系我们
+ 电子邮箱
+ team@devttl.com
+ 官方网站
+ https://devttl.com
+
+
+ 2025
+ 保留所有权利
+
+ 发送反馈
+ 取消
+
+
+ 意见反馈
+
+
+ 反馈类型
+ 问题反馈
+ 功能建议
+
+
+ 问题描述
+ 请详细描述您遇到的问题或建议...
+
+
+ 联系方式(可选)
+ 您的邮箱地址,方便我们回复
+
+
+ 感谢您的反馈!我们会尽快处理。
+ 请填写反馈内容
+
+
+ TaskMaster
+ 任务管理与倒数日应用
+ 让每一天都更有意义
+
+ 继续
+ 跳过
+ 开始使用
+
+ 欢迎使用 TaskMaster
+ 一个简洁而强大的任务管理工具,帮助您高效管理日常任务和重要日期
+
+ 智能任务管理
+ 创建、分类和跟踪您的任务。设置优先级,添加截止日期,让工作更有条理
+
+ 重要日期提醒
+ 设置重要日期的倒数计时,永远不会错过生日、纪念日或重要的截止日期
+
+ 准备就绪!
+ 现在您可以开始创建第一个任务,让我们一起提高工作效率吧!
+
+ 任务
+ 倒数日
+
+ 低
+ 中
+ 高
+ 紧急
+
+ 一次
+ 每天
+ 每周
+ 每月
+ 关闭
+
+
+ 工作
+ 学习
+ 考试
+ 项目
+
+
+ 家庭
+ 休闲
+ 购物
+ 美食
+ 家务
+
+
+ 健康
+ 健身
+ 作息
+
+
+ 音乐
+ 娱乐
+ 摄影
+ 影视
+
+
+ 出行
+ 旅行
+ 步行
+
+
+ 生日
+ 节日
+ 纪念日
+
+
+ 理财
+ 目标
+ 提醒
+
+ 加载失败,请检查网络连接
+ 重试
+ https://devttl.com
+
+
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/App.kt b/composeApp/src/commonMain/kotlin/com/taskttl/App.kt
index 48b8b9f..426b156 100644
--- a/composeApp/src/commonMain/kotlin/com/taskttl/App.kt
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/App.kt
@@ -1,37 +1,19 @@
package com.taskttl
-import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.foundation.Image
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.material.Button
-import androidx.compose.material.MaterialTheme
-import androidx.compose.material.Text
-import androidx.compose.runtime.*
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import org.jetbrains.compose.resources.painterResource
+import androidx.compose.runtime.Composable
+import com.taskttl.core.routes.AppNav
+import com.taskttl.ui.theme.AppTheme
import org.jetbrains.compose.ui.tooling.preview.Preview
-import taskttl.composeapp.generated.resources.Res
-import taskttl.composeapp.generated.resources.compose_multiplatform
-@Composable
+/**
+ * 应用
+ */
@Preview
+@Composable
fun App() {
- MaterialTheme {
- var showContent by remember { mutableStateOf(false) }
- Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
- Button(onClick = { showContent = !showContent }) {
- Text("Click me!")
- }
- AnimatedVisibility(showContent) {
- val greeting = remember { Greeting().greet() }
- Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
- Image(painterResource(Res.drawable.compose_multiplatform), null)
- Text("Compose: $greeting")
- }
- }
- }
+ AppTheme {
+ AppNav()
+
}
}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/Greeting.kt b/composeApp/src/commonMain/kotlin/com/taskttl/Greeting.kt
deleted file mode 100644
index ee21746..0000000
--- a/composeApp/src/commonMain/kotlin/com/taskttl/Greeting.kt
+++ /dev/null
@@ -1,9 +0,0 @@
-package com.taskttl
-
-class Greeting {
- private val platform = getPlatform()
-
- fun greet(): String {
- return "Hello, ${platform.name}!"
- }
-}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/Platform.kt b/composeApp/src/commonMain/kotlin/com/taskttl/Platform.kt
deleted file mode 100644
index 325146f..0000000
--- a/composeApp/src/commonMain/kotlin/com/taskttl/Platform.kt
+++ /dev/null
@@ -1,7 +0,0 @@
-package com.taskttl
-
-interface Platform {
- val name: String
-}
-
-expect fun getPlatform(): Platform
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/core/domain/FeedbackType.kt b/composeApp/src/commonMain/kotlin/com/taskttl/core/domain/FeedbackType.kt
new file mode 100644
index 0000000..85d1580
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/core/domain/FeedbackType.kt
@@ -0,0 +1,20 @@
+package com.taskttl.core.domain
+
+import kotlinx.serialization.Serializable
+import org.jetbrains.compose.resources.StringResource
+import taskttl.composeapp.generated.resources.Res
+import taskttl.composeapp.generated.resources.feedback_issue
+import taskttl.composeapp.generated.resources.feedback_suggestion
+
+/**
+ * 反馈类型
+ * @author admin
+ * @date 2025/10/05
+ * @constructor 创建[FeedbackType]
+ * @param [titleRes] 标题res
+ */
+@Serializable
+enum class FeedbackType(val titleRes: StringResource) {
+ ISSUE(Res.string.feedback_issue),
+ SUGGESTION(Res.string.feedback_suggestion)
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/core/routes/AppNav.kt b/composeApp/src/commonMain/kotlin/com/taskttl/core/routes/AppNav.kt
new file mode 100644
index 0000000..bc02260
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/core/routes/AppNav.kt
@@ -0,0 +1,43 @@
+package com.taskttl.core.routes
+
+import androidx.compose.runtime.Composable
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.rememberNavController
+import com.taskttl.presentation.onboarding.OnboardingScreen
+import com.taskttl.presentation.splash.SplashScreen
+
+/**
+ * 应用导航
+ * @author DevTTL
+ * @date 2025/06/25
+ */
+@Composable
+fun AppNav() {
+ val globalNavController = rememberNavController()
+
+ NavHost(
+ navController = globalNavController,
+ startDestination = Routes.Splash,
+ ) {
+ composable {
+ SplashScreen(navigatorToRoute = {
+ globalNavController.navigate(it) {
+ popUpTo { inclusive = true }
+ // 确保MainScreen是单例的
+ launchSingleTop = true
+ }
+ })
+ }
+ composable {
+ OnboardingScreen(navigatorToRoute = {
+ globalNavController.navigate(it) {
+ popUpTo { inclusive = true }
+ // 确保MainScreen是单例的
+ launchSingleTop = true
+ }
+ })
+ }
+ composable { MainNav() }
+ }
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/core/routes/MainNav.kt b/composeApp/src/commonMain/kotlin/com/taskttl/core/routes/MainNav.kt
new file mode 100644
index 0000000..cc0822a
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/core/routes/MainNav.kt
@@ -0,0 +1,199 @@
+package com.taskttl.core.routes
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.List
+import androidx.compose.material.icons.filled.AccessTime
+import androidx.compose.material.icons.filled.BarChart
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material3.Scaffold
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.navigation.NavDestination.Companion.hasRoute
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.currentBackStackEntryAsState
+import androidx.navigation.compose.rememberNavController
+import androidx.navigation.toRoute
+import com.taskttl.core.routes.Routes.Main
+import com.taskttl.core.ui.CustomBottomBar
+import com.taskttl.presentation.countdown.CountdownDetailScreen
+import com.taskttl.presentation.countdown.CountdownEditScreen
+import com.taskttl.presentation.countdown.CountdownScreen
+import com.taskttl.presentation.settings.AboutScreen
+import com.taskttl.presentation.category.CategoryEditScreen
+import com.taskttl.presentation.category.CategoryScreen
+import com.taskttl.presentation.settings.DataManagementScreen
+import com.taskttl.presentation.settings.FeedbackScreen
+import com.taskttl.presentation.settings.PrivacyScreen
+import com.taskttl.presentation.settings.SettingsScreen
+import com.taskttl.presentation.statistics.StatisticsScreen
+import com.taskttl.presentation.task.TaskDetailScreen
+import com.taskttl.presentation.task.TaskEditorScreen
+import com.taskttl.presentation.task.TaskScreen
+import taskttl.composeapp.generated.resources.Res
+import taskttl.composeapp.generated.resources.nav_countdown
+import taskttl.composeapp.generated.resources.nav_settings
+import taskttl.composeapp.generated.resources.nav_statistics
+import taskttl.composeapp.generated.resources.nav_todo
+
+/**
+ * 主屏幕
+ */
+@Composable
+fun MainNav() {
+ val mainNavController = rememberNavController()
+
+ val bottomItems = remember {
+ listOf(
+ Triple(Icons.AutoMirrored.Filled.List, Res.string.nav_todo, Main.Task),
+ Triple(Icons.Default.AccessTime, Res.string.nav_countdown, Main.Countdown),
+ Triple(Icons.Default.BarChart, Res.string.nav_statistics, Main.Statistics),
+ Triple(Icons.Default.Settings, Res.string.nav_settings, Main.Settings),
+ )
+ }
+
+ val currentDestination by mainNavController.currentBackStackEntryAsState()
+
+ Scaffold(
+ modifier = Modifier.background(Color(0xffF9F9F9)),
+ bottomBar = {
+ CustomBottomBar(
+ bottomItems = bottomItems,
+ selectedRoute = { route -> currentDestination?.destination?.hasRoute(route::class) == true },
+ onItemSelected = { route ->
+ if (currentDestination?.destination?.hasRoute(route::class) == true) {
+ return@CustomBottomBar
+ }
+ mainNavController.navigate(route) {
+ popUpTo(Main.Task) { saveState = true }
+ launchSingleTop = true
+ restoreState = true
+ }
+ }
+ )
+ }
+ ) { paddingValues ->
+ NavHost(
+ modifier = Modifier.fillMaxSize().padding(paddingValues),
+ navController = mainNavController,
+ startDestination = Main.Task
+ ) {
+ // 任务
+ composable {
+ TaskScreen(navController = mainNavController)
+ }
+ composable {
+ TaskEditorScreen(
+ taskId = null,
+ onNavigateBack = { mainNavController.popBackStack() }
+ )
+ }
+ composable { backStackEntry ->
+ val taskDetail: Main.Task.EditTask = backStackEntry.toRoute()
+ TaskEditorScreen(
+ taskDetail.taskId,
+ onNavigateBack = { mainNavController.popBackStack() }
+ )
+ }
+ composable { backStackEntry ->
+ val taskDetail: Main.Task.TaskDetail = backStackEntry.toRoute()
+ TaskDetailScreen(
+ taskId = taskDetail.taskId,
+ onNavigateBack = { mainNavController.popBackStack() },
+ onNavigateToEdit = { mainNavController.navigate(Main.Task.EditTask(taskDetail.taskId)) }
+ )
+ }
+
+ // 倒数日
+ composable {
+ CountdownScreen(navController = mainNavController)
+ }
+ composable {
+ CountdownEditScreen(
+ countdownId = null,
+ onNavigateBack = { mainNavController.popBackStack() }
+ )
+ }
+ composable { backStackEntry ->
+ val countdown: Main.Countdown.EditCountdown = backStackEntry.toRoute()
+ CountdownEditScreen(
+ countdownId = countdown.countdownId,
+ onNavigateBack = { mainNavController.popBackStack() }
+ )
+ }
+ composable { backStackEntry ->
+ val countdown: Main.Countdown.CountdownDetail = backStackEntry.toRoute()
+ CountdownDetailScreen(
+ countdownId = countdown.countdownId,
+ onNavigateBack = { mainNavController.popBackStack() },
+ onNavigateToEdit = {
+ mainNavController.navigate(Main.Countdown.EditCountdown(countdown.countdownId))
+ }
+ )
+ }
+
+
+ composable {
+ StatisticsScreen(navController = mainNavController)
+ }
+
+ // 设置界面
+ composable {
+ SettingsScreen(navController = mainNavController)
+ }
+ // 分类管理
+ composable { backStackEntry ->
+ CategoryScreen(
+ navController = mainNavController,
+ onAddCategory = { mainNavController.navigate(Main.Settings.AddCategory) },
+ onNavigateBack = { mainNavController.popBackStack() }
+ )
+ }
+ // 添加分类
+ composable {
+ CategoryEditScreen(
+ categoryId = null,
+ onNavigateBack = { mainNavController.popBackStack() }
+ )
+ }
+ // 编辑分类
+ composable { backStackEntry ->
+ val editCategory: Main.Settings.EditCategory = backStackEntry.toRoute()
+ CategoryEditScreen(
+ categoryId = editCategory.categoryId,
+ onNavigateBack = { mainNavController.popBackStack() }
+ )
+ }
+ // 数据管理
+ composable {
+ DataManagementScreen(onNavigateBack = { mainNavController.popBackStack() })
+ }
+ // 反馈页面
+ composable {
+ FeedbackScreen(
+ onNavigateBack = { mainNavController.popBackStack() },
+ onSubmit = {}
+ )
+ }
+ // 隐私
+ composable {
+ PrivacyScreen(
+ onNavigateBack = { mainNavController.popBackStack() }
+ )
+ }
+ // 关于页面
+ composable {
+ AboutScreen(
+ onNavigateBack = { mainNavController.popBackStack() }
+ )
+ }
+ }
+ }
+
+}
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/core/routes/Routes.kt b/composeApp/src/commonMain/kotlin/com/taskttl/core/routes/Routes.kt
new file mode 100644
index 0000000..c516cdd
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/core/routes/Routes.kt
@@ -0,0 +1,80 @@
+package com.taskttl.core.routes
+
+import kotlinx.serialization.Serializable
+
+/**
+ * 路线
+ * @author DevTTL
+ * @date 2025/06/25
+ * @constructor 创建[Routes]
+ */
+@Serializable
+sealed interface Routes {
+
+ @Serializable
+ data object Splash : Routes
+
+
+ @Serializable
+ data object Onboarding : Routes
+
+
+ @Serializable
+ data object Main : Routes {
+
+ @Serializable
+ data object Task : Routes {
+ @Serializable
+ data object AddTask : Routes
+
+ @Serializable
+ data class EditTask(val taskId: String) : Routes
+
+ @Serializable
+ data class TaskDetail(val taskId: String) : Routes
+ }
+
+ @Serializable
+ data object Countdown : Routes {
+ @Serializable
+ data object AddCountdown : Routes
+
+ @Serializable
+ data class EditCountdown(val countdownId: String) : Routes
+
+ @Serializable
+ data class CountdownDetail(val countdownId: String) : Routes
+
+ }
+
+ @Serializable
+ data object Statistics : Routes
+
+ @Serializable
+ data object Settings : Routes {
+
+ @Serializable
+ data object CategoryManagement : Routes
+
+ @Serializable
+ data object AddCategory : Routes
+
+ @Serializable
+ data class EditCategory(val categoryId: String) : Routes
+
+ @Serializable
+ data object DataManagement : Routes
+
+ @Serializable
+ data object Feedback : Routes
+
+ @Serializable
+ data object Privacy : Routes
+
+ @Serializable
+ data object About : Routes
+ }
+
+
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/core/ui/ActionButtonListItem.kt b/composeApp/src/commonMain/kotlin/com/taskttl/core/ui/ActionButtonListItem.kt
new file mode 100644
index 0000000..6245b8a
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/core/ui/ActionButtonListItem.kt
@@ -0,0 +1,332 @@
+package com.taskttl.core.ui
+
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.AnimationVector1D
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.spring
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.background
+import androidx.compose.foundation.gestures.detectHorizontalDragGestures
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Delete
+import androidx.compose.material.icons.rounded.Favorite
+import androidx.compose.material.icons.rounded.Share
+import androidx.compose.material3.FilledIconButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.UiComposable
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.layout.Layout
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.launch
+import kotlin.math.abs
+import kotlin.math.cbrt
+import kotlin.math.roundToInt
+import kotlin.math.sign
+
+private val PaddingSm = 8.dp
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun ActionButtonListItem(
+ modifier: Modifier = Modifier,
+ isOpen: Boolean,
+ actionHorizontalSpace: Dp = PaddingSm,
+ actionVerticalSpace: Dp = PaddingSm,
+ actionAlignment: Alignment.Horizontal = Alignment.End,
+ thresholdFraction: Float = 0.15f,
+ onOpenChange: (Boolean) -> Unit,
+ onClick: () -> Unit,
+ content: @Composable @UiComposable () -> Unit,
+) {
+ val scope = rememberCoroutineScope()
+ var maxOffsetX by remember { mutableFloatStateOf(0f) }
+
+ val offsetX = remember { Animatable(0f) }
+
+ val itemOffsets = remember { mutableStateListOf>() }
+// var isOpen by remember { mutableStateOf(false) }
+ val isItemOpen by rememberUpdatedState(isOpen)
+ LaunchedEffect(isItemOpen) {
+ if (isItemOpen) return@LaunchedEffect
+ offsetX.animateTo(
+ 0f,
+ animationSpec = spring(
+ dampingRatio = Spring.DampingRatioMediumBouncy,
+ stiffness = Spring.StiffnessLow
+ )
+ )
+ }
+ Layout(
+ content = content,
+ modifier = modifier
+ .pointerInput(isItemOpen) {
+ detectTapGestures {
+ if (isItemOpen) {
+ scope.launch {
+ offsetX.animateTo(
+ 0f,
+ animationSpec = spring(
+ dampingRatio = Spring.DampingRatioMediumBouncy,
+ stiffness = Spring.StiffnessLow
+ )
+ )
+ }
+ onOpenChange(false)
+ }else{
+ onClick()
+ }
+ }
+ }
+ .pointerInput(Unit) {
+ detectHorizontalDragGestures(
+ onHorizontalDrag = { change, dragAmount ->
+ change.consume()
+ val newOffset = offsetX.value + dragAmount
+ val clamped = when {
+ newOffset > maxOffsetX -> applyDamping(
+ newOffset,
+ maxOffsetX,
+ actionAlignment
+ )
+
+ newOffset < -maxOffsetX -> applyDamping(
+ newOffset,
+ maxOffsetX,
+ actionAlignment
+ )
+
+ else -> newOffset
+ }
+ scope.launch {
+ when (actionAlignment) {
+ Alignment.Start -> {
+ if (clamped > 0 && newOffset > maxOffsetX) {
+ offsetX.animateTo(clamped)
+ } else if (clamped > 0) {
+ offsetX.snapTo(clamped)
+ }
+ }
+
+ else -> {
+ if (clamped < 0 && newOffset < -maxOffsetX) {
+ offsetX.animateTo(clamped)
+ } else if (clamped < 0) {
+ offsetX.snapTo(clamped)
+ }
+ }
+ }
+ }
+ },
+ onDragEnd = {
+ val threshold = maxOffsetX * thresholdFraction
+ scope.launch {
+ val openThreshold =
+ if (actionAlignment == Alignment.Start) threshold else -threshold
+ val closeThreshold =
+ if (actionAlignment == Alignment.Start) maxOffsetX - threshold else -maxOffsetX + threshold
+ val openValue =
+ if (actionAlignment == Alignment.Start) maxOffsetX else -maxOffsetX
+ val closeValue = 0f
+ val isOverOpenThreshold = !isItemOpen && (
+ (actionAlignment == Alignment.Start && offsetX.value > openThreshold) ||
+ (actionAlignment != Alignment.Start && offsetX.value < openThreshold)
+ )
+
+ val isOverCloseThreshold = isItemOpen && (
+ (actionAlignment == Alignment.Start && offsetX.value < closeThreshold) ||
+ (actionAlignment != Alignment.Start && offsetX.value > closeThreshold)
+ )
+ val target = when {
+ isOverOpenThreshold -> {
+ onOpenChange(true)
+ openValue
+ }
+
+ isOverCloseThreshold -> {
+ onOpenChange(false)
+ closeValue
+ }
+
+ else -> if (isItemOpen) openValue else closeValue
+ }
+ offsetX.animateTo(
+ target,
+ animationSpec = spring(
+ dampingRatio = Spring.DampingRatioMediumBouncy,
+ stiffness = Spring.StiffnessLow
+ )
+ )
+ }
+ }
+ )
+ }
+ ) { measurables, constraints ->
+ val listItemPlaceable = measurables.first().measure(constraints)
+ val layoutWidth = listItemPlaceable.width
+ val layoutHeight = listItemPlaceable.height
+ val actionHorizontalSpacePx = actionHorizontalSpace.roundToPx()
+ val actionVerticalSpacePx = actionVerticalSpace.roundToPx()
+
+ val actionPlaceables =
+ measurables.subList(1, measurables.size).map {
+ it.measure(
+ constraints.copy(
+ minWidth = layoutHeight - actionVerticalSpacePx,
+ maxWidth = layoutHeight - actionVerticalSpacePx
+ )
+ )
+ }
+ val actionTotalWidth =
+ actionPlaceables.sumOf { it.width } + actionHorizontalSpacePx * (actionPlaceables.size + 1)
+ if (maxOffsetX != actionTotalWidth.toFloat()) {
+ maxOffsetX = actionTotalWidth.toFloat()
+ }
+ if (itemOffsets.size != actionPlaceables.size) {
+ itemOffsets.clear()
+ actionPlaceables.forEach { _ ->
+ if (actionAlignment == Alignment.Start) {
+ itemOffsets.add(Animatable(0f))
+ } else {
+ itemOffsets.add(Animatable(layoutWidth.toFloat()))
+ }
+ }
+ }
+ layout(layoutWidth, layoutHeight) {
+ listItemPlaceable.place(
+ offsetX.value.roundToInt(),
+ 0,
+ measurables.size.toFloat()
+ )
+
+ actionPlaceables.forEachIndexed { index, placeable ->
+ val (minX, maxX) = if (actionAlignment == Alignment.Start) {
+ 0 to (actionHorizontalSpacePx + placeable.width) * (index + 1)
+ } else {
+ layoutWidth - (actionHorizontalSpacePx + placeable.width) * (index + 1) to layoutWidth
+ }
+
+ val progress = (abs(offsetX.value) / maxOffsetX).coerceAtLeast(0f)
+ val xPosition = if (actionAlignment == Alignment.Start) {
+ (maxX - minX) * progress - placeable.width
+ } else {
+ maxX - (maxX - minX) * progress
+ }
+ val animatable = itemOffsets[index]
+ scope.launch {
+ animatable.animateTo(
+ xPosition,
+ animationSpec = spring(
+ dampingRatio = Spring.DampingRatioMediumBouncy,
+ stiffness = Spring.StiffnessLow
+ )
+ )
+ }
+ placeable.place(
+ animatable.value.roundToInt(),
+ layoutHeight / 2 - placeable.height / 2,
+ if (actionAlignment == Alignment.Start) {
+ index.toFloat()
+ } else {
+ actionPlaceables.size - index.toFloat()
+ }
+ )
+ }
+ }
+ }
+}
+
+private fun applyDamping(
+ offset: Float,
+ maxOffset: Float,
+ actionAlignment: Alignment.Horizontal
+): Float {
+ val scale = 25f
+ val (minBound, maxBound) = if (actionAlignment == Alignment.Start) {
+ 0f to maxOffset
+ } else {
+ -maxOffset to 0f
+ }
+ return when {
+ offset < minBound -> {
+ val over = offset - minBound
+ minBound + sign(over) * cbrt(abs(over).toDouble()).toFloat() * scale
+ }
+
+ offset > maxBound -> {
+ val over = offset - maxBound
+ maxBound + sign(over) * cbrt(abs(over).toDouble()).toFloat() * scale
+ }
+
+ else -> offset
+ }
+}
+
+@Composable
+private fun ActionButtonListItemPreview() {
+ var isOpen by remember { mutableStateOf(false) }
+ ActionButtonListItem(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(Color.LightGray),
+ isOpen = isOpen,
+ onOpenChange = { isOpen = it },
+ onClick = {}
+ ) {
+ ListItem(
+ headlineContent = { Text(text = "Hello World") }
+ )
+ FilledIconButton(
+ onClick = {},
+ shape = CircleShape,
+ modifier = Modifier
+ .size(32.dp)
+ .aspectRatio(1f)
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.Share,
+ contentDescription = Icons.Rounded.Share.name
+ )
+ }
+ FilledIconButton(
+ onClick = {}, modifier = Modifier
+ .size(32.dp)
+ .aspectRatio(1f)
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.Delete,
+ contentDescription = Icons.Rounded.Delete.name
+ )
+ }
+ FilledIconButton(
+ onClick = {}, modifier = Modifier
+ .size(32.dp)
+ .aspectRatio(1f)
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.Favorite,
+ contentDescription = Icons.Rounded.Favorite.name
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/core/ui/Chip.kt b/composeApp/src/commonMain/kotlin/com/taskttl/core/ui/Chip.kt
new file mode 100644
index 0000000..1ff4e58
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/core/ui/Chip.kt
@@ -0,0 +1,94 @@
+package com.taskttl.core.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import org.jetbrains.compose.resources.StringResource
+import org.jetbrains.compose.resources.stringResource
+
+/**
+ * Chip 组件
+ */
+@Composable
+fun Chip(
+ textRes: StringResource,
+ icon: ImageVector? = null,
+ backgroundColor: Color = Color(0xFFF5F5F5),
+ contentColor: Color = Color(0xFF555555),
+ modifier: Modifier = Modifier
+) {
+ Row(
+ modifier = modifier
+ .background(backgroundColor, shape = RoundedCornerShape(16.dp))
+ .padding(horizontal = 10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(6.dp)
+ ) {
+ if (icon != null) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ tint = contentColor,
+ modifier = Modifier.padding(end = 2.dp)
+ )
+ }
+ Text(
+ text = stringResource(textRes),
+ color = contentColor,
+ fontSize = 12.sp,
+ fontWeight = FontWeight.Medium,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+}
+
+/**
+ * Chip 组件
+ */
+@Composable
+fun Chip(
+ text: String,
+ icon: ImageVector? = null,
+ backgroundColor: Color = Color(0xFFF5F5F5),
+ contentColor: Color = Color(0xFF555555),
+ modifier: Modifier = Modifier
+) {
+ Row(
+ modifier = modifier
+ .background(backgroundColor, shape = RoundedCornerShape(16.dp))
+ .padding(horizontal = 10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(6.dp)
+ ) {
+ if (icon != null) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ tint = contentColor,
+ modifier = Modifier.padding(end = 2.dp)
+ )
+ }
+ Text(
+ text = text,
+ color = contentColor,
+ fontSize = 12.sp,
+ fontWeight = FontWeight.Medium,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/core/ui/CustomBottom.kt b/composeApp/src/commonMain/kotlin/com/taskttl/core/ui/CustomBottom.kt
new file mode 100644
index 0000000..c847195
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/core/ui/CustomBottom.kt
@@ -0,0 +1,107 @@
+package com.taskttl.core.ui
+
+import androidx.compose.foundation.LocalIndication
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.taskttl.core.routes.Routes
+import org.jetbrains.compose.resources.StringResource
+import org.jetbrains.compose.resources.stringResource
+
+/**
+ * 自定义底部栏
+ * @param [bottomItems] 底部项目
+ * @param [selectedRoute] 选定路线
+ * @param [onItemSelected] 在所选项目上
+ */
+@Composable
+fun CustomBottomBar(
+ bottomItems: List>,
+ selectedRoute: (Routes) -> Boolean,
+ onItemSelected: (Routes) -> Unit
+) {
+ val barHeight = 56.dp
+
+ Box(
+ modifier = Modifier.fillMaxWidth().height(barHeight)
+ .background(MaterialTheme.colorScheme.background)
+ ) {
+ Row(
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .fillMaxWidth()
+ .height(56.dp).background(Color.White)
+ .padding(horizontal = 8.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ bottomItems.forEachIndexed { index, item ->
+ BottomBarItem(
+ icon = item.first,
+ label = item.second,
+ isSelected = selectedRoute(item.third),
+ onClick = { onItemSelected(item.third) },
+ modifier = Modifier.weight(1f)
+ )
+ }
+ }
+ }
+}
+
+/**
+ * 底部栏项目
+ * @param [icon] 图标
+ * @param [label] 标签
+ * @param [isSelected] 选中
+ * @param [onClick] 单击
+ * @param [modifier] 修饰符
+ */
+@Composable
+fun BottomBarItem(
+ icon: ImageVector,
+ label: StringResource,
+ isSelected: Boolean,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ val color =
+ if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
+ val interactionSource = remember { MutableInteractionSource() }
+ Column(
+ modifier = modifier
+ .clickable(
+ interactionSource = interactionSource,
+ indication = LocalIndication.current,
+ onClick = onClick
+ )
+ .padding(vertical = 8.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = stringResource(label),
+ tint = color,
+ modifier = Modifier.size(24.dp)
+ )
+ Text(text = stringResource(label), fontSize = 10.sp, color = color)
+ }
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/core/ui/DevTTLWebView.kt b/composeApp/src/commonMain/kotlin/com/taskttl/core/ui/DevTTLWebView.kt
new file mode 100644
index 0000000..e308d77
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/core/ui/DevTTLWebView.kt
@@ -0,0 +1,12 @@
+package com.taskttl.core.ui
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+
+/**
+ * web视图
+ * @param [modifier] 修饰符
+ * @param [url] 网址
+ */
+@Composable
+expect fun DevTTLWebView(modifier: Modifier, url: String)
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/core/ui/NotchedBottomBar.kt b/composeApp/src/commonMain/kotlin/com/taskttl/core/ui/NotchedBottomBar.kt
new file mode 100644
index 0000000..d8e5226
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/core/ui/NotchedBottomBar.kt
@@ -0,0 +1,314 @@
+package com.taskttl.core.ui
+
+import androidx.annotation.DrawableRes
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.wrapContentSize
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.FloatingActionButtonDefaults
+import androidx.compose.material3.FloatingActionButtonElevation
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.NavigationBarItemDefaults
+import androidx.compose.material3.Surface
+import androidx.compose.material3.contentColorFor
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.graphics.Path
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.layout.SubcomposeLayout
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.util.fastForEachIndexed
+import androidx.compose.ui.util.fastMapIndexed
+
+
+/**
+ * Note: fabeSize maintains the same height of modifier
+ */
+@Composable
+fun NotchedBottomBar(
+ modifier: Modifier = Modifier,
+ icons: List,
+ selectedIndex: Int,
+ fabIcon: ImageVector,
+ fabIconSize: Dp = 28.dp,
+ onIconClick: (index: Int) -> Unit,
+ onFabClick: () -> Unit,
+ fabSize: Dp,
+ fabColor: Color = FloatingActionButtonDefaults.containerColor,
+ fabContainerColor: Color = contentColorFor(fabColor),
+ fabElevation: FloatingActionButtonElevation = FloatingActionButtonDefaults.elevation(),
+ iconColor: Color = NavigationBarItemDefaults.colors().unselectedIconColor,
+ selectedIconColor: Color = NavigationBarItemDefaults.colors().selectedIconColor,
+ iconContainerColor: Color = NavigationBarItemDefaults.colors().selectedIndicatorColor,
+ containerColor: Color = MaterialTheme.colorScheme.surfaceContainer
+) {
+ val density = LocalDensity.current
+ SubcomposeLayout(
+ modifier = modifier
+ ) { constraints ->
+ // measure fab
+ val fabPlaceables = subcompose("fab") {
+ FloatingActionButton(
+ onClick = onFabClick,
+ modifier = Modifier.size(fabSize),
+ containerColor = fabContainerColor,
+ contentColor = fabColor,
+ elevation = fabElevation,
+ shape = CircleShape
+ ) {
+ Icon(
+ modifier = Modifier.size(fabIconSize),
+ imageVector = fabIcon,
+ contentDescription = "fab",
+ tint = fabColor
+ )
+ }
+ }.map { it.measure(constraints) }
+
+ val fabWidth = fabPlaceables.maxOf { it.width }
+ val fabHeight = fabPlaceables.maxOf { it.height }
+
+ // measure icons
+ val iconPlaceables = icons.fastMapIndexed { index, icon ->
+ val measurable = subcompose("icon_$index") {
+ val selected = selectedIndex == index
+ Surface(
+ onClick = { onIconClick(index) },
+ shape = CircleShape,
+ modifier = Modifier
+ .padding(vertical = 12.dp)
+ .aspectRatio(16 / 9f),
+ color = if (selected) iconContainerColor else Color.Transparent,
+ contentColor = if (selected) selectedIconColor else iconColor
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = "$index-icon",
+ tint = if (selected) selectedIconColor else iconColor
+ )
+ }
+ }.first().measure(constraints)
+ measurable
+ }
+
+ val canvasHeight = iconPlaceables.maxOf { it.height }
+
+ // measure background
+ val backgroundPlaceables = subcompose("background") {
+ Canvas(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(with(density) { canvasHeight.toDp() })
+ ) {
+ val width = size.width
+ val height = size.height
+ val fabRadius = fabWidth / 2f
+ val centerX = width / 2f
+ val curveDepth = fabHeight * 0.6f
+ val controlOffsetX = fabWidth.toFloat()
+
+ val path = Path().apply {
+ moveTo(0f, 0f)
+ lineTo(centerX - fabRadius - controlOffsetX, 0f)
+ cubicTo(
+ centerX - fabRadius, 0f,
+ centerX - fabRadius, curveDepth,
+ centerX, curveDepth
+ )
+ cubicTo(
+ centerX + fabRadius, curveDepth,
+ centerX + fabRadius, 0f,
+ centerX + fabRadius + controlOffsetX, 0f
+ )
+ lineTo(width, 0f)
+ lineTo(width, height)
+ lineTo(0f, height)
+ close()
+ }
+ drawPath(path, color = containerColor)
+ }
+ }.map { it.measure(constraints) }
+
+ // calculate icon space
+ val totalItemsWidth = iconPlaceables.sumOf { it.width } + fabWidth
+ val spaceCount = icons.size + 1
+ val spaceWidth = (constraints.maxWidth - totalItemsWidth).coerceAtLeast(0) / spaceCount
+
+ layout(constraints.maxWidth, constraints.maxHeight) {
+ backgroundPlaceables.forEach {
+ it.place(0, constraints.maxHeight - it.height)
+ }
+
+ var x = spaceWidth
+ iconPlaceables.fastForEachIndexed { index, it ->
+ it.place(x, 0)
+ x += it.width + if (index == (icons.size / 2 - 1)) {
+ spaceWidth + fabWidth // keep the space for fab
+ } else {
+ spaceWidth
+ }
+ }
+
+ fabPlaceables.forEach {
+ it.place(
+ (constraints.maxWidth - it.width) / 2,
+ 0 - fabHeight / 2
+ )
+ }
+ }
+ }
+}
+
+
+/**
+ * Note: fabeSize maintains the same height of modifier
+ */
+@Composable
+fun NotchedBottomBar(
+ modifier: Modifier = Modifier,
+ icons: List,
+ selectedIndex: Int,
+ @DrawableRes fabIcon: Int,
+ fabIconSize: Dp = 28.dp,
+ onIconClick: (index: Int) -> Unit,
+ onFabClick: () -> Unit,
+ fabSize: Dp,
+ fabColor: Color = FloatingActionButtonDefaults.containerColor,
+ fabContainerColor: Color = contentColorFor(fabColor),
+ fabElevation: FloatingActionButtonElevation = FloatingActionButtonDefaults.elevation(),
+ iconColor: Color = NavigationBarItemDefaults.colors().unselectedIconColor,
+ selectedIconColor: Color = NavigationBarItemDefaults.colors().selectedIconColor,
+ iconContainerColor: Color = NavigationBarItemDefaults.colors().selectedIndicatorColor,
+ containerColor: Color = MaterialTheme.colorScheme.surfaceContainer
+) {
+ val density = LocalDensity.current
+ SubcomposeLayout(
+ modifier = modifier
+ ) { constraints ->
+ // measure fab
+ val fabPlaceables = subcompose("fab") {
+ FloatingActionButton(
+ onClick = onFabClick,
+ modifier = Modifier.size(fabSize),
+ containerColor = fabContainerColor,
+ contentColor = fabColor,
+ elevation = fabElevation,
+ shape = CircleShape
+ ) {
+ Image(
+ modifier = Modifier.size(fabIconSize),
+ painter = painterResource(fabIcon),
+ contentDescription = "fab",
+ colorFilter = ColorFilter.tint(fabColor)
+ )
+ }
+ }.map { it.measure(constraints) }
+
+ val fabWidth = fabPlaceables.maxOf { it.width }
+ val fabHeight = fabPlaceables.maxOf { it.height }
+
+ // measure icons
+ val iconPlaceables = icons.fastMapIndexed { index, icon ->
+ val measurable = subcompose("icon_$index") {
+ val selected = selectedIndex == index
+ Surface(
+ onClick = { onIconClick(index) },
+ shape = CircleShape,
+ modifier = Modifier
+ .padding(vertical = 12.dp)
+ .aspectRatio(16 / 9f),
+ color = if (selected) iconContainerColor else Color.Transparent,
+ contentColor = if (selected) selectedIconColor else iconColor
+ ) {
+ Image(
+ modifier= Modifier
+ .wrapContentSize().padding(vertical = 3.dp),
+ painter = painterResource(icon),
+ contentDescription = "$index-icon",
+ colorFilter = ColorFilter.tint(iconColor)
+ )
+ }
+ }.first().measure(constraints)
+ measurable
+ }
+
+ val canvasHeight = iconPlaceables.maxOf { it.height }
+
+ // measure background
+ val backgroundPlaceables = subcompose("background") {
+ Canvas(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(with(density) { canvasHeight.toDp() })
+ ) {
+ val width = size.width
+ val height = size.height
+ val fabRadius = fabWidth / 2f
+ val centerX = width / 2f
+ val curveDepth = fabHeight * 0.6f
+ val controlOffsetX = fabWidth.toFloat()
+
+ val path = Path().apply {
+ moveTo(0f, 0f)
+ lineTo(centerX - fabRadius - controlOffsetX, 0f)
+ cubicTo(
+ centerX - fabRadius, 0f,
+ centerX - fabRadius, curveDepth,
+ centerX, curveDepth
+ )
+ cubicTo(
+ centerX + fabRadius, curveDepth,
+ centerX + fabRadius, 0f,
+ centerX + fabRadius + controlOffsetX, 0f
+ )
+ lineTo(width, 0f)
+ lineTo(width, height)
+ lineTo(0f, height)
+ close()
+ }
+ drawPath(path, color = containerColor)
+ }
+ }.map { it.measure(constraints) }
+
+ // calculate icon space
+ val totalItemsWidth = iconPlaceables.sumOf { it.width } + fabWidth
+ val spaceCount = icons.size + 1
+ val spaceWidth = (constraints.maxWidth - totalItemsWidth).coerceAtLeast(0) / spaceCount
+
+ layout(constraints.maxWidth, constraints.maxHeight) {
+ backgroundPlaceables.forEach {
+ it.place(0, constraints.maxHeight - it.height)
+ }
+
+ var x = spaceWidth
+ iconPlaceables.fastForEachIndexed { index, it ->
+ it.place(x, 0)
+ x += it.width + if (index == (icons.size / 2 - 1)) {
+ spaceWidth + fabWidth // keep the space for fab
+ } else {
+ spaceWidth
+ }
+ }
+
+ fabPlaceables.forEach {
+ it.place(
+ (constraints.maxWidth - it.width) / 2,
+ 0 - fabHeight / 2
+ )
+ }
+ }
+ }
+}
+
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/core/utils/DateUtils.kt b/composeApp/src/commonMain/kotlin/com/taskttl/core/utils/DateUtils.kt
new file mode 100644
index 0000000..4610599
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/core/utils/DateUtils.kt
@@ -0,0 +1,84 @@
+package com.taskttl.core.utils
+
+import com.taskttl.data.local.model.CountdownTime
+import kotlinx.datetime.DatePeriod
+import kotlinx.datetime.DateTimeUnit
+import kotlinx.datetime.LocalDate
+import kotlinx.datetime.LocalDateTime
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.isoDayNumber
+import kotlinx.datetime.minus
+import kotlinx.datetime.number
+import kotlinx.datetime.plus
+import kotlinx.datetime.toInstant
+import kotlinx.datetime.toLocalDateTime
+import kotlinx.datetime.until
+import kotlin.time.Clock
+import kotlin.time.ExperimentalTime
+import kotlin.time.Instant
+
+
+/**
+ * 日期时间工具
+ * @author admin
+ * @date 2025/08/11
+ */
+@OptIn(ExperimentalTime::class)
+object DateUtils {
+
+ /**
+ * 获取当前周日期
+ * @return [List]
+ */
+ fun getCurrentWeekDates(): List {
+ val today = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
+ val monday = today.minus(DatePeriod(days = today.dayOfWeek.isoDayNumber - 1))
+ return List(7) { monday.plus(DatePeriod(days = it)) }
+ }
+
+
+ /**
+ * 到期日期格式文本
+ * @param [milliseconds] 时间戳
+ * @return [String]
+ */
+ fun dueDateFormattedText(milliseconds: Long): String {
+ val fromEpochMilliseconds = Instant.fromEpochMilliseconds(milliseconds)
+ val date = fromEpochMilliseconds.toLocalDateTime(TimeZone.currentSystemDefault()).date
+
+ val year = date.year
+ val month = date.month.number.toString().padStart(2, '0')
+ val day = date.day.toString().padStart(2, '0')
+ return "${year}-${month}-${day}"
+ }
+
+ /**
+ * 剩余天数
+ * @param [targetDate] 目标日期
+ * @return [Long]
+ */
+ fun daysRemaining(targetDate: LocalDateTime): Long {
+ val today = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
+ val target = targetDate.date
+ return target.toEpochDays() - today.toEpochDays()
+ }
+
+ fun calculateCountdownTime(targetDate: LocalDateTime): CountdownTime {
+ val now = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault())
+
+ val nowInstant = now.toInstant(TimeZone.currentSystemDefault())
+ val targetInstant = targetDate.toInstant(TimeZone.currentSystemDefault())
+
+ return if (targetInstant <= nowInstant) {
+ CountdownTime(0, 0, 0, 0, isExpired = true)
+ } else {
+ val totalSeconds = nowInstant.until(targetInstant, DateTimeUnit.SECOND)
+ val days = totalSeconds / (24 * 3600)
+ val hours = (totalSeconds % (24 * 3600)) / 3600
+ val minutes = (totalSeconds % 3600) / 60
+ val seconds = totalSeconds % 60
+
+ CountdownTime(days, hours, minutes, seconds, isExpired = false)
+ }
+ }
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/core/utils/LogUtils.kt b/composeApp/src/commonMain/kotlin/com/taskttl/core/utils/LogUtils.kt
new file mode 100644
index 0000000..7be9011
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/core/utils/LogUtils.kt
@@ -0,0 +1,15 @@
+package com.taskttl.core.utils
+
+enum class LogLevel { DEBUG, INFO, WARN, ERROR }
+
+/**
+ * 日志
+ * @author admin
+ * @date 2025/10/03
+ */
+expect object LogUtils {
+ fun d(tag: String, message: String)
+ fun i(tag: String, message: String)
+ fun w(tag: String, message: String)
+ fun e(tag: String, message: String, throwable: Throwable? = null)
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/core/utils/StorageUtils.kt b/composeApp/src/commonMain/kotlin/com/taskttl/core/utils/StorageUtils.kt
new file mode 100644
index 0000000..65e4a25
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/core/utils/StorageUtils.kt
@@ -0,0 +1,101 @@
+package com.taskttl.core.utils
+
+/**
+ * 存储工具
+ * @author admin
+ * @date 2025/08/11
+ */
+expect object StorageUtils {
+ /**
+ * 保存字符串值
+ * @param key 键
+ * @param value 值
+ */
+ fun saveString(key: String, value: String)
+
+ /**
+ * 获取字符串值
+ * @param key 键
+ * @param defaultValue 默认值
+ * @return 存储的字符串值或默认值
+ */
+ fun getString(key: String, defaultValue: String = ""): String
+
+ /**
+ * 保存整数值
+ * @param key 键
+ * @param value 值
+ */
+ fun saveInt(key: String, value: Int)
+
+ /**
+ * 获取整数值
+ * @param key 键
+ * @param defaultValue 默认值
+ * @return 存储的整数值或默认值
+ */
+ fun getInt(key: String, defaultValue: Int = 0): Int
+
+
+ /**
+ * 保存长整数值
+ * @param [key] 键
+ * @param [value] 值
+ */
+ fun saveLong(key: String, value: Long)
+
+ /**
+ * 获取长整数值
+ * @param key 键
+ * @param defaultValue 默认值
+ * @return 存储的长整数值或默认值
+ */
+ fun getLong(key: String, defaultValue: Long): Long
+
+ /**
+ * 保存布尔值
+ * @param key 键
+ * @param value 值
+ */
+ fun saveBoolean(key: String, value: Boolean)
+
+ /**
+ * 获取布尔值
+ * @param key 键
+ * @param defaultValue 默认值
+ * @return 存储的布尔值或默认值
+ */
+ fun getBoolean(key: String, defaultValue: Boolean = false): Boolean
+
+ /**
+ * 保存对象
+ * @param key 键
+ * @param value 对象
+ */
+ inline fun saveObject(key: String, value: T)
+
+ /**
+ * 获取对象
+ * @param key 键
+ * @return 存储的对象或null
+ */
+ inline fun getObject(key: String): T?
+
+ /**
+ * 包含
+ * @param [key] 钥匙
+ * @return [Boolean]
+ */
+ fun contains(key: String): Boolean
+
+ /**
+ * 删除键值对
+ * @param key 键
+ */
+ fun remove(key: String)
+
+ /**
+ * 清除所有数据
+ */
+ fun clear()
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/data/di/DataModels.kt b/composeApp/src/commonMain/kotlin/com/taskttl/data/di/DataModels.kt
new file mode 100644
index 0000000..88df36c
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/data/di/DataModels.kt
@@ -0,0 +1,32 @@
+package com.taskttl.data.di
+
+import com.taskttl.data.local.database.TaskTTLDatabase
+import com.taskttl.data.local.database.getDatabaseBuilder
+import com.taskttl.data.mapper.CategoryMapper
+import com.taskttl.data.mapper.CountdownMapper
+import com.taskttl.data.mapper.TaskMapper
+import com.taskttl.data.repository.impl.CategoryRepositoryImpl
+import com.taskttl.data.repository.impl.CountdownRepositoryImpl
+import com.taskttl.data.repository.impl.TaskRepositoryImpl
+import com.taskttl.data.repository.CategoryRepository
+import com.taskttl.data.repository.CountdownRepository
+import com.taskttl.data.repository.TaskRepository
+import org.koin.dsl.module
+
+
+/** 数据模块 */
+val dataModule = module {
+ single { getDatabaseBuilder() }
+
+ single { get().taskDao() }
+ single { get().countdownDao() }
+ single { get().categoryDao() }
+
+ single { TaskMapper() }
+ single { CountdownMapper() }
+ single { CategoryMapper() }
+
+ single { TaskRepositoryImpl(get(), get()) }
+ single { CountdownRepositoryImpl(get(), get()) }
+ single { CategoryRepositoryImpl(get(), get(), get(), get()) }
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/data/di/KoinModels.kt b/composeApp/src/commonMain/kotlin/com/taskttl/data/di/KoinModels.kt
new file mode 100644
index 0000000..3c7272b
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/data/di/KoinModels.kt
@@ -0,0 +1,46 @@
+package com.taskttl.data.di
+
+import com.taskttl.data.repository.impl.OnboardingRepositoryImpl
+import com.taskttl.data.repository.SettingsRepository
+import com.taskttl.data.repository.impl.SettingsRepositoryImpl
+import com.taskttl.data.repository.OnboardingRepository
+import com.taskttl.data.viewmodel.OnboardingViewModel
+import com.taskttl.data.viewmodel.SplashViewModel
+import com.taskttl.data.viewmodel.CategoryViewModel
+import com.taskttl.data.viewmodel.CountdownViewModel
+import com.taskttl.data.viewmodel.TaskViewModel
+import org.koin.core.KoinApplication
+import org.koin.core.context.startKoin
+import org.koin.core.module.Module
+import org.koin.core.module.dsl.singleOf
+import org.koin.core.module.dsl.viewModelOf
+import org.koin.dsl.bind
+import org.koin.dsl.module
+
+expect fun platformModule(): Module
+
+/**
+ * 初始化Koin
+ * @param [config] 配置
+ */
+fun initKoin(config: (KoinApplication.() -> Unit)? = null) {
+ startKoin {
+ config?.invoke(this)
+ modules(repositoryModule, viewModelModule, platformModule(), dataModule)
+ }
+}
+
+/** 存储库模块 */
+val repositoryModule = module {
+ singleOf(::OnboardingRepositoryImpl).bind(OnboardingRepository::class)
+ singleOf(::SettingsRepositoryImpl).bind(SettingsRepository::class)
+}
+
+/** 视图模型模块 */
+val viewModelModule = module {
+ viewModelOf(::SplashViewModel)
+ viewModelOf(::OnboardingViewModel)
+ viewModelOf(::TaskViewModel)
+ viewModelOf(::CategoryViewModel)
+ viewModelOf(::CountdownViewModel)
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/data/local/dao/CategoryDao.kt b/composeApp/src/commonMain/kotlin/com/taskttl/data/local/dao/CategoryDao.kt
new file mode 100644
index 0000000..a38e0db
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/data/local/dao/CategoryDao.kt
@@ -0,0 +1,109 @@
+package com.taskttl.data.local.dao
+
+import androidx.room.*
+import com.taskttl.data.local.entity.CategoryEntity
+import kotlinx.coroutines.flow.Flow
+
+/**
+ * 类别 Dao
+ * @author admin
+ * @date 2025/10/05
+ * @constructor 创建[CategoryDao]
+ */
+@Dao
+interface CategoryDao {
+ /**
+ * 获取全部分类
+ * @return [Flow>]
+ */
+ @Query("SELECT * FROM categories ORDER BY createdAt ASC")
+ fun getAllCategories(): Flow>
+
+ /**
+ * 获取分类通过类型
+ * @param [type] 类型
+ * @return [Flow>]
+ */
+ @Query("SELECT * FROM categories WHERE type = :type ORDER BY createdAt ASC")
+ fun getCategoriesByType(type: String): Flow>
+
+ /**
+ * 获取类别通过ID
+ * @param [id] ID
+ * @return [CategoryEntity?]
+ */
+ @Query("SELECT * FROM categories WHERE id = :id")
+ suspend fun getCategoryById(id: String): CategoryEntity?
+
+ /**
+ * 获取类别通过名字和类型
+ * @param [name] 名字
+ * @param [type] 类型
+ * @return [CategoryEntity?]
+ */
+ @Query("SELECT * FROM categories WHERE name = :name AND type = :type")
+ suspend fun getCategoryByNameAndType(name: String, type: String): CategoryEntity?
+
+ /**
+ * 插入类别
+ * @param [category] 类别
+ */
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insertCategory(category: CategoryEntity)
+
+ /**
+ * 插入类别
+ * @param [categories] 分类
+ */
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insertCategories(categories: List)
+
+ /**
+ * 更新类别
+ * @param [category] 类别
+ */
+ @Update
+ suspend fun updateCategory(category: CategoryEntity)
+
+ /**
+ * 删除类别
+ * @param [id] ID
+ */
+ @Query("DELETE FROM categories WHERE id = :id")
+ suspend fun deleteCategory(id: String)
+
+ /**
+ * 删除所有自定义类别
+ */
+ @Query("DELETE FROM categories")
+ suspend fun deleteAllCustomCategories()
+
+ /**
+ * 更新任务计数
+ */
+ @Query("UPDATE categories SET taskCount = (SELECT COUNT(*) FROM tasks WHERE category = categories.name) WHERE type = 'TASK'")
+ suspend fun updateTaskCounts()
+
+ /**
+ * 更新倒计时计数
+ */
+ @Query("UPDATE categories SET countdownCount = (SELECT COUNT(*) FROM countdowns WHERE category = categories.name) WHERE type = 'COUNTDOWN'")
+ suspend fun updateCountdownCounts()
+
+ /**
+ * 获取分类随着计数
+ * @param [type] 类型
+ * @return [Flow>]
+ */
+ @Query("""
+ SELECT c.*,
+ COALESCE(t.task_count, 0) as taskCount,
+ COALESCE(cd.countdown_count, 0) as countdownCount
+ FROM categories c
+ LEFT JOIN (SELECT category, COUNT(*) as task_count FROM tasks GROUP BY category) t ON c.name = t.category
+ LEFT JOIN (SELECT category, COUNT(*) as countdown_count FROM countdowns GROUP BY category) cd ON c.name = cd.category
+ WHERE c.type = :type
+ ORDER BY c.createdAt ASC
+ """)
+ fun getCategoriesWithCounts(type: String): Flow>
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/data/local/dao/CountdownDao.kt b/composeApp/src/commonMain/kotlin/com/taskttl/data/local/dao/CountdownDao.kt
new file mode 100644
index 0000000..f765a01
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/data/local/dao/CountdownDao.kt
@@ -0,0 +1,68 @@
+package com.taskttl.data.local.dao
+
+import androidx.room.*
+import com.taskttl.data.local.entity.CountdownEntity
+import com.taskttl.data.local.entity.CountdownWithCategory
+import kotlinx.coroutines.flow.Flow
+
+/**
+ * 倒数日 Dao
+ * @author admin
+ * @date 2025/10/05
+ * @constructor 创建[CountdownDao]
+ */
+@Dao
+interface CountdownDao {
+ /**
+ * 获取所有倒数日
+ * @return [Flow>]
+ */
+ @Transaction
+ @Query("SELECT * FROM countdowns ORDER BY targetDate ASC")
+ fun getAllCountdowns(): Flow>
+
+ /**
+ * 获取倒数日通过ID
+ * @param [id] ID
+ * @return [CountdownWithCategory?]
+ */
+ @Transaction
+ @Query("SELECT * FROM countdowns WHERE id = :id")
+ suspend fun getCountdownById(id: String): CountdownWithCategory?
+
+ /**
+ * 获取倒数日通过类别
+ * @param [category] 类别
+ * @return [Flow>]
+ */
+ @Transaction
+ @Query("SELECT * FROM countdowns WHERE category = :category ORDER BY targetDate ASC")
+ fun getCountdownsByCategory(category: String): Flow>
+
+ /**
+ * 插入倒数日
+ * @param [countdown] 倒数日
+ */
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insertCountdown(countdown: CountdownEntity)
+
+ /**
+ * 更新倒数日
+ * @param [countdown] 倒计时
+ */
+ @Update
+ suspend fun updateCountdown(countdown: CountdownEntity)
+
+ /**
+ * 删除倒数日
+ * @param [id] ID
+ */
+ @Query("DELETE FROM countdowns WHERE id = :id")
+ suspend fun deleteCountdown(id: String)
+
+ /**
+ * 删除所有倒数日
+ */
+ @Query("DELETE FROM countdowns")
+ suspend fun deleteAllCountdowns()
+}
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/data/local/dao/TaskDao.kt b/composeApp/src/commonMain/kotlin/com/taskttl/data/local/dao/TaskDao.kt
new file mode 100644
index 0000000..6244fa4
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/data/local/dao/TaskDao.kt
@@ -0,0 +1,82 @@
+package com.taskttl.data.local.dao
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import androidx.room.Transaction
+import androidx.room.Update
+import com.taskttl.data.local.entity.TaskEntity
+import com.taskttl.data.local.entity.TaskWithCategory
+import kotlinx.coroutines.flow.Flow
+
+/**
+ * 任务 Dao
+ * @author admin
+ * @date 2025/10/05
+ * @constructor 创建[TaskDao]
+ */
+@Dao
+interface TaskDao {
+ /**
+ * 获取所有任务
+ * @return [Flow>]
+ */
+ @Transaction
+ @Query("SELECT * FROM tasks ORDER BY createdAt DESC")
+ fun getAllTasks(): Flow>
+
+ /**
+ * 按id获取任务
+ * @param [id] 本我
+ * @return [TaskEntity?]
+ */
+ @Transaction
+ @Query("SELECT * FROM tasks WHERE id = :id")
+ suspend fun getTaskById(id: String): TaskWithCategory?
+
+ /**
+ * 按类别获取任务
+ * @param [categoryId] 类别ID
+ * @return [Flow>]
+ */
+ @Transaction
+ @Query("SELECT * FROM tasks WHERE category = :categoryId ORDER BY createdAt DESC")
+ fun getTasksByCategory(categoryId: String): Flow>
+
+ /**
+ * 搜索任务
+ * @param [query] 怎么翻译
+ * @return [Flow>]
+ */
+ @Transaction
+ @Query("SELECT * FROM tasks WHERE title LIKE '%' || :query || '%' OR description LIKE '%' || :query || '%' ORDER BY createdAt DESC")
+ fun searchTasks(query: String): Flow>
+
+ /**
+ * 插入任务
+ * @param [task] 任务
+ */
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insertTask(task: TaskEntity)
+
+ /**
+ * 更新任务
+ * @param [task] 任务
+ */
+ @Update
+ suspend fun updateTask(task: TaskEntity)
+
+ /**
+ * 删除任务
+ * @param [id] ID
+ */
+ @Query("DELETE FROM tasks WHERE id = :id")
+ suspend fun deleteTask(id: String)
+
+ /**
+ * 删除所有任务
+ */
+ @Query("DELETE FROM tasks")
+ suspend fun deleteAllTasks()
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/data/local/database/Database.kt b/composeApp/src/commonMain/kotlin/com/taskttl/data/local/database/Database.kt
new file mode 100644
index 0000000..bdf1b55
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/data/local/database/Database.kt
@@ -0,0 +1,47 @@
+package com.taskttl.data.local.database
+
+import androidx.room.Database
+import androidx.room.RoomDatabase
+import com.taskttl.data.local.dao.CategoryDao
+import com.taskttl.data.local.dao.CountdownDao
+import com.taskttl.data.local.dao.TaskDao
+import com.taskttl.data.local.entity.CategoryEntity
+import com.taskttl.data.local.entity.CountdownEntity
+import com.taskttl.data.local.entity.TaskEntity
+
+/**
+ * TaskTTL数据库
+ * @author admin
+ * @date 2025/10/05
+ * @constructor 创建[TaskTTLDatabase]
+ */
+@Database(
+ entities = [TaskEntity::class, CountdownEntity::class, CategoryEntity::class],
+ version = 2,
+ exportSchema = false
+)
+abstract class TaskTTLDatabase : RoomDatabase() {
+ /**
+ * 任务Dao
+ * @return [TaskDao]
+ */
+ abstract fun taskDao(): TaskDao
+
+ /**
+ * 倒数日Dao
+ * @return [CountdownDao]
+ */
+ abstract fun countdownDao(): CountdownDao
+
+ /**
+ * 类别Dao
+ * @return [CategoryDao]
+ */
+ abstract fun categoryDao(): CategoryDao
+}
+
+/**
+ * 获取数据库建造者
+ * @return [TaskTTLDatabase]
+ */
+expect fun getDatabaseBuilder(): TaskTTLDatabase
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/data/local/entity/CategoryEntity.kt b/composeApp/src/commonMain/kotlin/com/taskttl/data/local/entity/CategoryEntity.kt
new file mode 100644
index 0000000..eabeb0a
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/data/local/entity/CategoryEntity.kt
@@ -0,0 +1,33 @@
+package com.taskttl.data.local.entity
+
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+
+/**
+ * 类别实体
+ * @author admin
+ * @date 2025/10/05
+ * @constructor 创建[CategoryEntity]
+ * @param [id] ID
+ * @param [name] 名字
+ * @param [color] 颜色
+ * @param [icon] 图标
+ * @param [type] 类型
+ * @param [createdAt] 创建于
+ * @param [updatedAt] 更新于
+ * @param [taskCount] 任务计数
+ * @param [countdownCount] 倒计时计数
+ */
+@Entity(tableName = "categories")
+data class CategoryEntity(
+ @PrimaryKey
+ val id: String,
+ val name: String,
+ val color: String,
+ val icon: String,
+ val type: String,
+ val createdAt: String,
+ val updatedAt: String,
+ val taskCount: Int,
+ val countdownCount: Int
+)
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/data/local/entity/CountdownEntity.kt b/composeApp/src/commonMain/kotlin/com/taskttl/data/local/entity/CountdownEntity.kt
new file mode 100644
index 0000000..c37ac22
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/data/local/entity/CountdownEntity.kt
@@ -0,0 +1,33 @@
+package com.taskttl.data.local.entity
+
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+
+/**
+ * 倒数日实体
+ * @author admin
+ * @date 2025/10/05
+ * @constructor 创建[CountdownEntity]
+ * @param [id] ID
+ * @param [title] 标题
+ * @param [description] 描述
+ * @param [category] 类别
+ * @param [targetDate] 目标日期
+ * @param [createdAt] 创建于
+ * @param [updatedAt] 更新于
+ * @param [isActive] 是活跃
+ * @param [notificationEnabled] 通知已启用
+ */
+@Entity(tableName = "countdowns")
+data class CountdownEntity(
+ @PrimaryKey
+ val id: String,
+ val title: String,
+ val description: String,
+ val category: String,
+ val targetDate: String,
+ val createdAt: String,
+ val updatedAt: String,
+ val isActive: Boolean,
+ val notificationEnabled: String
+)
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/data/local/entity/CountdownWithCategory.kt b/composeApp/src/commonMain/kotlin/com/taskttl/data/local/entity/CountdownWithCategory.kt
new file mode 100644
index 0000000..0bc0ff8
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/data/local/entity/CountdownWithCategory.kt
@@ -0,0 +1,18 @@
+package com.taskttl.data.local.entity
+
+import androidx.room.Embedded
+import androidx.room.Relation
+
+/**
+ * 按类别倒数日
+ * @author admin
+ * @date 2025/10/05
+ * @constructor 创建[CountdownWithCategory]
+ * @param [countdown] 倒数日
+ * @param [category] 类别
+ */
+data class CountdownWithCategory(
+ @Embedded val countdown: CountdownEntity,
+ @Relation(parentColumn = "category", entityColumn = "id")
+ val category: CategoryEntity
+)
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/data/local/entity/TaskEntity.kt b/composeApp/src/commonMain/kotlin/com/taskttl/data/local/entity/TaskEntity.kt
new file mode 100644
index 0000000..e4cd5fd
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/data/local/entity/TaskEntity.kt
@@ -0,0 +1,35 @@
+package com.taskttl.data.local.entity
+
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+
+/**
+ * 任务实体
+ * @author admin
+ * @date 2025/10/05
+ * @constructor 创建[TaskEntity]
+ * @param [id] ID
+ * @param [title] 标题
+ * @param [description] 描述
+ * @param [category] 类别
+ * @param [priority] 优先级
+ * @param [isCompleted] 已完成
+ * @param [createdAt] 创建于
+ * @param [updatedAt] 更新于
+ * @param [dueDate] 截止日期
+ * @param [tags] 标签
+ */
+@Entity(tableName = "tasks")
+data class TaskEntity(
+ @PrimaryKey
+ val id: String,
+ val title: String,
+ val description: String,
+ val category: String,
+ val priority: String,
+ val isCompleted: Boolean,
+ val createdAt: String,
+ val updatedAt: String,
+ val dueDate: String?,
+ val tags: String
+)
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/data/local/entity/TaskWithCategory.kt b/composeApp/src/commonMain/kotlin/com/taskttl/data/local/entity/TaskWithCategory.kt
new file mode 100644
index 0000000..265bfc4
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/data/local/entity/TaskWithCategory.kt
@@ -0,0 +1,18 @@
+package com.taskttl.data.local.entity
+
+import androidx.room.Embedded
+import androidx.room.Relation
+
+/**
+ * 按类别任务
+ * @author admin
+ * @date 2025/10/05
+ * @constructor 创建[TaskWithCategory]
+ * @param [task] 任务
+ * @param [category] 类别
+ */
+data class TaskWithCategory(
+ @Embedded val task: TaskEntity,
+ @Relation(parentColumn = "category", entityColumn = "id")
+ val category: CategoryEntity
+)
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/data/local/model/Category.kt b/composeApp/src/commonMain/kotlin/com/taskttl/data/local/model/Category.kt
new file mode 100644
index 0000000..7b230a5
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/data/local/model/Category.kt
@@ -0,0 +1,259 @@
+package com.taskttl.data.local.model
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.Assignment
+import androidx.compose.material.icons.automirrored.filled.DirectionsWalk
+import androidx.compose.material.icons.filled.AccountBalanceWallet
+import androidx.compose.material.icons.filled.Alarm
+import androidx.compose.material.icons.filled.Book
+import androidx.compose.material.icons.filled.Cake
+import androidx.compose.material.icons.filled.CameraAlt
+import androidx.compose.material.icons.filled.Celebration
+import androidx.compose.material.icons.filled.CleaningServices
+import androidx.compose.material.icons.filled.DirectionsCar
+import androidx.compose.material.icons.filled.Favorite
+import androidx.compose.material.icons.filled.FavoriteBorder
+import androidx.compose.material.icons.filled.FitnessCenter
+import androidx.compose.material.icons.filled.Flag
+import androidx.compose.material.icons.filled.Flight
+import androidx.compose.material.icons.filled.Home
+import androidx.compose.material.icons.filled.Hotel
+import androidx.compose.material.icons.filled.LocalCafe
+import androidx.compose.material.icons.filled.Movie
+import androidx.compose.material.icons.filled.MusicNote
+import androidx.compose.material.icons.filled.Restaurant
+import androidx.compose.material.icons.filled.School
+import androidx.compose.material.icons.filled.ShoppingCart
+import androidx.compose.material.icons.filled.SportsEsports
+import androidx.compose.material.icons.filled.Work
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import kotlinx.datetime.LocalDateTime
+import kotlinx.serialization.Serializable
+import org.jetbrains.compose.resources.StringResource
+import taskttl.composeapp.generated.resources.Res
+import taskttl.composeapp.generated.resources.category_anniversary
+import taskttl.composeapp.generated.resources.category_birthday
+import taskttl.composeapp.generated.resources.category_book
+import taskttl.composeapp.generated.resources.category_briefcase
+import taskttl.composeapp.generated.resources.category_camera
+import taskttl.composeapp.generated.resources.category_car
+import taskttl.composeapp.generated.resources.category_cleaning
+import taskttl.composeapp.generated.resources.category_coffee
+import taskttl.composeapp.generated.resources.category_countdown
+import taskttl.composeapp.generated.resources.category_dumbbell
+import taskttl.composeapp.generated.resources.category_exam
+import taskttl.composeapp.generated.resources.category_festival
+import taskttl.composeapp.generated.resources.category_food
+import taskttl.composeapp.generated.resources.category_gamepad
+import taskttl.composeapp.generated.resources.category_goal
+import taskttl.composeapp.generated.resources.category_heart
+import taskttl.composeapp.generated.resources.category_home
+import taskttl.composeapp.generated.resources.category_money
+import taskttl.composeapp.generated.resources.category_movie
+import taskttl.composeapp.generated.resources.category_music
+import taskttl.composeapp.generated.resources.category_plane
+import taskttl.composeapp.generated.resources.category_project
+import taskttl.composeapp.generated.resources.category_reminder
+import taskttl.composeapp.generated.resources.category_shopping
+import taskttl.composeapp.generated.resources.category_sleep
+import taskttl.composeapp.generated.resources.category_task
+import taskttl.composeapp.generated.resources.category_walk
+
+/**
+ * 类别
+ * @author admin
+ * @date 2025/10/04
+ * @constructor 创建[Category]
+ * @param [id] ID
+ * @param [name] 名字
+ * @param [color] 颜色
+ * @param [icon] 图标
+ * @param [type] 类型
+ * @param [createdAt] 创建于
+ * @param [updatedAt] 更新于
+ * @param [taskCount] 任务计数
+ * @param [countdownCount] 倒计时计数
+ */
+@Serializable
+data class Category(
+ val id: String,
+ val name: String,
+ val color: CategoryColor,
+ val icon: CategoryIcon,
+ val type: CategoryType,
+ val createdAt: LocalDateTime,
+ val updatedAt: LocalDateTime,
+ val taskCount: Int = 0,
+ val countdownCount: Int = 0
+)
+
+/**
+ * 类别类型
+ * @author admin
+ * @date 2025/10/04
+ * @constructor 创建[CategoryType]
+ */
+@Serializable
+enum class CategoryType(val displayNameRes: StringResource) {
+ TASK(Res.string.category_task),
+ COUNTDOWN(Res.string.category_countdown)
+}
+
+/**
+ * 类别图标
+ * @author admin
+ * @date 2025/10/04
+ * @constructor 创建[CategoryIcon]
+ * @param [displayNameRes] 显示名称
+ * @param [icon] 图标
+ */
+@Serializable
+enum class CategoryIcon(val displayNameRes: StringResource, val icon: ImageVector) {
+ // 工作与学习
+ BRIEFCASE(Res.string.category_briefcase, Icons.Default.Work),
+ BOOK(Res.string.category_book, Icons.Default.Book),
+ EXAM(Res.string.category_exam, Icons.Default.School),
+ PROJECT(Res.string.category_project, Icons.AutoMirrored.Filled.Assignment),
+
+ // 生活与家庭
+ HOME(Res.string.category_home, Icons.Default.Home),
+ COFFEE(Res.string.category_coffee, Icons.Default.LocalCafe),
+ SHOPPING(Res.string.category_shopping, Icons.Default.ShoppingCart),
+ FOOD(Res.string.category_food, Icons.Default.Restaurant),
+ CLEANING(Res.string.category_cleaning, Icons.Default.CleaningServices),
+
+ // 健康与运动
+ HEART(Res.string.category_heart, Icons.Default.Favorite),
+ DUMBBELL(Res.string.category_dumbbell, Icons.Default.FitnessCenter),
+ SLEEP(Res.string.category_sleep, Icons.Default.Hotel),
+
+ // 娱乐与兴趣
+ MUSIC(Res.string.category_music, Icons.Default.MusicNote),
+ GAMEPAD(Res.string.category_gamepad, Icons.Default.SportsEsports),
+ CAMERA(Res.string.category_camera, Icons.Default.CameraAlt),
+ MOVIE(Res.string.category_movie, Icons.Default.Movie),
+
+ // 出行与旅行
+ CAR(Res.string.category_car, Icons.Default.DirectionsCar),
+ PLANE(Res.string.category_plane, Icons.Default.Flight),
+ WALK(Res.string.category_walk, Icons.AutoMirrored.Filled.DirectionsWalk),
+
+ // 节日与纪念
+ BIRTHDAY(Res.string.category_birthday, Icons.Default.Cake),
+ FESTIVAL(Res.string.category_festival, Icons.Default.Celebration),
+ ANNIVERSARY(Res.string.category_anniversary, Icons.Default.FavoriteBorder),
+
+ // 财务与计划
+ MONEY(Res.string.category_money, Icons.Default.AccountBalanceWallet),
+ GOAL(Res.string.category_goal, Icons.Default.Flag),
+ REMINDER(Res.string.category_reminder, Icons.Default.Alarm);
+}
+
+/**
+ * 类别颜色
+ * @author admin
+ * @date 2025/10/04
+ * @constructor 创建[CategoryColor]
+ * @param [hex] 十六进制
+ */
+@Serializable
+enum class CategoryColor(
+ val hex: Long,
+ val backgroundHex: Long,
+ val textHex: Long,
+ val iconHex: Long
+) {
+ BLUE(
+ hex = 0xFF667EEA,
+ backgroundHex = 0xFFE3E8FF,
+ textHex = 0xFF2C3E50,
+ iconHex = 0xFF3B4CCA
+ ),
+ GREEN(
+ hex = 0xFF4CAF50,
+ backgroundHex = 0xFFE8F5E9,
+ textHex = 0xFF1B5E20,
+ iconHex = 0xFF2E7D32
+ ),
+ ORANGE(
+ hex = 0xFFFF9800,
+ backgroundHex = 0xFFFFF3E0,
+ textHex = 0xFFE65100,
+ iconHex = 0xFFFB8C00
+ ),
+ PURPLE(
+ hex = 0xFF9C27B0,
+ backgroundHex = 0xFFF3E5F5,
+ textHex = 0xFF4A148C,
+ iconHex = 0xFF7B1FA2
+ ),
+ RED(
+ hex = 0xFFF44336,
+ backgroundHex = 0xFFFFEBEE,
+ textHex = 0xFFB71C1C,
+ iconHex = 0xFFE53935
+ ),
+ CYAN(
+ hex = 0xFF00BCD4,
+ backgroundHex = 0xFFE0F7FA,
+ textHex = 0xFF006064,
+ iconHex = 0xFF0097A7
+ ),
+ YELLOW(
+ hex = 0xFFFFEB3B,
+ backgroundHex = 0xFFFFFDE7,
+ textHex = 0xFFF57F17,
+ iconHex = 0xFFFBC02D
+ ),
+ PINK(
+ hex = 0xFFE91E63,
+ backgroundHex = 0xFFFCE4EC,
+ textHex = 0xFF880E4F,
+ iconHex = 0xFFD81B60
+ ),
+ GRAY(
+ hex = 0xFF9E9E9E,
+ backgroundHex = 0xFFF5F5F5,
+ textHex = 0xFF424242,
+ iconHex = 0xFF757575
+ );
+
+ val color: Color
+ get() = Color(hex)
+
+ val backgroundColor: Color
+ get() = Color(backgroundHex)
+
+ val textColor: Color
+ get() = Color(textHex)
+
+ val iconColor: Color
+ get() = Color(iconHex)
+}
+
+/**
+ * 类别统计
+ * @author admin
+ * @date 2025/10/04
+ * @constructor 创建[CategoryStatistics]
+ * @param [categoryId] 类别ID
+ * @param [categoryName] 类别名称
+ * @param [totalTasks] 总任务
+ * @param [completedTasks] 已完成任务
+ * @param [pendingTasks] 待处理任务
+ * @param [totalCountdowns] 总倒计时
+ * @param [activeCountdowns] 主动倒计时
+ * @param [completionRate] 完成率
+ */
+@Serializable
+data class CategoryStatistics(
+ val categoryId: String,
+ val categoryName: String,
+ val totalTasks: Int,
+ val completedTasks: Int,
+ val pendingTasks: Int,
+ val totalCountdowns: Int,
+ val activeCountdowns: Int,
+ val completionRate: Float
+)
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/data/local/model/Countdown.kt b/composeApp/src/commonMain/kotlin/com/taskttl/data/local/model/Countdown.kt
new file mode 100644
index 0000000..f603199
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/data/local/model/Countdown.kt
@@ -0,0 +1,75 @@
+package com.taskttl.data.local.model
+
+import kotlinx.datetime.LocalDateTime
+import kotlinx.serialization.Serializable
+import org.jetbrains.compose.resources.StringResource
+import taskttl.composeapp.generated.resources.Res
+import taskttl.composeapp.generated.resources.reminder_daily
+import taskttl.composeapp.generated.resources.reminder_monthly
+import taskttl.composeapp.generated.resources.reminder_off
+import taskttl.composeapp.generated.resources.reminder_once
+import taskttl.composeapp.generated.resources.reminder_weekly
+
+/**
+ * 倒数日
+ * @author admin
+ * @date 2025/10/04
+ * @constructor 创建[Countdown]
+ * @param [id] ID
+ * @param [title] 标题
+ * @param [description] 描述
+ * @param [category] 类别
+ * @param [targetDate] 目标日期
+ * @param [createdAt] 创建于
+ * @param [updatedAt] 更新于
+ * @param [isActive] 是活跃
+ * @param [notificationEnabled] 通知已启用
+ */
+@Serializable
+data class Countdown(
+ val id: String,
+ val title: String,
+ val description: String = "",
+ val category: Category,
+ val targetDate: LocalDateTime,
+ val createdAt: LocalDateTime,
+ val updatedAt: LocalDateTime,
+ var isActive: Boolean = true,
+ val notificationEnabled: ReminderFrequency = ReminderFrequency.OFF
+)
+
+/**
+ * 倒计时时间
+ * @author admin
+ * @date 2025/10/04
+ * @constructor 创建[CountdownTime]
+ * @param [days] 天
+ * @param [hours] 小时
+ * @param [minutes] 分钟
+ * @param [seconds] 秒
+ * @param [isExpired] 已过期
+ */
+@Serializable
+data class CountdownTime(
+ val days: Long,
+ val hours: Long,
+ val minutes: Long,
+ val seconds: Long,
+ val isExpired: Boolean = false
+)
+
+/**
+ * 提醒频率
+ * @author admin
+ * @date 2025/10/04
+ * @constructor 创建[ReminderFrequency]
+ * @param [displayNameRes] 显示名称
+ */
+@Serializable
+enum class ReminderFrequency(val displayNameRes: StringResource) {
+ ONCE(Res.string.reminder_once),
+ DAILY(Res.string.reminder_daily),
+ WEEKLY(Res.string.reminder_weekly),
+ MONTHLY(Res.string.reminder_monthly),
+ OFF(Res.string.reminder_off);
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/data/local/model/Onboarding.kt b/composeApp/src/commonMain/kotlin/com/taskttl/data/local/model/Onboarding.kt
new file mode 100644
index 0000000..bf50cba
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/data/local/model/Onboarding.kt
@@ -0,0 +1,61 @@
+package com.taskttl.data.local.model
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.CalendarToday
+import androidx.compose.material.icons.filled.CheckCircle
+import androidx.compose.material.icons.filled.RocketLaunch
+import androidx.compose.material.icons.filled.Star
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import org.jetbrains.compose.resources.StringResource
+import taskttl.composeapp.generated.resources.Res
+import taskttl.composeapp.generated.resources.onboarding_dates_desc
+import taskttl.composeapp.generated.resources.onboarding_dates_title
+import taskttl.composeapp.generated.resources.onboarding_ready_desc
+import taskttl.composeapp.generated.resources.onboarding_ready_title
+import taskttl.composeapp.generated.resources.onboarding_smart_desc
+import taskttl.composeapp.generated.resources.onboarding_smart_title
+import taskttl.composeapp.generated.resources.onboarding_welcome_desc
+import taskttl.composeapp.generated.resources.onboarding_welcome_title
+
+/**
+ * 引导页面
+ * @author admin
+ * @date 2025/10/05
+ * @constructor 创建[OnboardingPage]
+ * @param [icon] 图标
+ * @param [color] 颜色
+ * @param [titleRes] 标题
+ * @param [descRes] 描述
+ */
+enum class OnboardingPage(
+ val icon: ImageVector,
+ val color: Color,
+ val titleRes: StringResource,
+ val descRes: StringResource
+) {
+ Welcome(
+ icon = Icons.Default.RocketLaunch,
+ color = Color(0xFF667EEA),
+ titleRes = Res.string.onboarding_welcome_title,
+ descRes = Res.string.onboarding_welcome_desc
+ ),
+ SmartTasks(
+ icon = Icons.Default.CheckCircle,
+ color = Color(0xFF4CAF50),
+ titleRes = Res.string.onboarding_smart_title,
+ descRes = Res.string.onboarding_smart_desc
+ ),
+ ImportantDates(
+ icon = Icons.Default.CalendarToday,
+ color = Color(0xFFFF6B6B),
+ titleRes = Res.string.onboarding_dates_title,
+ descRes = Res.string.onboarding_dates_desc
+ ),
+ Ready(
+ icon = Icons.Default.Star,
+ color = Color(0xFFFFD700),
+ titleRes = Res.string.onboarding_ready_title,
+ descRes = Res.string.onboarding_ready_desc
+ );
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/data/local/model/Task.kt b/composeApp/src/commonMain/kotlin/com/taskttl/data/local/model/Task.kt
new file mode 100644
index 0000000..bff5317
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/data/local/model/Task.kt
@@ -0,0 +1,57 @@
+package com.taskttl.data.local.model
+
+import androidx.compose.ui.graphics.Color
+import kotlinx.datetime.LocalDateTime
+import kotlinx.serialization.Serializable
+import org.jetbrains.compose.resources.StringResource
+import taskttl.composeapp.generated.resources.Res
+import taskttl.composeapp.generated.resources.priority_high
+import taskttl.composeapp.generated.resources.priority_low
+import taskttl.composeapp.generated.resources.priority_medium
+import taskttl.composeapp.generated.resources.priority_urgent
+
+/**
+ * 任务
+ * @author admin
+ * @date 2025/10/04
+ * @constructor 创建[Task]
+ * @param [id] ID
+ * @param [title] 标题
+ * @param [description] 描述
+ * @param [category] 类别
+ * @param [priority] 优先级
+ * @param [isCompleted] 已完成
+ * @param [createdAt] 创建于
+ * @param [updatedAt] 更新于
+ * @param [dueDate] 截止日期
+ * @param [tags] 标签
+ */
+@Serializable
+data class Task(
+ val id: String,
+ val title: String,
+ val description: String = "",
+ val category: Category,
+ val priority: TaskPriority = TaskPriority.MEDIUM,
+ var isCompleted: Boolean = false,
+ val createdAt: LocalDateTime,
+ val updatedAt: LocalDateTime,
+ val dueDate: LocalDateTime? = null,
+ val tags: List = emptyList()
+)
+
+/**
+ * 任务优先级
+ * @author admin
+ * @date 2025/10/04
+ * @constructor 创建[TaskPriority]
+ * @param [displayNameRes] 显示名称
+ * @param [color] 颜色
+ */
+@Serializable
+enum class TaskPriority(val displayNameRes: StringResource, val color: Color) {
+ LOW(Res.string.priority_low, Color(0xff4caf50)),
+ MEDIUM(Res.string.priority_medium, Color(0xffff9800)),
+ HIGH(Res.string.priority_high, Color(0xfff44336)),
+ URGENT(Res.string.priority_urgent, Color(0xffe91e63))
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/data/mapper/CategoryMapper.kt b/composeApp/src/commonMain/kotlin/com/taskttl/data/mapper/CategoryMapper.kt
new file mode 100644
index 0000000..99eda56
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/data/mapper/CategoryMapper.kt
@@ -0,0 +1,45 @@
+package com.taskttl.data.mapper
+
+import com.taskttl.data.local.entity.CategoryEntity
+import com.taskttl.data.local.model.Category
+import com.taskttl.data.local.model.CategoryColor
+import com.taskttl.data.local.model.CategoryIcon
+import com.taskttl.data.local.model.CategoryType
+import kotlinx.datetime.LocalDateTime
+
+/**
+ * 类别映射器
+ * @author admin
+ * @date 2025/10/05
+ * @constructor 创建[CategoryMapper]
+ */
+class CategoryMapper {
+
+ fun domainToEntity(category: Category): CategoryEntity {
+ return CategoryEntity(
+ id = category.id,
+ name = category.name,
+ color = category.color.name,
+ icon = category.icon.name,
+ type = category.type.name,
+ createdAt = category.createdAt.toString(),
+ updatedAt = category.updatedAt.toString(),
+ taskCount = category.taskCount,
+ countdownCount = category.countdownCount
+ )
+ }
+
+ fun entityToDomain(entity: CategoryEntity): Category {
+ return Category(
+ id = entity.id,
+ name = entity.name,
+ color = CategoryColor.valueOf(entity.color),
+ icon = CategoryIcon.valueOf(entity.icon),
+ type = CategoryType.valueOf(entity.type),
+ createdAt = LocalDateTime.parse(entity.createdAt),
+ updatedAt = LocalDateTime.parse(entity.updatedAt),
+ taskCount = entity.taskCount,
+ countdownCount = entity.countdownCount
+ )
+ }
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/data/mapper/CountdownMapper.kt b/composeApp/src/commonMain/kotlin/com/taskttl/data/mapper/CountdownMapper.kt
new file mode 100644
index 0000000..2a89516
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/data/mapper/CountdownMapper.kt
@@ -0,0 +1,61 @@
+package com.taskttl.data.mapper
+
+import com.taskttl.data.local.entity.CountdownEntity
+import com.taskttl.data.local.entity.CountdownWithCategory
+import com.taskttl.data.local.model.Category
+import com.taskttl.data.local.model.CategoryColor
+import com.taskttl.data.local.model.CategoryIcon
+import com.taskttl.data.local.model.CategoryType
+import com.taskttl.data.local.model.Countdown
+import com.taskttl.data.local.model.ReminderFrequency
+import kotlinx.datetime.LocalDateTime
+
+/**
+ * 倒数日映射器
+ * @author admin
+ * @date 2025/10/05
+ * @constructor 创建[CountdownMapper]
+ */
+class CountdownMapper {
+
+ fun domainToEntity(countdown: Countdown): CountdownEntity {
+ return CountdownEntity(
+ id = countdown.id,
+ title = countdown.title,
+ description = countdown.description,
+ category = countdown.category.id,
+ targetDate = countdown.targetDate.toString(),
+ createdAt = countdown.createdAt.toString(),
+ updatedAt = countdown.updatedAt.toString(),
+ isActive = countdown.isActive,
+ notificationEnabled = countdown.notificationEnabled.name
+ )
+ }
+
+ fun entityToDomain(countdownWithCategory: CountdownWithCategory): Countdown {
+ val entity = countdownWithCategory.countdown
+ val categoryEntity = countdownWithCategory.category
+
+ return Countdown(
+ id = entity.id,
+ title = entity.title,
+ description = entity.description,
+ category = Category(
+ id = categoryEntity.id,
+ name = categoryEntity.name,
+ color = CategoryColor.valueOf(categoryEntity.color),
+ icon = CategoryIcon.valueOf(categoryEntity.icon),
+ type = CategoryType.valueOf(categoryEntity.type),
+ createdAt = LocalDateTime.parse(categoryEntity.createdAt),
+ updatedAt = LocalDateTime.parse(categoryEntity.updatedAt),
+ taskCount = categoryEntity.taskCount,
+ countdownCount = categoryEntity.countdownCount
+ ),
+ targetDate = LocalDateTime.parse(entity.targetDate),
+ createdAt = LocalDateTime.parse(entity.createdAt),
+ updatedAt = LocalDateTime.parse(entity.updatedAt),
+ isActive = entity.isActive,
+ notificationEnabled = ReminderFrequency.valueOf(entity.notificationEnabled)
+ )
+ }
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/data/mapper/TaskMapper.kt b/composeApp/src/commonMain/kotlin/com/taskttl/data/mapper/TaskMapper.kt
new file mode 100644
index 0000000..90561eb
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/data/mapper/TaskMapper.kt
@@ -0,0 +1,64 @@
+package com.taskttl.data.mapper
+
+import com.taskttl.data.local.entity.TaskEntity
+import com.taskttl.data.local.entity.TaskWithCategory
+import com.taskttl.data.local.model.Category
+import com.taskttl.data.local.model.CategoryColor
+import com.taskttl.data.local.model.CategoryIcon
+import com.taskttl.data.local.model.CategoryType
+import com.taskttl.data.local.model.Task
+import com.taskttl.data.local.model.TaskPriority
+import kotlinx.datetime.LocalDateTime
+import kotlinx.serialization.json.Json
+
+/**
+ * 任务映射器
+ * @author admin
+ * @date 2025/10/05
+ * @constructor 创建[TaskMapper]
+ */
+class TaskMapper {
+
+ fun domainToEntity(task: Task): TaskEntity {
+ return TaskEntity(
+ id = task.id,
+ title = task.title,
+ description = task.description,
+ category = task.category.id,
+ priority = task.priority.name,
+ isCompleted = task.isCompleted,
+ createdAt = task.createdAt.toString(),
+ updatedAt = task.updatedAt.toString(),
+ dueDate = task.dueDate?.toString(),
+ tags = Json.encodeToString(task.tags)
+ )
+ }
+
+ fun entityToDomain(taskWithCategory: TaskWithCategory): Task {
+ val entity = taskWithCategory.task
+ val categoryEntity = taskWithCategory.category
+
+ return Task(
+ id = entity.id,
+ title = entity.title,
+ description = entity.description,
+ category = Category(
+ id = categoryEntity.id,
+ name = categoryEntity.name,
+ color = CategoryColor.valueOf(categoryEntity.color),
+ icon = CategoryIcon.valueOf(categoryEntity.icon),
+ type = CategoryType.valueOf(categoryEntity.type),
+ createdAt = LocalDateTime.parse(categoryEntity.createdAt),
+ updatedAt = LocalDateTime.parse(categoryEntity.updatedAt),
+ taskCount = categoryEntity.taskCount,
+ countdownCount = categoryEntity.countdownCount
+ ),
+ priority = TaskPriority.valueOf(entity.priority),
+ isCompleted = entity.isCompleted,
+ createdAt = LocalDateTime.parse(entity.createdAt),
+ updatedAt = LocalDateTime.parse(entity.updatedAt),
+ dueDate = entity.dueDate?.let { LocalDateTime.parse(it) },
+ tags = Json.decodeFromString(entity.tags)
+ )
+ }
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/data/repository/CategoryRepository.kt b/composeApp/src/commonMain/kotlin/com/taskttl/data/repository/CategoryRepository.kt
new file mode 100644
index 0000000..65da803
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/data/repository/CategoryRepository.kt
@@ -0,0 +1,89 @@
+package com.taskttl.data.repository
+
+import com.taskttl.data.local.model.Category
+import com.taskttl.data.local.model.CategoryStatistics
+import com.taskttl.data.local.model.CategoryType
+import kotlinx.coroutines.flow.Flow
+
+/**
+ * 类别存储库
+ * @author admin
+ * @date 2025/10/04
+ * @constructor 创建[CategoryRepository]
+ */
+interface CategoryRepository {
+ /**
+ * 获取全部分类
+ * @return [Flow>]
+ */
+ suspend fun getAllCategories(): Flow>
+
+ /**
+ * 获取分类通过类型
+ * @param [type] 类型
+ * @return [Flow>]
+ */
+ suspend fun getCategoriesByType(type: CategoryType): Flow>
+
+ /**
+ * 获取类别通过ID
+ * @param [id] ID
+ * @return [Category?]
+ */
+ suspend fun getCategoryById(id: String): Category?
+
+ /**
+ * 获取类别通过名字和类型
+ * @param [name] 名字
+ * @param [type] 类型
+ * @return [Category?]
+ */
+ suspend fun getCategoryByNameAndType(name: String, type: CategoryType): Category?
+
+ /**
+ * 插入类别
+ * @param [category] 类别
+ */
+ suspend fun insertCategory(category: Category)
+
+ /**
+ * 插入类别
+ * @param [categories] 分类
+ */
+ suspend fun insertCategories(categories: List)
+
+ /**
+ * 更新类别
+ * @param [category] 类别
+ */
+ suspend fun updateCategory(category: Category)
+
+ /**
+ * 删除类别
+ * @param [id] ID
+ */
+ suspend fun deleteCategory(id: String)
+
+ /**
+ * 初始化默认类别
+ */
+ suspend fun initializeDefaultCategories()
+
+ /**
+ * 获取分类随着计数
+ * @param [type] 类型
+ * @return [Flow>]
+ */
+ suspend fun getCategoriesWithCounts(type: CategoryType): Flow>
+
+ /**
+ * 获取类别统计
+ * @return [Flow>]
+ */
+ suspend fun getCategoryStatistics(): Flow>
+
+ /**
+ * 更新类别计数
+ */
+ suspend fun updateCategoryCounts()
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/data/repository/CountdownRepository.kt b/composeApp/src/commonMain/kotlin/com/taskttl/data/repository/CountdownRepository.kt
new file mode 100644
index 0000000..308a91b
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/data/repository/CountdownRepository.kt
@@ -0,0 +1,50 @@
+package com.taskttl.data.repository
+
+import com.taskttl.data.local.model.Countdown
+import kotlinx.coroutines.flow.Flow
+
+/**
+ * 倒数日存储库
+ * @author admin
+ * @date 2025/10/04
+ * @constructor 创建[CountdownRepository]
+ */
+interface CountdownRepository {
+ /**
+ * 获取所有倒数日
+ * @return [Flow>]
+ */
+ suspend fun getAllCountdowns(): Flow>
+
+ /**
+ * 获取倒数日通过ID
+ * @param [id] ID
+ * @return [Countdown?]
+ */
+ suspend fun getCountdownById(id: String): Countdown?
+
+ /**
+ * 插入倒数日
+ * @param [countdown] 倒数日
+ */
+ suspend fun insertCountdown(countdown: Countdown)
+
+ /**
+ * 更新倒数日
+ * @param [countdown] 倒数日
+ */
+ suspend fun updateCountdown(countdown: Countdown)
+
+ /**
+ * 删除倒数日
+ * @param [id] ID
+ */
+ suspend fun deleteCountdown(id: String)
+
+ /**
+ * 获取倒数日通过类别
+ * @param [category] 类别
+ * @return [Flow>]
+ */
+ suspend fun getCountdownsByCategory(category: String): Flow>
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/data/repository/OnboardingRepository.kt b/composeApp/src/commonMain/kotlin/com/taskttl/data/repository/OnboardingRepository.kt
new file mode 100644
index 0000000..39a3f31
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/data/repository/OnboardingRepository.kt
@@ -0,0 +1,20 @@
+package com.taskttl.data.repository
+
+/**
+ * 设置存储库
+ * @author admin
+ * @date 2025/08/11
+ * @constructor 创建[OnboardingRepository]
+ */
+interface OnboardingRepository {
+ /**
+ * 在发布之前
+ * @return [Boolean]
+ */
+ suspend fun isLaunchedBefore(): Boolean
+
+ /**
+ * 马克发射
+ */
+ suspend fun markLaunched()
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/data/repository/SettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/taskttl/data/repository/SettingsRepository.kt
new file mode 100644
index 0000000..b41374d
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/data/repository/SettingsRepository.kt
@@ -0,0 +1,20 @@
+package com.taskttl.data.repository
+
+/**
+ * 设置存储库
+ * @author admin
+ * @date 2025/08/11
+ * @constructor 创建[SettingsRepository]
+ */
+interface SettingsRepository {
+ /**
+ * 在发布之前
+ * @return [Boolean]
+ */
+ suspend fun isLaunchedBefore(): Boolean
+
+ /**
+ * 马克发射
+ */
+ suspend fun markLaunched()
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/data/repository/TaskRepository.kt b/composeApp/src/commonMain/kotlin/com/taskttl/data/repository/TaskRepository.kt
new file mode 100644
index 0000000..1f4614c
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/data/repository/TaskRepository.kt
@@ -0,0 +1,57 @@
+package com.taskttl.data.repository
+
+import com.taskttl.data.local.model.Task
+import kotlinx.coroutines.flow.Flow
+
+/**
+ * 任务仓库
+ * @author admin
+ * @date 2025/09/27
+ * @constructor 创建[TaskRepository]
+ */
+interface TaskRepository {
+ /**
+ * 获取所有任务
+ * @return [Flow>]
+ */
+ suspend fun getAllTasks(): Flow>
+
+ /**
+ * 按id获取任务
+ * @param [id] 本我
+ * @return [Task?]
+ */
+ suspend fun getTaskById(id: String): Task?
+
+ /**
+ * 插入任务
+ * @param [task] 任务
+ */
+ suspend fun insertTask(task: Task)
+
+ /**
+ * 更新任务
+ * @param [task] 任务
+ */
+ suspend fun updateTask(task: Task)
+
+ /**
+ * 删除任务
+ * @param [id] 本我
+ */
+ suspend fun deleteTask(id: String)
+
+ /**
+ * 按类别获取任务
+ * @param [category] 类别
+ * @return [Flow>]
+ */
+ suspend fun getTasksByCategory(categoryId: String): Flow>
+
+ /**
+ * 搜索任务
+ * @param [query] 怎么翻译
+ * @return [Flow>]
+ */
+ suspend fun searchTasks(query: String): Flow>
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/data/repository/impl/CategoryRepositoryImpl.kt b/composeApp/src/commonMain/kotlin/com/taskttl/data/repository/impl/CategoryRepositoryImpl.kt
new file mode 100644
index 0000000..762a3ad
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/data/repository/impl/CategoryRepositoryImpl.kt
@@ -0,0 +1,259 @@
+package com.taskttl.data.repository.impl
+
+import com.taskttl.data.local.dao.CategoryDao
+import com.taskttl.data.local.dao.CountdownDao
+import com.taskttl.data.local.dao.TaskDao
+import com.taskttl.data.mapper.CategoryMapper
+import com.taskttl.data.local.model.Category
+import com.taskttl.data.local.model.CategoryColor
+import com.taskttl.data.local.model.CategoryIcon
+import com.taskttl.data.local.model.CategoryStatistics
+import com.taskttl.data.local.model.CategoryType
+import com.taskttl.data.repository.CategoryRepository
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.map
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.toLocalDateTime
+import kotlin.time.Clock
+import kotlin.time.ExperimentalTime
+import kotlin.uuid.ExperimentalUuidApi
+import kotlin.uuid.Uuid
+
+/**
+ * 类别存储库impl
+ * @author admin
+ * @date 2025/10/05
+ * @constructor 创建[CategoryRepositoryImpl]
+ * @param [categoryDao] 类别Dao
+ * @param [taskDao] 任务Dao
+ * @param [countdownDao] 倒数日Dao
+ * @param [categoryMapper] 类别映射器
+ */
+class CategoryRepositoryImpl(
+ private val categoryDao: CategoryDao,
+ private val taskDao: TaskDao,
+ private val countdownDao: CountdownDao,
+ private val categoryMapper: CategoryMapper
+) : CategoryRepository {
+
+ /**
+ * 获取全部分类
+ * @return [Flow>]
+ */
+ override suspend fun getAllCategories(): Flow> {
+ return categoryDao.getAllCategories().map { entities ->
+ entities.map { categoryMapper.entityToDomain(it) }
+ }
+ }
+
+ /**
+ * 获取分类通过类型
+ * @param [type] 类型
+ * @return [Flow>]
+ */
+ override suspend fun getCategoriesByType(type: CategoryType): Flow> {
+ return categoryDao.getCategoriesByType(type.name).map { entities ->
+ entities.map { categoryMapper.entityToDomain(it) }
+ }
+ }
+
+ /**
+ * 获取类别通过ID
+ * @param [id] ID
+ * @return [Category?]
+ */
+ override suspend fun getCategoryById(id: String): Category? {
+ return categoryDao.getCategoryById(id)?.let { categoryMapper.entityToDomain(it) }
+ }
+
+ /**
+ * 获取类别通过名字和类型
+ * @param [name] 名字
+ * @param [type] 类型
+ * @return [Category?]
+ */
+ override suspend fun getCategoryByNameAndType(name: String, type: CategoryType): Category? {
+ return categoryDao.getCategoryByNameAndType(name, type.name)?.let {
+ categoryMapper.entityToDomain(it)
+ }
+ }
+
+ /**
+ * 插入类别
+ * @param [category] 类别
+ */
+ override suspend fun insertCategory(category: Category) {
+ categoryDao.insertCategory(categoryMapper.domainToEntity(category))
+ }
+
+ /**
+ * 插入类别
+ * @param [categories] 分类
+ */
+ override suspend fun insertCategories(categories: List) {
+ val entities = categories.map { categoryMapper.domainToEntity(it) }
+ categoryDao.insertCategories(entities)
+ }
+
+ /**
+ * 更新类别
+ * @param [category] 类别
+ */
+ override suspend fun updateCategory(category: Category) {
+ categoryDao.updateCategory(categoryMapper.domainToEntity(category))
+ }
+
+ /**
+ * 删除类别
+ * @param [id] ID
+ */
+ override suspend fun deleteCategory(id: String) {
+ categoryDao.deleteCategory(id)
+ }
+
+ /**
+ * 初始化默认类别
+ */
+ @OptIn(ExperimentalTime::class, ExperimentalUuidApi::class)
+ override suspend fun initializeDefaultCategories() {
+ val now = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault())
+
+ // 默认任务分类
+ val defaultTaskCategories = listOf(
+ Category(
+ id = Uuid.random().toString(),
+ name = "工作",
+ color = CategoryColor.BLUE,
+ icon = CategoryIcon.BRIEFCASE,
+ type = CategoryType.TASK,
+ createdAt = now,
+ updatedAt = now
+ ),
+ Category(
+ id = Uuid.random().toString(),
+ name = "生活",
+ color = CategoryColor.GREEN,
+ icon = CategoryIcon.HOME,
+ type = CategoryType.TASK,
+ createdAt = now,
+ updatedAt = now
+ ),
+ Category(
+ id = Uuid.random().toString(),
+ name = "健康",
+ color = CategoryColor.ORANGE,
+ icon = CategoryIcon.HEART,
+ type = CategoryType.TASK,
+ createdAt = now,
+ updatedAt = now
+ ),
+ Category(
+ id = Uuid.random().toString(),
+ name = "学习",
+ color = CategoryColor.PURPLE,
+ icon = CategoryIcon.BOOK,
+ type = CategoryType.TASK,
+ createdAt = now,
+ updatedAt = now
+ )
+ )
+
+ // 默认倒数日分类
+ val defaultCountdownCategories = listOf(
+ Category(
+ id = Uuid.random().toString(),
+ name = "生日",
+ color = CategoryColor.PINK,
+ icon = CategoryIcon.BIRTHDAY,
+ type = CategoryType.COUNTDOWN,
+ createdAt = now,
+ updatedAt = now
+ ),
+ Category(
+ id = Uuid.random().toString(),
+ name = "节日",
+ color = CategoryColor.CYAN,
+ icon = CategoryIcon.FESTIVAL,
+ type = CategoryType.COUNTDOWN,
+ createdAt = now,
+ updatedAt = now
+ ),
+ Category(
+ id = Uuid.random().toString(),
+ name = "目标",
+ color = CategoryColor.YELLOW,
+ icon = CategoryIcon.GOAL,
+ type = CategoryType.COUNTDOWN,
+ createdAt = now,
+ updatedAt = now
+ ),
+ Category(
+ id = Uuid.random().toString(),
+ name = "考试",
+ color = CategoryColor.GREEN,
+ icon = CategoryIcon.EXAM,
+ type = CategoryType.COUNTDOWN,
+ createdAt = now,
+ updatedAt = now
+ )
+ )
+
+ insertCategories(defaultTaskCategories + defaultCountdownCategories)
+ }
+
+ /**
+ * 获取分类随着计数
+ * @param [type] 类型
+ * @return [Flow>]
+ */
+ override suspend fun getCategoriesWithCounts(type: CategoryType): Flow> {
+ return categoryDao.getCategoriesWithCounts(type.name).map { entities ->
+ entities.map { categoryMapper.entityToDomain(it) }
+ }
+ }
+
+ /**
+ * 获取类别统计
+ * @return [Flow>]
+ */
+ override suspend fun getCategoryStatistics(): Flow> {
+ return combine(
+ categoryDao.getAllCategories(),
+ taskDao.getAllTasks(),
+ countdownDao.getAllCountdowns()
+ ) { categories, tasks, countdowns ->
+ categories.map { category ->
+ val categoryTasks = tasks.filter { it.category == category }
+ val categoryCountdowns = countdowns.filter { it.category == category }
+
+ val totalTasks = categoryTasks.size
+ val completedTasks = categoryTasks.count { it.task.isCompleted }
+ val pendingTasks = totalTasks - completedTasks
+ val totalCountdowns = categoryCountdowns.size
+ val activeCountdowns = categoryCountdowns.count { it.countdown.isActive }
+ val completionRate =
+ if (totalTasks > 0) completedTasks.toFloat() / totalTasks else 0f
+
+ CategoryStatistics(
+ categoryId = category.id,
+ categoryName = category.name,
+ totalTasks = totalTasks,
+ completedTasks = completedTasks,
+ pendingTasks = pendingTasks,
+ totalCountdowns = totalCountdowns,
+ activeCountdowns = activeCountdowns,
+ completionRate = completionRate
+ )
+ }
+ }
+ }
+
+ /**
+ * 更新类别计数
+ */
+ override suspend fun updateCategoryCounts() {
+ categoryDao.updateTaskCounts()
+ categoryDao.updateCountdownCounts()
+ }
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/data/repository/impl/CountdownRepositoryImpl.kt b/composeApp/src/commonMain/kotlin/com/taskttl/data/repository/impl/CountdownRepositoryImpl.kt
new file mode 100644
index 0000000..cb85055
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/data/repository/impl/CountdownRepositoryImpl.kt
@@ -0,0 +1,76 @@
+package com.taskttl.data.repository.impl
+
+import com.taskttl.data.local.dao.CountdownDao
+import com.taskttl.data.mapper.CountdownMapper
+import com.taskttl.data.local.model.Countdown
+import com.taskttl.data.repository.CountdownRepository
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+
+/**
+ * 倒数日存储库impl
+ * @author admin
+ * @date 2025/10/05
+ * @constructor 创建[CountdownRepositoryImpl]
+ * @param [countdownDao] 倒数日Dao
+ * @param [countdownMapper] 倒数日映射器
+ */
+class CountdownRepositoryImpl(
+ private val countdownDao: CountdownDao,
+ private val countdownMapper: CountdownMapper
+) : CountdownRepository {
+
+ /**
+ * 获取全部倒数日
+ * @return [Flow>]
+ */
+ override suspend fun getAllCountdowns(): Flow> {
+ return countdownDao.getAllCountdowns().map { entities ->
+ entities.map { countdownMapper.entityToDomain(it) }
+ }
+ }
+
+ /**
+ * 获取倒数日通过ID
+ * @param [id] ID
+ * @return [Countdown?]
+ */
+ override suspend fun getCountdownById(id: String): Countdown? {
+ return countdownDao.getCountdownById(id)?.let { countdownMapper.entityToDomain(it) }
+ }
+
+ /**
+ * 插入倒数日
+ * @param [countdown] 倒数日
+ */
+ override suspend fun insertCountdown(countdown: Countdown) {
+ countdownDao.insertCountdown(countdownMapper.domainToEntity(countdown))
+ }
+
+ /**
+ * 更新倒数日
+ * @param [countdown] 倒数日
+ */
+ override suspend fun updateCountdown(countdown: Countdown) {
+ countdownDao.updateCountdown(countdownMapper.domainToEntity(countdown))
+ }
+
+ /**
+ * 删除倒数日
+ * @param [id] ID
+ */
+ override suspend fun deleteCountdown(id: String) {
+ countdownDao.deleteCountdown(id)
+ }
+
+ /**
+ * 获取倒数日通过类别
+ * @param [category] 类别
+ * @return [Flow>]
+ */
+ override suspend fun getCountdownsByCategory(category: String): Flow> {
+ return countdownDao.getCountdownsByCategory(category).map { entities ->
+ entities.map { countdownMapper.entityToDomain(it) }
+ }
+ }
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/data/repository/impl/OnboardingRepositoryImpl.kt b/composeApp/src/commonMain/kotlin/com/taskttl/data/repository/impl/OnboardingRepositoryImpl.kt
new file mode 100644
index 0000000..f8a36e3
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/data/repository/impl/OnboardingRepositoryImpl.kt
@@ -0,0 +1,30 @@
+package com.taskttl.data.repository.impl
+
+import com.taskttl.core.utils.StorageUtils
+import com.taskttl.data.repository.OnboardingRepository
+
+/**
+ * 设置存储库impl
+ * @author admin
+ * @date 2025/08/11
+ * @constructor 创建[OnboardingRepositoryImpl]
+ */
+class OnboardingRepositoryImpl : OnboardingRepository {
+ private val onboardingResult = "ONBOARDING_RESULT"
+
+ /**
+ * 在发布之前
+ * @return [Boolean]
+ */
+ override suspend fun isLaunchedBefore(): Boolean {
+ return StorageUtils.getBoolean(onboardingResult, true)
+ }
+
+ /**
+ * 马克发射
+ */
+ override suspend fun markLaunched() {
+ StorageUtils.saveBoolean(onboardingResult, false)
+ }
+
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/data/repository/impl/SettingsRepositoryImpl.kt b/composeApp/src/commonMain/kotlin/com/taskttl/data/repository/impl/SettingsRepositoryImpl.kt
new file mode 100644
index 0000000..80d34c4
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/data/repository/impl/SettingsRepositoryImpl.kt
@@ -0,0 +1,30 @@
+package com.taskttl.data.repository.impl
+
+import com.taskttl.core.utils.StorageUtils
+import com.taskttl.data.repository.SettingsRepository
+
+/**
+ * 设置存储库impl
+ * @author admin
+ * @date 2025/08/11
+ * @constructor 创建[SettingsRepositoryImpl]
+ */
+class SettingsRepositoryImpl : SettingsRepository {
+ private val onboardingResult = "ONBOARDING_RESULT"
+
+ /**
+ * 在发布之前
+ * @return [Boolean]
+ */
+ override suspend fun isLaunchedBefore(): Boolean {
+ return StorageUtils.getBoolean(onboardingResult, true)
+ }
+
+ /**
+ * 马克发射
+ */
+ override suspend fun markLaunched() {
+ StorageUtils.saveBoolean(onboardingResult, false)
+ }
+
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/data/repository/impl/TaskRepositoryImpl.kt b/composeApp/src/commonMain/kotlin/com/taskttl/data/repository/impl/TaskRepositoryImpl.kt
new file mode 100644
index 0000000..cdaa269
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/data/repository/impl/TaskRepositoryImpl.kt
@@ -0,0 +1,87 @@
+package com.taskttl.data.repository.impl
+
+import com.taskttl.data.local.dao.TaskDao
+import com.taskttl.data.mapper.TaskMapper
+import com.taskttl.data.local.model.Task
+import com.taskttl.data.repository.TaskRepository
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+
+/**
+ * 任务存储库impl
+ * @author admin
+ * @date 2025/09/27
+ * @constructor 创建[TaskRepositoryImpl]
+ * @param [taskDao] 任务dao
+ * @param [taskMapper] 任务映射器
+ */
+class TaskRepositoryImpl(
+ private val taskDao: TaskDao,
+ private val taskMapper: TaskMapper
+) : TaskRepository {
+
+ /**
+ * 获取所有任务
+ * @return [Flow>]
+ */
+ override suspend fun getAllTasks(): Flow> {
+ return taskDao.getAllTasks().map { entities ->
+ entities.map { taskMapper.entityToDomain(it) }
+ }
+ }
+
+ /**
+ * 按id获取任务
+ * @param [id] 本我
+ * @return [Task?]
+ */
+ override suspend fun getTaskById(id: String): Task? {
+ return taskDao.getTaskById(id)?.let { taskMapper.entityToDomain(it) }
+ }
+
+ /**
+ * 插入任务
+ * @param [task] 任务
+ */
+ override suspend fun insertTask(task: Task) {
+ taskDao.insertTask(taskMapper.domainToEntity(task))
+ }
+
+ /**
+ * 更新任务
+ * @param [task] 任务
+ */
+ override suspend fun updateTask(task: Task) {
+ taskDao.updateTask(taskMapper.domainToEntity(task))
+ }
+
+ /**
+ * 删除任务
+ * @param [id] 本我
+ */
+ override suspend fun deleteTask(id: String) {
+ taskDao.deleteTask(id)
+ }
+
+ /**
+ * 按类别获取任务
+ * @param [categoryId] 类别
+ * @return [Flow>]
+ */
+ override suspend fun getTasksByCategory(categoryId: String): Flow> {
+ return taskDao.getTasksByCategory(categoryId).map { entities ->
+ entities.map { taskMapper.entityToDomain(it) }
+ }
+ }
+
+ /**
+ * 搜索任务
+ * @param [query] 怎么翻译
+ * @return [Flow>]
+ */
+ override suspend fun searchTasks(query: String): Flow> {
+ return taskDao.searchTasks(query).map { entities ->
+ entities.map { taskMapper.entityToDomain(it) }
+ }
+ }
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/data/state/CategoryState.kt b/composeApp/src/commonMain/kotlin/com/taskttl/data/state/CategoryState.kt
new file mode 100644
index 0000000..6119e42
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/data/state/CategoryState.kt
@@ -0,0 +1,92 @@
+package com.taskttl.data.state
+
+import com.taskttl.data.local.model.Category
+import com.taskttl.data.local.model.CategoryStatistics
+import com.taskttl.data.local.model.CategoryType
+
+/**
+ * 类别状态
+ * @author admin
+ * @date 2025/10/05
+ * @constructor 创建[CategoryState]
+ * @param [categories] 分类
+ * @param [editingCategory] 编辑类别
+ * @param [taskCategories] 任务类别
+ * @param [countdownCategories] 倒计时类别
+ * @param [categoryStatistics] 类别统计
+ * @param [selectedCategory] 所选类别
+ * @param [selectedType] 选择类型
+ * @param [isLoading] 正在加载
+ * @param [error] 错误
+ * @param [showAddDialog] 显示添加对话
+ * @param [showEditDialog] 显示编辑对话
+ * @param [showDeleteDialog] 显示删除对话
+ */
+data class CategoryState(
+ val categories: List = emptyList(),
+ val editingCategory: Category? = null,
+ val taskCategories: List = emptyList(),
+ val countdownCategories: List = emptyList(),
+ val categoryStatistics: List = emptyList(),
+ val selectedCategory: Category? = null,
+ val selectedType: CategoryType = CategoryType.TASK,
+ val isLoading: Boolean = false,
+ val error: String? = null,
+ val showAddDialog: Boolean = false,
+ val showEditDialog: Boolean = false,
+ val showDeleteDialog: Boolean = false
+)
+
+/**
+ * 类别意图
+ * @author admin
+ * @date 2025/10/05
+ * @constructor 创建[CategoryIntent]
+ */
+sealed class CategoryIntent {
+ /**
+ * 加载类别
+ * @author admin
+ * @date 2025/10/03
+ */
+ object LoadCategories : CategoryIntent()
+
+ /**
+ * 按类型划分加载类别
+ * @author admin
+ * @date 2025/10/03
+ * @constructor 创建[LoadCategoriesByType]
+ * @param [type] 类型
+ */
+ data class LoadCategoriesByType(val type: CategoryType) : CategoryIntent()
+ object LoadCategoryStatistics : CategoryIntent()
+ data class SelectCategory(val category: Category?) : CategoryIntent()
+ data class SelectType(val type: CategoryType) : CategoryIntent()
+
+ data class GetCategoryById(val categoryId: String) : CategoryIntent()
+ data class AddCategory(val category: Category) : CategoryIntent()
+ data class UpdateCategory(val category: Category) : CategoryIntent()
+ data class DeleteCategory(val categoryId: String) : CategoryIntent()
+ object InitializeDefaultCategories : CategoryIntent()
+ object UpdateCategoryCounts : CategoryIntent()
+ object ShowAddDialog : CategoryIntent()
+ object HideAddDialog : CategoryIntent()
+ data class ShowEditDialog(val category: Category) : CategoryIntent()
+ object HideEditDialog : CategoryIntent()
+ data class ShowDeleteDialog(val category: Category) : CategoryIntent()
+ object HideDeleteDialog : CategoryIntent()
+ object ClearError : CategoryIntent()
+}
+
+/**
+ * 类别效应
+ * @author admin
+ * @date 2025/10/05
+ * @constructor 创建[CategoryEffect]
+ */
+sealed class CategoryEffect {
+ data class ShowMessage(val message: String) : CategoryEffect()
+ data class NavigateToCategoryDetail(val categoryId: String) : CategoryEffect()
+ object NavigateBack : CategoryEffect()
+ data class ShowConfirmDialog(val message: String, val onConfirm: () -> Unit) : CategoryEffect()
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/data/state/CountdownState.kt b/composeApp/src/commonMain/kotlin/com/taskttl/data/state/CountdownState.kt
new file mode 100644
index 0000000..934807a
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/data/state/CountdownState.kt
@@ -0,0 +1,56 @@
+package com.taskttl.data.state
+
+import com.taskttl.data.local.model.Category
+import com.taskttl.data.local.model.Countdown
+
+/**
+ * 倒数日状态
+ * @author admin
+ * @date 2025/10/05
+ * @constructor 创建[CountdownState]
+ * @param [countdowns] 倒数读秒
+ * @param [categories] 分类
+ * @param [editingCountdown] 编辑倒计时
+ * @param [filteredCountdowns] 过滤倒计时
+ * @param [selectedCategory] 所选类别
+ * @param [isLoading] 正在加载
+ * @param [error] 错误
+ */
+data class CountdownState(
+ val countdowns: List = emptyList(),
+ val categories: List = emptyList(),
+ val editingCountdown: Countdown? = null,
+ val filteredCountdowns: List = emptyList(),
+ val selectedCategory: Category? = null,
+ val isLoading: Boolean = false,
+ val error: String? = null
+)
+
+/**
+ * 倒数日意图
+ * @author admin
+ * @date 2025/10/05
+ * @constructor 创建[CountdownIntent]
+ */
+sealed class CountdownIntent {
+ object LoadCountdowns : CountdownIntent()
+
+ data class GetCountdownById(val countdownId: String) : CountdownIntent()
+ data class AddCountdown(val countdown: Countdown) : CountdownIntent()
+ data class UpdateCountdown(val countdown: Countdown) : CountdownIntent()
+ data class DeleteCountdown(val countdownId: String) : CountdownIntent()
+ data class FilterByCategory(val category: Category?) : CountdownIntent()
+ object ClearError : CountdownIntent()
+}
+
+/**
+ * 倒数日效应
+ * @author admin
+ * @date 2025/10/05
+ * @constructor 创建[CountdownEffect]
+ */
+sealed class CountdownEffect {
+ data class ShowMessage(val message: String) : CountdownEffect()
+ data class NavigateToCountdownDetail(val countdownId: String) : CountdownEffect()
+ object NavigateBack : CountdownEffect()
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/data/state/OnboardingState.kt b/composeApp/src/commonMain/kotlin/com/taskttl/data/state/OnboardingState.kt
new file mode 100644
index 0000000..c9fd9ce
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/data/state/OnboardingState.kt
@@ -0,0 +1,44 @@
+package com.taskttl.data.state
+
+import com.taskttl.data.local.model.OnboardingPage
+
+/**
+ * 引导状态
+ * @author DevTTL
+ * @date 2025/09/06
+ * @constructor 创建[OnboardingState]
+ */
+data class OnboardingState(
+ val isLoading: Boolean = false,
+ val pages: List
+)
+
+/**
+ * 引导意图
+ * @author DevTTL
+ * @date 2025/09/06
+ * @constructor 创建[OnboardingIntent]
+ */
+sealed class OnboardingIntent {}
+
+/**
+ * 引导活动
+ * @author DevTTL
+ * @date 2025/09/06
+ * @constructor 创建[OnboardingEvent]
+ */
+sealed class OnboardingEvent {
+ /**
+ * 下一页
+ * @author DevTTL
+ * @date 2025/09/06
+ */
+ data object NextPage : OnboardingEvent()
+
+ /**
+ * 导航Main
+ * @author admin
+ * @date 2025/10/05
+ */
+ data object NavMain : OnboardingEvent()
+}
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/data/state/SplashState.kt b/composeApp/src/commonMain/kotlin/com/taskttl/data/state/SplashState.kt
new file mode 100644
index 0000000..d616ee2
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/data/state/SplashState.kt
@@ -0,0 +1,30 @@
+package com.taskttl.data.state
+
+/**
+ * 启动页状态
+ * @author admin
+ * @date 2025/10/05
+ * @constructor 创建[SplashState]
+ */
+sealed interface SplashState {
+ /**
+ * 加载中
+ * @author admin
+ * @date 2025/08/11
+ */
+ data object Loading : SplashState
+
+ /**
+ * 导航到首页
+ * @author admin
+ * @date 2025/08/11
+ */
+ data object NavigateToMain : SplashState
+
+ /**
+ * 导航到引导页
+ * @author admin
+ * @date 2025/08/11
+ */
+ data object NavigateToOnboarding : SplashState
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/data/state/TaskState.kt b/composeApp/src/commonMain/kotlin/com/taskttl/data/state/TaskState.kt
new file mode 100644
index 0000000..c652629
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/data/state/TaskState.kt
@@ -0,0 +1,179 @@
+package com.taskttl.data.state
+
+import com.taskttl.data.local.model.Category
+import com.taskttl.data.local.model.Task
+
+/**
+ * 任务状态
+ * @author admin
+ * @date 2025/09/27
+ * @constructor 创建[TaskState]
+ * @param [tasks] 任务
+ * @param [filteredTasks] 已筛选任务
+ * @param [selectedCategory] 所选类别
+ * @param [isSearch] 正在搜索
+ * @param [searchQuery] 搜索查询
+ * @param [isLoading] 正在加载
+ * @param [error] 错误
+ * @param [showCompleted] 显示已完成
+ */
+data class TaskState(
+ val tasks: List = emptyList(),
+ val categories: List = emptyList(),
+ val editingTask: Task? = null,
+ val filteredTasks: List = emptyList(),
+ val selectedCategory: Category? = null,
+ val isSearch: Boolean = false,
+ val searchQuery: String = "",
+ val isLoading: Boolean = false,
+ val error: String? = null,
+ val showCompleted: Boolean = false
+)
+
+/**
+ * 任务意图
+ * @author admin
+ * @date 2025/09/27
+ * @constructor 创建[TaskIntent]
+ */
+sealed class TaskIntent {
+ /**
+ * 加载任务
+ * @author admin
+ * @date 2025/09/27
+ */
+ object LoadTasks : TaskIntent()
+
+ data class GetTaskById(val taskId: String) : TaskIntent()
+
+ /**
+ * 添加任务
+ * @author admin
+ * @date 2025/09/27
+ * @constructor 创建[AddTask]
+ * @param [task] 任务
+ */
+ data class AddTask(val task: Task) : TaskIntent()
+
+ /**
+ * 更新任务
+ * @author admin
+ * @date 2025/09/27
+ * @constructor 创建[UpdateTask]
+ * @param [task] 任务
+ */
+ data class UpdateTask(val task: Task) : TaskIntent()
+
+ /**
+ * 删除任务
+ * @author admin
+ * @date 2025/09/27
+ * @constructor 创建[DeleteTask]
+ * @param [taskId] 任务ID
+ */
+ data class DeleteTask(val taskId: String) : TaskIntent()
+
+ /**
+ * 切换任务完成
+ * @author admin
+ * @date 2025/09/27
+ * @constructor 创建[ToggleTaskCompletion]
+ * @param [taskId] 任务ID
+ */
+ data class ToggleTaskCompletion(val taskId: String) : TaskIntent()
+
+ /**
+ * 按类别筛选
+ * @author admin
+ * @date 2025/09/27
+ * @constructor 创建[FilterByCategory]
+ * @param [category] 类别
+ */
+ data class FilterByCategory(val category: Category?) : TaskIntent()
+
+ /**
+ * 搜索任务
+ * @author admin
+ * @date 2025/09/27
+ * @constructor 创建[SearchTasks]
+ * @param [query] 怎么翻译
+ */
+ data class SearchTasks(val query: String) : TaskIntent()
+
+ /**
+ * 切换显示完成
+ * @author admin
+ * @date 2025/09/27
+ * @constructor 创建[ToggleShowCompleted]
+ * @param [show] 显示
+ */
+ data class ToggleShowCompleted(val show: Boolean) : TaskIntent()
+
+ /**
+ * 搜索视图
+ * @author admin
+ * @date 2025/09/27
+ */
+ object SearchView : TaskIntent()
+
+ /**
+ * 清除错误
+ * @author admin
+ * @date 2025/09/27
+ */
+ object ClearError : TaskIntent()
+
+ /**
+ * 导航返回
+ * @author admin
+ * @date 2025/09/27
+ */
+ object NavigateBack : TaskIntent()
+
+ /**
+ * 导航到编辑任务
+ * @author admin
+ * @date 2025/09/27
+ */
+ object NavigateToEditTask : TaskIntent()
+}
+
+/**
+ * 任务效果
+ * @author admin
+ * @date 2025/09/27
+ * @constructor 创建[TaskEffect]
+ */
+sealed class TaskEffect {
+ /**
+ * 显示消息
+ * @author admin
+ * @date 2025/09/27
+ * @constructor 创建[ShowMessage]
+ * @param [message] 消息
+ */
+ data class ShowMessage(val message: String) : TaskEffect()
+
+ /**
+ * 导航到任务详细信息
+ * @author admin
+ * @date 2025/09/27
+ * @constructor 创建[NavigateToTaskDetail]
+ * @param [taskId] 任务ID
+ */
+ data class NavigateToTaskDetail(val taskId: String) : TaskEffect()
+
+ /**
+ * 导航到编辑任务
+ * @author admin
+ * @date 2025/09/27
+ */
+ data object NavigateToEditTask : TaskEffect()
+
+ /**
+ * 导航返回
+ * @author admin
+ * @date 2025/09/27
+ */
+ object NavigateBack : TaskEffect()
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/data/viewmodel/CategoryViewModel.kt b/composeApp/src/commonMain/kotlin/com/taskttl/data/viewmodel/CategoryViewModel.kt
new file mode 100644
index 0000000..f3367f3
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/data/viewmodel/CategoryViewModel.kt
@@ -0,0 +1,253 @@
+package com.taskttl.data.viewmodel
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.taskttl.data.local.model.Category
+import com.taskttl.data.local.model.CategoryType
+import com.taskttl.data.repository.CategoryRepository
+import com.taskttl.data.state.CategoryEffect
+import com.taskttl.data.state.CategoryIntent
+import com.taskttl.data.state.CategoryState
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+
+/**
+ * 类别视图模型
+ * @author admin
+ * @date 2025/10/04
+ * @constructor 创建[CategoryViewModel]
+ * @param [categoryRepository] 类别存储库
+ */
+class CategoryViewModel(private val categoryRepository: CategoryRepository) : ViewModel() {
+
+ private val _state = MutableStateFlow(CategoryState())
+ val state: StateFlow = _state.asStateFlow()
+
+ private val _effects = MutableSharedFlow()
+ val effects: SharedFlow = _effects.asSharedFlow()
+
+ init {
+ handleIntent(CategoryIntent.LoadCategories)
+ handleIntent(CategoryIntent.LoadCategoryStatistics)
+ }
+
+ fun handleIntent(intent: CategoryIntent) {
+ when (intent) {
+ is CategoryIntent.LoadCategories -> loadCategories()
+ is CategoryIntent.LoadCategoriesByType -> loadCategoriesByType(intent.type)
+ is CategoryIntent.LoadCategoryStatistics -> loadCategoryStatistics()
+ is CategoryIntent.SelectCategory -> selectCategory(intent.category)
+ is CategoryIntent.SelectType -> selectType(intent.type)
+ is CategoryIntent.GetCategoryById -> getCategoryById(intent.categoryId)
+ is CategoryIntent.AddCategory -> addCategory(intent.category)
+ is CategoryIntent.UpdateCategory -> updateCategory(intent.category)
+ is CategoryIntent.DeleteCategory -> deleteCategory(intent.categoryId)
+ is CategoryIntent.InitializeDefaultCategories -> initializeDefaultCategories()
+ is CategoryIntent.UpdateCategoryCounts -> updateCategoryCounts()
+ is CategoryIntent.ShowAddDialog -> showAddDialog()
+ is CategoryIntent.HideAddDialog -> hideAddDialog()
+ is CategoryIntent.ShowEditDialog -> showEditDialog(intent.category)
+ is CategoryIntent.HideEditDialog -> hideEditDialog()
+ is CategoryIntent.ShowDeleteDialog -> showDeleteDialog(intent.category)
+ is CategoryIntent.HideDeleteDialog -> hideDeleteDialog()
+ is CategoryIntent.ClearError -> clearError()
+ }
+ }
+
+ /**
+ * 加载类别
+ */
+ private fun loadCategories() {
+ viewModelScope.launch {
+ _state.value = _state.value.copy(isLoading = true, error = null)
+ try {
+ categoryRepository.getAllCategories().collect { categories ->
+ val taskCategories = categories.filter { it.type == CategoryType.TASK }
+ val countdownCategories =
+ categories.filter { it.type == CategoryType.COUNTDOWN }
+
+ _state.value = _state.value.copy(
+ categories = categories,
+ taskCategories = taskCategories,
+ countdownCategories = countdownCategories,
+ isLoading = false
+ )
+ }
+ } catch (e: Exception) {
+ _state.value = _state.value.copy(
+ isLoading = false,
+ error = e.message ?: "加载分类失败"
+ )
+ }
+ }
+ }
+
+ /**
+ * 按id获取类别
+ * @param [categoryId] 类别ID
+ */
+ private fun getCategoryById(categoryId: String) {
+ viewModelScope.launch {
+ try {
+ val category = categoryRepository.getCategoryById(categoryId)
+ _state.value = _state.value.copy(editingCategory = category)
+ } catch (e: Exception) {
+ _state.value = _state.value.copy(error = e.message ?: "查询分类失败")
+ }
+ }
+ }
+
+ /**
+ * 加载分类通过类型
+ * @param [type] 类型
+ */
+ private fun loadCategoriesByType(type: CategoryType) {
+ viewModelScope.launch {
+ try {
+ categoryRepository.getCategoriesByType(type).collect { categories ->
+ when (type) {
+ CategoryType.TASK -> {
+ _state.value = _state.value.copy(taskCategories = categories)
+ }
+
+ CategoryType.COUNTDOWN -> {
+ _state.value = _state.value.copy(countdownCategories = categories)
+ }
+ }
+ }
+ } catch (e: Exception) {
+ _state.value = _state.value.copy(error = e.message ?: "加载分类失败")
+ }
+ }
+ }
+
+ /**
+ * 加载类别统计
+ */
+ private fun loadCategoryStatistics() {
+ viewModelScope.launch {
+ try {
+ categoryRepository.getCategoryStatistics().collect { statistics ->
+ _state.value = _state.value.copy(categoryStatistics = statistics)
+ }
+ } catch (e: Exception) {
+ _state.value = _state.value.copy(error = e.message ?: "加载统计数据失败")
+ }
+ }
+ }
+
+ private fun selectCategory(category: Category?) {
+ _state.value = _state.value.copy(selectedCategory = category)
+ }
+
+ private fun selectType(type: CategoryType) {
+ _state.value = _state.value.copy(selectedType = type)
+ }
+
+ /**
+ * 添加类别
+ * @param [category] 类别
+ */
+ private fun addCategory(category: Category) {
+ viewModelScope.launch {
+ try {
+ categoryRepository.insertCategory(category)
+ _effects.emit(CategoryEffect.ShowMessage("分类添加成功"))
+ _effects.emit(CategoryEffect.NavigateBack)
+ } catch (e: Exception) {
+ _state.value = _state.value.copy(error = e.message ?: "添加分类失败")
+ }
+ }
+ }
+
+ private fun updateCategory(category: Category) {
+ viewModelScope.launch {
+ try {
+ categoryRepository.updateCategory(category)
+ _effects.emit(CategoryEffect.ShowMessage("分类更新成功"))
+ _effects.emit(CategoryEffect.NavigateBack)
+ hideEditDialog()
+ } catch (e: Exception) {
+ _state.value = _state.value.copy(error = e.message ?: "更新分类失败")
+ }
+ }
+ }
+
+ private fun deleteCategory(categoryId: String) {
+ viewModelScope.launch {
+ try {
+ categoryRepository.deleteCategory(categoryId)
+ _effects.emit(CategoryEffect.ShowMessage("分类删除成功"))
+ hideDeleteDialog()
+ } catch (e: Exception) {
+ _state.value = _state.value.copy(error = e.message ?: "删除分类失败")
+ }
+ }
+ }
+
+ private fun initializeDefaultCategories() {
+ viewModelScope.launch {
+ try {
+ categoryRepository.initializeDefaultCategories()
+ _effects.emit(CategoryEffect.ShowMessage("默认分类初始化成功"))
+ } catch (e: Exception) {
+ _state.value = _state.value.copy(error = e.message ?: "初始化默认分类失败")
+ }
+ }
+ }
+
+ private fun updateCategoryCounts() {
+ viewModelScope.launch {
+ try {
+ categoryRepository.updateCategoryCounts()
+ } catch (e: Exception) {
+ _state.value = _state.value.copy(error = e.message ?: "更新分类计数失败")
+ }
+ }
+ }
+
+ private fun showAddDialog() {
+ _state.value = _state.value.copy(showAddDialog = true)
+ }
+
+ private fun hideAddDialog() {
+ _state.value = _state.value.copy(showAddDialog = false)
+ }
+
+ private fun showEditDialog(category: Category) {
+ _state.value = _state.value.copy(
+ selectedCategory = category,
+ showEditDialog = true
+ )
+ }
+
+ private fun hideEditDialog() {
+ _state.value = _state.value.copy(
+ selectedCategory = null,
+ showEditDialog = false
+ )
+ }
+
+ private fun showDeleteDialog(category: Category) {
+ _state.value = _state.value.copy(
+ selectedCategory = category,
+ showDeleteDialog = true
+ )
+ }
+
+ private fun hideDeleteDialog() {
+ _state.value = _state.value.copy(
+ selectedCategory = null,
+ showDeleteDialog = false
+ )
+ }
+
+ private fun clearError() {
+ _state.value = _state.value.copy(error = null)
+ }
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/data/viewmodel/CountdownViewModel.kt b/composeApp/src/commonMain/kotlin/com/taskttl/data/viewmodel/CountdownViewModel.kt
new file mode 100644
index 0000000..8e22037
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/data/viewmodel/CountdownViewModel.kt
@@ -0,0 +1,144 @@
+package com.taskttl.data.viewmodel
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.taskttl.data.local.model.Category
+import com.taskttl.data.local.model.CategoryType
+import com.taskttl.data.local.model.Countdown
+import com.taskttl.data.repository.CategoryRepository
+import com.taskttl.data.repository.CountdownRepository
+import com.taskttl.data.state.CountdownEffect
+import com.taskttl.data.state.CountdownIntent
+import com.taskttl.data.state.CountdownState
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+
+/**
+ * 倒计时视图模型
+ * @author admin
+ * @date 2025/10/04
+ * @constructor 创建[CountdownViewModel]
+ * @param [countdownRepository] 倒计时存储库
+ */
+class CountdownViewModel(
+ private val countdownRepository: CountdownRepository,
+ private val categoryRepository: CategoryRepository
+) : ViewModel() {
+
+ private val _state = MutableStateFlow(CountdownState())
+ val state: StateFlow = _state.asStateFlow()
+
+ private val _effects = MutableSharedFlow()
+ val effects: SharedFlow = _effects.asSharedFlow()
+
+ init {
+ handleIntent(CountdownIntent.LoadCountdowns)
+ }
+
+ fun handleIntent(intent: CountdownIntent) {
+ when (intent) {
+ is CountdownIntent.LoadCountdowns -> loadCountdowns()
+ is CountdownIntent.GetCountdownById -> getCountdownById(intent.countdownId)
+ is CountdownIntent.AddCountdown -> addCountdown(intent.countdown)
+ is CountdownIntent.UpdateCountdown -> updateCountdown(intent.countdown)
+ is CountdownIntent.DeleteCountdown -> deleteCountdown(intent.countdownId)
+ is CountdownIntent.FilterByCategory -> filterByCategory(intent.category)
+ is CountdownIntent.ClearError -> clearError()
+ }
+ }
+
+ private fun loadCountdowns() {
+ viewModelScope.launch {
+ _state.value = _state.value.copy(isLoading = true, error = null)
+ try {
+ launch {
+ categoryRepository.getCategoriesByType(CategoryType.COUNTDOWN)
+ .collect { categories ->
+ _state.value = _state.value.copy(categories = categories)
+ }
+ }
+ launch {
+ countdownRepository.getAllCountdowns().collect { countdowns ->
+ _state.value = _state.value.copy(
+ countdowns = countdowns,
+ filteredCountdowns = filterCountdowns(countdowns),
+ isLoading = false
+ )
+ }
+ }
+ } catch (e: Exception) {
+ _state.value =
+ _state.value.copy(isLoading = false, error = e.message ?: "加载倒数日失败")
+ }
+ }
+ }
+
+
+ private fun getCountdownById(countdownId: String) {
+ viewModelScope.launch {
+ try {
+ val countdown = countdownRepository.getCountdownById(countdownId)
+ _state.value = _state.value.copy(editingCountdown = countdown)
+ } catch (e: Exception) {
+ _state.value = _state.value.copy(error = e.message ?: "查询倒数日失败")
+ }
+ }
+ }
+
+ private fun addCountdown(countdown: Countdown) {
+ viewModelScope.launch {
+ try {
+ countdownRepository.insertCountdown(countdown)
+ _effects.emit(CountdownEffect.ShowMessage("倒数日添加成功"))
+ _effects.emit(CountdownEffect.NavigateBack)
+ } catch (e: Exception) {
+ _state.value = _state.value.copy(error = e.message ?: "添加倒数日失败")
+ }
+ }
+ }
+
+ private fun updateCountdown(countdown: Countdown) {
+ viewModelScope.launch {
+ try {
+ countdownRepository.updateCountdown(countdown)
+ _effects.emit(CountdownEffect.ShowMessage("倒数日更新成功"))
+ } catch (e: Exception) {
+ _state.value = _state.value.copy(error = e.message ?: "更新倒数日失败")
+ }
+ }
+ }
+
+ private fun deleteCountdown(countdownId: String) {
+ viewModelScope.launch {
+ try {
+ countdownRepository.deleteCountdown(countdownId)
+ _effects.emit(CountdownEffect.ShowMessage("倒数日删除成功"))
+ } catch (e: Exception) {
+ _state.value = _state.value.copy(error = e.message ?: "删除倒数日失败")
+ }
+ }
+ }
+
+ private fun filterByCategory(category: Category?) {
+ _state.value = _state.value.copy(
+ selectedCategory = category,
+ filteredCountdowns = filterCountdowns(_state.value.countdowns)
+ )
+ }
+
+ private fun clearError() {
+ _state.value = _state.value.copy(error = null)
+ }
+
+ private fun filterCountdowns(countdowns: List): List {
+ val currentState = _state.value
+ return countdowns.filter { countdown ->
+ currentState.selectedCategory?.let { countdown.category == it } ?: true
+ }
+ }
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/data/viewmodel/OnboardingViewModel.kt b/composeApp/src/commonMain/kotlin/com/taskttl/data/viewmodel/OnboardingViewModel.kt
new file mode 100644
index 0000000..f34d333
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/data/viewmodel/OnboardingViewModel.kt
@@ -0,0 +1,49 @@
+package com.taskttl.data.viewmodel
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.taskttl.data.repository.CategoryRepository
+import com.taskttl.data.repository.OnboardingRepository
+import com.taskttl.data.state.OnboardingEvent
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.launch
+
+/**
+ * 入职视图模型
+ * @author admin
+ * @date 2025/10/05
+ * @constructor 创建[OnboardingViewModel]
+ * @param [onboardingRepository] 引导存储库
+ * @param [categoryRepository] 类别存储库
+ */
+class OnboardingViewModel(
+ private val onboardingRepository: OnboardingRepository,
+ private val categoryRepository: CategoryRepository
+) : ViewModel() {
+
+ private val _events = Channel()
+ val events: Flow = _events.receiveAsFlow()
+
+ /**
+ * 发送事件 - 提供统一的事件发送机制
+ * @param event 事件
+ */
+ fun sendEvent(event: OnboardingEvent) {
+ viewModelScope.launch {
+ _events.trySend(event)
+ }
+ }
+
+ /**
+ * 标记引导完成
+ */
+ fun markOnboardingCompleted() {
+ viewModelScope.launch {
+ categoryRepository.initializeDefaultCategories()
+ onboardingRepository.markLaunched()
+ _events.trySend(OnboardingEvent.NavMain)
+ }
+ }
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/data/viewmodel/SplashViewModel.kt b/composeApp/src/commonMain/kotlin/com/taskttl/data/viewmodel/SplashViewModel.kt
new file mode 100644
index 0000000..97d2bad
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/data/viewmodel/SplashViewModel.kt
@@ -0,0 +1,37 @@
+package com.taskttl.data.viewmodel
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.taskttl.data.repository.OnboardingRepository
+import com.taskttl.data.state.SplashState
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+
+/**
+ * 启动页视图模型
+ * @author admin
+ * @date 2025/08/11
+ * @constructor 创建[SplashViewModel]
+ * @param [settings] 挡
+ */
+class SplashViewModel(
+ private val onboardingRepository: OnboardingRepository,
+) : ViewModel() {
+
+ private val _uiState = MutableStateFlow(SplashState.Loading)
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ init {
+ viewModelScope.launch {
+ delay(1200) // 模拟启动等待
+ val hasLaunched = onboardingRepository.isLaunchedBefore()
+ _uiState.value =
+ if (hasLaunched) SplashState.NavigateToOnboarding else SplashState.NavigateToMain
+ }
+ }
+}
+
+
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/data/viewmodel/TaskViewModel.kt b/composeApp/src/commonMain/kotlin/com/taskttl/data/viewmodel/TaskViewModel.kt
new file mode 100644
index 0000000..6480e35
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/data/viewmodel/TaskViewModel.kt
@@ -0,0 +1,254 @@
+package com.taskttl.data.viewmodel
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.taskttl.core.utils.LogUtils
+import com.taskttl.data.local.model.Category
+import com.taskttl.data.local.model.CategoryType
+import com.taskttl.data.local.model.Task
+import com.taskttl.data.repository.CategoryRepository
+import com.taskttl.data.repository.TaskRepository
+import com.taskttl.data.state.TaskEffect
+import com.taskttl.data.state.TaskIntent
+import com.taskttl.data.state.TaskState
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+
+/**
+ * 任务视图模型
+ * @author admin
+ * @date 2025/09/27
+ * @constructor 创建[TaskViewModel]
+ * @param [taskRepository] 任务仓库
+ */
+class TaskViewModel(
+ private val taskRepository: TaskRepository,
+ private val categoryRepository: CategoryRepository
+) : ViewModel() {
+
+ private val _state = MutableStateFlow(TaskState())
+ val state: StateFlow = _state.asStateFlow()
+
+ private val _effects = MutableSharedFlow()
+ val effects: SharedFlow = _effects.asSharedFlow()
+
+ init {
+ handleIntent(TaskIntent.LoadTasks)
+ }
+
+ fun handleIntent(intent: TaskIntent) {
+ when (intent) {
+ is TaskIntent.LoadTasks -> loadTasks()
+ is TaskIntent.GetTaskById -> getTaskById(intent.taskId)
+ is TaskIntent.AddTask -> addTask(intent.task)
+ is TaskIntent.UpdateTask -> updateTask(intent.task)
+ is TaskIntent.DeleteTask -> deleteTask(intent.taskId)
+ is TaskIntent.ToggleTaskCompletion -> toggleTaskCompletion(intent.taskId)
+ is TaskIntent.FilterByCategory -> filterByCategory(intent.category)
+ is TaskIntent.SearchTasks -> searchTasks(intent.query)
+ is TaskIntent.ToggleShowCompleted -> toggleShowCompleted(intent.show)
+ is TaskIntent.SearchView -> searchView()
+ is TaskIntent.ClearError -> clearError()
+ is TaskIntent.NavigateBack -> navigateBack()
+ is TaskIntent.NavigateToEditTask -> navigateToEditTask()
+ }
+ }
+
+ /**
+ * 导航返回
+ */
+ private fun navigateBack() {
+ viewModelScope.launch {
+ _effects.emit(TaskEffect.NavigateBack)
+ }
+ }
+
+ /**
+ * 导航到编辑任务
+ */
+ private fun navigateToEditTask() {
+ viewModelScope.launch {
+ _effects.emit(TaskEffect.NavigateToEditTask)
+ }
+ }
+
+ /**
+ * 加载任务
+ */
+ private fun loadTasks() {
+ viewModelScope.launch {
+ _state.value = _state.value.copy(isLoading = true, error = null)
+ try {
+ launch {
+ categoryRepository.getCategoriesByType(CategoryType.TASK).collect { categories ->
+ LogUtils.e("DevTTL",categories.toString())
+ _state.value = _state.value.copy(categories = categories)
+ }
+ }
+ launch {
+ taskRepository.getAllTasks().collect { tasks ->
+ _state.value = _state.value.copy(
+ tasks = tasks,
+ filteredTasks = filterTasks(tasks),
+ isLoading = false
+ )
+ }
+ }
+ } catch (e: Exception) {
+ LogUtils.e("DevTTL",e.message.toString())
+ _state.value =
+ _state.value.copy(isLoading = false, error = e.message ?: "加载任务失败")
+ }
+ }
+ }
+
+ /**
+ * 按id获取任务
+ * @param [taskId] 任务ID
+ */
+ private fun getTaskById(taskId: String) {
+ viewModelScope.launch {
+ try {
+ val task = taskRepository.getTaskById(taskId)
+ _state.value = _state.value.copy(editingTask = task)
+ } catch (e: Exception) {
+ _state.value = _state.value.copy(error = e.message ?: "查询任务失败")
+ }
+ }
+ }
+
+ /**
+ * 添加任务
+ * @param [task] 任务
+ */
+ private fun addTask(task: Task) {
+ viewModelScope.launch {
+ try {
+ taskRepository.insertTask(task)
+ _effects.emit(TaskEffect.ShowMessage("任务添加成功"))
+ _effects.emit(TaskEffect.NavigateBack)
+ } catch (e: Exception) {
+ _state.value = _state.value.copy(error = e.message ?: "添加任务失败")
+ }
+ }
+ }
+
+ /**
+ * 更新任务
+ * @param [task] 任务
+ */
+ private fun updateTask(task: Task) {
+ viewModelScope.launch {
+ try {
+ taskRepository.updateTask(task)
+ _effects.emit(TaskEffect.ShowMessage("任务更新成功"))
+ _effects.emit(TaskEffect.NavigateBack)
+ } catch (e: Exception) {
+ _state.value = _state.value.copy(error = e.message ?: "更新任务失败")
+ }
+ }
+ }
+
+ /**
+ * 删除任务
+ * @param [taskId] 任务ID
+ */
+ private fun deleteTask(taskId: String) {
+ viewModelScope.launch {
+ try {
+ taskRepository.deleteTask(taskId)
+ _effects.emit(TaskEffect.ShowMessage("任务删除成功"))
+ } catch (e: Exception) {
+ _state.value = _state.value.copy(error = e.message ?: "删除任务失败")
+ }
+ }
+ }
+
+ /**
+ * 切换任务完成
+ * @param [taskId] 任务ID
+ */
+ private fun toggleTaskCompletion(taskId: String) {
+ viewModelScope.launch {
+ try {
+ val task = _state.value.tasks.find { it.id == taskId }
+ task?.let {
+ val updatedTask = it.copy(isCompleted = !it.isCompleted)
+ taskRepository.updateTask(updatedTask)
+ }
+ } catch (e: Exception) {
+ _state.value = _state.value.copy(error = e.message ?: "更新任务状态失败")
+ }
+ }
+ }
+
+ /**
+ * 按类别筛选
+ * @param [category] 类别
+ */
+ private fun filterByCategory(category: Category?) {
+ _state.value = _state.value.copy(selectedCategory = category)
+ _state.value = _state.value.copy(filteredTasks = filterTasks(_state.value.tasks))
+ }
+
+ /**
+ * 搜索任务
+ * @param [query] 怎么翻译
+ */
+ private fun searchTasks(query: String) {
+ _state.value = _state.value.copy(searchQuery = query)
+ _state.value = _state.value.copy(filteredTasks = filterTasks(_state.value.tasks))
+ }
+
+ /**
+ * 切换显示完成
+ * @param [show] 显示
+ */
+ private fun toggleShowCompleted(show: Boolean) {
+ _state.value = _state.value.copy(showCompleted = show)
+ _state.value = _state.value.copy(filteredTasks = filterTasks(_state.value.tasks))
+ }
+
+ /**
+ * 搜索视图
+ */
+ private fun searchView() {
+ _state.value = _state.value.copy(isSearch = !_state.value.isSearch)
+ }
+
+ /**
+ * 清除错误
+ */
+ private fun clearError() {
+ _state.value = _state.value.copy(error = null)
+ }
+
+ /**
+ * 筛选任务
+ * @param [tasks] 任务
+ * @return [List]
+ */
+ private fun filterTasks(tasks: List): List {
+ val currentState = _state.value
+ return tasks.filter { task ->
+ val categoryMatch = currentState.selectedCategory?.let { task.category == it } ?: true
+
+ val searchMatch = if (currentState.searchQuery.isBlank()) {
+ true
+ } else {
+ val titleSearch = task.title.contains(currentState.searchQuery, ignoreCase = true)
+ val contextSearch =
+ task.description.contains(currentState.searchQuery, ignoreCase = true)
+ titleSearch || contextSearch
+ }
+ val completionMatch =
+ (currentState.showCompleted && task.isCompleted) || (!currentState.showCompleted && !task.isCompleted)
+ categoryMatch && searchMatch && completionMatch
+ }
+ }
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/presentation/category/CategoryEditorScreen.kt b/composeApp/src/commonMain/kotlin/com/taskttl/presentation/category/CategoryEditorScreen.kt
new file mode 100644
index 0000000..4a419d9
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/presentation/category/CategoryEditorScreen.kt
@@ -0,0 +1,372 @@
+package com.taskttl.presentation.category
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.List
+import androidx.compose.material.icons.filled.AccessTime
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Edit
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import com.taskttl.data.local.model.Category
+import com.taskttl.data.local.model.CategoryColor
+import com.taskttl.data.local.model.CategoryIcon
+import com.taskttl.data.local.model.CategoryType
+import com.taskttl.data.state.CategoryEffect
+import com.taskttl.data.state.CategoryIntent
+import com.taskttl.data.viewmodel.CategoryViewModel
+import com.taskttl.ui.components.AppHeader
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.toLocalDateTime
+import org.jetbrains.compose.resources.stringResource
+import org.koin.compose.viewmodel.koinViewModel
+import taskttl.composeapp.generated.resources.Res
+import taskttl.composeapp.generated.resources.label_category_name
+import taskttl.composeapp.generated.resources.label_category_type
+import taskttl.composeapp.generated.resources.label_countdown_category
+import taskttl.composeapp.generated.resources.label_select_color
+import taskttl.composeapp.generated.resources.label_select_icon
+import taskttl.composeapp.generated.resources.label_task_category
+import taskttl.composeapp.generated.resources.placeholder_category_name
+import taskttl.composeapp.generated.resources.title_add_category
+import taskttl.composeapp.generated.resources.title_edit_category
+import kotlin.time.Clock
+import kotlin.time.ExperimentalTime
+import kotlin.uuid.ExperimentalUuidApi
+import kotlin.uuid.Uuid
+
+/**
+ * 类别编辑屏幕
+ * @param [categoryId] 类别ID
+ * @param [onNavigateBack] 上导航返回
+ * @param [viewModel] 视图模型
+ */
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalTime::class, ExperimentalUuidApi::class)
+@Composable
+fun CategoryEditScreen(
+ categoryId: String? = null,
+ onNavigateBack: () -> Unit,
+ viewModel: CategoryViewModel = koinViewModel()
+) {
+ LaunchedEffect(categoryId) {
+ categoryId?.let { viewModel.handleIntent(CategoryIntent.GetCategoryById(it)) }
+ }
+
+ val state by viewModel.state.collectAsState()
+ val editingCategory = state.editingCategory
+
+ var name by remember { mutableStateOf("") }
+ var color by remember { mutableStateOf(CategoryColor.BLUE) }
+ var icon by remember { mutableStateOf(CategoryIcon.BRIEFCASE) }
+ var type by remember { mutableStateOf(CategoryType.TASK) }
+
+ LaunchedEffect(Unit) {
+ viewModel.effects.collect { effect ->
+ when (effect) {
+ is CategoryEffect.NavigateBack -> {
+ onNavigateBack.invoke()
+ }
+
+ else -> {}
+ }
+ }
+ }
+
+ LaunchedEffect(editingCategory) {
+ editingCategory?.let {
+ name = it.name
+ color = it.color
+ icon = it.icon
+ type = it.type
+ }
+ }
+
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ Column(
+ modifier = Modifier.fillMaxSize()
+ ) {
+ AppHeader(
+ title = if (categoryId == null) Res.string.title_add_category else Res.string.title_edit_category,
+ showBack = true,
+ onBackClick = { onNavigateBack.invoke() },
+ trailingIcon = if (categoryId == null) Icons.Default.Add else Icons.Default.Edit,
+ onTrailingClick = {
+ if (name.isNotBlank()) {
+ val now = Clock.System.now()
+ .toLocalDateTime(TimeZone.currentSystemDefault())
+ val category = Category(
+ id = editingCategory?.id ?: Uuid.random().toString(),
+ name = name.trim(),
+ color = color,
+ icon = icon,
+ type = type,
+ createdAt = editingCategory?.createdAt ?: now,
+ updatedAt = now,
+ taskCount = editingCategory?.taskCount ?: 0,
+ countdownCount = editingCategory?.countdownCount ?: 0,
+ )
+
+ if (categoryId == null) {
+ viewModel.handleIntent(CategoryIntent.AddCategory(category))
+ } else {
+ viewModel.handleIntent(CategoryIntent.UpdateCategory(category))
+ }
+ }
+ }
+ )
+
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(24.dp)
+ ) {
+ item {
+ // 分类名字
+ OutlinedTextField(
+ value = name,
+ onValueChange = { name = it },
+ label = { Text(stringResource(Res.string.label_category_name)) },
+ placeholder = { Text(stringResource(Res.string.placeholder_category_name)) },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true
+ )
+ }
+
+ item {
+ // 分类类型
+ Text(
+ text = stringResource(Res.string.label_category_type),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Medium
+ )
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ // 将 weight 放在这里传入
+ CategoryTypeOption(
+ modifier = Modifier.weight(1f),
+ icon = Icons.AutoMirrored.Filled.List,
+ text = stringResource(Res.string.label_task_category),
+ selected = type == CategoryType.TASK,
+ onClick = { type = CategoryType.TASK }
+ )
+ CategoryTypeOption(
+ modifier = Modifier.weight(1f),
+ icon = Icons.Default.AccessTime,
+ text = stringResource(Res.string.label_countdown_category),
+ selected = type == CategoryType.COUNTDOWN,
+ onClick = { type = CategoryType.COUNTDOWN }
+ )
+ }
+ }
+
+ item {
+ Text(
+ text = stringResource(Res.string.label_select_color),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Medium
+ )
+ Spacer(modifier = Modifier.height(12.dp))
+
+ FlowRow(
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ CategoryColor.entries.forEach { item ->
+ ColorOption(
+ colorLong = item,
+ selected = color == item,
+ onClick = { color = item },
+ modifier = Modifier.size(40.dp)
+ )
+ }
+ }
+ }
+
+ item {
+ Text(
+ text = stringResource(Res.string.label_select_icon),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Medium
+ )
+ Spacer(modifier = Modifier.height(12.dp))
+
+ FlowRow(
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ CategoryIcon.entries.forEach {item ->
+ IconOption(
+ item = item,
+ selected = icon == item,
+ onClick = { icon = item }
+ )
+ }
+ }
+
+ // LazyVerticalGrid(
+ // columns = GridCells.Fixed(6),
+ // horizontalArrangement = Arrangement.spacedBy(8.dp),
+ // verticalArrangement = Arrangement.spacedBy(8.dp),
+ // modifier = Modifier.height(280.dp)
+ // ) {
+ // items(CategoryIcon.entries) { item ->
+ // IconOption(
+ // item = item,
+ // selected = icon == item,
+ // onClick = { icon = item }
+ // )
+ // }
+ // }
+ }
+ }
+ }
+ }
+}
+
+/**
+ * 分类类型选项(可接收 modifier,由父级传 weight)
+ */
+@Composable
+private fun CategoryTypeOption(
+ modifier: Modifier = Modifier,
+ icon: ImageVector,
+ text: String,
+ selected: Boolean,
+ onClick: () -> Unit
+) {
+ Card(
+ modifier = modifier
+ .clickable { onClick() },
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
+ border = if (selected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(8.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Box(
+ modifier = Modifier
+ .size(36.dp)
+ .clip(RoundedCornerShape(6.dp))
+ .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(icon, contentDescription = text, tint = MaterialTheme.colorScheme.primary)
+ }
+
+ Spacer(modifier = Modifier.height(6.dp))
+
+ Text(
+ text = text,
+ style = MaterialTheme.typography.bodyMedium,
+ color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
+ )
+ }
+ }
+}
+
+/**
+ * 图标选项
+ */
+@Composable
+private fun IconOption(item: CategoryIcon, selected: Boolean, onClick: () -> Unit) {
+ Box(
+ modifier = Modifier
+ .size(48.dp)
+ .clip(RoundedCornerShape(8.dp))
+ .background(if (selected) MaterialTheme.colorScheme.primary.copy(alpha = 0.06f) else Color.White)
+ .border(
+ BorderStroke(
+ if (selected) 2.dp else 1.dp,
+ if (selected) MaterialTheme.colorScheme.primary else Color(0xFFE6E6E6)
+ ),
+ RoundedCornerShape(8.dp)
+ )
+ .clickable { onClick() },
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ imageVector = item.icon,
+ contentDescription = stringResource(item.displayNameRes),
+ tint = Color(0xFF666666)
+ )
+ }
+}
+
+/**
+ * 颜色选项
+ */
+@Composable
+private fun ColorOption(
+ colorLong: CategoryColor,
+ selected: Boolean,
+ onClick: () -> Unit,
+ modifier: Modifier
+) {
+ Box(
+ modifier = modifier
+ .aspectRatio(1f) // 保证宽高相等 ✅
+ .clip(CircleShape) // 真正的圆形 ✅
+ .clip(RoundedCornerShape(36.dp))
+ .background(Color(colorLong.hex))
+ .border(
+ BorderStroke(
+ if (selected) 2.dp else 0.dp,
+ if (selected) Color.Black else Color.Transparent
+ ),
+ CircleShape
+ )
+ .clickable { onClick() }
+ )
+}
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/presentation/category/CategoryScreen.kt b/composeApp/src/commonMain/kotlin/com/taskttl/presentation/category/CategoryScreen.kt
new file mode 100644
index 0000000..46bba8b
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/presentation/category/CategoryScreen.kt
@@ -0,0 +1,364 @@
+package com.taskttl.presentation.category
+
+
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Edit
+import androidx.compose.material.icons.rounded.Delete
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FilledIconButton
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.IconButtonDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.shadow
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.navigation.NavHostController
+import com.taskttl.core.routes.Routes.Main
+import com.taskttl.core.ui.ActionButtonListItem
+import com.taskttl.data.local.model.Category
+import com.taskttl.data.local.model.CategoryType
+import com.taskttl.data.state.CategoryEffect
+import com.taskttl.data.state.CategoryIntent
+import com.taskttl.data.viewmodel.CategoryViewModel
+import com.taskttl.ui.components.AppHeader
+import org.jetbrains.compose.resources.stringResource
+import org.koin.compose.viewmodel.koinViewModel
+import taskttl.composeapp.generated.resources.Res
+import taskttl.composeapp.generated.resources.label_add_category_hint
+import taskttl.composeapp.generated.resources.label_countdown_count
+import taskttl.composeapp.generated.resources.label_edit
+import taskttl.composeapp.generated.resources.label_no_category
+import taskttl.composeapp.generated.resources.label_task_count
+import taskttl.composeapp.generated.resources.title_add_category
+import taskttl.composeapp.generated.resources.title_category
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun CategoryScreen(
+ navController: NavHostController,
+ onAddCategory: () -> Unit,
+ onNavigateBack: () -> Unit,
+ viewModel: CategoryViewModel = koinViewModel()
+) {
+ val state by viewModel.state.collectAsState()
+
+ LaunchedEffect(Unit) {
+ viewModel.effects.collect { effect ->
+ when (effect) {
+ is CategoryEffect.NavigateBack -> {
+ onNavigateBack.invoke()
+ }
+
+ else -> {}
+ }
+ }
+ }
+
+ // 错误处理
+ state.error?.let { error ->
+ LaunchedEffect(error) { viewModel.handleIntent(CategoryIntent.ClearError) }
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color.White)
+ ) {
+ Column(
+ modifier = Modifier.fillMaxSize()
+ ) {
+ AppHeader(
+ title = Res.string.title_category,
+ showBack = true,
+ onBackClick = { onNavigateBack.invoke() }
+ )
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color(0xFFF5F5F5))
+ .padding(16.dp)
+ ) {
+
+ CategoryFilterTabs(
+ selectedType = state.selectedType,
+ onSelected = { viewModel.handleIntent(CategoryIntent.SelectType(it)) },
+ modifier = Modifier.fillMaxWidth()
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ // 分类列表
+ val categories = when (state.selectedType) {
+ CategoryType.TASK -> state.taskCategories
+ CategoryType.COUNTDOWN -> state.countdownCategories
+ }
+
+ if (state.isLoading) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ CircularProgressIndicator()
+ }
+ } else if (categories.isEmpty()) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Text(
+ text = stringResource(Res.string.label_no_category),
+ style = MaterialTheme.typography.bodyLarge
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = stringResource(Res.string.label_add_category_hint),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ } else {
+ LazyColumn(
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ item { Spacer(Modifier) }
+ itemsIndexed(
+ items = categories,
+ key = { index, item -> item.name }) { index, category ->
+ var isOpen by remember { mutableStateOf(false) }
+
+ CategoryCardItem(
+ category = category,
+ isOpen = isOpen,
+ onOpenChange = {},
+ onEditClick = {
+ navController.navigate(Main.Settings.EditCategory(category.id))
+ },
+ onDeleteClick = {
+ viewModel.handleIntent(CategoryIntent.DeleteCategory(category.id))
+ }
+ )
+ }
+ }
+ }
+ }
+ }
+
+ // 悬浮按钮
+ FloatingActionButton(
+ onClick = { onAddCategory.invoke() },
+ modifier = Modifier
+ .align(Alignment.BottomEnd)
+ .padding(20.dp),
+ containerColor = Color(0xFF667EEA)
+ ) {
+ Icon(
+ imageVector = Icons.Default.Add,
+ contentDescription = stringResource(Res.string.title_add_category)
+ )
+ }
+
+
+ }
+}
+
+@Composable
+fun CategoryCardItem(
+ category: Category,
+ isOpen: Boolean,
+ alignment: Alignment.Horizontal = Alignment.End,
+ onOpenChange: (Boolean) -> Unit,
+ onEditClick: () -> Unit,
+ onDeleteClick: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ ActionButtonListItem(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(12.dp)),
+ isOpen = isOpen,
+ actionAlignment = alignment,
+ onOpenChange = onOpenChange,
+ onClick = {}
+ ) {
+ Card(
+ modifier = modifier.fillMaxWidth().background(Color.Transparent),
+ colors = CardDefaults.cardColors(containerColor = Color.White),
+ elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // 分类颜色指示器
+ Box(
+ modifier = Modifier
+ .size(40.dp)
+ .clip(CircleShape)
+ .background(category.color.backgroundColor),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ imageVector = category.icon.icon,
+ contentDescription = stringResource(category.icon.displayNameRes),
+ tint = category.color.iconColor,
+ modifier = Modifier.size(24.dp)
+ )
+ }
+
+ Spacer(modifier = Modifier.width(16.dp))
+
+ // 分类信息
+ Column(
+ modifier = Modifier.weight(1f)
+ ) {
+ Text(
+ text = category.name,
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Medium
+ )
+
+ Spacer(modifier = Modifier.height(4.dp))
+
+ Row {
+ Text(
+ text = when (category.type) {
+ CategoryType.TASK -> stringResource(
+ Res.string.label_task_count,
+ category.taskCount
+ )
+
+ CategoryType.COUNTDOWN -> stringResource(
+ Res.string.label_countdown_count,
+ category.countdownCount
+ )
+ },
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+
+ // 操作按钮
+ Row {
+ IconButton(onClick = onEditClick) {
+ Icon(
+ Icons.Default.Edit,
+ contentDescription = stringResource(Res.string.label_edit),
+ tint = MaterialTheme.colorScheme.primary
+ )
+ }
+ }
+ }
+ }
+ FilledIconButton(
+ onClick = { onDeleteClick.invoke() },
+ shape = CircleShape,
+ modifier = Modifier
+ .size(32.dp)
+ .aspectRatio(1f),
+ colors = IconButtonDefaults.filledIconButtonColors(
+ containerColor = Color(0xffff1111),
+ contentColor = Color.White
+ )
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.Delete,
+ contentDescription = Icons.Rounded.Delete.name
+ )
+ }
+ }
+}
+
+@Composable
+fun CategoryFilterTabs(
+ selectedType: CategoryType,
+ onSelected: (CategoryType) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ val containerShape = RoundedCornerShape(8.dp)
+ val tabShape = RoundedCornerShape(6.dp)
+
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .background(color = Color(0xFFF5F5F5), shape = containerShape)
+ .border(width = 1.dp, color = Color(0xFFE0E0E0), shape = containerShape)
+ .padding(4.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ CategoryType.entries.forEach { type ->
+ val active = type == selectedType
+ val textColor by animateColorAsState(
+ if (active) Color(0xFF333333) else Color(
+ 0xFF666666
+ )
+ )
+ val backgroundColor by animateColorAsState(if (active) Color.White else Color.Transparent)
+ val elevation = if (active) 4.dp else 0.dp
+
+ Box(
+ modifier = Modifier
+ .weight(1f)
+ .shadow(elevation, shape = tabShape, clip = false)
+ .background(backgroundColor, tabShape)
+ .clickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null // ripple 可选
+ ) { onSelected(type) }
+ .height(36.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = stringResource(type.displayNameRes),
+ color = textColor,
+ fontSize = 14.sp
+ )
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/presentation/countdown/CountdownDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/taskttl/presentation/countdown/CountdownDetailScreen.kt
new file mode 100644
index 0000000..9ebd356
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/presentation/countdown/CountdownDetailScreen.kt
@@ -0,0 +1,258 @@
+package com.taskttl.presentation.countdown
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.CalendarToday
+import androidx.compose.material.icons.filled.Edit
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.taskttl.core.ui.Chip
+import com.taskttl.core.utils.DateUtils
+import com.taskttl.data.state.CountdownEffect
+import com.taskttl.data.viewmodel.CountdownViewModel
+import com.taskttl.ui.components.AppHeader
+import org.jetbrains.compose.resources.stringResource
+import org.koin.compose.viewmodel.koinViewModel
+import taskttl.composeapp.generated.resources.Res
+import taskttl.composeapp.generated.resources.countdown_not_found
+import taskttl.composeapp.generated.resources.created_at
+import taskttl.composeapp.generated.resources.detail_information
+import taskttl.composeapp.generated.resources.event_description
+import taskttl.composeapp.generated.resources.label_days
+import taskttl.composeapp.generated.resources.reminder
+import taskttl.composeapp.generated.resources.title_countdown_info
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun CountdownDetailScreen(
+ countdownId: String,
+ onNavigateBack: () -> Unit,
+ onNavigateToEdit: () -> Unit,
+ viewModel: CountdownViewModel = koinViewModel()
+) {
+ val state by viewModel.state.collectAsState()
+ val countdown = state.countdowns.find { it.id == countdownId }
+
+ LaunchedEffect(Unit) {
+ viewModel.effects.collect { effect ->
+ when (effect) {
+ is CountdownEffect.NavigateBack -> {
+ onNavigateBack()
+ }
+
+ else -> {}
+ }
+ }
+ }
+
+ if (countdown == null) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(stringResource(Res.string.countdown_not_found))
+ }
+ return
+ }
+
+ // 剩余天数
+ val daysRemaining = DateUtils.daysRemaining(countdown.targetDate)
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color.White)
+ ) {
+ Column(
+ modifier = Modifier.fillMaxSize()
+ ) {
+ AppHeader(
+ title = Res.string.title_countdown_info,
+ showBack = true,
+ onBackClick = { onNavigateBack.invoke() },
+ trailingIcon = Icons.Default.Edit,
+ onTrailingClick = { onNavigateToEdit.invoke() }
+ )
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color(0xFFF5F5F5))
+ .padding(16.dp)
+ .verticalScroll(rememberScrollState()),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(20.dp))
+ .background(
+ brush = androidx.compose.ui.graphics.Brush.linearGradient(
+ colors = listOf(
+ countdown.category.color.backgroundColor,
+ Color.Transparent
+ )
+ )
+ )
+ .padding(20.dp)
+ ) {
+ Column(modifier = Modifier.align(Alignment.CenterStart)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ text = countdown.title,
+ fontSize = 18.sp,
+ fontWeight = FontWeight.ExtraBold,
+ color = Color(0xFF111111)
+ )
+ Spacer(modifier = Modifier.width(6.dp))
+ Chip(text = countdown.category.name)
+ }
+ Spacer(modifier = Modifier.height(6.dp))
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ imageVector = Icons.Default.CalendarToday,
+ contentDescription = null
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = countdown.targetDate.toString(),
+ fontSize = 14.sp,
+ color = Color(0xFF444444)
+ )
+ }
+ }
+
+
+ Column(
+ modifier = Modifier.align(Alignment.TopEnd),
+ horizontalAlignment = Alignment.End
+ ) {
+ Text(
+ text = daysRemaining.toString(),
+ fontSize = 44.sp,
+ fontWeight = FontWeight.ExtraBold,
+ color = Color(0xFF111111)
+ )
+ Text(
+ text = stringResource(Res.string.label_days),
+ fontSize = 12.sp,
+ fontWeight = FontWeight.SemiBold,
+ color = Color(0xFF666666)
+ )
+ }
+ }
+
+ countdown.description.let {
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Column(modifier = Modifier.fillMaxWidth()) {
+ Text(
+ text = stringResource(Res.string.event_description),
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 14.sp,
+ color = Color(0xFF333333)
+ )
+ Spacer(modifier = Modifier.height(10.dp))
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(12.dp))
+ .background(Color.White)
+ .padding(12.dp)
+ ) {
+ Column {
+ Text(
+ text = countdown.description,
+ color = countdown.category.color.textColor,
+ lineHeight = 20.sp
+ )
+ }
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ Column(modifier = Modifier.fillMaxWidth()) {
+ Text(
+ text = stringResource(Res.string.detail_information),
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 14.sp,
+ color = Color(0xFF333333)
+ )
+ Spacer(modifier = Modifier.height(10.dp))
+ Column {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(10.dp)
+ ) {
+ InfoItem(
+ iconTint = Color(0xFF667EEA),
+ text = "${stringResource(Res.string.reminder)}:${
+ stringResource(countdown.notificationEnabled.displayNameRes)
+ }",
+ modifier = Modifier.weight(1f)
+ )
+ }
+ Spacer(modifier = Modifier.height(10.dp))
+ Row(modifier = Modifier.fillMaxWidth()) {
+ InfoItem(
+ iconTint = Color(0xFF999999),
+ text = "${stringResource(Res.string.created_at)}:${countdown.createdAt}",
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+
+@Composable
+private fun InfoItem(iconTint: Color, text: String, modifier: Modifier = Modifier) {
+ Row(
+ modifier = modifier
+ .clip(RoundedCornerShape(10.dp))
+ .background(Color.White)
+ .padding(12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Box(
+ modifier = Modifier
+ .size(18.dp)
+ .clip(RoundedCornerShape(6.dp))
+ .background(iconTint)
+ ) {}
+ Spacer(modifier = Modifier.width(10.dp))
+ Text(text = text, fontSize = 13.sp, color = Color(0xFF555555))
+ }
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/presentation/countdown/CountdownEditorScreen.kt b/composeApp/src/commonMain/kotlin/com/taskttl/presentation/countdown/CountdownEditorScreen.kt
new file mode 100644
index 0000000..94bc783
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/presentation/countdown/CountdownEditorScreen.kt
@@ -0,0 +1,274 @@
+package com.taskttl.presentation.countdown
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.DateRange
+import androidx.compose.material.icons.filled.Edit
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FilterChip
+import androidx.compose.material3.FilterChipDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.taskttl.data.local.model.Category
+import com.taskttl.data.local.model.Countdown
+import com.taskttl.data.local.model.ReminderFrequency
+import com.taskttl.data.state.CountdownEffect
+import com.taskttl.data.state.CountdownIntent
+import com.taskttl.data.viewmodel.CountdownViewModel
+import com.taskttl.ui.components.AppHeader
+import com.taskttl.ui.components.CategoryCard
+import com.taskttl.ui.components.CompactDatePickerDialog
+import kotlinx.datetime.LocalDateTime
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.toLocalDateTime
+import org.jetbrains.compose.resources.stringResource
+import org.koin.compose.viewmodel.koinViewModel
+import taskttl.composeapp.generated.resources.Res
+import taskttl.composeapp.generated.resources.desc_select_date
+import taskttl.composeapp.generated.resources.label_countdown_description
+import taskttl.composeapp.generated.resources.label_countdown_title
+import taskttl.composeapp.generated.resources.label_notification_setting
+import taskttl.composeapp.generated.resources.label_select_category
+import taskttl.composeapp.generated.resources.label_target_date
+import taskttl.composeapp.generated.resources.title_add_countdown
+import taskttl.composeapp.generated.resources.title_edit_countdown
+import kotlin.time.Clock
+import kotlin.time.ExperimentalTime
+import kotlin.uuid.ExperimentalUuidApi
+import kotlin.uuid.Uuid
+
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalTime::class, ExperimentalUuidApi::class)
+@Composable
+fun CountdownEditScreen(
+ countdownId: String? = null,
+ onNavigateBack: () -> Unit,
+ viewModel: CountdownViewModel = koinViewModel()
+) {
+ LaunchedEffect(countdownId) {
+ countdownId?.let { viewModel.handleIntent(CountdownIntent.GetCountdownById(it)) }
+ }
+
+ val state by viewModel.state.collectAsState()
+ val editingCountdown = state.editingCountdown
+
+ var showDatePicker by remember { mutableStateOf(false) }
+
+ var title by remember { mutableStateOf("") }
+ var description by remember { mutableStateOf("") }
+ var selectedCategory by remember { mutableStateOf(null) }
+ var targetDate by remember { mutableStateOf(null) }
+ var notificationEnabled by remember { mutableStateOf(ReminderFrequency.OFF) }
+
+ LaunchedEffect(Unit) {
+ viewModel.effects.collect { effect ->
+ when (effect) {
+ is CountdownEffect.NavigateBack -> {
+ onNavigateBack.invoke()
+ }
+
+ else -> {}
+ }
+ }
+ }
+
+ LaunchedEffect(editingCountdown) {
+ editingCountdown?.let {
+ title = it.title
+ description = it.description
+ selectedCategory = it.category
+ targetDate = it.targetDate
+ notificationEnabled = it.notificationEnabled
+ }
+ }
+
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ Column(
+ modifier = Modifier.fillMaxSize()
+ ) {
+ AppHeader(
+ title = if (countdownId == null) Res.string.title_add_countdown else Res.string.title_edit_countdown,
+ showBack = true,
+ onBackClick = { onNavigateBack.invoke() },
+ trailingIcon = if (countdownId == null) Icons.Default.Add else Icons.Default.Edit,
+ onTrailingClick = {
+ if (title.isNotBlank() && targetDate != null && selectedCategory != null) {
+ val now = Clock.System.now()
+ .toLocalDateTime(TimeZone.currentSystemDefault())
+ val countdown = Countdown(
+ id = editingCountdown?.id ?: Uuid.random().toString(),
+ title = title.trim(),
+ description = description.trim(),
+ category = selectedCategory!!,
+ targetDate = targetDate!!,
+ createdAt = now,
+ updatedAt = now,
+ notificationEnabled = notificationEnabled
+ )
+
+ if (countdownId == null) {
+ viewModel.handleIntent(CountdownIntent.AddCountdown(countdown))
+ } else {
+ editingCountdown?.let { countdown.isActive = editingCountdown.isActive }
+ viewModel.handleIntent(CountdownIntent.UpdateCountdown(countdown))
+ }
+ }
+ }
+ )
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(16.dp),
+ ) {
+ // 倒数日标题
+ OutlinedTextField(
+ value = title,
+ onValueChange = { title = it },
+ label = { Text(stringResource(Res.string.label_countdown_title)) },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // 倒数日描述
+ OutlinedTextField(
+ value = description,
+ onValueChange = { description = it },
+ label = { Text(stringResource(Res.string.label_countdown_description)) },
+ modifier = Modifier.fillMaxWidth(),
+ minLines = 3,
+ maxLines = 5
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // 分类选择
+ Text(
+ text = stringResource(Res.string.label_select_category),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Medium
+ )
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ LazyVerticalGrid(
+ columns = GridCells.Fixed(4),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = Modifier.height(80.dp)
+ ) {
+ items(state.categories) { category ->
+ CategoryCard(
+ category = category,
+ isSelected = selectedCategory == category,
+ onClick = { selectedCategory = category }
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // 目标日期
+ OutlinedTextField(
+ value = targetDate?.toString() ?: "",
+ onValueChange = { },
+ label = { Text(stringResource(Res.string.label_target_date)) },
+ modifier = Modifier.fillMaxWidth().clickable { showDatePicker = true },
+ readOnly = true,
+ trailingIcon = {
+ IconButton(onClick = { showDatePicker = true }) {
+ Icon(
+ Icons.Default.DateRange,
+ contentDescription = stringResource(Res.string.desc_select_date)
+ )
+ }
+ }
+ )
+
+ CompactDatePickerDialog(
+ show = showDatePicker,
+ initialSelected = targetDate,
+ onConfirm = { selected -> targetDate = selected },
+ onDismiss = { showDatePicker = false }
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 12.dp)
+ ) {
+ Text(
+ text = stringResource(Res.string.label_notification_setting),
+ style = MaterialTheme.typography.titleMedium,
+ modifier = Modifier.padding(bottom = 8.dp)
+ )
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ ReminderFrequency.entries.forEach { frequency ->
+ FilterChip(
+ selected = notificationEnabled == frequency,
+ onClick = { notificationEnabled = frequency },
+ label = {
+ Text(
+ text = stringResource(frequency.displayNameRes),
+ textAlign = TextAlign.Center,
+ fontSize = 10.sp,
+ modifier = Modifier.fillMaxWidth()
+ )
+ },
+ modifier = Modifier.weight(1f),
+ colors = FilterChipDefaults.filterChipColors(
+ selectedContainerColor = MaterialTheme.colorScheme.primary.copy(
+ alpha = 0.2f
+ ),
+ selectedLabelColor = MaterialTheme.colorScheme.primary
+ ),
+ )
+
+ Spacer(modifier = Modifier.width(8.dp))
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/presentation/countdown/CountdownScreen.kt b/composeApp/src/commonMain/kotlin/com/taskttl/presentation/countdown/CountdownScreen.kt
new file mode 100644
index 0000000..f148a3b
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/presentation/countdown/CountdownScreen.kt
@@ -0,0 +1,543 @@
+package com.taskttl.presentation.countdown
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.CalendarToday
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.Edit
+import androidx.compose.material.icons.filled.FilterList
+import androidx.compose.material.icons.filled.MoreHoriz
+import androidx.compose.material.icons.filled.Share
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.shadow
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.navigation.NavHostController
+import com.taskttl.core.routes.Routes
+import com.taskttl.core.utils.DateUtils
+import com.taskttl.data.local.model.Countdown
+import com.taskttl.data.state.CountdownEffect
+import com.taskttl.data.state.CountdownIntent
+import com.taskttl.data.viewmodel.CountdownViewModel
+import com.taskttl.ui.components.AppHeader
+import com.taskttl.ui.components.CategoryFilter
+import org.jetbrains.compose.resources.stringResource
+import org.jetbrains.compose.ui.tooling.preview.Preview
+import org.koin.compose.viewmodel.koinViewModel
+import taskttl.composeapp.generated.resources.Res
+import taskttl.composeapp.generated.resources.delete
+import taskttl.composeapp.generated.resources.desc_add_countdown
+import taskttl.composeapp.generated.resources.label_countdown_list
+import taskttl.composeapp.generated.resources.label_days
+import taskttl.composeapp.generated.resources.label_edit
+import taskttl.composeapp.generated.resources.text_add_countdown_tip
+import taskttl.composeapp.generated.resources.text_no_countdowns
+import taskttl.composeapp.generated.resources.title_countdown
+import taskttl.composeapp.generated.resources.title_edit_countdown
+
+@Composable
+@Preview
+fun CountdownScreen(
+ navController: NavHostController,
+ viewModel: CountdownViewModel = koinViewModel()
+) {
+ val state by viewModel.state.collectAsState()
+
+ LaunchedEffect(Unit) {
+ viewModel.effects.collect { effect ->
+ when (effect) {
+ is CountdownEffect.NavigateBack -> {
+ navController.popBackStack()
+ }
+
+ is CountdownEffect.NavigateToCountdownDetail -> {
+ // onNavigateToCountdownDetail(effect.countdownId)
+ }
+
+ else -> {}
+ }
+ }
+ }
+
+ state.error?.let { error ->
+ LaunchedEffect(error) {
+ viewModel.handleIntent(CountdownIntent.ClearError)
+ }
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color.White)
+ ) {
+ Column(modifier = Modifier.fillMaxSize()) {
+ AppHeader(
+ title = Res.string.title_countdown,
+ trailingIcon = Icons.Default.FilterList
+ )
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color(0xFFF5F5F5))
+ .padding(16.dp)
+ ) {
+ // 分类筛选
+ CategoryFilter(
+ categories = state.categories,
+ selectedCategory = state.selectedCategory,
+ onCategorySelected = {
+ viewModel.handleIntent(CountdownIntent.FilterByCategory(it))
+ },
+ modifier = Modifier.fillMaxWidth(),
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Text(
+ text = "${stringResource(Res.string.label_countdown_list)} (${state.filteredCountdowns.size})",
+ style = MaterialTheme.typography.titleMedium
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ when {
+ state.isLoading -> {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ CircularProgressIndicator()
+ }
+ }
+
+ state.filteredCountdowns.isEmpty() -> {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Text(
+ text = stringResource(Res.string.text_no_countdowns),
+ style = MaterialTheme.typography.bodyLarge
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = stringResource(Res.string.text_add_countdown_tip),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+
+ else -> {
+ LazyColumn(
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ items(state.filteredCountdowns) { countdown ->
+ CountdownCard(
+ countdown = countdown,
+ onCardClick = {
+ navController.navigate(
+ Routes.Main.Countdown.CountdownDetail(countdown.id)
+ )
+ },
+ onEdit = {
+ navController.navigate(
+ Routes.Main.Countdown.EditCountdown(countdown.id)
+ )
+ },
+ onDelete = {
+ viewModel.handleIntent(
+ CountdownIntent.DeleteCountdown(countdown.id)
+ )
+ }
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // 悬浮按钮
+ FloatingActionButton(
+ onClick = { navController.navigate(Routes.Main.Countdown.AddCountdown) },
+ modifier = Modifier
+ .align(Alignment.BottomEnd)
+ .padding(20.dp),
+ containerColor = Color(0xFF667EEA)
+ ) {
+ Icon(
+ imageVector = Icons.Default.Add,
+ contentDescription = stringResource(Res.string.desc_add_countdown)
+ )
+ }
+ }
+}
+
+@Composable
+fun CountdownCard(
+ countdown: Countdown,
+ onEdit: () -> Unit = {},
+ onDelete: () -> Unit = {},
+ onCardClick: () -> Unit = {}
+) {
+ val countdownTime = DateUtils.calculateCountdownTime(countdown.targetDate)
+ countdown.category
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .shadow(4.dp, RoundedCornerShape(12.dp))
+ .background(Color.White, RoundedCornerShape(12.dp))
+ .clickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null
+ ) { onCardClick() }
+ ) {
+ Column {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(4.dp)
+ .background(countdown.category.color.backgroundColor)
+ )
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(start = 6.dp, end = 6.dp, top = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = countdown.title,
+ fontSize = 18.sp,
+ fontWeight = FontWeight.SemiBold,
+ color = Color(0xFF1A1A1A)
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ imageVector = Icons.Default.CalendarToday,
+ contentDescription = null,
+ tint = Color(0xFF666666),
+ modifier = Modifier.size(14.dp),
+ )
+ Spacer(modifier = Modifier.width(6.dp))
+ Text(
+ text = countdown.targetDate.toString(),
+ fontSize = 14.sp,
+ color = Color(0xFF666666)
+ )
+ }
+ }
+
+ Column(
+ horizontalAlignment = Alignment.End,
+ modifier = Modifier.widthIn(min = 80.dp)
+ ) {
+ Text(
+ text = countdownTime.days.toString(),
+ fontSize = 32.sp,
+ fontWeight = FontWeight.Bold,
+ color = countdown.category.color.textColor
+ )
+ Text(
+ text = stringResource(Res.string.label_days),
+ fontSize = 12.sp,
+ fontWeight = FontWeight.Medium,
+ color = Color(0xFF999999)
+ )
+ }
+ }
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(start = 6.dp, end = 6.dp, bottom = 8.dp)
+ ) {
+ if (countdown.description.isNotBlank()) {
+ Spacer(modifier = Modifier.height(6.dp))
+ Text(
+ text = countdown.description,
+ fontSize = 13.sp,
+ color = Color(0xFF999999),
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ }
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 6.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Row(
+ modifier = Modifier.background(
+ countdown.category.color.backgroundColor,
+ RoundedCornerShape(20.dp)
+ ).padding(horizontal = 12.dp, vertical = 6.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = countdown.category.icon.icon,
+ contentDescription = null,
+ tint = countdown.category.color.iconColor,
+ modifier = Modifier.size(14.dp)
+ )
+ Spacer(modifier = Modifier.width(6.dp))
+ Text(
+ text = countdown.category.name,
+ fontSize = 12.sp,
+ fontWeight = FontWeight.Medium,
+ color = countdown.category.color.textColor
+ )
+ }
+
+ var showReminderDialog by remember { mutableStateOf(false) }
+ var showMoreMenu by remember { mutableStateOf(false) }
+
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ // IconBut({ showReminderDialog = true }, Icons.Default.Notifications)
+ // Spacer(modifier = Modifier.width(6.dp))
+ Box {
+ IconBut({ showMoreMenu = true }, Icons.Default.MoreHoriz)
+ DropdownMenu(
+ expanded = showMoreMenu,
+ onDismissRequest = { showMoreMenu = false }
+ ) {
+ DropdownMenuItem(
+ text = { Text(stringResource(Res.string.label_edit)) },
+ onClick = {
+ onEdit.invoke();
+ showMoreMenu = false
+ }
+ )
+ // DropdownMenuItem(
+ // text = { Text("分享倒数日") },
+ // onClick = {
+ // // onShare();
+ // showMoreMenu = false
+ // }
+ // )
+ DropdownMenuItem(
+ text = {
+ Text(
+ text = stringResource(Res.string.delete),
+ color = Color.Red
+ )
+ },
+ onClick = {
+ onDelete.invoke();
+ showMoreMenu = false
+ }
+ )
+ }
+ }
+ }
+
+ if (showReminderDialog) {
+ ReminderDialog(
+ onDismiss = { showReminderDialog = false },
+ onSave = { /* TODO: 保存提醒逻辑 */ }
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun IconBut(onClick: () -> Unit = {}, icon: ImageVector) {
+ Box(
+ modifier = Modifier
+ .size(32.dp)
+ .clip(CircleShape)
+ .background(Color(0xFFF5F5F5))
+ .clickable { onClick() },
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ tint = Color(0xFF666666),
+ modifier = Modifier.size(20.dp)
+ )
+ }
+}
+
+@Composable
+fun ReminderDialog(
+ onDismiss: () -> Unit,
+ onSave: (String) -> Unit
+) {
+ var isEnabled by remember { mutableStateOf(true) }
+ var timeBefore by remember { mutableStateOf("1天前") }
+ var expanded by remember { mutableStateOf(false) }
+
+ AlertDialog(
+ onDismissRequest = { onDismiss() },
+ title = { Text("提醒设置") },
+ text = {
+ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text("启用提醒")
+ Spacer(modifier = Modifier.weight(1f))
+ Switch(checked = isEnabled, onCheckedChange = { isEnabled = it })
+ }
+
+ if (isEnabled) {
+ Text("提前提醒时间")
+ Box {
+ OutlinedButton(
+ onClick = { expanded = true },
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(timeBefore)
+ }
+ DropdownMenu(
+ expanded = expanded,
+ onDismissRequest = { expanded = false }
+ ) {
+ listOf("当天", "1天前", "3天前", "1周前").forEach {
+ DropdownMenuItem(
+ text = { Text(it) },
+ onClick = {
+ timeBefore = it
+ expanded = false
+ }
+ )
+ }
+ }
+ }
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = {
+ onSave(timeBefore)
+ onDismiss()
+ }) {
+ Text("保存")
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { onDismiss() }) {
+ Text("取消")
+ }
+ }
+ )
+}
+
+@Composable
+fun MoreActionsDialog(
+ onDismiss: () -> Unit,
+ onEdit: () -> Unit,
+ onShare: () -> Unit,
+ onDelete: () -> Unit
+) {
+ AlertDialog(
+ onDismissRequest = { onDismiss() },
+ title = { Text("更多操作") },
+ text = {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ ActionItem("编辑倒数日", Icons.Default.Edit) {
+ onEdit(); onDismiss()
+ }
+ ActionItem("分享倒数日", Icons.Default.Share) {
+ onShare(); onDismiss()
+ }
+ ActionItem("删除倒数日", Icons.Default.Delete, danger = true) {
+ onDelete(); onDismiss()
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = { onDismiss() }) {
+ Text("关闭")
+ }
+ }
+ )
+}
+
+@Composable
+private fun ActionItem(
+ text: String,
+ icon: ImageVector,
+ danger: Boolean = false,
+ onClick: () -> Unit
+) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(8.dp))
+ .clickable { onClick() }
+ .background(if (danger) Color(0xFFFFE6E6) else Color(0xFFF5F5F5))
+ .padding(horizontal = 12.dp, vertical = 10.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ tint = if (danger) Color(0xFFE53E3E) else Color(0xFF555555)
+ )
+ Spacer(modifier = Modifier.width(12.dp))
+ Text(
+ text = text,
+ color = if (danger) Color(0xFFE53E3E) else Color(0xFF333333),
+ style = MaterialTheme.typography.bodyMedium
+ )
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/presentation/onboarding/OnboardingScreen.kt b/composeApp/src/commonMain/kotlin/com/taskttl/presentation/onboarding/OnboardingScreen.kt
new file mode 100644
index 0000000..2abf469
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/presentation/onboarding/OnboardingScreen.kt
@@ -0,0 +1,207 @@
+package com.taskttl.presentation.onboarding
+
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.taskttl.core.routes.Routes
+import com.taskttl.data.local.model.OnboardingPage
+import com.taskttl.data.state.OnboardingEvent
+import com.taskttl.data.viewmodel.OnboardingViewModel
+import kotlinx.coroutines.flow.collectLatest
+import org.jetbrains.compose.resources.stringResource
+import org.jetbrains.compose.ui.tooling.preview.Preview
+import org.koin.compose.viewmodel.koinViewModel
+import taskttl.composeapp.generated.resources.Res
+import taskttl.composeapp.generated.resources.continue_text
+import taskttl.composeapp.generated.resources.get_start_text
+import taskttl.composeapp.generated.resources.skip_text
+
+/**
+ * 引导视图
+ * @param [navigatorToRoute] 导航到路线
+ * @param [viewModel] 视图模型
+ */
+@Preview
+@Composable
+fun OnboardingScreen(
+ navigatorToRoute: (Routes) -> Unit,
+ viewModel: OnboardingViewModel = koinViewModel()
+) {
+ val onboardingPages = OnboardingPage.entries
+
+ val pagerState =
+ rememberPagerState(0, initialPageOffsetFraction = 0f, pageCount = { onboardingPages.size })
+
+ LaunchedEffect(Unit) {
+ viewModel.events.collectLatest { event ->
+ when (event) {
+ is OnboardingEvent.NextPage -> {
+ pagerState.animateScrollToPage(pagerState.currentPage + 1)
+ }
+
+ is OnboardingEvent.NavMain -> {
+ navigatorToRoute(Routes.Main)
+ }
+ }
+
+ }
+ }
+ Box(modifier = Modifier.fillMaxSize()) {
+ // 右上角跳过
+ TextButton(
+ onClick = { viewModel.markOnboardingCompleted() },
+ modifier = Modifier
+ .align(Alignment.TopEnd)
+ .padding(top = 32.dp, end = 16.dp)
+ ) {
+ Text(
+ stringResource(Res.string.skip_text) + ">",
+ fontSize = 16.sp,
+ color = Color.Black.copy(alpha = 0.6f)
+ )
+ }
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(horizontal = 30.dp, vertical = 40.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ HorizontalPager(
+ state = pagerState,
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxWidth(),
+ ) { page ->
+ OnboardingPage(pageData = onboardingPages[page])
+ }
+
+ Spacer(modifier = Modifier.height(20.dp))
+
+ // 圆点指示器
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ repeat(onboardingPages.size) { index ->
+ IndicatorDot(active = pagerState.currentPage == index)
+ }
+ }
+
+ Spacer(modifier = Modifier.height(30.dp))
+
+ // 底部按钮
+ if (pagerState.currentPage < onboardingPages.lastIndex) {
+ Button(
+ onClick = {
+ if (pagerState.currentPage < onboardingPages.lastIndex) {
+ viewModel.sendEvent(OnboardingEvent.NextPage)
+ }
+ },
+ modifier = Modifier.fillMaxWidth(),
+ colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF667EEA)),
+ shape = MaterialTheme.shapes.medium
+ ) {
+ Text(
+ stringResource(Res.string.continue_text),
+ color = Color.White,
+ fontSize = 18.sp
+ )
+ }
+ } else {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Button(
+ onClick = { viewModel.markOnboardingCompleted() },
+ modifier = Modifier.fillMaxWidth(),
+ colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF667EEA)),
+ shape = MaterialTheme.shapes.medium
+ ) {
+ Text(
+ stringResource(Res.string.get_start_text),
+ color = Color.White,
+ fontSize = 18.sp
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun OnboardingPage(pageData: OnboardingPage) {
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Icon(
+ imageVector = pageData.icon,
+ contentDescription = null,
+ tint = pageData.color,
+ modifier = Modifier
+ .size(200.dp)
+ .padding(bottom = 30.dp)
+ )
+
+ Text(
+ text = stringResource(pageData.titleRes),
+ fontSize = 28.sp,
+ fontWeight = FontWeight.Bold,
+ color = Color(0xFF333333),
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(bottom = 20.dp)
+ )
+
+ Text(
+ text = stringResource(pageData.descRes),
+ fontSize = 16.sp,
+ color = Color(0xFF666666),
+ lineHeight = 24.sp,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(horizontal = 16.dp)
+ )
+ }
+}
+
+@Composable
+fun IndicatorDot(active: Boolean) {
+ Box(
+ modifier = Modifier
+ .size(12.dp)
+ .background(
+ color = if (active) Color(0xFF667EEA) else Color(0xFFDDDDDD),
+ shape = CircleShape
+ )
+ )
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/presentation/settings/AboutScreen.kt b/composeApp/src/commonMain/kotlin/com/taskttl/presentation/settings/AboutScreen.kt
new file mode 100644
index 0000000..a9a33c3
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/presentation/settings/AboutScreen.kt
@@ -0,0 +1,313 @@
+package com.taskttl.presentation.settings
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.Assignment
+import androidx.compose.material.icons.filled.Email
+import androidx.compose.material.icons.filled.Language
+import androidx.compose.material3.Card
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import com.taskttl.ui.components.AppHeader
+import org.jetbrains.compose.resources.StringResource
+import org.jetbrains.compose.resources.stringResource
+import taskttl.composeapp.generated.resources.Res
+import taskttl.composeapp.generated.resources.all_rights_reserved
+import taskttl.composeapp.generated.resources.app_intro_content
+import taskttl.composeapp.generated.resources.app_intro_title
+import taskttl.composeapp.generated.resources.app_name
+import taskttl.composeapp.generated.resources.app_name_description
+import taskttl.composeapp.generated.resources.build_version
+import taskttl.composeapp.generated.resources.contact_us
+import taskttl.composeapp.generated.resources.copyright_year
+import taskttl.composeapp.generated.resources.developer_text
+import taskttl.composeapp.generated.resources.devttl_team
+import taskttl.composeapp.generated.resources.email
+import taskttl.composeapp.generated.resources.email_text
+import taskttl.composeapp.generated.resources.tech_stack
+import taskttl.composeapp.generated.resources.tech_stack_compose
+import taskttl.composeapp.generated.resources.tech_stack_kmp
+import taskttl.composeapp.generated.resources.tech_stack_koin
+import taskttl.composeapp.generated.resources.tech_stack_mvi
+import taskttl.composeapp.generated.resources.tech_stack_room
+import taskttl.composeapp.generated.resources.title_about
+import taskttl.composeapp.generated.resources.version
+import taskttl.composeapp.generated.resources.web_text
+import taskttl.composeapp.generated.resources.web_url
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun AboutScreen(
+ onNavigateBack: () -> Unit
+) {
+ Box(modifier = Modifier.fillMaxSize()) {
+ Column(
+ modifier = Modifier.fillMaxSize()
+ ) {
+ AppHeader(
+ title = Res.string.title_about,
+ showBack = true,
+ onBackClick = { onNavigateBack.invoke() }
+ )
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color(0xFFF5F5F5))
+ .verticalScroll(rememberScrollState())
+ .padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Spacer(modifier = Modifier.height(32.dp))
+
+ // 应用图标和名称
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.Assignment,
+ contentDescription = stringResource(Res.string.app_name),
+ modifier = Modifier.size(80.dp),
+ tint = MaterialTheme.colorScheme.primary
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Text(
+ text = stringResource(Res.string.app_name),
+ style = MaterialTheme.typography.headlineMedium,
+ fontWeight = FontWeight.Bold
+ )
+
+ Text(
+ text = stringResource(Res.string.app_name_description),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // 版本信息
+ Card(
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp)
+ ) {
+ AboutInfoRow(labelRes = Res.string.version, value = "1.0.0")
+ Spacer(modifier = Modifier.height(8.dp))
+ AboutInfoRow(labelRes = Res.string.build_version, value = "1")
+ }
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+ // 应用描述
+ Card(
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp)
+ ) {
+ Text(
+ text = stringResource(Res.string.app_intro_title),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Medium
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Text(
+ text = stringResource(Res.string.app_intro_content),
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Justify
+ )
+ }
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+ // 技术栈
+ Card(
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp)
+ ) {
+ Text(
+ text = stringResource(Res.string.tech_stack),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Medium
+ )
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ TechStackItem("Kotlin Multiplatform", Res.string.tech_stack_kmp)
+ TechStackItem("Jetpack Compose", Res.string.tech_stack_compose)
+ TechStackItem("Room Database", Res.string.tech_stack_room)
+ TechStackItem("Koin", Res.string.tech_stack_koin)
+ TechStackItem("MVI Architecture", Res.string.tech_stack_mvi)
+ }
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+ // 开发者信息
+ Card(
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp)
+ ) {
+ Text(
+ text = stringResource(Res.string.developer_text),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Medium
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Text(
+ text = stringResource(Res.string.devttl_team),
+ style = MaterialTheme.typography.bodyMedium
+ )
+ }
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+ // 联系方式
+ Card(
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp)
+ ) {
+ Text(
+ text = stringResource(Res.string.contact_us),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Medium
+ )
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ ContactItem(
+ icon = Icons.Default.Email,
+ labelRes = Res.string.email_text,
+ valueRes = Res.string.email
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ ContactItem(
+ icon = Icons.Default.Language,
+ labelRes = Res.string.web_text,
+ valueRes = Res.string.web_url
+ )
+ }
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+ // 版权信息
+ Text(
+ text = "© ${stringResource(Res.string.copyright_year)} " +
+ "${stringResource(Res.string.devttl_team)}. " +
+ "${stringResource(Res.string.all_rights_reserved)}.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center
+ )
+
+ Spacer(modifier = Modifier.height(32.dp))
+ }
+ }
+ }
+}
+
+@Composable
+private fun AboutInfoRow(
+ labelRes: StringResource,
+ value: String
+) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(
+ text = stringResource(labelRes),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ text = value,
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.Medium
+ )
+ }
+}
+
+@Composable
+private fun TechStackItem(
+ name: String,
+ descriptionRes: StringResource
+) {
+ Column {
+ Text(
+ text = name,
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.Medium
+ )
+ Text(
+ text = stringResource(descriptionRes),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ }
+}
+
+@Composable
+private fun ContactItem(
+ icon: ImageVector,
+ labelRes: StringResource,
+ valueRes: StringResource
+) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = stringResource(labelRes),
+ modifier = Modifier.size(20.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ Spacer(modifier = Modifier.width(12.dp))
+
+ Column {
+ Text(
+ text = stringResource(labelRes),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ text = stringResource(valueRes),
+ style = MaterialTheme.typography.bodyMedium
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/presentation/settings/DataManagementScreen.kt b/composeApp/src/commonMain/kotlin/com/taskttl/presentation/settings/DataManagementScreen.kt
new file mode 100644
index 0000000..595c7b6
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/presentation/settings/DataManagementScreen.kt
@@ -0,0 +1,371 @@
+package com.taskttl.presentation.settings
+
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.AttachFile
+import androidx.compose.material.icons.filled.ChevronRight
+import androidx.compose.material.icons.filled.CleaningServices
+import androidx.compose.material.icons.filled.CloudDownload
+import androidx.compose.material.icons.filled.CloudUpload
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.History
+import androidx.compose.material.icons.filled.Sync
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.RadioButton
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.unit.dp
+import com.taskttl.ui.components.AppHeader
+import org.jetbrains.compose.resources.StringResource
+import org.jetbrains.compose.resources.stringResource
+import taskttl.composeapp.generated.resources.Res
+import taskttl.composeapp.generated.resources.cancel
+import taskttl.composeapp.generated.resources.confirm
+import taskttl.composeapp.generated.resources.desc_auto_backup
+import taskttl.composeapp.generated.resources.desc_clear_all_data
+import taskttl.composeapp.generated.resources.desc_clear_all_data_dialog
+import taskttl.composeapp.generated.resources.desc_clear_completed_tasks
+import taskttl.composeapp.generated.resources.desc_clear_expired_countdowns
+import taskttl.composeapp.generated.resources.desc_export_data
+import taskttl.composeapp.generated.resources.desc_import_data
+import taskttl.composeapp.generated.resources.export
+import taskttl.composeapp.generated.resources.import
+import taskttl.composeapp.generated.resources.label_csv_format
+import taskttl.composeapp.generated.resources.label_enter
+import taskttl.composeapp.generated.resources.label_json_format
+import taskttl.composeapp.generated.resources.label_select_export_format
+import taskttl.composeapp.generated.resources.label_select_file
+import taskttl.composeapp.generated.resources.label_select_import_file
+import taskttl.composeapp.generated.resources.title_auto_backup
+import taskttl.composeapp.generated.resources.title_backup_restore
+import taskttl.composeapp.generated.resources.title_clear_all_data
+import taskttl.composeapp.generated.resources.title_clear_completed_tasks
+import taskttl.composeapp.generated.resources.title_clear_expired_countdowns
+import taskttl.composeapp.generated.resources.title_data_clean
+import taskttl.composeapp.generated.resources.title_data_management
+import taskttl.composeapp.generated.resources.title_export_data
+import taskttl.composeapp.generated.resources.title_import_data
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun DataManagementScreen(
+ onNavigateBack: () -> Unit
+) {
+ var showExportDialog by remember { mutableStateOf(false) }
+ var showImportDialog by remember { mutableStateOf(false) }
+ var showClearDataDialog by remember { mutableStateOf(false) }
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color.White)
+ ) {
+ Column(
+ modifier = Modifier.fillMaxSize()
+ ) {
+ AppHeader(
+ title = Res.string.title_data_management,
+ showBack = true,
+ onBackClick = { onNavigateBack.invoke() }
+ )
+
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color(0xFFF5F5F5))
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ item {
+ Text(
+ text = stringResource(Res.string.title_backup_restore),
+ style = MaterialTheme.typography.titleLarge
+ )
+ }
+
+ item {
+ DataManagementCard(
+ icon = Icons.Default.CloudUpload,
+ titleRes = Res.string.title_export_data,
+ descriptionRes = Res.string.desc_export_data,
+ onClick = { showExportDialog = true }
+ )
+ }
+
+ item {
+ DataManagementCard(
+ icon = Icons.Default.CloudDownload,
+ titleRes = Res.string.title_import_data,
+ descriptionRes = Res.string.desc_import_data,
+ onClick = { showImportDialog = true }
+ )
+ }
+
+ item {
+ DataManagementCard(
+ icon = Icons.Default.Sync,
+ titleRes = Res.string.title_auto_backup,
+ descriptionRes = Res.string.desc_auto_backup,
+ onClick = { /* TODO: 自动备份设置 */ }
+ )
+ }
+
+ item {
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ text = stringResource(Res.string.title_data_clean),
+ style = MaterialTheme.typography.titleLarge
+ )
+ }
+
+ item {
+ DataManagementCard(
+ icon = Icons.Default.Delete,
+ titleRes = Res.string.title_clear_all_data,
+ descriptionRes = Res.string.desc_clear_all_data,
+ onClick = { showClearDataDialog = true },
+ isDestructive = true
+ )
+ }
+
+ item {
+ DataManagementCard(
+ icon = Icons.Default.CleaningServices,
+ titleRes = Res.string.title_clear_completed_tasks,
+ descriptionRes = Res.string.desc_clear_completed_tasks,
+ onClick = { /* TODO: 清理已完成任务 */ }
+ )
+ }
+
+ item {
+ DataManagementCard(
+ icon = Icons.Default.History,
+ titleRes = Res.string.title_clear_expired_countdowns,
+ descriptionRes = Res.string.desc_clear_expired_countdowns,
+ onClick = { /* TODO: 清理过期倒数日 */ }
+ )
+ }
+ }
+ }
+
+ // 导出对话框
+ if (showExportDialog) {
+ ExportDataDialog(
+ onDismiss = { showExportDialog = false },
+ onExport = { format ->
+ // TODO: 实现导出功能
+ showExportDialog = false
+ }
+ )
+ }
+
+ // 导入对话框
+ if (showImportDialog) {
+ ImportDataDialog(
+ onDismiss = { showImportDialog = false },
+ onImport = {
+ // TODO: 实现导入功能
+ showImportDialog = false
+ }
+ )
+ }
+
+ // 清除数据确认对话框
+ if (showClearDataDialog) {
+ AlertDialog(
+ onDismissRequest = { showClearDataDialog = false },
+ title = { Text(stringResource(Res.string.title_clear_all_data)) },
+ text = { Text(stringResource(Res.string.desc_clear_all_data_dialog)) },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ // TODO: 实现清除数据功能
+ showClearDataDialog = false
+ }
+ ) {
+ Text(stringResource(Res.string.confirm), color = MaterialTheme.colorScheme.error)
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { showClearDataDialog = false }) {
+ Text(stringResource(Res.string.cancel))
+ }
+ }
+ )
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun DataManagementCard(
+ icon: ImageVector,
+ titleRes: StringResource,
+ descriptionRes: StringResource,
+ onClick: () -> Unit,
+ isDestructive: Boolean = false
+) {
+ Card(
+ onClick = onClick,
+ colors = if (isDestructive) {
+ CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f)
+ )
+ } else {
+ CardDefaults.cardColors()
+ }
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = stringResource(titleRes),
+ tint = if (isDestructive) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(32.dp)
+ )
+
+ Spacer(modifier = Modifier.width(16.dp))
+
+ Column(
+ modifier = Modifier.weight(1f)
+ ) {
+ Text(
+ text = stringResource(titleRes),
+ style = MaterialTheme.typography.titleMedium,
+ color = if (isDestructive) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface
+ )
+ Text(
+ text = stringResource(descriptionRes),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+
+ Icon(
+ imageVector = Icons.Default.ChevronRight,
+ contentDescription = stringResource(Res.string.label_enter),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+}
+
+@Composable
+private fun ExportDataDialog(
+ onDismiss: () -> Unit,
+ onExport: (String) -> Unit
+) {
+ var selectedFormat by remember { mutableStateOf("JSON") }
+
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text(stringResource(Res.string.title_export_data)) },
+ text = {
+ Column {
+ Text("${stringResource(Res.string.label_select_export_format)}:")
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Row(
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ RadioButton(
+ selected = selectedFormat == "JSON",
+ onClick = { selectedFormat = "JSON" }
+ )
+ Text(stringResource(Res.string.label_json_format))
+ }
+
+ Row(
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ RadioButton(
+ selected = selectedFormat == "CSV",
+ onClick = { selectedFormat = "CSV" }
+ )
+ Text(stringResource(Res.string.label_csv_format))
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = { onExport(selectedFormat) }) {
+ Text(stringResource(Res.string.export))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) {
+ Text(stringResource(Res.string.cancel))
+ }
+ }
+ )
+}
+
+@Composable
+private fun ImportDataDialog(
+ onDismiss: () -> Unit,
+ onImport: () -> Unit
+) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text(stringResource(Res.string.title_import_data)) },
+ text = {
+ Column {
+ Text("${stringResource(Res.string.label_select_import_file)}:")
+ Spacer(modifier = Modifier.height(16.dp))
+
+ OutlinedButton(
+ onClick = { /* TODO: 文件选择器 */ },
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Icon(
+ Icons.Default.AttachFile,
+ contentDescription = stringResource(Res.string.label_select_file)
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(stringResource(Res.string.label_select_file))
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = onImport) {
+ Text(stringResource(Res.string.import))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) {
+ Text(stringResource(Res.string.cancel))
+ }
+ }
+ )
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/presentation/settings/FeedbackScreen.kt b/composeApp/src/commonMain/kotlin/com/taskttl/presentation/settings/FeedbackScreen.kt
new file mode 100644
index 0000000..49788a6
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/presentation/settings/FeedbackScreen.kt
@@ -0,0 +1,215 @@
+package com.taskttl.presentation.settings
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.Send
+import androidx.compose.material.icons.filled.BugReport
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import com.taskttl.core.domain.FeedbackType
+import com.taskttl.ui.components.AppHeader
+import org.jetbrains.compose.resources.stringResource
+import taskttl.composeapp.generated.resources.Res
+import taskttl.composeapp.generated.resources.button_cancel
+import taskttl.composeapp.generated.resources.button_send_feedback
+import taskttl.composeapp.generated.resources.feedback_contact
+import taskttl.composeapp.generated.resources.feedback_contact_placeholder
+import taskttl.composeapp.generated.resources.feedback_description
+import taskttl.composeapp.generated.resources.feedback_placeholder
+import taskttl.composeapp.generated.resources.feedback_type
+import taskttl.composeapp.generated.resources.title_feedback
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun FeedbackScreen(
+ onNavigateBack: () -> Unit,
+ onSubmit: () -> Unit
+) {
+ var feedbackType by remember { mutableStateOf(FeedbackType.ISSUE) }
+ var contact by remember { mutableStateOf("") }
+ var description by remember { mutableStateOf("") }
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ Column(
+ modifier = Modifier.fillMaxSize()
+ ) {
+ AppHeader(
+ title = Res.string.title_feedback,
+ showBack = true,
+ onBackClick = { onNavigateBack.invoke() },
+ trailingIcon = Icons.AutoMirrored.Filled.Send,
+ onTrailingClick = {
+ onSubmit()
+ }
+ )
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color(0xFFF5F5F5))
+ .verticalScroll(rememberScrollState())
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ // 反馈类型
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(containerColor = Color.White),
+ shape = RoundedCornerShape(12.dp),
+ elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
+ ) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Text(
+ stringResource(Res.string.feedback_type),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Medium
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ FeedbackType.entries.forEach { item ->
+ FeedbackTypeOption(
+ text = stringResource(item.titleRes),
+ selected = feedbackType == item,
+ onClick = { feedbackType = item },
+ icon = Icons.Default.BugReport,
+ modifier = Modifier.weight(1f)
+ )
+
+ }
+ }
+ }
+ }
+
+ // 问题描述
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(containerColor = Color.White),
+ shape = RoundedCornerShape(12.dp),
+ elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
+ ) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Text(
+ stringResource(Res.string.feedback_description),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Medium
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ OutlinedTextField(
+ value = description,
+ onValueChange = { description = it },
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(120.dp),
+ placeholder = { Text(stringResource(Res.string.feedback_placeholder)) }
+ )
+ }
+ }
+
+ // 联系方式
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(containerColor = Color.White),
+ shape = RoundedCornerShape(12.dp),
+ elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
+ ) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Text(
+ stringResource(Res.string.feedback_contact),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Medium
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ OutlinedTextField(
+ value = contact,
+ onValueChange = { contact = it },
+ modifier = Modifier.fillMaxWidth(),
+ placeholder = { Text(stringResource(Res.string.feedback_contact_placeholder)) }
+ )
+ }
+ }
+
+ // 底部按钮
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Button(
+ onClick = {
+ onSubmit()
+ },
+ modifier = Modifier.weight(1f)
+ ) {
+ Text(stringResource(Res.string.button_send_feedback))
+ }
+ OutlinedButton(
+ onClick = { onNavigateBack.invoke() },
+ modifier = Modifier.weight(1f)
+ ) {
+ Text(stringResource(Res.string.button_cancel))
+ }
+ }
+
+ Spacer(modifier = Modifier.height(32.dp))
+ }
+ }
+ }
+}
+
+@Composable
+private fun FeedbackTypeOption(
+ text: String,
+ selected: Boolean,
+ onClick: () -> Unit,
+ icon: ImageVector,
+ modifier: Modifier = Modifier
+) {
+ val borderColor = if (selected) MaterialTheme.colorScheme.primary else Color.LightGray
+ val bgColor = if (selected) Color(0xFFF0F4FF) else Color.Transparent
+ val textColor =
+ if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
+
+ OutlinedButton(
+ onClick = onClick,
+ modifier = modifier,
+ colors = ButtonDefaults.outlinedButtonColors(containerColor = bgColor),
+ border = BorderStroke(2.dp, borderColor),
+ shape = MaterialTheme.shapes.medium
+ ) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Icon(icon, contentDescription = null, tint = textColor)
+ Text(text, color = textColor)
+ }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/presentation/settings/PrivacyScreen.kt b/composeApp/src/commonMain/kotlin/com/taskttl/presentation/settings/PrivacyScreen.kt
new file mode 100644
index 0000000..e61f644
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/presentation/settings/PrivacyScreen.kt
@@ -0,0 +1,49 @@
+package com.taskttl.presentation.settings
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import com.taskttl.core.ui.DevTTLWebView
+import com.taskttl.ui.components.AppHeader
+import org.jetbrains.compose.resources.stringResource
+import taskttl.composeapp.generated.resources.Res
+import taskttl.composeapp.generated.resources.privacy_url
+import taskttl.composeapp.generated.resources.title_about
+
+@Composable
+fun PrivacyScreen(onNavigateBack: () -> Unit) {
+ Box(modifier = Modifier.fillMaxSize()) {
+ Column(
+ modifier = Modifier.fillMaxSize()
+ ) {
+ AppHeader(
+ title = Res.string.title_about,
+ showBack = true,
+ onBackClick = { onNavigateBack.invoke() }
+ )
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color(0xFFF5F5F5))
+ .verticalScroll(rememberScrollState())
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ DevTTLWebView(
+ modifier = Modifier.fillMaxSize(),
+ url = stringResource(Res.string.privacy_url)
+ )
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/presentation/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/taskttl/presentation/settings/SettingsScreen.kt
new file mode 100644
index 0000000..449f1ba
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/presentation/settings/SettingsScreen.kt
@@ -0,0 +1,317 @@
+package com.taskttl.presentation.settings
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.Help
+import androidx.compose.material.icons.filled.ChevronRight
+import androidx.compose.material.icons.filled.Person
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material.icons.filled.Share
+import androidx.compose.material.icons.filled.Storage
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.navigation.NavHostController
+import com.taskttl.core.routes.Routes
+import com.taskttl.ui.components.AppHeader
+import org.jetbrains.compose.resources.StringResource
+import org.jetbrains.compose.resources.stringResource
+import org.jetbrains.compose.ui.tooling.preview.Preview
+import taskttl.composeapp.generated.resources.Res
+import taskttl.composeapp.generated.resources.section_data_management
+import taskttl.composeapp.generated.resources.section_general_settings
+import taskttl.composeapp.generated.resources.section_help_feedback
+import taskttl.composeapp.generated.resources.section_social_share
+import taskttl.composeapp.generated.resources.setting_about_app
+import taskttl.composeapp.generated.resources.setting_about_app_desc
+import taskttl.composeapp.generated.resources.setting_category_management
+import taskttl.composeapp.generated.resources.setting_category_management_desc
+import taskttl.composeapp.generated.resources.setting_dark_mode
+import taskttl.composeapp.generated.resources.setting_dark_mode_desc
+import taskttl.composeapp.generated.resources.setting_data_management
+import taskttl.composeapp.generated.resources.setting_data_management_desc
+import taskttl.composeapp.generated.resources.setting_feedback
+import taskttl.composeapp.generated.resources.setting_feedback_desc
+import taskttl.composeapp.generated.resources.setting_invite_friend
+import taskttl.composeapp.generated.resources.setting_invite_friend_desc
+import taskttl.composeapp.generated.resources.setting_language
+import taskttl.composeapp.generated.resources.setting_language_desc
+import taskttl.composeapp.generated.resources.setting_privacy_policy
+import taskttl.composeapp.generated.resources.setting_privacy_policy_desc
+import taskttl.composeapp.generated.resources.setting_push_notification
+import taskttl.composeapp.generated.resources.setting_push_notification_desc
+import taskttl.composeapp.generated.resources.setting_share_achievement
+import taskttl.composeapp.generated.resources.setting_share_achievement_desc
+import taskttl.composeapp.generated.resources.title_app_settings
+
+/**
+ * 设置屏幕
+ * @param [navController] 导航控制器
+ */
+@Composable
+@Preview
+fun SettingsScreen(
+ navController: NavHostController,
+) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color.White)
+ ) {
+ Column(
+ modifier = Modifier.fillMaxSize()
+ ) {
+ AppHeader(
+ title = Res.string.title_app_settings,
+ trailingIcon = Icons.Default.Person,
+ )
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color(0xFFF5F5F5))
+ .verticalScroll(rememberScrollState())
+ .padding(16.dp)
+ ) {
+ // 用户信息卡片
+ // Column(
+ // modifier = Modifier
+ // .fillMaxWidth()
+ // .background(
+ // brush = Brush.linearGradient(
+ // colors = listOf(Color(0xFF667EEA), Color(0xFF764BA2))
+ // ),
+ // shape = RoundedCornerShape(16.dp)
+ // )
+ // .padding(20.dp),
+ // horizontalAlignment = Alignment.CenterHorizontally
+ // ) {
+ // Box(
+ // modifier = Modifier
+ // .size(60.dp)
+ // .background(Color.White.copy(alpha = 0.2f), shape = CircleShape),
+ // contentAlignment = Alignment.Center
+ // ) {
+ // Icon(
+ // imageVector = Icons.Default.Person,
+ // contentDescription = "用户头像",
+ // tint = Color.White
+ // )
+ // }
+ // Spacer(modifier = Modifier.height(12.dp))
+ // Text(
+ // "TaskMaster 用户",
+ // color = Color.White,
+ // fontWeight = FontWeight.Medium,
+ // fontSize = 18.sp
+ // )
+ // Text(
+ // "已使用 30 天 · 完成 156 个任务",
+ // color = Color.White.copy(alpha = 0.9f),
+ // fontSize = 14.sp
+ // )
+ // }
+ //
+ // Spacer(modifier = Modifier.height(24.dp))
+
+
+ // 通用设置
+ // SectionTitle(Icons.Default.Settings, Res.string.section_general_settings)
+
+ // var notificationEnabled by remember { mutableStateOf(true) }
+ // SettingItem(
+ // titleRes = Res.string.setting_push_notification,
+ // descriptionRes = Res.string.setting_push_notification_desc,
+ // showSwitch = true,
+ // switchState = notificationEnabled,
+ // onSwitchChanged = { notificationEnabled = it }
+ // )
+
+ // var darkMode by remember { mutableStateOf(false) }
+ //
+ // SettingItem(
+ // titleRes = Res.string.setting_dark_mode,
+ // descriptionRes = Res.string.setting_dark_mode_desc,
+ // showSwitch = true,
+ // switchState = darkMode,
+ // onSwitchChanged = { darkMode = it }
+ // )
+
+ // SettingItem(
+ // titleRes = Res.string.setting_language,
+ // descriptionRes = Res.string.setting_language_desc,
+ // showArrow = true
+ // )
+
+ Spacer(modifier = Modifier.height(16.dp))
+ // 数据管理
+ SectionTitle(Icons.Default.Storage, Res.string.section_data_management)
+ SettingItem(
+ titleRes = Res.string.setting_category_management,
+ descriptionRes = Res.string.setting_category_management_desc,
+ showArrow = true,
+ onClick = {
+ navController.navigate(Routes.Main.Settings.CategoryManagement)
+ }
+ )
+ // SettingItem(
+ // titleRes = Res.string.setting_data_management,
+ // descriptionRes = Res.string.setting_data_management_desc,
+ // showArrow = true,
+ // onClick = { navController.navigate(Routes.Main.Settings.DataManagement) }
+ // )
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // // 社交分享
+ // SectionTitle(Icons.Default.Share, Res.string.section_social_share)
+ // SettingItem(
+ // titleRes = Res.string.setting_share_achievement,
+ // descriptionRes = Res.string.setting_share_achievement_desc,
+ // showArrow = true
+ // )
+ // SettingItem(
+ // titleRes = Res.string.setting_invite_friend,
+ // descriptionRes = Res.string.setting_invite_friend_desc,
+ // showArrow = true
+ // )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // 帮助与反馈
+ SectionTitle(Icons.AutoMirrored.Filled.Help, Res.string.section_help_feedback)
+ SettingItem(
+ titleRes = Res.string.setting_feedback,
+ descriptionRes = Res.string.setting_feedback_desc,
+ showArrow = true,
+ onClick = { navController.navigate(Routes.Main.Settings.Feedback) }
+ )
+ SettingItem(
+ titleRes = Res.string.setting_privacy_policy,
+ descriptionRes = Res.string.setting_privacy_policy_desc,
+ showArrow = true,
+ onClick = { navController.navigate(Routes.Main.Settings.Privacy) }
+ )
+ SettingItem(
+ titleRes = Res.string.setting_about_app,
+ descriptionRes = Res.string.setting_about_app_desc,
+ showArrow = true,
+ onClick = { navController.navigate(Routes.Main.Settings.About) }
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+ }
+ }
+ }
+}
+
+/**
+ * 分区标题
+ * @param [icon] 图标
+ * @param [titleRes] 标题
+ */
+@Composable
+fun SectionTitle(icon: ImageVector, titleRes: StringResource) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.padding(vertical = 8.dp)
+ ) {
+ Icon(
+ icon,
+ contentDescription = null,
+ tint = Color(0xFF667EEA),
+ modifier = Modifier.size(18.dp)
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ stringResource(titleRes),
+ fontWeight = FontWeight.Bold,
+ fontSize = 16.sp,
+ color = Color(0xFF333333)
+ )
+ }
+}
+
+/**
+ * 设置项
+ * @param [titleRes] 标题
+ * @param [descriptionRes] 描述
+ * @param [showSwitch] 显示开关
+ * @param [switchState] 开关状态
+ * @param [onSwitchChanged] 开关已更改
+ * @param [showArrow] 显示箭头
+ * @param [modifier] 修饰符
+ * @param [onClick] 点击
+ */
+@Composable
+fun SettingItem(
+ titleRes: StringResource,
+ descriptionRes: StringResource,
+ showSwitch: Boolean = false,
+ switchState: Boolean = false,
+ onSwitchChanged: ((Boolean) -> Unit)? = null,
+ showArrow: Boolean = false,
+ modifier: Modifier = Modifier,
+ onClick: (() -> Unit)? = null
+) {
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .clickable(enabled = onClick != null) { onClick?.invoke() }
+ .background(Color.White, shape = RoundedCornerShape(12.dp))
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = stringResource(titleRes),
+ fontWeight = FontWeight.Medium,
+ fontSize = 16.sp,
+ color = Color(0xFF333333)
+ )
+ descriptionRes.let {
+ Spacer(modifier = Modifier.height(2.dp))
+ Text(stringResource(it), fontSize = 14.sp, color = Color(0xFF666666))
+ }
+ }
+
+ if (showSwitch && onSwitchChanged != null) {
+ Switch(checked = switchState, onCheckedChange = onSwitchChanged)
+ } else if (showArrow) {
+ Icon(
+ imageVector = Icons.Default.ChevronRight,
+ contentDescription = null,
+ tint = Color(0xFFCCCCCC)
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(12.dp))
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/presentation/splash/SplashScreen.kt b/composeApp/src/commonMain/kotlin/com/taskttl/presentation/splash/SplashScreen.kt
new file mode 100644
index 0000000..9b5eadb
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/presentation/splash/SplashScreen.kt
@@ -0,0 +1,143 @@
+package com.taskttl.presentation.splash
+
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ListAlt
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.scale
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.taskttl.core.routes.Routes
+import com.taskttl.data.state.SplashState
+import com.taskttl.data.viewmodel.SplashViewModel
+import org.jetbrains.compose.resources.stringResource
+import org.jetbrains.compose.ui.tooling.preview.Preview
+import org.koin.compose.viewmodel.koinViewModel
+import taskttl.composeapp.generated.resources.Res
+import taskttl.composeapp.generated.resources.app_name
+import taskttl.composeapp.generated.resources.app_name_remark
+
+/**
+ * 启动视图
+ * @param [navigatorToRoute] 导航到路线
+ * @param [viewModel] 视图模型
+ */
+@Preview
+@Composable
+fun SplashScreen(
+ navigatorToRoute: (Routes) -> Unit,
+ viewModel: SplashViewModel = koinViewModel()
+) {
+ val state by viewModel.uiState.collectAsState()
+
+ LaunchedEffect(state) {
+ when (state) {
+ SplashState.NavigateToOnboarding -> navigatorToRoute(Routes.Onboarding)
+ SplashState.NavigateToMain -> navigatorToRoute(Routes.Main)
+ else -> {}
+ }
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(
+ Brush.linearGradient(
+ colors = listOf(Color(0xFF667eea), Color(0xFF764ba2)),
+ start = Offset(0f, 0f),
+ end = Offset.Infinite
+ )
+ )
+ .padding(12.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ // 图标
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.ListAlt,
+ contentDescription = "logo",
+ tint = Color.White.copy(alpha = 0.9f),
+ modifier = Modifier
+ .size(200.dp)
+ .padding(bottom = 20.dp)
+ )
+
+ // 标题
+ Text(
+ text = stringResource(Res.string.app_name),
+ fontSize = 32.sp,
+ fontWeight = FontWeight.Bold,
+ color = Color.White,
+ modifier = Modifier.padding(bottom = 10.dp)
+ )
+
+ // 副标题
+ Text(
+ text = stringResource(Res.string.app_name_remark),
+ fontSize = 16.sp,
+ color = Color.White.copy(alpha = 0.8f),
+ modifier = Modifier.padding(bottom = 50.dp)
+ )
+
+ // 底部三个小点
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ PulsingDot(delayMillis = 0)
+ PulsingDot(delayMillis = 200)
+ PulsingDot(delayMillis = 400)
+ }
+ }
+ }
+}
+
+
+@Composable
+fun PulsingDot(delayMillis: Int) {
+ val infiniteTransition = rememberInfiniteTransition(label = "")
+ val scale by infiniteTransition.animateFloat(
+ initialValue = 1f,
+ targetValue = 1.5f,
+ animationSpec = infiniteRepeatable(
+ animation = tween(1500, easing = LinearEasing, delayMillis = delayMillis),
+ repeatMode = RepeatMode.Reverse
+ ),
+ label = ""
+ )
+
+ Box(
+ modifier = Modifier
+ .size(8.dp)
+ .scale(scale)
+ .background(Color.White, shape = CircleShape)
+ )
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/presentation/statistics/StatisticsScreen.kt b/composeApp/src/commonMain/kotlin/com/taskttl/presentation/statistics/StatisticsScreen.kt
new file mode 100644
index 0000000..36c457d
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/presentation/statistics/StatisticsScreen.kt
@@ -0,0 +1,352 @@
+package com.taskttl.presentation.statistics
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.Assignment
+import androidx.compose.material.icons.automirrored.filled.TrendingUp
+import androidx.compose.material.icons.filled.CalendarToday
+import androidx.compose.material.icons.filled.CheckCircle
+import androidx.compose.material.icons.filled.Schedule
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ProgressIndicatorDefaults
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.navigation.NavHostController
+import com.taskttl.core.routes.Routes
+import com.taskttl.core.ui.Chip
+import com.taskttl.data.local.model.Category
+import com.taskttl.data.state.CountdownIntent
+import com.taskttl.data.state.TaskIntent
+import com.taskttl.data.viewmodel.CountdownViewModel
+import com.taskttl.data.viewmodel.TaskViewModel
+import com.taskttl.ui.components.AppHeader
+import org.jetbrains.compose.resources.StringResource
+import org.jetbrains.compose.resources.stringResource
+import org.jetbrains.compose.ui.tooling.preview.Preview
+import org.koin.compose.viewmodel.koinViewModel
+import taskttl.composeapp.generated.resources.Res
+import taskttl.composeapp.generated.resources.category_countdown
+import taskttl.composeapp.generated.resources.category_statistics
+import taskttl.composeapp.generated.resources.category_task
+import taskttl.composeapp.generated.resources.completed
+import taskttl.composeapp.generated.resources.completion_rate
+import taskttl.composeapp.generated.resources.overview
+import taskttl.composeapp.generated.resources.setting_category_management
+import taskttl.composeapp.generated.resources.title_statistics
+import taskttl.composeapp.generated.resources.total_tasks
+
+@Composable
+@Preview
+fun StatisticsScreen(
+ navController: NavHostController,
+ taskViewModel: TaskViewModel = koinViewModel(),
+ countdownViewModel: CountdownViewModel = koinViewModel()
+) {
+
+ val taskState by taskViewModel.state.collectAsState()
+ val countdownState by countdownViewModel.state.collectAsState()
+
+ LaunchedEffect(Unit) {
+ taskViewModel.handleIntent(TaskIntent.LoadTasks)
+ countdownViewModel.handleIntent(CountdownIntent.LoadCountdowns)
+ }
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color.White)
+ ) {
+ AppHeader(
+ title = Res.string.title_statistics,
+ trailingIcon = Icons.Default.CalendarToday
+ )
+
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color(0xFFF5F5F5))
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ // 总览统计
+ item {
+ Text(
+ text = stringResource(Res.string.overview),
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Bold
+ )
+ }
+
+ item {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ StatisticCard(
+ titleRes = Res.string.total_tasks,
+ value = taskState.tasks.size.toString(),
+ icon = Icons.AutoMirrored.Filled.Assignment,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.weight(1f)
+ )
+
+ StatisticCard(
+ titleRes = Res.string.completed,
+ value = taskState.tasks.count { it.isCompleted }.toString(),
+ icon = Icons.Default.CheckCircle,
+ color = Color(0xFF4CAF50),
+ modifier = Modifier.weight(1f)
+ )
+ }
+ }
+
+ item {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ StatisticCard(
+ titleRes = Res.string.category_countdown,
+ value = countdownState.countdowns.size.toString(),
+ icon = Icons.Default.Schedule,
+ color = Color(0xFFFF9800),
+ modifier = Modifier.weight(1f)
+ )
+
+ val completionRate = if (taskState.tasks.isNotEmpty()) {
+ (taskState.tasks.count { it.isCompleted }
+ .toFloat() / taskState.tasks.size * 100).toInt()
+ } else 0
+
+ StatisticCard(
+ titleRes = Res.string.completion_rate,
+ value = "$completionRate%",
+ icon = Icons.AutoMirrored.Filled.TrendingUp,
+ color = Color(0xFF9C27B0),
+ modifier = Modifier.weight(1f)
+ )
+ }
+ }
+
+ // 分类统计
+ item {
+ Spacer(modifier = Modifier.height(8.dp))
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = stringResource(Res.string.category_statistics),
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Bold
+ )
+ TextButton(onClick = {
+ navController.navigate(Routes.Main.Settings.CategoryManagement)
+ }) {
+ Text(stringResource(Res.string.setting_category_management))
+ }
+ }
+ }
+
+ item {
+ taskState.categories.let {
+ taskState.categories.forEach { category ->
+ val categoryTasks = taskState.tasks.filter { it.category == category }
+ val completedTasks = categoryTasks.count { it.isCompleted }
+ CategoryStatisticItem(
+ category = category,
+ totalCount = categoryTasks.size,
+ completedCount = completedTasks,
+ typeRes = Res.string.category_task
+ )
+ }
+ }
+
+ countdownState.categories.let {
+ countdownState.categories.forEach { category ->
+ val categoryCountdowns =
+ countdownState.countdowns.filter { it.category == category }
+ val activeCountdowns = categoryCountdowns.count { it.isActive }
+
+ CategoryStatisticItem(
+ category = category,
+ totalCount = categoryCountdowns.size,
+ completedCount = activeCountdowns,
+ typeRes = Res.string.category_countdown
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun StatisticCard(
+ titleRes: StringResource,
+ value: String,
+ icon: ImageVector,
+ color: Color,
+ modifier: Modifier = Modifier
+) {
+ Card(
+ modifier = modifier,
+ colors = CardDefaults.cardColors(containerColor = color.copy(alpha = 0.1f))
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = stringResource(titleRes),
+ tint = color,
+ modifier = Modifier.size(32.dp)
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Text(
+ text = value,
+ style = MaterialTheme.typography.headlineMedium,
+ fontWeight = FontWeight.Bold,
+ color = color
+ )
+
+ Text(
+ text = stringResource(titleRes),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+}
+
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun CategoryStatisticItem(
+ category: Category,
+ totalCount: Int,
+ completedCount: Int,
+ typeRes: StringResource
+) {
+ if (totalCount == 0) return
+
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(containerColor = Color.White),
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // 分类颜色指示器
+ Box(
+ modifier = Modifier
+ .size(40.dp)
+ .clip(CircleShape)
+ .background(category.color.backgroundColor),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ imageVector = category.icon.icon,
+ contentDescription = stringResource(category.icon.displayNameRes),
+ tint = category.color.iconColor,
+ modifier = Modifier.size(24.dp)
+ )
+ }
+
+ Spacer(modifier = Modifier.width(16.dp))
+
+ // 分类信息
+ Column(
+ modifier = Modifier.weight(1f)
+ ) {
+ Row() {
+ Text(
+ text = category.name,
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Medium,
+ color = category.color.textColor
+ )
+ Spacer(modifier = Modifier.width(6.dp))
+
+ Chip(
+ textRes = category.type.displayNameRes,
+ )
+ }
+
+ Text(
+ text = "${stringResource(typeRes)}: $completedCount/$totalCount",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+
+ // 进度条
+ Column(
+ horizontalAlignment = Alignment.End
+ ) {
+ val progress = if (totalCount > 0) completedCount.toFloat() / totalCount else 0f
+
+ Text(
+ text = "${(progress * 100).toInt()}%",
+ style = MaterialTheme.typography.labelMedium,
+ fontWeight = FontWeight.Medium,
+ color = category.color.textColor
+ )
+
+ Spacer(modifier = Modifier.height(4.dp))
+
+ LinearProgressIndicator(
+ progress = { progress },
+ modifier = Modifier
+ .width(80.dp)
+ .height(6.dp)
+ .clip(RoundedCornerShape(3.dp)),
+ color = category.color.textColor,
+ trackColor = ProgressIndicatorDefaults.linearTrackColor,
+ strokeCap = ProgressIndicatorDefaults.LinearStrokeCap,
+ )
+ }
+ }
+ }
+ Spacer(modifier = Modifier.height(10.dp))
+}
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/presentation/task/TaskDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/taskttl/presentation/task/TaskDetailScreen.kt
new file mode 100644
index 0000000..6de4297
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/presentation/task/TaskDetailScreen.kt
@@ -0,0 +1,232 @@
+package com.taskttl.presentation.task
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.Label
+import androidx.compose.material.icons.filled.CalendarToday
+import androidx.compose.material.icons.filled.CheckCircle
+import androidx.compose.material.icons.filled.Edit
+import androidx.compose.material.icons.filled.Timer
+import androidx.compose.material.icons.outlined.Circle
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.taskttl.data.state.TaskEffect
+import com.taskttl.data.state.TaskIntent
+import com.taskttl.data.viewmodel.TaskViewModel
+import com.taskttl.ui.components.AppHeader
+import org.jetbrains.compose.resources.stringResource
+import org.koin.compose.viewmodel.koinViewModel
+import taskttl.composeapp.generated.resources.Res
+import taskttl.composeapp.generated.resources.desc_completed
+import taskttl.composeapp.generated.resources.desc_incomplete
+import taskttl.composeapp.generated.resources.label_created_at
+import taskttl.composeapp.generated.resources.label_description
+import taskttl.composeapp.generated.resources.label_due_date
+import taskttl.composeapp.generated.resources.label_none
+import taskttl.composeapp.generated.resources.text_task_not_found
+import taskttl.composeapp.generated.resources.title_task_info
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun TaskDetailScreen(
+ taskId: String,
+ onNavigateBack: () -> Unit,
+ onNavigateToEdit: () -> Unit,
+ viewModel: TaskViewModel = koinViewModel()
+) {
+ val state by viewModel.state.collectAsState()
+ val task = state.tasks.find { it.id == taskId }
+
+ LaunchedEffect(Unit) {
+ viewModel.effects.collect { effect ->
+ when (effect) {
+ is TaskEffect.NavigateBack -> {
+ onNavigateBack.invoke()
+ }
+
+ is TaskEffect.NavigateToEditTask -> {
+ onNavigateToEdit.invoke()
+ }
+
+ else -> {}
+ }
+ }
+ }
+
+ if (task == null) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(stringResource(Res.string.text_task_not_found))
+ }
+ return
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color.White)
+ ) {
+ Column(
+ modifier = Modifier.fillMaxSize()
+ ) {
+
+ AppHeader(
+ title = Res.string.title_task_info,
+ showBack = true,
+ onBackClick = { viewModel.handleIntent(TaskIntent.NavigateBack) },
+ trailingIcon = Icons.Default.Edit,
+ onTrailingClick = { viewModel.handleIntent(TaskIntent.NavigateToEditTask) }
+ )
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(16.dp),
+ verticalArrangement = Arrangement.Top
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ // Checkbox 圆点
+ IconButton(
+ onClick = { viewModel.handleIntent(TaskIntent.ToggleTaskCompletion(taskId)) },
+ modifier = Modifier.size(24.dp)
+ ) {
+ val isCompleted =
+ if (task.isCompleted) Res.string.desc_completed else Res.string.desc_incomplete
+ Icon(
+ imageVector = if (task.isCompleted) Icons.Filled.CheckCircle else Icons.Outlined.Circle,
+ contentDescription = stringResource(isCompleted),
+ tint = if (task.isCompleted) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ Spacer(modifier = Modifier.width(12.dp))
+ Text(
+ text = task.title,
+ modifier = Modifier.weight(1f),
+ fontSize = 20.sp,
+ color = Color(0xFF333333),
+ fontWeight = FontWeight.SemiBold
+ )
+
+
+ // 优先级指示器
+ Box(
+ modifier = Modifier
+ .background(
+ color = task.priority.color.copy(alpha = 0.1f),
+ shape = RoundedCornerShape(12.dp)
+ )
+ .padding(horizontal = 8.dp, vertical = 4.dp)
+ ) {
+ Text(
+ text = stringResource(task.priority.displayNameRes),
+ style = MaterialTheme.typography.labelSmall,
+ color = task.priority.color
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // 类型
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = 12.dp)
+ .background(Color(0xFFF8F9FA), RoundedCornerShape(12.dp))
+ .padding(12.dp)
+ ) {
+ Icon(Icons.AutoMirrored.Filled.Label, contentDescription = null)
+ Spacer(modifier = Modifier.width(12.dp))
+ Text(text = task.category.name, color = task.category.color.textColor)
+ }
+
+ // 截止日期
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = 12.dp)
+ .background(Color(0xFFF8F9FA), RoundedCornerShape(12.dp))
+ .padding(12.dp)
+ ) {
+ Icon(Icons.Default.CalendarToday, contentDescription = null)
+ Spacer(modifier = Modifier.width(12.dp))
+ Text(
+ text = "${stringResource(Res.string.label_due_date)}:${
+ task.dueDate ?: stringResource(Res.string.label_none)
+ }"
+ )
+ }
+
+
+ // 创建日期
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = 12.dp)
+ .background(Color(0xFFF8F9FA), RoundedCornerShape(12.dp))
+ .padding(12.dp)
+ ) {
+ Icon(Icons.Default.Timer, contentDescription = null)
+ Spacer(modifier = Modifier.width(12.dp))
+ Text(text = "${stringResource(Res.string.label_created_at)}:${task.createdAt}")
+ }
+
+
+ Spacer(modifier = Modifier.height(20.dp))
+
+ Text(
+ text = stringResource(Res.string.label_description),
+ fontWeight = FontWeight.SemiBold,
+ color = Color(0xFF333333)
+ )
+
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 8.dp)
+ .background(Color(0xFFF8F9FA), RoundedCornerShape(8.dp))
+ .padding(15.dp)
+ ) {
+ Text(text = task.description, color = Color(0xFF666666), lineHeight = 20.sp)
+ }
+ }
+
+ }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/presentation/task/TaskEditorScreen.kt b/composeApp/src/commonMain/kotlin/com/taskttl/presentation/task/TaskEditorScreen.kt
new file mode 100644
index 0000000..a72e3a1
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/presentation/task/TaskEditorScreen.kt
@@ -0,0 +1,281 @@
+package com.taskttl.presentation.task
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.DateRange
+import androidx.compose.material.icons.filled.Edit
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FilterChip
+import androidx.compose.material3.FilterChipDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.taskttl.data.local.model.Category
+import com.taskttl.data.local.model.Task
+import com.taskttl.data.local.model.TaskPriority
+import com.taskttl.data.state.TaskEffect
+import com.taskttl.data.state.TaskIntent
+import com.taskttl.data.viewmodel.TaskViewModel
+import com.taskttl.ui.components.AppHeader
+import com.taskttl.ui.components.CategoryCard
+import com.taskttl.ui.components.CompactDatePickerDialog
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.toLocalDateTime
+import org.jetbrains.compose.resources.stringResource
+import org.jetbrains.compose.ui.tooling.preview.Preview
+import org.koin.compose.viewmodel.koinViewModel
+import taskttl.composeapp.generated.resources.Res
+import taskttl.composeapp.generated.resources.desc_select_date
+import taskttl.composeapp.generated.resources.hint_tags
+import taskttl.composeapp.generated.resources.title_add_task
+import taskttl.composeapp.generated.resources.title_due_date
+import taskttl.composeapp.generated.resources.title_edit_task
+import taskttl.composeapp.generated.resources.title_priority
+import taskttl.composeapp.generated.resources.title_select_category
+import taskttl.composeapp.generated.resources.title_tags
+import taskttl.composeapp.generated.resources.title_task_description
+import taskttl.composeapp.generated.resources.title_task_title
+import kotlin.time.Clock
+import kotlin.time.ExperimentalTime
+import kotlin.uuid.ExperimentalUuidApi
+import kotlin.uuid.Uuid
+
+
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalTime::class, ExperimentalUuidApi::class)
+@Composable
+@Preview
+fun TaskEditorScreen(
+ taskId: String? = null,
+ onNavigateBack: () -> Unit,
+ viewModel: TaskViewModel = koinViewModel()
+) {
+
+ LaunchedEffect(taskId) {
+ taskId?.let { viewModel.handleIntent(TaskIntent.GetTaskById(it)) }
+ }
+ val state by viewModel.state.collectAsState()
+ val existingTask = state.editingTask
+
+ var showDatePicker by remember { mutableStateOf(false) }
+
+ var title by remember { mutableStateOf("") }
+ var description by remember { mutableStateOf("") }
+ var selectedCategory by remember { mutableStateOf(null) }
+ var selectedPriority by remember { mutableStateOf(TaskPriority.MEDIUM) }
+ var dueDate by remember { mutableStateOf(existingTask?.dueDate) }
+ var tags by remember { mutableStateOf("") }
+
+ LaunchedEffect(Unit) {
+ viewModel.effects.collect { effect ->
+ when (effect) {
+ is TaskEffect.NavigateBack -> {
+ onNavigateBack.invoke()
+ }
+
+ else -> {}
+ }
+ }
+ }
+
+ LaunchedEffect(existingTask) {
+ existingTask?.let {
+ title = it.title
+ description = it.description
+ selectedCategory = it.category
+ selectedPriority = it.priority
+ dueDate = it.dueDate
+ tags = it.tags.joinToString(",")
+ }
+ }
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ Column(
+ modifier = Modifier.fillMaxSize()
+ ) {
+ AppHeader(
+ title = if (taskId == null) Res.string.title_add_task else Res.string.title_edit_task,
+ showBack = true,
+ onBackClick = { viewModel.handleIntent(TaskIntent.NavigateBack) },
+ trailingIcon = if (taskId == null) Icons.Default.Add else Icons.Default.Edit,
+ onTrailingClick = {
+ if (title.isNotBlank() && selectedCategory != null) {
+ val now = Clock.System.now()
+ .toLocalDateTime(TimeZone.currentSystemDefault())
+ val task = Task(
+ id = existingTask?.id ?: Uuid.random().toString(),
+ title = title.trim(),
+ description = description.trim(),
+ category = selectedCategory!!,
+ priority = selectedPriority,
+ createdAt = now,
+ updatedAt = now,
+ dueDate = dueDate,
+ tags = tags.split(",").map { it.trim() }.filter { it.isNotEmpty() }
+ )
+ if (taskId == null) {
+ viewModel.handleIntent(TaskIntent.AddTask(task))
+ } else {
+ existingTask?.let { task.isCompleted = existingTask.isCompleted }
+ viewModel.handleIntent(TaskIntent.UpdateTask(task))
+ }
+ }
+ }
+ )
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(16.dp),
+ ) {
+ // 任务标题
+ OutlinedTextField(
+ value = title,
+ onValueChange = { title = it },
+ label = { Text(stringResource(Res.string.title_task_title)) },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // 任务描述
+ OutlinedTextField(
+ value = description,
+ onValueChange = { description = it },
+ label = { Text(stringResource(Res.string.title_task_description)) },
+ modifier = Modifier.fillMaxWidth(),
+ minLines = 3,
+ maxLines = 5
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // 分类选择
+ Text(
+ text = stringResource(Res.string.title_select_category),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Medium
+ )
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ LazyVerticalGrid(
+ columns = GridCells.Fixed(4),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = Modifier.height(80.dp)
+ ) {
+ items(state.categories) { category ->
+ CategoryCard(
+ category = category,
+ isSelected = selectedCategory == category,
+ onClick = { selectedCategory = category }
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // 优先级选择
+ Text(
+ text = stringResource(Res.string.title_priority),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Medium
+ )
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ TaskPriority.entries.forEach { priority ->
+ FilterChip(
+ selected = selectedPriority == priority,
+ onClick = { selectedPriority = priority },
+ label = {
+ Text(
+ text = stringResource(priority.displayNameRes),
+ textAlign = TextAlign.Center,
+ fontSize = 10.sp,
+ modifier = Modifier.fillMaxWidth()
+ )
+ },
+ modifier = Modifier.weight(1f),
+ colors = FilterChipDefaults.filterChipColors(
+ selectedContainerColor = priority.color.copy(alpha = 0.2f),
+ selectedLabelColor = priority.color
+ ),
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // 截止日期
+ OutlinedTextField(
+ value = dueDate?.toString() ?: "",
+ onValueChange = { },
+ label = { Text(stringResource(Res.string.title_due_date)) },
+ modifier = Modifier.fillMaxWidth(),
+ readOnly = true,
+ trailingIcon = {
+ IconButton(onClick = { showDatePicker = true }) {
+ Icon(
+ Icons.Default.DateRange,
+ contentDescription = stringResource(Res.string.desc_select_date)
+ )
+ }
+ }
+ )
+
+ CompactDatePickerDialog(
+ show = showDatePicker,
+ initialSelected = dueDate,
+ onConfirm = { selected -> dueDate = selected },
+ onDismiss = { showDatePicker = false }
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // 标签
+ OutlinedTextField(
+ value = tags,
+ onValueChange = { tags = it },
+ label = { Text(stringResource(Res.string.title_tags)) },
+ modifier = Modifier.fillMaxWidth(),
+ placeholder = { Text(stringResource(Res.string.hint_tags)) }
+ )
+ }
+ }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/presentation/task/TaskScreen.kt b/composeApp/src/commonMain/kotlin/com/taskttl/presentation/task/TaskScreen.kt
new file mode 100644
index 0000000..5cac69b
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/presentation/task/TaskScreen.kt
@@ -0,0 +1,396 @@
+package com.taskttl.presentation.task
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.CheckCircle
+import androidx.compose.material.icons.filled.Search
+import androidx.compose.material.icons.outlined.Circle
+import androidx.compose.material.icons.rounded.Delete
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.FilledIconButton
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.IconButtonDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.style.TextDecoration
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.navigation.NavHostController
+import com.taskttl.core.routes.Routes
+import com.taskttl.core.ui.ActionButtonListItem
+import com.taskttl.data.local.model.Task
+import com.taskttl.data.state.TaskEffect
+import com.taskttl.data.state.TaskIntent
+import com.taskttl.data.viewmodel.TaskViewModel
+import com.taskttl.ui.components.AppHeader
+import com.taskttl.ui.components.CategoryFilter
+import com.taskttl.ui.components.SearchBar
+import org.jetbrains.compose.resources.stringResource
+import org.jetbrains.compose.ui.tooling.preview.Preview
+import org.koin.compose.viewmodel.koinViewModel
+import taskttl.composeapp.generated.resources.Res
+import taskttl.composeapp.generated.resources.desc_completed
+import taskttl.composeapp.generated.resources.desc_incomplete
+import taskttl.composeapp.generated.resources.label_show_completed
+import taskttl.composeapp.generated.resources.label_task_list
+import taskttl.composeapp.generated.resources.text_add_task_hint
+import taskttl.composeapp.generated.resources.text_no_tasks
+import taskttl.composeapp.generated.resources.title_add_task
+import taskttl.composeapp.generated.resources.title_task
+
+@Composable
+@Preview
+fun TaskScreen(
+ navController: NavHostController,
+ viewModel: TaskViewModel = koinViewModel()
+) {
+ val state by viewModel.state.collectAsState()
+
+ var isOpenIndex by remember { mutableStateOf(null) }
+
+ fun closeExpandedItem() {
+ isOpenIndex = null
+ }
+
+ LaunchedEffect(Unit) {
+ viewModel.effects.collect { effect ->
+ when (effect) {
+ is TaskEffect.NavigateToTaskDetail -> {
+ navController.navigate(Routes.Main.Task.TaskDetail(effect.taskId))
+ }
+
+ else -> {}
+ }
+ }
+ }
+
+ state.error?.let { error ->
+ LaunchedEffect(error) { viewModel.handleIntent(TaskIntent.ClearError) }
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color.White)
+ ) {
+ Column(
+ modifier = Modifier.fillMaxSize()
+ ) {
+ AppHeader(
+ title = Res.string.title_task,
+ trailingIcon = Icons.Default.Search,
+ onTrailingClick = { viewModel.handleIntent(TaskIntent.SearchView) }
+ )
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color(0xFFF5F5F5))
+ .padding(16.dp)
+ ) {
+ if (state.isSearch) {
+ SearchBar(
+ query = state.searchQuery,
+ onQueryChange = { viewModel.handleIntent(TaskIntent.SearchTasks(it)) },
+ modifier = Modifier.fillMaxWidth()
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ }
+
+ CategoryFilter(
+ categories = state.categories,
+ selectedCategory = state.selectedCategory,
+ onCategorySelected = { viewModel.handleIntent(TaskIntent.FilterByCategory(it)) },
+ modifier = Modifier.fillMaxWidth()
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = "${stringResource(Res.string.label_task_list)} (${state.filteredTasks.size})",
+ style = MaterialTheme.typography.titleMedium
+ )
+
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Checkbox(
+ checked = state.showCompleted,
+ onCheckedChange = {
+ viewModel.handleIntent(TaskIntent.ToggleShowCompleted(it))
+ }
+ )
+ Text(
+ stringResource(Res.string.label_show_completed),
+ style = MaterialTheme.typography.bodySmall
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ when {
+ state.isLoading -> {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ CircularProgressIndicator()
+ }
+ }
+
+ state.filteredTasks.isEmpty() -> {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Text(
+ text = stringResource(Res.string.text_no_tasks),
+ style = MaterialTheme.typography.bodyLarge
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = stringResource(Res.string.text_add_task_hint),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+
+ else -> {
+ LazyColumn(
+ modifier = Modifier.fillMaxSize().clickable(
+ indication = null,
+ interactionSource = remember { MutableInteractionSource() }
+ ) { if (isOpenIndex != null) closeExpandedItem() },
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ item { Spacer(Modifier) }
+ itemsIndexed(
+ state.filteredTasks,
+ key = { index, item -> item.title }) { index, task ->
+ var isOpen by remember { mutableStateOf(false) }
+
+ TaskCardItem(
+ task = task,
+ isOpen = isOpenIndex == index && isOpen,
+ onOpenChange = {
+ isOpenIndex = if (it) index else null
+ isOpen = it
+ },
+ onClick = {
+ navController.navigate(Routes.Main.Task.TaskDetail(task.id))
+ },
+ onToggleComplete = {
+ viewModel.handleIntent(TaskIntent.ToggleTaskCompletion(task.id))
+ },
+ onDeleteTask = {
+ viewModel.handleIntent(TaskIntent.DeleteTask(task.id))
+ }
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+
+
+ // 悬浮按钮
+ FloatingActionButton(
+ onClick = { navController.navigate(Routes.Main.Task.AddTask) },
+ modifier = Modifier
+ .align(Alignment.BottomEnd)
+ .padding(20.dp),
+ containerColor = Color(0xFF667EEA)
+ ) {
+ Icon(
+ imageVector = Icons.Default.Add,
+ contentDescription = stringResource(Res.string.title_add_task)
+ )
+ }
+ }
+}
+
+/**
+ * 任务卡项目
+ * @param [task] 任务
+ * @param [isOpen] 是开放
+ * @param [alignment] 对齐
+ * @param [onOpenChange] 论开放式变革
+ * @param [onClick] 单击
+ * @param [onToggleComplete] 切换完成
+ * @param [onDeleteTask] 关于删除任务
+ * @param [modifier] 修饰符
+ */
+@Composable
+fun TaskCardItem(
+ task: Task,
+ isOpen: Boolean,
+ alignment: Alignment.Horizontal = Alignment.End,
+ onOpenChange: (Boolean) -> Unit,
+ onClick: () -> Unit,
+ onToggleComplete: () -> Unit,
+ onDeleteTask: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ ActionButtonListItem(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(12.dp)),
+ isOpen = isOpen,
+ actionAlignment = alignment,
+ onOpenChange = onOpenChange,
+ onClick = onClick
+ ) {
+ Card(
+ modifier = modifier
+ .fillMaxWidth()
+ .background(Color.Transparent, shape = RoundedCornerShape(12.dp))
+ .clickable { onClick.invoke() },
+ colors = CardDefaults.cardColors(containerColor = Color.White),
+ elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // 完成状态按钮
+ IconButton(
+ onClick = onToggleComplete,
+ modifier = Modifier.size(24.dp)
+ ) {
+ val isCompleted =
+ if (task.isCompleted) Res.string.desc_completed else Res.string.desc_incomplete
+ Icon(
+ imageVector = if (task.isCompleted) Icons.Filled.CheckCircle else Icons.Outlined.Circle,
+ contentDescription = stringResource(isCompleted),
+ tint = if (task.isCompleted) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+
+ Spacer(modifier = Modifier.width(12.dp))
+
+ // 任务内容
+ Column(
+ modifier = Modifier.weight(1f)
+ ) {
+ Text(
+ text = task.title,
+ style = MaterialTheme.typography.titleMedium,
+ textDecoration = if (task.isCompleted) TextDecoration.LineThrough else null,
+ color = if (task.isCompleted) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+
+ if (task.description.isNotBlank()) {
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = task.description,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.width(12.dp))
+
+ Row(
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // 优先级标签
+ Box(
+ modifier = Modifier
+ .background(
+ color = task.priority.color.copy(alpha = 0.1f),
+ shape = RoundedCornerShape(12.dp)
+ )
+ .padding(horizontal = 8.dp, vertical = 4.dp)
+ ) {
+ Text(
+ text = stringResource(task.priority.displayNameRes),
+ style = MaterialTheme.typography.labelSmall,
+ color = task.priority.color
+ )
+ }
+ Spacer(modifier = Modifier.width(8.dp))
+ // 分类标签
+ Box(
+ modifier = Modifier
+ .background(
+ color = task.category.color.backgroundColor,
+ shape = RoundedCornerShape(12.dp)
+ )
+ .padding(horizontal = 8.dp, vertical = 4.dp)
+ ) {
+ Text(
+ text = task.category.name,
+ style = MaterialTheme.typography.labelSmall,
+ color = task.category.color.textColor
+ )
+ }
+ }
+ }
+ }
+ FilledIconButton(
+ onClick = { onDeleteTask.invoke() },
+ shape = CircleShape,
+ modifier = Modifier
+ .size(32.dp)
+ .aspectRatio(1f),
+ colors = IconButtonDefaults.filledIconButtonColors(
+ containerColor = Color(0xffff1111),
+ contentColor = Color.White
+ )
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.Delete,
+ contentDescription = Icons.Rounded.Delete.name
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/ui/components/AppHeader.kt b/composeApp/src/commonMain/kotlin/com/taskttl/ui/components/AppHeader.kt
new file mode 100644
index 0000000..4883abd
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/ui/components/AppHeader.kt
@@ -0,0 +1,81 @@
+package com.taskttl.ui.components
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import org.jetbrains.compose.resources.StringResource
+import org.jetbrains.compose.resources.stringResource
+import taskttl.composeapp.generated.resources.Res
+import taskttl.composeapp.generated.resources.action
+import taskttl.composeapp.generated.resources.back
+
+/**
+ * 应用程序标题
+ * @param [title] 标题
+ * @param [showBack] 显示返回
+ * @param [onBackClick] 上返回点击
+ * @param [trailingIcon] 尾随图标
+ * @param [onTrailingClick] 尾随点击
+ */
+@Composable
+fun AppHeader(
+ title: StringResource,
+ showBack: Boolean = false,
+ onBackClick: (() -> Unit)? = null,
+ trailingIcon: ImageVector? = null,
+ onTrailingClick: (() -> Unit)? = null
+) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(
+ Brush.linearGradient(
+ colors = listOf(Color(0xFF667EEA), Color(0xFF764BA2))
+ )
+ )
+ .padding(horizontal = 20.dp, vertical = 15.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ if (showBack && onBackClick != null) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = stringResource(Res.string.back),
+ tint = Color.White,
+ modifier = Modifier.clickable { onBackClick() }
+ )
+ }
+
+ Text(
+ text = stringResource(title),
+ color = Color.White,
+ fontSize = 20.sp,
+ fontWeight = FontWeight.Bold
+ )
+
+ if (trailingIcon != null) {
+ Icon(
+ imageVector = trailingIcon,
+ contentDescription = stringResource(Res.string.action),
+ tint = Color.White,
+ modifier = Modifier.clickable { onTrailingClick?.invoke() }
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/ui/components/CategoryCard.kt b/composeApp/src/commonMain/kotlin/com/taskttl/ui/components/CategoryCard.kt
new file mode 100644
index 0000000..9aed2b1
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/ui/components/CategoryCard.kt
@@ -0,0 +1,79 @@
+package com.taskttl.ui.components
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.unit.dp
+import com.taskttl.data.local.model.Category
+import org.jetbrains.compose.resources.stringResource
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun CategoryCard(
+ category: Category,
+ isSelected: Boolean,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Card(
+ modifier = modifier
+ .fillMaxWidth()
+ .clickable { onClick.invoke() },
+ colors = CardDefaults.cardColors(
+ containerColor = if (isSelected) {
+ category.color.backgroundColor.copy(alpha = 0.1f)
+ } else {
+ MaterialTheme.colorScheme.surface
+ }
+ ),
+ border = if (isSelected) BorderStroke(2.dp, category.color.backgroundColor) else null
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(4.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Box(
+ modifier = Modifier
+ .size(30.dp)
+ .clip(RoundedCornerShape(4.dp))
+ .background(category.color.backgroundColor.copy(alpha = 0.2f)),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ imageVector = category.icon.icon,
+ contentDescription = stringResource(category.icon.displayNameRes),
+ tint = category.color.iconColor,
+ modifier = Modifier.size(25.dp)
+ )
+ }
+
+ Spacer(modifier = Modifier.height(4.dp))
+
+ Text(
+ text = category.name,
+ style = MaterialTheme.typography.bodyMedium,
+ color = if (isSelected) category.color.textColor else MaterialTheme.colorScheme.onSurface
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/ui/components/CategoryFilter.kt b/composeApp/src/commonMain/kotlin/com/taskttl/ui/components/CategoryFilter.kt
new file mode 100644
index 0000000..22519f2
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/ui/components/CategoryFilter.kt
@@ -0,0 +1,50 @@
+package com.taskttl.ui.components
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.FilterChip
+import androidx.compose.material3.FilterChipDefaults
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.taskttl.data.local.model.Category
+import org.jetbrains.compose.resources.stringResource
+import taskttl.composeapp.generated.resources.Res
+import taskttl.composeapp.generated.resources.all_text
+
+@Composable
+fun CategoryFilter(
+ categories: List,
+ selectedCategory: Category?,
+ onCategorySelected: (Category?) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ LazyRow(
+ modifier = modifier,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ // 全部分类
+ item {
+ FilterChip(
+ selected = selectedCategory == null,
+ onClick = { onCategorySelected(null) },
+ label = { Text(stringResource(Res.string.all_text)) }
+ )
+ }
+
+ // 各个分类
+ items(categories) { category ->
+ FilterChip(
+ selected = selectedCategory == category,
+ onClick = { onCategorySelected(category) },
+ label = { Text(category.name, color = category.color.textColor) },
+ colors = FilterChipDefaults.filterChipColors(
+ selectedContainerColor = category.color.backgroundColor,
+ selectedLabelColor = category.color.backgroundColor
+ )
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/ui/components/CategoryStatisticsCard.kt b/composeApp/src/commonMain/kotlin/com/taskttl/ui/components/CategoryStatisticsCard.kt
new file mode 100644
index 0000000..f9f2691
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/ui/components/CategoryStatisticsCard.kt
@@ -0,0 +1,177 @@
+package com.taskttl.ui.components
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ProgressIndicatorDefaults
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import com.taskttl.data.local.model.CategoryStatistics
+import org.jetbrains.compose.resources.stringResource
+import taskttl.composeapp.generated.resources.Res
+import taskttl.composeapp.generated.resources.active
+import taskttl.composeapp.generated.resources.completed
+import taskttl.composeapp.generated.resources.completion_rate
+import taskttl.composeapp.generated.resources.total_countdowns
+import taskttl.composeapp.generated.resources.total_tasks
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun CategoryStatisticsCard(
+ statistics: CategoryStatistics,
+ categoryColor: Long,
+ modifier: Modifier = Modifier
+) {
+ Card(
+ modifier = modifier.fillMaxWidth(),
+ elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp)
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Box(
+ modifier = Modifier
+ .size(32.dp)
+ .clip(CircleShape)
+ .background(Color(categoryColor))
+ )
+
+ Spacer(modifier = Modifier.width(12.dp))
+
+ Text(
+ text = statistics.categoryName,
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Medium
+ )
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // 任务统计
+ if (statistics.totalTasks > 0) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Column {
+ Text(
+ text = stringResource(Res.string.total_tasks),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ text = "${statistics.totalTasks}",
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Bold
+ )
+ }
+
+ Column {
+ Text(
+ text = stringResource(Res.string.completed),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ text = "${statistics.completedTasks}",
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.primary
+ )
+ }
+
+ Column {
+ Text(
+ text = stringResource(Res.string.completion_rate),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ text = "${(statistics.completionRate * 100).toInt()}%",
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Bold,
+ color = if (statistics.completionRate >= 0.8f)
+ MaterialTheme.colorScheme.primary
+ else MaterialTheme.colorScheme.onSurface
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ // 进度条
+ LinearProgressIndicator(
+ progress = { statistics.completionRate },
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(8.dp)
+ .clip(RoundedCornerShape(4.dp)),
+ color = Color(categoryColor),
+ trackColor = ProgressIndicatorDefaults.linearTrackColor,
+ strokeCap = ProgressIndicatorDefaults.LinearStrokeCap,
+ )
+ }
+
+ // 倒数日统计
+ if (statistics.totalCountdowns > 0) {
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Column {
+ Text(
+ text = stringResource(Res.string.total_countdowns),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ text = "${statistics.totalCountdowns}",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Medium
+ )
+ }
+
+ Column {
+ Text(
+ text = stringResource(Res.string.active),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ text = "${statistics.activeCountdowns}",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Medium,
+ color = MaterialTheme.colorScheme.primary
+ )
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/ui/components/CompactDatePickerDialog.kt b/composeApp/src/commonMain/kotlin/com/taskttl/ui/components/CompactDatePickerDialog.kt
new file mode 100644
index 0000000..540a730
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/ui/components/CompactDatePickerDialog.kt
@@ -0,0 +1,65 @@
+package com.taskttl.ui.components
+
+import androidx.compose.material3.DatePicker
+import androidx.compose.material3.DatePickerDialog
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.rememberDatePickerState
+import androidx.compose.runtime.Composable
+import kotlinx.datetime.LocalDateTime
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.toInstant
+import kotlinx.datetime.toLocalDateTime
+import org.jetbrains.compose.resources.stringResource
+import taskttl.composeapp.generated.resources.Res
+import taskttl.composeapp.generated.resources.cancel
+import taskttl.composeapp.generated.resources.confirm
+import kotlin.time.Clock
+import kotlin.time.ExperimentalTime
+import kotlin.time.Instant
+
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalTime::class)
+@Composable
+fun CompactDatePickerDialog(
+ show: Boolean,
+ initialSelected: LocalDateTime?,
+ onConfirm: (LocalDateTime) -> Unit,
+ onDismiss: () -> Unit
+) {
+ if (!show) return
+
+ val initialMillis = (
+ initialSelected?.toInstant(TimeZone.currentSystemDefault())
+ ?: Clock.System.now()
+ ).toEpochMilliseconds()
+
+ val state = rememberDatePickerState(initialSelectedDateMillis = initialMillis)
+
+ DatePickerDialog(
+ onDismissRequest = onDismiss,
+ confirmButton = {
+ TextButton(
+ onClick = {
+ val millis = state.selectedDateMillis
+ if (millis != null) {
+ val instant = Instant.fromEpochMilliseconds(millis)
+ val selected = instant.toLocalDateTime(TimeZone.currentSystemDefault())
+ onConfirm(selected)
+ }
+ onDismiss()
+ }
+ ) { Text(stringResource(Res.string.confirm)) }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) { Text(stringResource(Res.string.cancel)) }
+ }
+ ) {
+ DatePicker(
+ state = state,
+ headline = null,
+ title = null,
+ showModeToggle = false
+ )
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/ui/components/SearchBar.kt b/composeApp/src/commonMain/kotlin/com/taskttl/ui/components/SearchBar.kt
new file mode 100644
index 0000000..a7a7126
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/ui/components/SearchBar.kt
@@ -0,0 +1,51 @@
+package com.taskttl.ui.components
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Clear
+import androidx.compose.material.icons.filled.Search
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import org.jetbrains.compose.resources.StringResource
+import org.jetbrains.compose.resources.stringResource
+import taskttl.composeapp.generated.resources.Res
+import taskttl.composeapp.generated.resources.clear_text
+import taskttl.composeapp.generated.resources.search
+import taskttl.composeapp.generated.resources.search_placeholder
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SearchBar(
+ query: String,
+ onQueryChange: (String) -> Unit,
+ modifier: Modifier = Modifier,
+ placeholder: StringResource = Res.string.search_placeholder
+) {
+ OutlinedTextField(
+ value = query,
+ onValueChange = onQueryChange,
+ modifier = modifier,
+ placeholder = { Text(stringResource(placeholder)) },
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.Default.Search,
+ contentDescription = stringResource(Res.string.search)
+ )
+ },
+ trailingIcon = {
+ if (query.isNotEmpty()) {
+ IconButton(onClick = { onQueryChange("") }) {
+ Icon(
+ imageVector = Icons.Default.Clear,
+ contentDescription = stringResource(Res.string.clear_text)
+ )
+ }
+ }
+ },
+ singleLine = true
+ )
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/ui/theme/Theme.kt b/composeApp/src/commonMain/kotlin/com/taskttl/ui/theme/Theme.kt
new file mode 100644
index 0000000..d314011
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/ui/theme/Theme.kt
@@ -0,0 +1,109 @@
+package com.taskttl.ui.theme
+
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+
+/** 浅色方案 */
+private val LightColorScheme = lightColorScheme(
+ primary = Color(0xFF6750A4),
+ onPrimary = Color(0xFFFFFFFF),
+ primaryContainer = Color(0xFFEADDFF),
+ onPrimaryContainer = Color(0xFF21005D),
+ secondary = Color(0xFF625B71),
+ onSecondary = Color(0xFFFFFFFF),
+ secondaryContainer = Color(0xFFE8DEF8),
+ onSecondaryContainer = Color(0xFF1D192B),
+ tertiary = Color(0xFF7D5260),
+ onTertiary = Color(0xFFFFFFFF),
+ tertiaryContainer = Color(0xFFFFD8E4),
+ onTertiaryContainer = Color(0xFF31111D),
+ error = Color(0xFFBA1A1A),
+ onError = Color(0xFFFFFFFF),
+ errorContainer = Color(0xFFFFDAD6),
+ onErrorContainer = Color(0xFF410002),
+ background = Color(0xFFFFFBFE),
+ onBackground = Color(0xFF1C1B1F),
+ surface = Color(0xFFFFFBFE),
+ onSurface = Color(0xFF1C1B1F),
+ surfaceVariant = Color(0xFFE7E0EC),
+ onSurfaceVariant = Color(0xFF49454F),
+ outline = Color(0xFF79747E),
+ outlineVariant = Color(0xFFCAC4D0),
+ scrim = Color(0xFF000000),
+ inverseSurface = Color(0xFF313033),
+ inverseOnSurface = Color(0xFFF4EFF4),
+ inversePrimary = Color(0xFFD0BCFF),
+ surfaceDim = Color(0xFFDDD8DD),
+ surfaceBright = Color(0xFFFFFBFE),
+ surfaceContainerLowest = Color(0xFFFFFFFF),
+ surfaceContainerLow = Color(0xFFF7F2FA),
+ surfaceContainer = Color(0xFFF1ECF4),
+ surfaceContainerHigh = Color(0xFFECE6F0),
+ surfaceContainerHighest = Color(0xFFE6E0E9)
+)
+
+/** 深色配色方案 */
+private val DarkColorScheme = darkColorScheme(
+ primary = Color(0xFFD0BCFF),
+ onPrimary = Color(0xFF381E72),
+ primaryContainer = Color(0xFF4F378B),
+ onPrimaryContainer = Color(0xFFEADDFF),
+ secondary = Color(0xFFCCC2DC),
+ onSecondary = Color(0xFF332D41),
+ secondaryContainer = Color(0xFF4A4458),
+ onSecondaryContainer = Color(0xFFE8DEF8),
+ tertiary = Color(0xFFEFB8C8),
+ onTertiary = Color(0xFF492532),
+ tertiaryContainer = Color(0xFF633B48),
+ onTertiaryContainer = Color(0xFFFFD8E4),
+ error = Color(0xFFFFB4AB),
+ onError = Color(0xFF690005),
+ errorContainer = Color(0xFF93000A),
+ onErrorContainer = Color(0xFFFFDAD6),
+ background = Color(0xFF10131C),
+ onBackground = Color(0xFFE6E0E9),
+ surface = Color(0xFF10131C),
+ onSurface = Color(0xFFE6E0E9),
+ surfaceVariant = Color(0xFF49454F),
+ onSurfaceVariant = Color(0xFFCAC4D0),
+ outline = Color(0xFF938F99),
+ outlineVariant = Color(0xFF49454F),
+ scrim = Color(0xFF000000),
+ inverseSurface = Color(0xFFE6E0E9),
+ inverseOnSurface = Color(0xFF313033),
+ inversePrimary = Color(0xFF6750A4),
+ surfaceDim = Color(0xFF10131C),
+ surfaceBright = Color(0xFF383B42),
+ surfaceContainerLowest = Color(0xFF0B0E17),
+ surfaceContainerLow = Color(0xFF191C24),
+ surfaceContainer = Color(0xFF1D2028),
+ surfaceContainerHigh = Color(0xFF282A32),
+ surfaceContainerHighest = Color(0xFF33353D)
+)
+
+/**
+ * 应用主题
+ * @param [darkTheme] 黑暗主题
+ * @param [content] 内容
+ */
+@Composable
+fun AppTheme(
+ darkTheme: Boolean = isSystemInDarkTheme(),
+ content: @Composable () -> Unit
+) {
+ val colorScheme = if (darkTheme) {
+ DarkColorScheme
+ } else {
+ LightColorScheme
+ }
+
+ MaterialTheme(
+ colorScheme = colorScheme,
+ typography = mainTypography(),
+ content = content
+ )
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/ui/theme/Type.kt b/composeApp/src/commonMain/kotlin/com/taskttl/ui/theme/Type.kt
new file mode 100644
index 0000000..1ddadb0
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/taskttl/ui/theme/Type.kt
@@ -0,0 +1,40 @@
+package com.taskttl.ui.theme
+
+import androidx.compose.material3.Typography
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+
+
+/**
+ * 主要排版
+ * @return [Typography]
+ */
+@Composable
+fun mainTypography(): Typography {
+ return Typography(
+ bodyLarge = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 16.sp,
+ lineHeight = 24.sp,
+ letterSpacing = 0.5.sp
+ ),
+ titleLarge = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 22.sp,
+ lineHeight = 28.sp,
+ letterSpacing = 0.sp
+ ),
+ labelSmall = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Medium,
+ fontSize = 11.sp,
+ lineHeight = 16.sp,
+ letterSpacing = 0.5.sp
+ )
+ )
+}
\ No newline at end of file
diff --git a/composeApp/src/desktopMain/kotlin/com/taskttl/Platform.jvm.kt b/composeApp/src/desktopMain/kotlin/com/taskttl/Platform.jvm.kt
deleted file mode 100644
index 4b939f8..0000000
--- a/composeApp/src/desktopMain/kotlin/com/taskttl/Platform.jvm.kt
+++ /dev/null
@@ -1,7 +0,0 @@
-package com.taskttl
-
-class JVMPlatform: Platform {
- override val name: String = "Java ${System.getProperty("java.version")}"
-}
-
-actual fun getPlatform(): Platform = JVMPlatform()
\ No newline at end of file
diff --git a/composeApp/src/google-services.json b/composeApp/src/google-services.json
new file mode 100644
index 0000000..1671afe
--- /dev/null
+++ b/composeApp/src/google-services.json
@@ -0,0 +1,29 @@
+{
+ "project_info": {
+ "project_number": "324197572456",
+ "project_id": "taskttl",
+ "storage_bucket": "taskttl.firebasestorage.app"
+ },
+ "client": [
+ {
+ "client_info": {
+ "mobilesdk_app_id": "1:324197572456:android:89a936ee50428f652440dd",
+ "android_client_info": {
+ "package_name": "com.taskttl"
+ }
+ },
+ "oauth_client": [],
+ "api_key": [
+ {
+ "current_key": "AIzaSyBPQBhVV-aMHpvyT-S3VgjXN__Dj0Z-Jes"
+ }
+ ],
+ "services": {
+ "appinvite_service": {
+ "other_platform_oauth_client": []
+ }
+ }
+ }
+ ],
+ "configuration_version": "1"
+}
\ No newline at end of file
diff --git a/composeApp/src/iosMain/kotlin/com/taskttl/Platform.ios.kt b/composeApp/src/iosMain/kotlin/com/taskttl/Platform.ios.kt
deleted file mode 100644
index c66a764..0000000
--- a/composeApp/src/iosMain/kotlin/com/taskttl/Platform.ios.kt
+++ /dev/null
@@ -1,9 +0,0 @@
-package com.taskttl
-
-import platform.UIKit.UIDevice
-
-class IOSPlatform: Platform {
- override val name: String = UIDevice.currentDevice.systemName() + " " + UIDevice.currentDevice.systemVersion
-}
-
-actual fun getPlatform(): Platform = IOSPlatform()
\ No newline at end of file
diff --git a/composeApp/src/iosMain/kotlin/com/taskttl/core/utils/LogUtils.ios.kt b/composeApp/src/iosMain/kotlin/com/taskttl/core/utils/LogUtils.ios.kt
new file mode 100644
index 0000000..deaf1ec
--- /dev/null
+++ b/composeApp/src/iosMain/kotlin/com/taskttl/core/utils/LogUtils.ios.kt
@@ -0,0 +1,21 @@
+package com.taskttl.core.utils
+
+import platform.Foundation.NSLog
+
+actual object LogUtils {
+ actual fun d(tag: String, message: String) {
+ NSLog("DEBUG [$tag]: $message")
+ }
+
+ actual fun i(tag: String, message: String) {
+ NSLog("INFO [$tag]: $message")
+ }
+
+ actual fun w(tag: String, message: String) {
+ NSLog("WARN [$tag]: $message")
+ }
+
+ actual fun e(tag: String, message: String, throwable: Throwable?) {
+ NSLog("ERROR [$tag]: $message ${throwable?.message ?: ""}")
+ }
+}
\ No newline at end of file
diff --git a/composeApp/src/jsMain/kotlin/com/taskttl/core/utils/LogUtils.js.kt b/composeApp/src/jsMain/kotlin/com/taskttl/core/utils/LogUtils.js.kt
new file mode 100644
index 0000000..7a7a1ed
--- /dev/null
+++ b/composeApp/src/jsMain/kotlin/com/taskttl/core/utils/LogUtils.js.kt
@@ -0,0 +1,15 @@
+package com.taskttl.core.utils
+
+actual object LogUtils {
+ actual fun d(tag: String, message: String) {
+ }
+
+ actual fun i(tag: String, message: String) {
+ }
+
+ actual fun w(tag: String, message: String) {
+ }
+
+ actual fun e(tag: String, message: String, throwable: Throwable?) {
+ }
+}
\ No newline at end of file
diff --git a/composeApp/src/jvmMain/kotlin/com/taskttl/core/utils/LogUtils.jvm.kt b/composeApp/src/jvmMain/kotlin/com/taskttl/core/utils/LogUtils.jvm.kt
new file mode 100644
index 0000000..29d4bd9
--- /dev/null
+++ b/composeApp/src/jvmMain/kotlin/com/taskttl/core/utils/LogUtils.jvm.kt
@@ -0,0 +1,19 @@
+package com.taskttl.core.utils
+
+actual object LogUtils {
+ actual fun d(tag: String, message: String) {
+ println("DEBUG [$tag]: $message")
+ }
+
+ actual fun i(tag: String, message: String) {
+ println("INFO [$tag]: $message")
+ }
+
+ actual fun w(tag: String, message: String) {
+ println("WARN [$tag]: $message")
+ }
+
+ actual fun e(tag: String, message: String, throwable: Throwable?) {
+ println("ERROR [$tag]: $message ${throwable?.message ?: ""}")
+ }
+}
\ No newline at end of file
diff --git a/composeApp/src/jvmMain/kotlin/com/taskttl/data/di/KoinModels.desktop.kt b/composeApp/src/jvmMain/kotlin/com/taskttl/data/di/KoinModels.desktop.kt
new file mode 100644
index 0000000..9c77e92
--- /dev/null
+++ b/composeApp/src/jvmMain/kotlin/com/taskttl/data/di/KoinModels.desktop.kt
@@ -0,0 +1,9 @@
+package com.taskttl.data.di
+
+import com.taskttl.data.local.database.TaskTTLDatabase
+import com.taskttl.data.local.database.getDatabaseBuilder
+import org.koin.dsl.module
+
+actual fun platformModule() = module {
+ single { getDatabaseBuilder() }
+}
\ No newline at end of file
diff --git a/composeApp/src/jvmMain/kotlin/com/taskttl/data/local/database/Database.desktop.kt b/composeApp/src/jvmMain/kotlin/com/taskttl/data/local/database/Database.desktop.kt
new file mode 100644
index 0000000..4926e43
--- /dev/null
+++ b/composeApp/src/jvmMain/kotlin/com/taskttl/data/local/database/Database.desktop.kt
@@ -0,0 +1,11 @@
+package com.taskttl.data.local.database
+
+import androidx.room.Room
+import java.io.File
+
+actual fun getDatabaseBuilder(): TaskTTLDatabase {
+ val dbFile = File(System.getProperty("java.io.tmpdir"), "taskttl_dababase.db")
+ return Room.databaseBuilder(
+ name = dbFile.absolutePath,
+ ).build()
+}
\ No newline at end of file
diff --git a/composeApp/src/desktopMain/kotlin/com/taskttl/main.kt b/composeApp/src/jvmMain/kotlin/com/taskttl/main.kt
similarity index 100%
rename from composeApp/src/desktopMain/kotlin/com/taskttl/main.kt
rename to composeApp/src/jvmMain/kotlin/com/taskttl/main.kt
diff --git a/composeApp/src/wasmJsMain/kotlin/com/taskttl/Platform.wasmJs.kt b/composeApp/src/wasmJsMain/kotlin/com/taskttl/Platform.wasmJs.kt
deleted file mode 100644
index 5be0afd..0000000
--- a/composeApp/src/wasmJsMain/kotlin/com/taskttl/Platform.wasmJs.kt
+++ /dev/null
@@ -1,7 +0,0 @@
-package com.taskttl
-
-class WasmPlatform: Platform {
- override val name: String = "Web with Kotlin/Wasm"
-}
-
-actual fun getPlatform(): Platform = WasmPlatform()
\ No newline at end of file
diff --git a/composeApp/src/webMain/kotlin/com/taskttl/main.kt b/composeApp/src/webMain/kotlin/com/taskttl/main.kt
new file mode 100644
index 0000000..c07731a
--- /dev/null
+++ b/composeApp/src/webMain/kotlin/com/taskttl/main.kt
@@ -0,0 +1,11 @@
+package com.taskttl
+
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.window.ComposeViewport
+
+@OptIn(ExperimentalComposeUiApi::class)
+fun main() {
+ ComposeViewport {
+ App()
+ }
+}
\ No newline at end of file
diff --git a/composeApp/src/webMain/resources/index.html b/composeApp/src/webMain/resources/index.html
new file mode 100644
index 0000000..1baf91d
--- /dev/null
+++ b/composeApp/src/webMain/resources/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ TaskTTL
+
+
+
+
+
+
\ No newline at end of file
diff --git a/composeApp/src/webMain/resources/styles.css b/composeApp/src/webMain/resources/styles.css
new file mode 100644
index 0000000..0549b10
--- /dev/null
+++ b/composeApp/src/webMain/resources/styles.css
@@ -0,0 +1,7 @@
+html, body {
+ width: 100%;
+ height: 100%;
+ margin: 0;
+ padding: 0;
+ overflow: hidden;
+}
\ No newline at end of file
diff --git a/composeApp/webpack.config.d/watch.js b/composeApp/webpack.config.d/watch.js
new file mode 100644
index 0000000..04713d2
--- /dev/null
+++ b/composeApp/webpack.config.d/watch.js
@@ -0,0 +1,21 @@
+/*
+ * Temporary workaround for [KT-80582](https://youtrack.jetbrains.com/issue/KT-80582)
+ *
+ * This file should be safe to be removed once the ticket is closed and the project is updated to Kotlin version which solves that issue.
+ */
+config.watchOptions = config.watchOptions || {
+ ignored: ["**/*.kt", "**/node_modules"]
+}
+
+if (config.devServer) {
+ config.devServer.static = config.devServer.static.map(file => {
+ if (typeof file === "string") {
+ return {
+ directory: file,
+ watch: false,
+ }
+ } else {
+ return file
+ }
+ })
+}
\ No newline at end of file
diff --git a/gradle.properties b/gradle.properties
index 964007c..4d047bd 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -1,10 +1,20 @@
#Kotlin
kotlin.code.style=official
-kotlin.daemon.jvmargs=-Xmx2048M
+kotlin.daemon.jvmargs=-Xmx3072M
#Gradle
-org.gradle.jvmargs=-Xmx2048M -Dfile.encoding=UTF-8
+org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8
+org.gradle.configuration-cache=true
+org.gradle.caching=true
#Android
android.nonTransitiveRClass=true
-android.useAndroidX=true
\ No newline at end of file
+android.useAndroidX=true
+
+#IOS
+kotlin.native.ignoreDisabledTargets=true
+
+ksp.verbose=true
+
+kotlin.kmp.eagerUnresolvedDependenciesDiagnostic=false
+kotlin.kmp.unresolvedDependenciesDiagnostic=false
\ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index f2e0aab..82d25cb 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,39 +1,109 @@
[versions]
-agp = "8.5.2"
-android-compileSdk = "35"
-android-minSdk = "24"
-android-targetSdk = "35"
-androidx-activityCompose = "1.10.1"
-androidx-appcompat = "1.7.0"
+agp = "8.13.0"
+androidx-activity = "1.11.0"
+androidx-appcompat = "1.7.1"
androidx-constraintlayout = "2.2.1"
-androidx-core-ktx = "1.15.0"
-androidx-espresso-core = "3.6.1"
-androidx-lifecycle = "2.8.4"
-androidx-material = "1.12.0"
-androidx-test-junit = "1.2.1"
-compose-multiplatform = "1.7.3"
+androidx-core = "1.17.0"
+androidx-espresso = "3.7.0"
+androidx-lifecycle = "2.9.4"
+androidx-testExt = "1.3.0"
+composeHotReload = "1.0.0-beta09"
+composeMultiplatform = "1.9.0"
junit = "4.13.2"
-kotlin = "2.1.10"
-kotlinx-coroutines = "1.10.1"
+kotlin = "2.2.20"
+kotlinx-coroutines = "1.10.2"
+
+navigationCompose = "2.9.0"
+koin = "4.1.1"
+
+coil3 = "3.3.0"
+kotlinx-datetime = "0.7.1"
+icons = "1.7.3"
+
+google = "4.4.3"
+firebase = "34.3.0"
+facebookAndroidSdkVersion = "18.1.3"
+
+kotlinx-serialization = "1.9.0"
+
+mmkv = "2.2.4"
+
+sqlite = "2.6.1"
+room = "2.8.1"
+ksp = "2.2.20-2.0.2"
+
+# 环境
+android-compileSdk = "36"
+android-minSdk = "24"
+android-targetSdk = "36"
+android-facebookAppId = "1203530117944408"
+android-facebookClientToken = "1ee2da9430c1a589e8aa623bfaaaa586"
+
[libraries]
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
-kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
-junit = { group = "junit", name = "junit", version.ref = "junit" }
-androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-core-ktx" }
-androidx-test-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-junit" }
-androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "androidx-espresso-core" }
-androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidx-appcompat" }
-androidx-material = { group = "com.google.android.material", name = "material", version.ref = "androidx-material" }
-androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "androidx-constraintlayout" }
-androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" }
-androidx-lifecycle-viewmodel = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel", version.ref = "androidx-lifecycle" }
-androidx-lifecycle-runtime-compose = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
-kotlinx-coroutines-swing = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
+kotlin-testJunit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
+junit = { module = "junit:junit", version.ref = "junit" }
+androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core" }
+androidx-testExt-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-testExt" }
+androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-espresso" }
+androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
+androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "androidx-constraintlayout" }
+androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
+androidx-lifecycle-viewmodel = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel", version.ref = "androidx-lifecycle" }
+androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
+kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
+
+# 导航
+navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
+
+# koin 依赖注入
+koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" }
+koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin" }
+koin-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel", version.ref = "koin" }
+koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" }
+
+# coil3
+coil3-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil3" }
+coil3-svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coil3"}
+coil3-gif = { module = "io.coil-kt.coil3:coil-gif", version.ref = "coil3" }
+coil3-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil3" }
+
+# 时间
+kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" }
+
+# 图标
+material-icons-core = { module = "org.jetbrains.compose.material:material-icons-core", version.ref = "icons" }
+material-icons-extended = { module = "org.jetbrains.compose.material:material-icons-extended", version.ref = "icons" }
+
+# firebase
+firebase-analytics = { module = "com.google.firebase:firebase-analytics" }
+firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebase" }
+
+# facebook
+android-facebook-android-sdk = { module = "com.facebook.android:facebook-android-sdk", version.ref = "facebookAndroidSdkVersion" }
+
+# JSON
+kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
+
+# 安卓MMKV
+android-mmkv = { module = "com.tencent:mmkv" ,version.ref = "mmkv"}
+
+# Room数据库
+androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "sqlite" }
+androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
+androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
+androidx-room-sqlite-wrapper = { module = "androidx.room:room-sqlite-wrapper", version.ref = "room" }
+
[plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" }
androidLibrary = { id = "com.android.library", version.ref = "agp" }
-composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" }
+composeHotReload = { id = "org.jetbrains.compose.hot-reload", version.ref = "composeHotReload" }
+composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" }
composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
-kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
\ No newline at end of file
+kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
+jetbrains-kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin"}
+gms-google = {id = "com.google.gms.google-services", version.ref = "google"}
+ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
+androidx-room = { id = "androidx.room", version.ref = "room" }
\ No newline at end of file
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 09523c0..2e11132 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 0611f63..7a420c4 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -28,4 +28,8 @@ dependencyResolutionManagement {
}
}
+plugins {
+ id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
+}
+
include(":composeApp")
\ No newline at end of file