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

@@ -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()
}
}