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