This commit is contained in:
2025-10-12 22:08:39 +08:00
parent bf6748a2fe
commit ede72fdedc
52 changed files with 1325 additions and 247 deletions

View File

@@ -22,7 +22,7 @@ kotlin {
jvmTarget.set(JvmTarget.JVM_11)
}
}
listOf(
iosX64(),
iosArm64(),
@@ -33,7 +33,7 @@ kotlin {
isStatic = true
}
}
jvm()
js {
@@ -46,7 +46,7 @@ kotlin {
browser()
binaries.executable()
}
sourceSets {
androidMain.dependencies {
implementation(compose.preview)
@@ -55,6 +55,9 @@ kotlin {
// Koin依赖注入
implementation(libs.koin.android)
// Ktor网络请求
implementation(libs.ktor.client.android)
// firebase
implementation(project.dependencies.platform(libs.firebase.bom))
implementation(libs.firebase.analytics)
@@ -66,6 +69,9 @@ kotlin {
// sqlite
implementation(libs.androidx.room.sqlite.wrapper)
// admob
implementation(libs.android.play.services.ads.identifier)
}
commonMain.dependencies {
implementation(compose.runtime)
@@ -86,6 +92,12 @@ kotlin {
implementation(libs.koin.compose)
implementation(libs.koin.viewmodel)
// Ktor网络请求
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.logging)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
// coil
implementation(libs.coil3.compose)
implementation(libs.coil3.svg)
@@ -106,6 +118,13 @@ kotlin {
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.sqlite.bundled)
}
iosMain.dependencies {
// ktor网络请求
implementation(libs.ktor.client.darwin)
// implementation(libs.kotlinx.coroutines.core)
// implementation(libs.kotlinx.coroutines.core.native)
}
jvmMain.dependencies {
implementation(compose.desktop.currentOs)
implementation(libs.kotlinx.coroutinesSwing)
@@ -127,8 +146,11 @@ android {
versionCode = 1
versionName = "1.0"
buildConfigField("String", "APP_NAME", "\"TaskTTL\"")
manifestPlaceholders["facebookAppId"] = libs.versions.android.facebookAppId.get()
manifestPlaceholders["facebookClientToken"] = libs.versions.android.facebookClientToken.get()
manifestPlaceholders["facebookClientToken"] =
libs.versions.android.facebookClientToken.get()
}
packaging {
resources {
@@ -137,7 +159,20 @@ android {
}
buildTypes {
getByName("release") {
isMinifyEnabled = true
isShrinkResources = true
buildConfigField("Boolean", "DEBUG", "false")
buildConfigField("Integer", "APP_ID", "1")
buildConfigField("Integer", "VERSION_CODE", libs.versions.android.versionCode.get())
}
getByName("debug") {
isMinifyEnabled = false
buildConfigField("Boolean", "DEBUG", "true")
buildConfigField("Integer", "APP_ID", "999")
buildConfigField("Integer", "VERSION_CODE", libs.versions.android.versionCode.get())
}
}
compileOptions {

View File

@@ -1,10 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:tools="http://schemas.android.com/tools"
xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 添加网络权限 -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- 添加广告ID权限 -->
<uses-permission android:name="com.google.android.gms.permission.AD_ID" />
<uses-permission android:name="com.google.android.gms.permission.AD_ID"
tools:ignore="AdvertisingIdPolicy" />
<application
android:name=".MainApplication"
@@ -13,6 +15,8 @@
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:networkSecurityConfig="@xml/network_security_config"
android:theme="@android:style/Theme.Material.Light.NoActionBar">
<activity
android:exported="true"

View File

@@ -0,0 +1,13 @@
package com.taskttl.core.network
import com.taskttl.BuildConfig
import com.taskttl.core.utils.LogUtils
import io.ktor.client.plugins.logging.Logger
actual val defaultLogger: Logger = object : Logger {
override fun log(message: String) {
if (BuildConfig.DEBUG) {
LogUtils.e("DevTTL_NetWork", message)
}
}
}

View File

@@ -32,7 +32,7 @@ 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.retry
import taskttl.composeapp.generated.resources.webview_loading_error
@OptIn(ExperimentalMaterial3Api::class)
@@ -157,7 +157,7 @@ actual fun DevTTLWebView(modifier: Modifier, url: String) {
contentDescription = null
)
Text(
text = stringResource(Res.string.btn_retry),
text = stringResource(Res.string.retry),
modifier = Modifier.padding(start = 8.dp)
)
}

View File

@@ -0,0 +1,139 @@
package com.taskttl.core.utils
import android.annotation.SuppressLint
import android.content.Context
import android.os.Build
import com.google.android.gms.ads.identifier.AdvertisingIdClient
import com.taskttl.BuildConfig
import com.taskttl.MainApplication
import com.taskttl.core.domain.BaseReq
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import java.security.MessageDigest
import java.util.UUID
actual object DeviceUtils {
private val appContext: Context
get() = MainApplication.instance.applicationContext
private const val PREF_UNIQUE_ID = "PREF_UNIQUE_ID"
// 请求超时时间
private const val REQUEST_TIMEOUT_MS = 5_000L
actual suspend fun getUniqueId(): String {
// 首先检查是否已经生成过唯一ID
val savedId = StorageUtils.getString(PREF_UNIQUE_ID)
// 如果已有保存的ID直接返回
if (savedId.isNotBlank()) {
return savedId
}
// 检查网络状态如果没有网络直接生成本地ID
// if (!isNetworkAvailable()) {
// val localId = generateAppInstanceId()
// MMKVUtils.saveString(PREF_UNIQUE_ID, localId)
// return localId
// }
// 尝试获取Google广告ID
val adId = try {
getGoogleAdId()
} catch (e: Exception) {
null
}
val uniqueId = if (isValidAdId(adId)) {
adId.toString()
} else {
// 如果广告ID无效生成应用实例ID
generateAppInstanceId()
}
// 保存生成的ID
StorageUtils.saveString(PREF_UNIQUE_ID, uniqueId)
return uniqueId
}
actual suspend fun getDeviceInfo(): BaseReq {
return BaseReq(
appName = BuildConfig.APP_NAME,
versionCode = BuildConfig.VERSION_CODE,
appId = BuildConfig.APP_ID,
uniqueId = getUniqueId(),
deviceInfo = Build.MODEL ?: "0",
deviceVersion = Build.VERSION.RELEASE,
language = ""
)
}
@SuppressLint("AdvertisingIdPolicy")
private suspend fun getGoogleAdId(): String? {
return withContext(Dispatchers.IO) {
try {
withTimeout(REQUEST_TIMEOUT_MS) {
val adInfo = AdvertisingIdClient.getAdvertisingIdInfo(appContext)
if (adInfo.isLimitAdTrackingEnabled) null else adInfo.id
}
} catch (e: TimeoutCancellationException) {
null
} catch (e: Exception) {
null
}
}
}
/**
* 验证是否是有效广告id
* @param [adId] 广告id
* @return [Boolean]
*/
private fun isValidAdId(adId: String?): Boolean {
return !adId.isNullOrBlank() && !adId.matches(Regex("^0+$")) && adId != "00000000-0000-0000-0000-000000000000"
}
private fun generateAppInstanceId(): String {
val deviceIdentifiers = mutableListOf<String>()
// 设备硬件信息
deviceIdentifiers.add(buildDeviceInfo())
return hashString(deviceIdentifiers.joinToString("|"))
}
private fun buildDeviceInfo(): String {
return StringBuilder().apply {
append(Build.BOARD).append(":")
append(Build.BRAND).append(":")
append(Build.DEVICE).append(":")
append(Build.HARDWARE).append(":")
append(Build.MANUFACTURER).append(":")
append(Build.MODEL).append(":")
append(Build.PRODUCT)
}.toString()
}
private fun hashString(input: String): String {
return try {
val digest = MessageDigest.getInstance("SHA-256")
val hash = digest.digest(input.toByteArray(Charsets.UTF_8))
// 返回前64字符
bytesToHex(hash).take(64)
} catch (e: Exception) {
UUID.randomUUID().toString().replace("-", "").take(64)
}
}
private fun bytesToHex(bytes: ByteArray): String {
val hexChars = "0123456789abcdef"
return StringBuilder(bytes.size * 2).apply {
bytes.forEach { byte ->
val i = byte.toInt() and 0xff
append(hexChars[i shr 4])
append(hexChars[i and 0x0f])
}
}.toString()
}
}

View File

@@ -1,5 +1,7 @@
package com.taskttl.core.utils
import com.taskttl.BuildConfig
import io.ktor.client.plugins.logging.Logger
import android.util.Log as AndroidLog
/**
@@ -23,4 +25,4 @@ actual object LogUtils {
actual fun e(tag: String, message: String, throwable: Throwable?) {
AndroidLog.e(tag, message, throwable)
}
}
}

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">10.0.0.5</domain>
</domain-config>
</network-security-config>

View File

@@ -1,27 +1,38 @@
<resources>
<!-- Onboarding -->
<string name="continue_text">继续</string>
<string name="skip_text">跳过</string>
<string name="get_start_text">开始使用</string>
<string name="onboarding_welcome_title">欢迎使用 TaskTTL</string>
<string name="onboarding_welcome_desc">一个简洁而强大的任务管理工具,帮助您高效管理日常任务和重要日期</string>
<string name="onboarding_smart_title">智能任务管理</string>
<string name="onboarding_smart_desc">创建、分类和跟踪您的任务。设置优先级,添加截止日期,让工作更有条理</string>
<string name="onboarding_dates_title">重要日期提醒</string>
<string name="onboarding_dates_desc">设置重要日期的倒数计时,永远不会错过生日、纪念日或重要的截止日期</string>
<string name="onboarding_ready_title">准备就绪!</string>
<string name="onboarding_ready_desc">现在您可以开始创建第一个任务,让我们一起提高工作效率吧!</string>
<!-- 应用信息 -->
<string name="app_name">TaskTTL</string>
<string name="app_name_description">任务管理与倒数日应用</string>
<string name="app_name_remark">让每一天都更有意义</string>
<string name="version">版本</string>
<string name="build_version">构建版本</string>
<!-- 导航栏 -->
<string name="nav_todo">待办</string>
<string name="nav_countdown">倒数日</string>
<string name="nav_statistics">统计</string>
<string name="nav_settings">设置</string>
<!-- 任务模块 -->
<string name="search_placeholder">搜索任务...</string>
<string name="title_task">我的任务</string>
<string name="title_task_info">任务详情</string>
<string name="title_add_task">添加任务</string>
<string name="title_edit_task">编辑任务</string>
<string name="title_countdown">倒数日</string>
<string name="title_countdown_info">倒数日详情</string>
<string name="title_add_countdown">添加倒数日</string>
<string name="title_edit_countdown">编辑倒数日</string>
<string name="title_statistics">统计</string>
<string name="title_about">关于</string>
<string name="title_category">分类管理</string>
<string name="title_add_category">添加分类</string>
<string name="title_edit_category">编辑分类</string>
<string name="label_task_list">任务列表</string>
<string name="label_show_completed">显示已完成</string>
<string name="text_no_tasks">暂无任务</string>
@@ -44,6 +55,23 @@
<string name="label_created_at">创建时间:</string>
<string name="label_description">任务描述</string>
<string name="task_add_success">任务添加成功</string>
<string name="task_add_failed">添加任务失败</string>
<string name="task_update_success">任务更新成功</string>
<string name="task_update_failed">更新任务失败</string>
<string name="task_delete_success">任务删除成功</string>
<string name="task_delete_failed">删除任务失败</string>
<string name="task_load_failed">加载任务失败</string>
<string name="task_query_failed">查询任务失败</string>
<string name="task_status_update_success">更新任务状态成功</string>
<string name="task_status_update_failed">更新任务状态失败</string>
<!-- 倒数日模块 -->
<string name="title_countdown">倒数日</string>
<string name="title_countdown_info">倒数日详情</string>
<string name="title_add_countdown">添加倒数日</string>
<string name="title_edit_countdown">编辑倒数日</string>
<string name="label_countdown_list">倒数日列表</string>
<string name="label_days"></string>
<string name="text_no_countdowns">暂无倒数日</string>
@@ -52,46 +80,86 @@
<string name="label_countdown_title">倒数日标题</string>
<string name="label_countdown_description">倒数日描述</string>
<string name="label_select_category">选择分类</string>
<string name="label_target_date">目标日期</string>
<string name="label_notification_setting">通知设置</string>
<string name="countdown_not_found">倒数日不存在</string>
<string name="event_description">事件描述</string>
<string name="detail_information">详细信息</string>
<string name="reminder">提醒</string>
<string name="countdown_add_success">倒数日添加成功</string>
<string name="countdown_add_failed">添加倒数日失败</string>
<string name="countdown_update_success">倒数日更新成功</string>
<string name="countdown_update_failed">更新倒数日失败</string>
<string name="countdown_delete_success">倒数日删除成功</string>
<string name="countdown_delete_failed">删除倒数日失败</string>
<string name="countdown_load_failed">加载倒数日失败</string>
<string name="countdown_query_failed">查询倒数日失败</string>
<!-- 统计模块 -->
<string name="title_statistics">统计</string>
<string name="overview">总览</string>
<string name="category_statistics">分类统计</string>
<string name="total_tasks">总任务</string>
<string name="completed">已完成</string>
<string name="completion_rate">完成率</string>
<string name="total_countdowns">倒数日总数</string>
<string name="active">活跃中</string>
<string name="overview">总览</string>
<string name="category_statistics">分类统计</string>
<!-- 分类模块 -->
<string name="category_task">任务</string>
<string name="category_countdown">倒数日</string>
<!-- 空状态 -->
<string name="countdown_not_found">倒数日不存在</string>
<string name="title_category">分类管理</string>
<string name="title_add_category">添加分类</string>
<string name="title_edit_category">编辑分类</string>
<string name="label_no_category">暂无分类</string>
<string name="label_add_category_hint">点击右下角按钮添加新分类</string>
<string name="label_category_name">分类名称</string>
<string name="placeholder_category_name">输入分类名称...</string>
<string name="label_category_type">分类类型</string>
<string name="label_select_color">选择颜色</string>
<string name="label_select_icon">选择图标</string>
<string name="label_task_category">任务分类</string>
<string name="label_countdown_category">倒数日分类</string>
<!-- 倒数日卡片 -->
<string name="unit_days"></string>
<string name="event_description">事件描述</string>
<string name="detail_information">详细信息</string>
<string name="label_task_count">%1$d 个任务</string>
<string name="label_countdown_count">%1$d 个倒数日</string>
<!-- 信息项 -->
<string name="reminder">提醒</string>
<string name="created_at">创建时间</string>
<!-- 分类操作反馈 -->
<string name="category_add_success">分类添加成功</string>
<string name="category_add_failed">添加分类失败</string>
<string name="category_update_success">分类更新成功</string>
<string name="category_update_failed">更新分类失败</string>
<string name="category_delete_success">分类删除成功</string>
<string name="category_delete_failed">删除分类失败</string>
<string name="category_load_failed">加载分类失败</string>
<string name="category_stat_failed">加载统计数据失败</string>
<string name="category_init_success">默认分类初始化成功</string>
<string name="category_init_failed">初始化默认分类失败</string>
<string name="category_not_found">查询分类失败</string>
<string name="category_count_update_failed">更新分类计数失败</string>
<!-- 通用 -->
<!-- 通用操作 -->
<string name="enter">进入</string>
<string name="edit">编辑</string>
<string name="cancel">取消</string>
<string name="confirm">确定</string>
<string name="delete">删除</string>
<string name="export">导出</string>
<string name="import">导入</string>
<string name="choose_file">选择文件</string>
<string name="back">返回</string>
<string name="action">操作</string>
<string name="all_text">全部</string>
<string name="search_placeholder">搜索任务...</string>
<string name="search">搜索</string>
<string name="clear_text">清除</string>
<string name="all_text">全部</string>
<string name="retry">重试</string>
<string name="choose_file">选择文件</string>
<string name="error_title">错误</string>
<string name="webview_loading_error">加载失败,请检查网络连接</string>
<!-- DataManagement 模块 -->
<!-- 数据管理 -->
<string name="title_data_management">数据管理</string>
<string name="title_export_data">导出数据</string>
<string name="desc_export_data">将所有任务和倒数日导出为文件</string>
@@ -101,45 +169,27 @@
<string name="desc_auto_backup">定期自动备份数据到云端</string>
<string name="title_clear_all_data">清除所有数据</string>
<string name="desc_clear_all_data">删除所有任务、倒数日和设置</string>
<string name="title_clear_completed_tasks">清理已完成任务</string>
<string name="desc_clear_completed_tasks">删除所有已完成的任务</string>
<string name="title_clear_expired_countdowns">清理过期倒数日</string>
<string name="desc_clear_expired_countdowns">删除所有已过期的倒数日</string>
<string name="desc_clear_all_data_dialog">此操作将删除所有任务、倒数日和设置,且无法恢复。</string>
<string name="title_clear_completed_tasks">清理已完成任务</string>
<string name="title_clear_expired_countdowns">清理过期倒数日</string>
<string name="desc_clear_completed_tasks">删除所有已完成的任务</string>
<string name="desc_clear_expired_countdowns">删除所有已过期的倒数日</string>
<string name="label_select_import_file">选择要导入的文件</string>
<string name="label_enter">进入</string>
<string name="title_data_clean">数据清理</string>
<string name="title_backup_restore">备份与恢复</string>
<!-- 对话框 / 文件选择 -->
<string name="title_data_clean">数据清理</string>
<string name="label_select_import_file">选择要导入的文件</string>
<string name="label_select_file">选择文件</string>
<string name="label_select_export_format">选择导出格式</string>
<string name="label_json_format">JSON格式</string>
<string name="label_csv_format">CSV格式</string>
<string name="label_no_category">暂无分类</string>
<string name="label_add_category_hint">点击右下角按钮添加新分类</string>
<string name="label_edit">编辑</string>
<string name="label_task_count">%1$d 个任务</string>
<string name="label_countdown_count">%1$d 个倒数日</string>
<string name="label_category_name">分类名字</string>
<string name="placeholder_category_name">输入分类名称...</string>
<string name="label_category_type">分类类型</string>
<string name="label_select_color">选择颜色</string>
<string name="label_select_icon">选择图标</string>
<string name="label_task_category">任务分类</string>
<string name="label_countdown_category">倒数日分类</string>
<!-- 标题 -->
<!-- 设置模块 -->
<string name="title_app_settings">应用设置</string>
<string name="section_general_settings">通用设置</string>
<string name="section_data_management">数据管理</string>
<string name="section_social_share">社交分享</string>
<string name="section_help_feedback">帮助与反馈</string>
<!-- 通用设置 -->
<string name="setting_push_notification">推送通知</string>
<string name="setting_push_notification_desc">接收任务和倒数日提醒</string>
<string name="setting_dark_mode">深色模式</string>
@@ -147,19 +197,16 @@
<string name="setting_language">语言设置</string>
<string name="setting_language_desc">简体中文</string>
<!-- 数据管理 -->
<string name="setting_category_management">分类管理</string>
<string name="setting_category_management_desc">管理分类</string>
<string name="setting_data_management">数据管理</string>
<string name="setting_data_management_desc">备份和恢复数据</string>
<!-- 社交分享 -->
<string name="setting_share_achievement">分享成就</string>
<string name="setting_share_achievement_desc">分享任务完成成就</string>
<string name="setting_invite_friend">推荐给朋友</string>
<string name="setting_invite_friend_desc">邀请朋友使用 TaskMaster</string>
<!-- 帮助与反馈 -->
<string name="setting_feedback">意见反馈</string>
<string name="setting_feedback_desc">告诉我们您的想法</string>
<string name="setting_privacy_policy">隐私政策</string>
@@ -167,95 +214,47 @@
<string name="setting_about_app">关于应用</string>
<string name="setting_about_app_desc">版本 1.0.0</string>
<!-- 用户信息 -->
<string name="user_name">TaskMaster 用户</string>
<string name="user_info">已使用 %1$d 天 · 完成 %2$d 个任务</string>
<!-- 反馈与帮助 -->
<string name="title_feedback">意见反馈</string>
<string name="feedback_type">反馈类型</string>
<string name="feedback_issue">问题反馈</string>
<string name="feedback_suggestion">功能建议</string>
<string name="feedback_description">问题描述</string>
<string name="feedback_placeholder">请详细描述您遇到的问题或建议...</string>
<string name="feedback_contact">联系方式(可选)</string>
<string name="feedback_contact_placeholder">您的邮箱地址,方便我们回复</string>
<string name="feedback_submitted">感谢您的反馈!我们会尽快处理。</string>
<string name="feedback_error_empty">请填写反馈内容</string>
<string name="button_send_feedback">发送反馈</string>
<!-- 其他 -->
<string name="category_name_placeholder">输入分类名称...</string>
<!-- 版本信息 -->
<string name="version">版本</string>
<string name="build_version">构建版本</string>
<!-- 应用介绍 -->
<!-- 关于页面 -->
<string name="title_about">关于</string>
<string name="app_intro_title">应用介绍</string>
<string name="app_intro_content">
TaskTTL 是一款现代化的任务管理与倒数日应用,帮助您高效管理日常任务与重要日期。
支持分类管理、优先级设置与统计分析,让生活更有条理。
TaskTTL 是一款现代化的任务管理与倒数日应用,
支持分类管理、优先级设置与统计分析,让生活更有条理。
</string>
<!-- 技术栈 -->
<string name="title_privacy">隐私协议</string>
<string name="tech_stack">技术栈</string>
<string name="tech_stack_kmp">Kotlin Multiplatform跨平台开发框架</string>
<string name="tech_stack_compose">Jetpack Compose现代化 UI 框架)</string>
<string name="tech_stack_room">Room Database本地存储</string>
<string name="tech_stack_koin">Koin依赖注入框架</string>
<string name="tech_stack_ktor">Ktor网络请求</string>
<string name="tech_stack_mvi">MVI Architecture响应式架构模式</string>
<!-- 开发者信息 -->
<string name="developer_text">开发者</string>
<string name="devttl_team">DevTTL 团队</string>
<!-- 联系我们 -->
<string name="contact_us">联系我们</string>
<string name="email_text">电子邮箱</string>
<string name="email">team@devttl.com</string>
<string name="email">admin@devttl.com</string>
<string name="web_text">官方网站</string>
<string name="web_url">https://devttl.com</string>
<!-- 版权信息 -->
<string name="copyright_year">2025</string>
<string name="all_rights_reserved">保留所有权利</string>
<string name="button_send_feedback">发送反馈</string>
<string name="button_cancel">取消</string>
<!-- 页面标题 -->
<string name="title_feedback">意见反馈</string>
<!-- 反馈类型 -->
<string name="feedback_type">反馈类型</string>
<string name="feedback_issue">问题反馈</string>
<string name="feedback_suggestion">功能建议</string>
<!-- 问题描述 -->
<string name="feedback_description">问题描述</string>
<string name="feedback_placeholder">请详细描述您遇到的问题或建议...</string>
<!-- 联系方式 -->
<string name="feedback_contact">联系方式(可选)</string>
<string name="feedback_contact_placeholder">您的邮箱地址,方便我们回复</string>
<!-- 提交提示 -->
<string name="feedback_submitted">感谢您的反馈!我们会尽快处理。</string>
<string name="feedback_error_empty">请填写反馈内容</string>
<string name="app_name">TaskMaster</string>
<string name="app_name_description">任务管理与倒数日应用</string>
<string name="app_name_remark">让每一天都更有意义</string>
<string name="continue_text">继续</string>
<string name="skip_text">跳过</string>
<string name="get_start_text">开始使用</string>
<string name="onboarding_welcome_title">欢迎使用 TaskMaster</string>
<string name="onboarding_welcome_desc">一个简洁而强大的任务管理工具,帮助您高效管理日常任务和重要日期</string>
<string name="onboarding_smart_title">智能任务管理</string>
<string name="onboarding_smart_desc">创建、分类和跟踪您的任务。设置优先级,添加截止日期,让工作更有条理</string>
<string name="onboarding_dates_title">重要日期提醒</string>
<string name="onboarding_dates_desc">设置重要日期的倒数计时,永远不会错过生日、纪念日或重要的截止日期</string>
<string name="onboarding_ready_title">准备就绪!</string>
<string name="onboarding_ready_desc">现在您可以开始创建第一个任务,让我们一起提高工作效率吧!</string>
<string name="category_task">任务</string>
<string name="category_countdown">倒数日</string>
<string name="priority_low"></string>
<string name="priority_medium"></string>
<string name="priority_high"></string>
@@ -306,8 +305,9 @@
<string name="category_goal">目标</string>
<string name="category_reminder">提醒</string>
<string name="webview_loading_error">加载失败,请检查网络连接</string>
<string name="btn_retry">重试</string>
<string name="privacy_url">https://devttl.com</string>
<string name="privacy_url">https://sites.google.com/view/taskttl/privacy</string>
</resources>
<string name="feedback_success">反馈成功</string>
<string name="feedback_error">反馈失败,请检查网络连接或稍后重试</string>
</resources>

View File

@@ -14,6 +14,5 @@ import org.jetbrains.compose.ui.tooling.preview.Preview
fun App() {
AppTheme {
AppNav()
}
}
}

View File

@@ -0,0 +1,86 @@
package com.taskttl.core.config
import com.taskttl.core.domain.BaseReq
import kotlinx.serialization.*
import kotlinx.serialization.json.*
import kotlinx.serialization.descriptors.*
import kotlinx.serialization.encoding.*
/**
* T: 请求类
* B: BaseReq 类型
*/
class FlattenBaseReqSerializer<T : Any, B : BaseReq>(
private val valueSerializer: KSerializer<T>,
private val baseSelector: (T) -> B
) : KSerializer<T> {
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("FlattenBaseReq") {
for (i in 0 until valueSerializer.descriptor.elementsCount) {
val name = valueSerializer.descriptor.getElementName(i)
element(name, PrimitiveSerialDescriptor(name, PrimitiveKind.STRING), isOptional = true)
}
// baseReq 的字段也展开
element("appName", PrimitiveSerialDescriptor("appName", PrimitiveKind.STRING))
element("versionCode", PrimitiveSerialDescriptor("versionCode", PrimitiveKind.INT))
element("appId", PrimitiveSerialDescriptor("appId", PrimitiveKind.INT))
element("uniqueId", PrimitiveSerialDescriptor("uniqueId", PrimitiveKind.STRING))
element("deviceInfo", PrimitiveSerialDescriptor("deviceInfo", PrimitiveKind.STRING))
element("deviceVersion", PrimitiveSerialDescriptor("deviceVersion", PrimitiveKind.STRING))
element("language", PrimitiveSerialDescriptor("language", PrimitiveKind.STRING))
}
override fun serialize(encoder: Encoder, value: T) {
val jsonEncoder = encoder as? JsonEncoder ?: error("Only JSON supported")
val base = baseSelector(value)
val jsonObj = jsonEncoder.json.encodeToJsonElement(valueSerializer, value).jsonObject.toMutableMap()
// 将 baseReq 字段平级展开
jsonObj["appName"] = JsonPrimitive(base.appName)
jsonObj["versionCode"] = JsonPrimitive(base.versionCode)
jsonObj["appId"] = JsonPrimitive(base.appId)
jsonObj["uniqueId"] = JsonPrimitive(base.uniqueId)
jsonObj["deviceInfo"] = JsonPrimitive(base.deviceInfo)
jsonObj["deviceVersion"] = JsonPrimitive(base.deviceVersion)
jsonObj["language"] = JsonPrimitive(base.language)
jsonEncoder.encodeJsonElement(JsonObject(jsonObj))
}
override fun deserialize(decoder: Decoder): T {
val jsonDecoder = decoder as? JsonDecoder ?: error("Only JSON supported")
val jsonObj = jsonDecoder.decodeJsonElement().jsonObject.toMutableMap()
// 解析 BaseReq
val base = BaseReq(
appName = jsonObj["appName"]!!.jsonPrimitive.content,
versionCode = jsonObj["versionCode"]!!.jsonPrimitive.int,
appId = jsonObj["appId"]!!.jsonPrimitive.int,
uniqueId = jsonObj["uniqueId"]!!.jsonPrimitive.content,
deviceInfo = jsonObj["deviceInfo"]!!.jsonPrimitive.content,
deviceVersion = jsonObj["deviceVersion"]!!.jsonPrimitive.content,
language = jsonObj["language"]!!.jsonPrimitive.content
)
// 删除 baseReq 字段
jsonObj.remove("appName")
jsonObj.remove("versionCode")
jsonObj.remove("appId")
jsonObj.remove("uniqueId")
jsonObj.remove("deviceInfo")
jsonObj.remove("deviceVersion")
jsonObj.remove("language")
// 反序列化剩下的业务字段
val t = jsonDecoder.json.decodeFromJsonElement(valueSerializer, JsonObject(jsonObj))
// 将 base 通过构造器或 copy 注入业务对象
if (t is BaseReqHolder) t.baseReq = base
return t
}
}
/** 业务对象实现接口,持有 BaseReq */
interface BaseReqHolder {
var baseReq: BaseReq
}

View File

@@ -0,0 +1,11 @@
package com.taskttl.core.domain
import kotlinx.serialization.Serializable
/**
* API统一响应格式
* @author DevTTL
* @date 2025/03/10
*/
@Serializable
data class ApiResponse<T>(val code: Int, val msg: String, val data: T)

View File

@@ -0,0 +1,58 @@
package com.taskttl.core.domain
import com.taskttl.core.config.FlattenBaseReqSerializer
import com.taskttl.core.utils.JsonUtils
import kotlinx.serialization.InternalSerializationApi
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import kotlinx.serialization.serializer
/**
* 基础请求
* @author admin
* @date 2025/04/09
* @constructor 创建[BaseReq]
* @param [appName] 应用程序名称
* @param [versionCode] 版本代码
* @param [appId] 应用ID
* @param [uniqueId] 唯一id
* @param [deviceInfo] 设备信息
* @param [deviceVersion] 设备版本
* @param [language] 语言
*/
@Serializable
open class BaseReq(
open val appName: String,
open val versionCode: Int,
open val appId: Int,
open val uniqueId: String,
open val deviceInfo: String,
open val deviceVersion: String,
open val language: String,
)
@Serializable
open class BaseReqWith(
@Transient var baseReq: BaseReq = defaultBaseReq(),
)
// 通用扩展函数
@OptIn(InternalSerializationApi::class)
fun <T : BaseReqWith> T.toJson(): String {
val serializer: KSerializer<T> = FlattenBaseReqSerializer(
valueSerializer = this::class.serializer() as KSerializer<T>,
baseSelector = { it.baseReq }
)
return JsonUtils.default.encodeToString(serializer, this)
}
fun defaultBaseReq(): BaseReq = BaseReq(
appName = "",
versionCode = 0,
appId = 0,
uniqueId = "",
deviceInfo = "",
deviceVersion = "",
language = ""
)

View File

@@ -14,7 +14,7 @@ import taskttl.composeapp.generated.resources.feedback_suggestion
* @param [titleRes] 标题res
*/
@Serializable
enum class FeedbackType(val titleRes: StringResource) {
ISSUE(Res.string.feedback_issue),
SUGGESTION(Res.string.feedback_suggestion)
enum class FeedbackType(var code: String,val titleRes: StringResource) {
ISSUE("1",Res.string.feedback_issue),
SUGGESTION("2",Res.string.feedback_suggestion)
}

View File

@@ -0,0 +1,48 @@
package com.taskttl.core.domain.constant
/**
* 埋点事件
* @author DevTTL
* @date 2025/09/04
* @constructor 创建[PointEvent]
* @param [eventName] 事件名称
* @param [eventCode] 事件代码
*/
enum class PointEvent(val eventName: String, val eventCode: Int) {
// 启动类事件
FirstAppLaunch("first_app_launch", 1001),
AppLaunch("app_launch", 1002),
AppExit("app_exit", 1003),
// 页面浏览类
PageViewHome("page_view_home", 2001),
PageViewDetail("page_view_detail", 2002),
PageViewProfile("page_view_profile", 2003),
// 按钮点击类
ClickAdd("click_add", 3001),
ClickBack("click_back", 3002),
ClickShare("click_share", 3003),
ClickLogin("click_login", 3004),
// 弹窗展示类
DialogShownUpdate("dialog_shown_update", 4001),
DialogShownPermission("dialog_shown_permission", 4002),
// 分享类
ShareSuccess("share_success", 5001),
ShareCancel("share_cancel", 5002),
// 登录注册类
LoginSuccess("login_success", 6001),
RegisterSuccess("register_success", 6002),
// 广告类
AdShownBanner("ad_shown_banner", 9001),
AdClickReward("ad_click_reward", 9002);
fun toMap(): Map<String, Any> = mapOf(
"eventName" to eventName,
"eventCode" to eventCode,
)
}

View File

@@ -0,0 +1,18 @@
package com.taskttl.core.network
/**
* api配置
* @author DevTTL
* @date 2025/10/11
*/
object ApiConfig {
/** 基本地址 */
const val BASE_URL = "http://10.0.0.5:8888/api/v1"
// const val BASE_URL = "https://api.tikttl.com/api/v1"
/** 反馈地址 */
const val FEEDBACK_URL = "$BASE_URL/feedback"
/** 埋点地址 */
const val POINT_URL = "$BASE_URL/point"
}

View File

@@ -0,0 +1,106 @@
package com.taskttl.core.network
import com.taskttl.core.domain.ApiResponse
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.plugins.HttpRequestRetry
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logger
import io.ktor.client.plugins.logging.Logging
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.HttpResponse
import io.ktor.http.ContentType
import io.ktor.http.contentType
import io.ktor.http.userAgent
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
/**
* ktor客户端
* @author DevTTL
* @date 2025/03/08
*/
object KtorClient {
// 超时配置(毫秒)
private const val CONNECT_TIMEOUT = 15000L
private const val REQUEST_TIMEOUT = 30000L
private const val SOCKET_TIMEOUT = 60000L
// 重试配置
private const val MAX_RETRIES = 3
private const val RETRY_DELAY = 1000L
val httpClient = HttpClient {
install(ContentNegotiation) {
json(Json {
prettyPrint = true
isLenient = true
ignoreUnknownKeys = true // JSON多了字段也不报错
encodeDefaults = false
})
}
// 日志插件
install(Logging) {
logger = defaultLogger
level = LogLevel.ALL // 记录请求头、请求体、响应头、响应体
}
// 超时配置
install(HttpTimeout) {
connectTimeoutMillis = CONNECT_TIMEOUT
requestTimeoutMillis = REQUEST_TIMEOUT
socketTimeoutMillis = SOCKET_TIMEOUT
}
// 重试配置
install(HttpRequestRetry) {
retryOnServerErrors(maxRetries = MAX_RETRIES)
retryOnException(maxRetries = MAX_RETRIES)
exponentialDelay()
// 自定义重试条件
modifyRequest { request ->
request.headers.append("X-Retry-Count", retryCount.toString())
}
}
}
/**
* Post Json
* @param [url] 请求链接
* @param [body] 请求内容
* @return [String]
*/
suspend inline fun <reified T> postJson(url: String, body: String): T {
try {
val response = httpClient.post(url) {
contentType(ContentType.Application.Json)
userAgent("TaskTTL")
setBody(body)
}
return handleResponse(response)
} catch (e: Exception) {
throw Exception(e)
}
}
/**
* 处理API响应
* @param [response] HTTP响应
* @return [T] 响应数据
* @throws Exception 当响应码不为200时抛出异常
*/
suspend inline fun <reified T> handleResponse(response: HttpResponse): T {
// 使用统一响应处理
val apiResponse = response.body<ApiResponse<T>>()
if (apiResponse.code != 200) {
throw Exception(apiResponse.msg)
}
return apiResponse.data
}
}
expect val defaultLogger: Logger

View File

@@ -176,10 +176,7 @@ fun MainNav() {
}
// 反馈页面
composable<Main.Settings.Feedback> {
FeedbackScreen(
onNavigateBack = { mainNavController.popBackStack() },
onSubmit = {}
)
FeedbackScreen(onNavigateBack = { mainNavController.popBackStack() })
}
// 隐私
composable<Main.Settings.Privacy> {

View File

@@ -0,0 +1,31 @@
package com.taskttl.core.ui
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import org.jetbrains.compose.resources.stringResource
import taskttl.composeapp.generated.resources.Res
import taskttl.composeapp.generated.resources.confirm
import taskttl.composeapp.generated.resources.error_title
/**
* 错误对话框
* @param [errorMessage] 错误信息
* @param [onDismiss] 解雇
*/
@Composable
fun ErrorDialog(errorMessage: String?, onDismiss: () -> Unit) {
if (errorMessage != null) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(Res.string.error_title)) },
text = { Text(errorMessage) },
confirmButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(Res.string.confirm))
}
}
)
}
}

View File

@@ -0,0 +1,30 @@
package com.taskttl.core.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
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
@Composable
fun LoadingScreen() {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color(0x88000000))
// 拦截所有点击事件,防止点击到底层
.clickable(
indication = null, // 无点击动画
interactionSource = remember { MutableInteractionSource() }
) { },
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}

View File

@@ -0,0 +1,25 @@
package com.taskttl.core.utils
import com.taskttl.core.domain.BaseReq
/**
* 设备工具
* @author DevTTL
* @date 2025/03/12
* @constructor 创建[DeviceUtils]
*/
expect object DeviceUtils {
/**
* 获取唯一id
* @return [String]
*/
suspend fun getUniqueId(): String
/**
* 获取设备信息
* @return [BaseReq]
*/
suspend fun getDeviceInfo(): BaseReq
}

View File

@@ -0,0 +1,17 @@
package com.taskttl.core.utils
import kotlinx.serialization.json.Json
/**
* json-utils
* @author DevTTL
* @date 2025/10/11
*/
object JsonUtils {
val default: Json = Json {
prettyPrint = true
encodeDefaults = true
isLenient = true
ignoreUnknownKeys = true
}
}

View File

@@ -1,9 +1,11 @@
package com.taskttl.core.utils
import io.ktor.client.plugins.logging.Logger
enum class LogLevel { DEBUG, INFO, WARN, ERROR }
/**
* 日志
* 日志工具类
* @author admin
* @date 2025/10/03
*/

View File

@@ -1,5 +1,10 @@
package com.taskttl.core.utils
/**
* 吐司工具类
* @author DevTTL
* @date 2025/10/11
*/
expect object ToastUtils {
fun show(message: String)
}

View File

@@ -1,13 +1,14 @@
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.repository.SettingsRepository
import com.taskttl.data.repository.impl.OnboardingRepositoryImpl
import com.taskttl.data.repository.impl.SettingsRepositoryImpl
import com.taskttl.data.viewmodel.CategoryViewModel
import com.taskttl.data.viewmodel.CountdownViewModel
import com.taskttl.data.viewmodel.FeedbackViewModel
import com.taskttl.data.viewmodel.OnboardingViewModel
import com.taskttl.data.viewmodel.SplashViewModel
import com.taskttl.data.viewmodel.TaskViewModel
import org.koin.core.KoinApplication
import org.koin.core.context.startKoin
@@ -43,4 +44,5 @@ val viewModelModule = module {
viewModelOf(::TaskViewModel)
viewModelOf(::CategoryViewModel)
viewModelOf(::CountdownViewModel)
viewModelOf(::FeedbackViewModel)
}

View File

@@ -0,0 +1,46 @@
package com.taskttl.data.network
import com.taskttl.core.domain.constant.PointEvent
import com.taskttl.core.domain.toJson
import com.taskttl.core.network.ApiConfig
import com.taskttl.core.network.KtorClient
import com.taskttl.core.utils.DeviceUtils
import com.taskttl.core.utils.LogUtils
import com.taskttl.data.network.domain.req.FeedbackReq
import com.taskttl.data.network.domain.req.PointReq
import com.taskttl.data.network.domain.resp.FeedbackResp
import org.jetbrains.compose.resources.getString
import taskttl.composeapp.generated.resources.Res
import taskttl.composeapp.generated.resources.feedback_error
object TaskTTLApi {
/**
* 发布反馈
* @param [feedbackReq] 反馈请求
* @return [String]
*/
suspend fun postFeedback(feedbackReq: FeedbackReq): FeedbackResp {
try {
feedbackReq.baseReq = DeviceUtils.getDeviceInfo()
return KtorClient.postJson(ApiConfig.FEEDBACK_URL, feedbackReq.toJson())
} catch (e: Exception) {
LogUtils.e("DevTTL",e.message.toString())
throw Exception(getString(Res.string.feedback_error))
}
}
/**
* 邮递埋点
* @param [point] 埋点
* @return [PointReq]
*/
suspend fun postPoint(point: PointEvent) {
try {
val pointReq = PointReq(point.eventName, point.eventCode)
pointReq.baseReq = DeviceUtils.getDeviceInfo()
return KtorClient.postJson(ApiConfig.POINT_URL, pointReq.toJson())
} catch (_: Exception) {
}
}
}

View File

@@ -0,0 +1,24 @@
package com.taskttl.data.network.domain.req
import com.taskttl.core.domain.BaseReqWith
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* 反馈请求
* @author DevTTL
* @date 2025/03/31
* @constructor 创建[FeedbackReq]
* @param [contactInfo] 联系方式
* @param [contentBody] 反馈内容
* @param [baseReq] 基础请求
*/
@Serializable
data class FeedbackReq(
@SerialName("type")
val type: String,
@SerialName("contactInfo")
val contactInfo: String,
@SerialName("contentBody")
val contentBody: String,
) : BaseReqWith()

View File

@@ -0,0 +1,21 @@
package com.taskttl.data.network.domain.req
import com.taskttl.core.domain.BaseReqWith
import kotlinx.serialization.Serializable
/**
* 埋点请求
* @author admin
* @date 2025/04/08
* @constructor 创建[PointReq]
* @param [eventName] 事件名称
* @param [eventCode] 事件代码
* @param [baseReq] 基本要求
*/
@Serializable
data class PointReq(
val eventName: String,
val eventCode: Int,
) : BaseReqWith()

View File

@@ -0,0 +1,11 @@
package com.taskttl.data.network.domain.resp
import kotlinx.serialization.Serializable
/**
* 反馈响应
* @author admin
* @date 2025/10/09
*/
@Serializable
object FeedbackResp

View File

@@ -51,6 +51,5 @@ sealed class CountdownIntent {
*/
sealed class CountdownEffect {
data class ShowMessage(val message: String) : CountdownEffect()
data class NavigateToCountdownDetail(val countdownId: String) : CountdownEffect()
object NavigateBack : CountdownEffect()
}

View File

@@ -0,0 +1,43 @@
package com.taskttl.data.state
import com.taskttl.data.network.domain.req.FeedbackReq
data class FeedbackState(
val isLoading: Boolean = false,
val error: String? = null
)
sealed class FeedbackIntent {
/**
* 清除错误
* @author admin
* @date 2025/09/27
*/
object ClearError : FeedbackIntent()
data class SubmitFeedback(var feedback: FeedbackReq) : FeedbackIntent()
}
/**
* 反馈效应
* @author admin
* @date 2025/10/12
* @constructor 创建[FeedbackEffect]
*/
sealed class FeedbackEffect {
/**
* 导航返回
* @author admin
* @date 2025/10/12
*/
object NavigateBack : FeedbackEffect()
/**
* 显示消息
* @author admin
* @date 2025/10/12
* @constructor 创建[ShowMessage]
* @param [message] 消息
*/
data class ShowMessage(val message: String) : FeedbackEffect()
}

View File

@@ -15,6 +15,20 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.getString
import taskttl.composeapp.generated.resources.Res
import taskttl.composeapp.generated.resources.category_add_failed
import taskttl.composeapp.generated.resources.category_add_success
import taskttl.composeapp.generated.resources.category_count_update_failed
import taskttl.composeapp.generated.resources.category_delete_failed
import taskttl.composeapp.generated.resources.category_delete_success
import taskttl.composeapp.generated.resources.category_init_failed
import taskttl.composeapp.generated.resources.category_init_success
import taskttl.composeapp.generated.resources.category_load_failed
import taskttl.composeapp.generated.resources.category_not_found
import taskttl.composeapp.generated.resources.category_stat_failed
import taskttl.composeapp.generated.resources.category_update_failed
import taskttl.composeapp.generated.resources.category_update_success
/**
* 类别视图模型
@@ -81,7 +95,7 @@ class CategoryViewModel(private val categoryRepository: CategoryRepository) : Vi
} catch (e: Exception) {
_state.value = _state.value.copy(
isLoading = false,
error = e.message ?: "加载分类失败"
error = e.message ?: getString(Res.string.category_load_failed)
)
}
}
@@ -97,7 +111,8 @@ class CategoryViewModel(private val categoryRepository: CategoryRepository) : Vi
val category = categoryRepository.getCategoryById(categoryId)
_state.value = _state.value.copy(editingCategory = category)
} catch (e: Exception) {
_state.value = _state.value.copy(error = e.message ?: "查询分类失败")
_state.value =
_state.value.copy(error = e.message ?: getString(Res.string.category_not_found))
}
}
}
@@ -121,7 +136,9 @@ class CategoryViewModel(private val categoryRepository: CategoryRepository) : Vi
}
}
} catch (e: Exception) {
_state.value = _state.value.copy(error = e.message ?: "加载分类失败")
_state.value = _state.value.copy(
error = e.message ?: getString(Res.string.category_load_failed)
)
}
}
}
@@ -136,7 +153,9 @@ class CategoryViewModel(private val categoryRepository: CategoryRepository) : Vi
_state.value = _state.value.copy(categoryStatistics = statistics)
}
} catch (e: Exception) {
_state.value = _state.value.copy(error = e.message ?: "加载统计数据失败")
_state.value = _state.value.copy(
error = e.message ?: getString(Res.string.category_stat_failed)
)
}
}
}
@@ -157,10 +176,12 @@ class CategoryViewModel(private val categoryRepository: CategoryRepository) : Vi
viewModelScope.launch {
try {
categoryRepository.insertCategory(category)
_effects.emit(CategoryEffect.ShowMessage("分类添加成功"))
_effects.emit(CategoryEffect.ShowMessage(getString(Res.string.category_add_success)))
_effects.emit(CategoryEffect.NavigateBack)
} catch (e: Exception) {
_state.value = _state.value.copy(error = e.message ?: "添加分类失败")
_state.value = _state.value.copy(
error = e.message ?: getString(Res.string.category_add_failed)
)
}
}
}
@@ -169,11 +190,13 @@ class CategoryViewModel(private val categoryRepository: CategoryRepository) : Vi
viewModelScope.launch {
try {
categoryRepository.updateCategory(category)
_effects.emit(CategoryEffect.ShowMessage("分类更新成功"))
_effects.emit(CategoryEffect.ShowMessage(getString(Res.string.category_update_success)))
_effects.emit(CategoryEffect.NavigateBack)
hideEditDialog()
} catch (e: Exception) {
_state.value = _state.value.copy(error = e.message ?: "更新分类失败")
_state.value = _state.value.copy(
error = e.message ?: getString(Res.string.category_update_failed)
)
}
}
}
@@ -182,10 +205,12 @@ class CategoryViewModel(private val categoryRepository: CategoryRepository) : Vi
viewModelScope.launch {
try {
categoryRepository.deleteCategory(categoryId)
_effects.emit(CategoryEffect.ShowMessage("分类删除成功"))
_effects.emit(CategoryEffect.ShowMessage(getString(Res.string.category_delete_success)))
hideDeleteDialog()
} catch (e: Exception) {
_state.value = _state.value.copy(error = e.message ?: "删除分类失败")
_state.value = _state.value.copy(
error = e.message ?: getString(Res.string.category_delete_failed)
)
}
}
}
@@ -194,9 +219,11 @@ class CategoryViewModel(private val categoryRepository: CategoryRepository) : Vi
viewModelScope.launch {
try {
categoryRepository.initializeDefaultCategories()
_effects.emit(CategoryEffect.ShowMessage("默认分类初始化成功"))
_effects.emit(CategoryEffect.ShowMessage(getString(Res.string.category_init_success)))
} catch (e: Exception) {
_state.value = _state.value.copy(error = e.message ?: "初始化默认分类失败")
_state.value = _state.value.copy(
error = e.message ?: getString(Res.string.category_init_failed)
)
}
}
}
@@ -206,7 +233,9 @@ class CategoryViewModel(private val categoryRepository: CategoryRepository) : Vi
try {
categoryRepository.updateCategoryCounts()
} catch (e: Exception) {
_state.value = _state.value.copy(error = e.message ?: "更新分类计数失败")
_state.value = _state.value.copy(
error = e.message ?: getString(Res.string.category_count_update_failed)
)
}
}
}

View File

@@ -17,6 +17,16 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.getString
import taskttl.composeapp.generated.resources.Res
import taskttl.composeapp.generated.resources.countdown_add_failed
import taskttl.composeapp.generated.resources.countdown_add_success
import taskttl.composeapp.generated.resources.countdown_delete_failed
import taskttl.composeapp.generated.resources.countdown_delete_success
import taskttl.composeapp.generated.resources.countdown_load_failed
import taskttl.composeapp.generated.resources.countdown_query_failed
import taskttl.composeapp.generated.resources.countdown_update_failed
import taskttl.composeapp.generated.resources.countdown_update_success
/**
* 倒计时视图模型
@@ -73,7 +83,10 @@ class CountdownViewModel(
}
} catch (e: Exception) {
_state.value =
_state.value.copy(isLoading = false, error = e.message ?: "加载倒数日失败")
_state.value.copy(
isLoading = false,
error = e.message ?: getString(Res.string.countdown_load_failed)
)
}
}
}
@@ -85,7 +98,9 @@ class CountdownViewModel(
val countdown = countdownRepository.getCountdownById(countdownId)
_state.value = _state.value.copy(editingCountdown = countdown)
} catch (e: Exception) {
_state.value = _state.value.copy(error = e.message ?: "查询倒数日失败")
_state.value = _state.value.copy(
error = e.message ?: getString(Res.string.countdown_query_failed)
)
}
}
}
@@ -94,10 +109,12 @@ class CountdownViewModel(
viewModelScope.launch {
try {
countdownRepository.insertCountdown(countdown)
_effects.emit(CountdownEffect.ShowMessage("倒数日添加成功"))
_effects.emit(CountdownEffect.ShowMessage(getString(Res.string.countdown_add_success)))
_effects.emit(CountdownEffect.NavigateBack)
} catch (e: Exception) {
_state.value = _state.value.copy(error = e.message ?: "添加倒数日失败")
_state.value = _state.value.copy(
error = e.message ?: getString(Res.string.countdown_add_failed)
)
}
}
}
@@ -106,9 +123,11 @@ class CountdownViewModel(
viewModelScope.launch {
try {
countdownRepository.updateCountdown(countdown)
_effects.emit(CountdownEffect.ShowMessage("倒数日更新成功"))
_effects.emit(CountdownEffect.ShowMessage(getString(Res.string.countdown_update_success)))
} catch (e: Exception) {
_state.value = _state.value.copy(error = e.message ?: "更新倒数日失败")
_state.value = _state.value.copy(
error = e.message ?: getString(Res.string.countdown_update_failed)
)
}
}
}
@@ -117,9 +136,9 @@ class CountdownViewModel(
viewModelScope.launch {
try {
countdownRepository.deleteCountdown(countdownId)
_effects.emit(CountdownEffect.ShowMessage("倒数日删除成功"))
_effects.emit(CountdownEffect.ShowMessage(getString(Res.string.countdown_delete_success)))
} catch (e: Exception) {
_state.value = _state.value.copy(error = e.message ?: "删除倒数日失败")
_state.value = _state.value.copy(error = e.message ?: getString(Res.string.countdown_delete_failed))
}
}
}

View File

@@ -0,0 +1,73 @@
package com.taskttl.data.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.taskttl.data.network.TaskTTLApi
import com.taskttl.data.network.domain.req.FeedbackReq
import com.taskttl.data.state.FeedbackEffect
import com.taskttl.data.state.FeedbackIntent
import com.taskttl.data.state.FeedbackState
import kotlinx.coroutines.delay
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
import org.jetbrains.compose.resources.getString
import taskttl.composeapp.generated.resources.Res
import taskttl.composeapp.generated.resources.feedback_error
import taskttl.composeapp.generated.resources.feedback_success
/**
* 反馈视图模型
* @author admin
* @date 2025/10/12
* @constructor 创建[FeedbackViewModel]
*/
class FeedbackViewModel() : ViewModel() {
private val _state = MutableStateFlow(FeedbackState())
val state: StateFlow<FeedbackState> = _state.asStateFlow()
private val _effects = MutableSharedFlow<FeedbackEffect>()
val effects: SharedFlow<FeedbackEffect> = _effects.asSharedFlow()
fun handleIntent(intent: FeedbackIntent) {
when (intent) {
is FeedbackIntent.SubmitFeedback -> submitFeedback(intent.feedback)
is FeedbackIntent.ClearError -> clearError()
}
}
private fun submitFeedback(feedback: FeedbackReq) {
viewModelScope.launch {
_state.value = _state.value.copy(isLoading = true, error = null)
try {
delay(10_000)
TaskTTLApi.postFeedback(feedback)
_effects.emit(FeedbackEffect.ShowMessage(getString(Res.string.feedback_success)))
_state.value = _state.value.copy(isLoading = false)
_effects.emit(FeedbackEffect.NavigateBack)
} catch (e: Exception) {
_state.value = _state.value.copy(isLoading = false, error = e.message)
_effects.emit(
FeedbackEffect.ShowMessage(
e.message ?: getString(Res.string.feedback_error)
)
)
}
}
}
/**
* 清除错误
*/
private fun clearError() {
_state.value = _state.value.copy(error = null)
}
}

View File

@@ -2,9 +2,13 @@ package com.taskttl.data.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.taskttl.core.domain.constant.PointEvent
import com.taskttl.core.utils.DeviceUtils
import com.taskttl.core.utils.LogUtils
import com.taskttl.core.utils.StorageUtils
import com.taskttl.data.network.TaskTTLApi
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
@@ -26,12 +30,18 @@ class SplashViewModel(
init {
viewModelScope.launch {
delay(1200) // 模拟启动等待
DeviceUtils.getUniqueId()
val firstResult = StorageUtils.getBoolean(PointEvent.FirstAppLaunch.eventName, false)
if (!firstResult) {
TaskTTLApi.postPoint(PointEvent.FirstAppLaunch)
StorageUtils.saveBoolean(PointEvent.FirstAppLaunch.eventName, true)
} else {
TaskTTLApi.postPoint(PointEvent.AppLaunch)
}
val hasLaunched = onboardingRepository.isLaunchedBefore()
_uiState.value =
if (hasLaunched) SplashState.NavigateToOnboarding else SplashState.NavigateToMain
}
}
}

View File

@@ -18,6 +18,18 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.getString
import taskttl.composeapp.generated.resources.Res
import taskttl.composeapp.generated.resources.task_add_failed
import taskttl.composeapp.generated.resources.task_add_success
import taskttl.composeapp.generated.resources.task_delete_failed
import taskttl.composeapp.generated.resources.task_delete_success
import taskttl.composeapp.generated.resources.task_load_failed
import taskttl.composeapp.generated.resources.task_query_failed
import taskttl.composeapp.generated.resources.task_status_update_failed
import taskttl.composeapp.generated.resources.task_status_update_success
import taskttl.composeapp.generated.resources.task_update_failed
import taskttl.composeapp.generated.resources.task_update_success
/**
* 任务视图模型
@@ -85,10 +97,11 @@ class TaskViewModel(
_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)
}
categoryRepository.getCategoriesByType(CategoryType.TASK)
.collect { categories ->
LogUtils.e("DevTTL", categories.toString())
_state.value = _state.value.copy(categories = categories)
}
}
launch {
taskRepository.getAllTasks().collect { tasks ->
@@ -100,9 +113,12 @@ class TaskViewModel(
}
}
} catch (e: Exception) {
LogUtils.e("DevTTL",e.message.toString())
LogUtils.e("DevTTL", e.message.toString())
_state.value =
_state.value.copy(isLoading = false, error = e.message ?: "加载任务失败")
_state.value.copy(
isLoading = false,
error = e.message ?: getString(Res.string.task_load_failed)
)
}
}
}
@@ -117,7 +133,8 @@ class TaskViewModel(
val task = taskRepository.getTaskById(taskId)
_state.value = _state.value.copy(editingTask = task)
} catch (e: Exception) {
_state.value = _state.value.copy(error = e.message ?: "查询任务失败")
_state.value =
_state.value.copy(error = e.message ?: getString(Res.string.task_query_failed))
}
}
}
@@ -130,10 +147,11 @@ class TaskViewModel(
viewModelScope.launch {
try {
taskRepository.insertTask(task)
_effects.emit(TaskEffect.ShowMessage("任务添加成功"))
_effects.emit(TaskEffect.ShowMessage(getString(Res.string.task_add_success)))
_effects.emit(TaskEffect.NavigateBack)
} catch (e: Exception) {
_state.value = _state.value.copy(error = e.message ?: "添加任务失败")
_state.value =
_state.value.copy(error = e.message ?: getString(Res.string.task_add_failed))
}
}
}
@@ -146,10 +164,11 @@ class TaskViewModel(
viewModelScope.launch {
try {
taskRepository.updateTask(task)
_effects.emit(TaskEffect.ShowMessage("任务更新成功"))
_effects.emit(TaskEffect.ShowMessage(getString(Res.string.task_update_success)))
_effects.emit(TaskEffect.NavigateBack)
} catch (e: Exception) {
_state.value = _state.value.copy(error = e.message ?: "更新任务失败")
_state.value =
_state.value.copy(error = e.message ?: getString(Res.string.task_update_failed))
}
}
}
@@ -162,9 +181,10 @@ class TaskViewModel(
viewModelScope.launch {
try {
taskRepository.deleteTask(taskId)
_effects.emit(TaskEffect.ShowMessage("任务删除成功"))
_effects.emit(TaskEffect.ShowMessage(getString(Res.string.task_delete_success)))
} catch (e: Exception) {
_state.value = _state.value.copy(error = e.message ?: "删除任务失败")
_state.value =
_state.value.copy(error = e.message ?: getString(Res.string.task_delete_failed))
}
}
}
@@ -180,9 +200,12 @@ class TaskViewModel(
task?.let {
val updatedTask = it.copy(isCompleted = !it.isCompleted)
taskRepository.updateTask(updatedTask)
_effects.emit(TaskEffect.ShowMessage(getString(Res.string.task_status_update_success)))
}
} catch (e: Exception) {
_state.value = _state.value.copy(error = e.message ?: "更新任务状态失败")
_state.value = _state.value.copy(
error = e.message ?: getString(Res.string.task_status_update_failed)
)
}
}
}

View File

@@ -48,12 +48,15 @@ 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.ui.LoadingScreen
import com.taskttl.core.utils.ToastUtils
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.state.TaskEffect
import com.taskttl.data.viewmodel.CategoryViewModel
import com.taskttl.ui.components.AppHeader
import kotlinx.datetime.TimeZone
@@ -88,6 +91,17 @@ fun CategoryEditScreen(
onNavigateBack: () -> Unit,
viewModel: CategoryViewModel = koinViewModel()
) {
LaunchedEffect(Unit) {
viewModel.effects.collect { effect ->
when (effect) {
is CategoryEffect.ShowMessage -> {
ToastUtils.show(effect.message)
}
else -> {}
}
}
}
LaunchedEffect(categoryId) {
categoryId?.let { viewModel.handleIntent(CategoryIntent.GetCategoryById(it)) }
}
@@ -269,6 +283,7 @@ fun CategoryEditScreen(
}
}
}
if (state.isLoading) LoadingScreen()
}
}

View File

@@ -55,10 +55,15 @@ 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.core.ui.ErrorDialog
import com.taskttl.core.ui.LoadingScreen
import com.taskttl.core.utils.ToastUtils
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.state.TaskEffect
import com.taskttl.data.state.TaskIntent
import com.taskttl.data.viewmodel.CategoryViewModel
import com.taskttl.ui.components.AppHeader
import org.jetbrains.compose.resources.stringResource
@@ -66,7 +71,7 @@ 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.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
@@ -85,6 +90,9 @@ fun CategoryScreen(
LaunchedEffect(Unit) {
viewModel.effects.collect { effect ->
when (effect) {
is CategoryEffect.ShowMessage -> {
ToastUtils.show(effect.message)
}
is CategoryEffect.NavigateBack -> {
onNavigateBack.invoke()
}
@@ -96,7 +104,10 @@ fun CategoryScreen(
// 错误处理
state.error?.let { error ->
LaunchedEffect(error) { viewModel.handleIntent(CategoryIntent.ClearError) }
ErrorDialog(
errorMessage = state.error,
onDismiss = { viewModel.handleIntent(CategoryIntent.ClearError) }
)
}
Box(
@@ -166,7 +177,7 @@ fun CategoryScreen(
item { Spacer(Modifier) }
itemsIndexed(
items = categories,
key = { index, item -> item.name }) { index, category ->
key = { index, item -> item.id }) { index, category ->
var isOpen by remember { mutableStateOf(false) }
CategoryCardItem(
@@ -199,8 +210,7 @@ fun CategoryScreen(
contentDescription = stringResource(Res.string.title_add_category)
)
}
if (state.isLoading) LoadingScreen()
}
}
@@ -288,7 +298,7 @@ fun CategoryCardItem(
IconButton(onClick = onEditClick) {
Icon(
Icons.Default.Edit,
contentDescription = stringResource(Res.string.label_edit),
contentDescription = stringResource(Res.string.edit),
tint = MaterialTheme.colorScheme.primary
)
}

View File

@@ -33,6 +33,7 @@ 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.ui.LoadingScreen
import com.taskttl.core.utils.DateUtils
import com.taskttl.data.state.CountdownEffect
import com.taskttl.data.viewmodel.CountdownViewModel
@@ -41,9 +42,9 @@ 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_created_at
import taskttl.composeapp.generated.resources.label_days
import taskttl.composeapp.generated.resources.reminder
import taskttl.composeapp.generated.resources.title_countdown_info
@@ -225,7 +226,7 @@ fun CountdownDetailScreen(
Row(modifier = Modifier.fillMaxWidth()) {
InfoItem(
iconTint = Color(0xFF999999),
text = "${stringResource(Res.string.created_at)}${countdown.createdAt}",
text = "${stringResource(Res.string.label_created_at)}${countdown.createdAt}",
modifier = Modifier.fillMaxWidth()
)
}
@@ -233,6 +234,7 @@ fun CountdownDetailScreen(
}
}
}
if (state.isLoading) LoadingScreen()
}
}

View File

@@ -40,6 +40,8 @@ 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.ui.LoadingScreen
import com.taskttl.core.utils.ToastUtils
import com.taskttl.data.local.model.Category
import com.taskttl.data.local.model.Countdown
import com.taskttl.data.local.model.ReminderFrequency
@@ -59,10 +61,10 @@ 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 taskttl.composeapp.generated.resources.title_select_category
import kotlin.time.Clock
import kotlin.time.ExperimentalTime
import kotlin.uuid.ExperimentalUuidApi
@@ -75,6 +77,17 @@ fun CountdownEditScreen(
onNavigateBack: () -> Unit,
viewModel: CountdownViewModel = koinViewModel()
) {
LaunchedEffect(Unit) {
viewModel.effects.collect { effect ->
when (effect) {
is CountdownEffect.ShowMessage -> {
ToastUtils.show(effect.message)
}
else -> {}
}
}
}
LaunchedEffect(countdownId) {
countdownId?.let { viewModel.handleIntent(CountdownIntent.GetCountdownById(it)) }
}
@@ -178,7 +191,7 @@ fun CountdownEditScreen(
// 分类选择
Text(
text = stringResource(Res.string.label_select_category),
text = stringResource(Res.string.title_select_category),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium
)
@@ -270,5 +283,6 @@ fun CountdownEditScreen(
}
}
}
if (state.isLoading) LoadingScreen()
}
}

View File

@@ -57,8 +57,13 @@ 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.ui.ErrorDialog
import com.taskttl.core.ui.LoadingScreen
import com.taskttl.core.utils.DateUtils
import com.taskttl.core.utils.ToastUtils
import com.taskttl.data.local.model.Countdown
import com.taskttl.data.state.CategoryEffect
import com.taskttl.data.state.CategoryIntent
import com.taskttl.data.state.CountdownEffect
import com.taskttl.data.state.CountdownIntent
import com.taskttl.data.viewmodel.CountdownViewModel
@@ -72,7 +77,7 @@ 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.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
@@ -89,23 +94,21 @@ fun CountdownScreen(
LaunchedEffect(Unit) {
viewModel.effects.collect { effect ->
when (effect) {
is CountdownEffect.ShowMessage -> {
ToastUtils.show(effect.message)
}
is CountdownEffect.NavigateBack -> {
navController.popBackStack()
}
is CountdownEffect.NavigateToCountdownDetail -> {
// onNavigateToCountdownDetail(effect.countdownId)
}
else -> {}
}
}
}
state.error?.let { error ->
LaunchedEffect(error) {
viewModel.handleIntent(CountdownIntent.ClearError)
}
ErrorDialog(
errorMessage = state.error,
onDismiss = { viewModel.handleIntent(CountdownIntent.ClearError) }
)
}
Box(
@@ -217,6 +220,7 @@ fun CountdownScreen(
contentDescription = stringResource(Res.string.desc_add_countdown)
)
}
if (state.isLoading) LoadingScreen()
}
}
@@ -356,7 +360,7 @@ fun CountdownCard(
onDismissRequest = { showMoreMenu = false }
) {
DropdownMenuItem(
text = { Text(stringResource(Res.string.label_edit)) },
text = { Text(stringResource(Res.string.edit)) },
onClick = {
onEdit.invoke();
showMoreMenu = false

View File

@@ -51,6 +51,7 @@ 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_ktor
import taskttl.composeapp.generated.resources.tech_stack_mvi
import taskttl.composeapp.generated.resources.tech_stack_room
import taskttl.composeapp.generated.resources.title_about
@@ -165,6 +166,7 @@ fun AboutScreen(
TechStackItem("Jetpack Compose", Res.string.tech_stack_compose)
TechStackItem("Room Database", Res.string.tech_stack_room)
TechStackItem("Koin", Res.string.tech_stack_koin)
TechStackItem("Ktor", Res.string.tech_stack_ktor)
TechStackItem("MVI Architecture", Res.string.tech_stack_mvi)
}
}

View File

@@ -59,7 +59,7 @@ 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.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
@@ -275,7 +275,7 @@ private fun DataManagementCard(
Icon(
imageVector = Icons.Default.ChevronRight,
contentDescription = stringResource(Res.string.label_enter),
contentDescription = stringResource(Res.string.enter),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}

View File

@@ -28,6 +28,8 @@ import androidx.compose.material3.OutlinedButton
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
@@ -39,11 +41,20 @@ 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.core.ui.ErrorDialog
import com.taskttl.core.ui.LoadingScreen
import com.taskttl.core.utils.ToastUtils
import com.taskttl.data.network.domain.req.FeedbackReq
import com.taskttl.data.state.FeedbackEffect
import com.taskttl.data.state.FeedbackIntent
import com.taskttl.data.state.TaskIntent
import com.taskttl.data.viewmodel.FeedbackViewModel
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.button_cancel
import taskttl.composeapp.generated.resources.button_send_feedback
import taskttl.composeapp.generated.resources.cancel
import taskttl.composeapp.generated.resources.feedback_contact
import taskttl.composeapp.generated.resources.feedback_contact_placeholder
import taskttl.composeapp.generated.resources.feedback_description
@@ -55,12 +66,31 @@ import taskttl.composeapp.generated.resources.title_feedback
@Composable
fun FeedbackScreen(
onNavigateBack: () -> Unit,
onSubmit: () -> Unit
viewModel: FeedbackViewModel = koinViewModel()
) {
val state by viewModel.state.collectAsState()
LaunchedEffect(Unit) {
viewModel.effects.collect { effect ->
when (effect) {
is FeedbackEffect.NavigateBack -> onNavigateBack.invoke()
is FeedbackEffect.ShowMessage -> ToastUtils.show(effect.message)
}
}
}
var feedbackType by remember { mutableStateOf(FeedbackType.ISSUE) }
var contact by remember { mutableStateOf("") }
var description by remember { mutableStateOf("") }
state.error?.let { error ->
ErrorDialog(
errorMessage = state.error,
onDismiss = { viewModel.handleIntent(FeedbackIntent.ClearError) }
)
}
Box(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier.fillMaxSize()
@@ -71,7 +101,9 @@ fun FeedbackScreen(
onBackClick = { onNavigateBack.invoke() },
trailingIcon = Icons.AutoMirrored.Filled.Send,
onTrailingClick = {
onSubmit()
if (contact.isNotBlank()) {
submitFeedback(viewModel, feedbackType, contact, description)
}
}
)
@@ -167,7 +199,9 @@ fun FeedbackScreen(
) {
Button(
onClick = {
onSubmit()
if (contact.isNotBlank()) {
submitFeedback(viewModel, feedbackType, contact, description)
}
},
modifier = Modifier.weight(1f)
) {
@@ -177,13 +211,14 @@ fun FeedbackScreen(
onClick = { onNavigateBack.invoke() },
modifier = Modifier.weight(1f)
) {
Text(stringResource(Res.string.button_cancel))
Text(stringResource(Res.string.cancel))
}
}
Spacer(modifier = Modifier.height(32.dp))
}
}
if (state.isLoading) LoadingScreen()
}
}
@@ -213,3 +248,17 @@ private fun FeedbackTypeOption(
}
}
}
private fun submitFeedback(
viewModel: FeedbackViewModel,
type: FeedbackType,
contact: String,
description: String
) {
val feedback = FeedbackReq(
type = type.code,
contactInfo = contact,
contentBody = description.trim()
)
viewModel.handleIntent(FeedbackIntent.SubmitFeedback(feedback))
}

View File

@@ -1,23 +1,18 @@
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
import taskttl.composeapp.generated.resources.title_privacy
@Composable
fun PrivacyScreen(onNavigateBack: () -> Unit) {
@@ -26,7 +21,7 @@ fun PrivacyScreen(onNavigateBack: () -> Unit) {
modifier = Modifier.fillMaxSize()
) {
AppHeader(
title = Res.string.title_about,
title = Res.string.title_privacy,
showBack = true,
onBackClick = { onNavigateBack.invoke() }
)
@@ -34,10 +29,7 @@ fun PrivacyScreen(onNavigateBack: () -> Unit) {
Column(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFFF5F5F5))
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
.background(Color(0xFFF5F5F5)),
) {
DevTTLWebView(
modifier = Modifier.fillMaxSize(),

View File

@@ -44,6 +44,7 @@ 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.core.ui.LoadingScreen
import com.taskttl.data.local.model.Category
import com.taskttl.data.state.CountdownIntent
import com.taskttl.data.state.TaskIntent

View File

@@ -37,6 +37,7 @@ 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.LoadingScreen
import com.taskttl.data.state.TaskEffect
import com.taskttl.data.state.TaskIntent
import com.taskttl.data.viewmodel.TaskViewModel
@@ -228,5 +229,6 @@ fun TaskDetailScreen(
}
}
if (state.isLoading) LoadingScreen()
}
}

View File

@@ -38,6 +38,9 @@ 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.core.ui.LoadingScreen
import com.taskttl.core.utils.ToastUtils
import com.taskttl.data.local.model.Category
import com.taskttl.data.local.model.Task
import com.taskttl.data.local.model.TaskPriority
@@ -78,6 +81,18 @@ fun TaskEditorScreen(
viewModel: TaskViewModel = koinViewModel()
) {
LaunchedEffect(Unit) {
viewModel.effects.collect { effect ->
when (effect) {
is TaskEffect.ShowMessage -> {
ToastUtils.show(effect.message)
}
else -> {}
}
}
}
LaunchedEffect(taskId) {
taskId?.let { viewModel.handleIntent(TaskIntent.GetTaskById(it)) }
}
@@ -277,5 +292,6 @@ fun TaskEditorScreen(
)
}
}
if (state.isLoading) LoadingScreen()
}
}

View File

@@ -53,6 +53,9 @@ 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.core.ui.ErrorDialog
import com.taskttl.core.ui.LoadingScreen
import com.taskttl.core.utils.ToastUtils
import com.taskttl.data.local.model.Task
import com.taskttl.data.state.TaskEffect
import com.taskttl.data.state.TaskIntent
@@ -90,6 +93,10 @@ fun TaskScreen(
LaunchedEffect(Unit) {
viewModel.effects.collect { effect ->
when (effect) {
is TaskEffect.ShowMessage -> {
ToastUtils.show(effect.message)
}
is TaskEffect.NavigateToTaskDetail -> {
navController.navigate(Routes.Main.Task.TaskDetail(effect.taskId))
}
@@ -100,7 +107,10 @@ fun TaskScreen(
}
state.error?.let { error ->
LaunchedEffect(error) { viewModel.handleIntent(TaskIntent.ClearError) }
ErrorDialog(
errorMessage = state.error,
onDismiss = { viewModel.handleIntent(TaskIntent.ClearError) }
)
}
Box(
@@ -207,7 +217,7 @@ fun TaskScreen(
item { Spacer(Modifier) }
itemsIndexed(
state.filteredTasks,
key = { index, item -> item.title }) { index, task ->
key = { index, item -> item.id }) { index, task ->
var isOpen by remember { mutableStateOf(false) }
TaskCardItem(
@@ -248,6 +258,7 @@ fun TaskScreen(
contentDescription = stringResource(Res.string.title_add_task)
)
}
if (state.isLoading) LoadingScreen()
}
}

View File

@@ -0,0 +1,10 @@
package com.taskttl.core.network
import com.taskttl.core.utils.LogUtils
import io.ktor.client.plugins.logging.Logger
actual val defaultLogger: Logger = object : Logger {
override fun log(message: String) {
LogUtils.e("DevTTL_NetWork", message)
}
}

View File

@@ -1,5 +1,6 @@
package com.taskttl.core.utils
import io.ktor.client.plugins.logging.Logger
import platform.Foundation.NSLog
actual object LogUtils {
@@ -18,4 +19,4 @@ actual object LogUtils {
actual fun e(tag: String, message: String, throwable: Throwable?) {
NSLog("ERROR [$tag]: $message ${throwable?.message ?: ""}")
}
}
}

View File

@@ -17,4 +17,6 @@ kotlin.native.ignoreDisabledTargets=true
ksp.verbose=true
kotlin.kmp.eagerUnresolvedDependenciesDiagnostic=false
kotlin.kmp.unresolvedDependenciesDiagnostic=false
kotlin.kmp.unresolvedDependenciesDiagnostic=false
android.overridePathCheck=true

View File

@@ -7,7 +7,7 @@ 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"
composeHotReload = "1.0.0-rc02"
composeMultiplatform = "1.9.0"
junit = "4.13.2"
kotlin = "2.2.20"
@@ -15,27 +15,31 @@ kotlinx-coroutines = "1.10.2"
navigationCompose = "2.9.0"
koin = "4.1.1"
ktor = "3.3.1"
coil3 = "3.3.0"
kotlinx-datetime = "0.7.1"
icons = "1.7.3"
google = "4.4.3"
firebase = "34.3.0"
google = "4.4.4"
firebase = "34.4.0"
facebookAndroidSdkVersion = "18.1.3"
playServicesAds = "18.2.0"
kotlinx-serialization = "1.9.0"
mmkv = "2.2.4"
sqlite = "2.6.1"
room = "2.8.1"
room = "2.8.2"
ksp = "2.2.20-2.0.2"
# 环境
android-compileSdk = "36"
android-minSdk = "24"
android-targetSdk = "36"
android-versionCode = "100000"
android-versionName = "1.0.0"
android-facebookAppId = "1203530117944408"
android-facebookClientToken = "1ee2da9430c1a589e8aa623bfaaaa586"
@@ -63,9 +67,18 @@ 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" }
# ktor 网络请求
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" }
#implementation("io.ktor:ktor-client-cio:2.3.12") # 桌面
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor"}
# 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-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" }
@@ -87,7 +100,7 @@ android-facebook-android-sdk = { module = "com.facebook.android:facebook-android
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
# 安卓MMKV
android-mmkv = { module = "com.tencent:mmkv" ,version.ref = "mmkv"}
android-mmkv = { module = "com.tencent:mmkv", version.ref = "mmkv" }
# Room数据库
androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "sqlite" }
@@ -95,6 +108,8 @@ androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
androidx-room-sqlite-wrapper = { module = "androidx.room:room-sqlite-wrapper", version.ref = "room" }
# 谷歌Ads
android-play-services-ads-identifier = { module = "com.google.android.gms:play-services-ads-identifier", version.ref = "playServicesAds" }
[plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" }
@@ -103,7 +118,7 @@ composeHotReload = { id = "org.jetbrains.compose.hot-reload", version.ref = "com
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" }
jetbrains-kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin"}
gms-google = {id = "com.google.gms.google-services", version.ref = "google"}
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" }