This commit is contained in:
2025-11-15 20:23:59 +08:00
parent e954d9d3ed
commit 837ac187e3
80 changed files with 1297 additions and 960 deletions

View File

@@ -52,6 +52,9 @@ kotlin {
implementation(compose.preview)
implementation(libs.androidx.activity.compose)
// 启动
implementation(libs.androidx.splashscreen)
// Koin依赖注入
implementation(libs.koin.android)
@@ -64,9 +67,6 @@ kotlin {
// facebook
implementation(libs.android.facebook.android.sdk)
// mmkv
implementation(libs.android.mmkv)
// sqlite
implementation(libs.androidx.room.sqlite.wrapper)
@@ -91,6 +91,7 @@ kotlin {
implementation(compose.components.resources)
implementation(compose.components.uiToolingPreview)
implementation(libs.androidx.lifecycle.viewmodel)
implementation(libs.androidx.lifecycle.savedstate)
implementation(libs.androidx.lifecycle.runtimeCompose)
@@ -127,6 +128,10 @@ kotlin {
// Room
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.sqlite.bundled)
// Settings
implementation(libs.multiplatform.settings)
implementation(libs.multiplatform.settings.no.arg)
}
iosMain.dependencies {
// ktor网络请求
@@ -138,6 +143,9 @@ kotlin {
jvmMain.dependencies {
implementation(compose.desktop.currentOs)
implementation(libs.kotlinx.coroutinesSwing)
// ktor网络请求
implementation(libs.ktor.client.java)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
@@ -180,6 +188,8 @@ android {
getByName("debug") {
isMinifyEnabled = false
applicationIdSuffix = ".debug"
buildConfigField("Boolean", "DEBUG", "true")
buildConfigField("Integer", "APP_ID", "999")
buildConfigField("Integer", "VERSION_CODE", libs.versions.android.versionCode.get())

View File

@@ -26,6 +26,7 @@
tools:targetApi="33">
<activity
android:name=".MainActivity"
android:theme="@style/Theme.Splash"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|mnc|colorMode|density|fontScale|fontWeightAdjustment|keyboard|layoutDirection|locale|mcc|navigation|smallestScreenSize|touchscreen|uiMode"
android:exported="true">
<intent-filter>
@@ -66,7 +67,7 @@
<!-- AlarmReceiver -->
<receiver
android:name=".core.alarm.AlarmReceiver"
android:name=".core.receiver.AlarmReceiver"
android:enabled="true"
android:exported="true"
>
@@ -77,7 +78,7 @@
</receiver>
<receiver
android:name=".core.alarm.BootReceiver"
android:name=".core.receiver.BootReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />

View File

@@ -1,29 +1,45 @@
package com.taskttl
import android.content.res.Configuration
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsControllerCompat
import com.taskttl.app.App
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
// ✅ Android 12+ 启动系统原生 Splash
val splashScreen = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
installSplashScreen()
} else null
// splashScreen?.apply {
// // setKeepOnScreenCondition { isChecking }
// }
super.onCreate(savedInstanceState)
enableEdgeToEdge()
// 设置全屏显示
WindowCompat.setDecorFitsSystemWindows(window, false)
setStatusBar()
setContent {
App()
}
}
/**
* 设置状态栏
*/
private fun setStatusBar() {
// 设置状态栏图标颜色
val controller = WindowInsetsControllerCompat(window, window.decorView)
// windowInsetsController.hide(WindowInsetsCompat.Type.statusBars() or WindowInsetsCompat.Type.navigationBars())
// windowInsetsController.systemBarsBehavior =
// WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
// windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
// 显示状态栏和导航栏
// controller.show(WindowInsetsCompat.Type.systemBars())
@@ -32,19 +48,9 @@ class MainActivity : ComponentActivity() {
// controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_DEFAULT
// 使用系统原生方法检测暗色主题
val isDarkTheme = resources.configuration.uiMode and
Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES
val isDarkTheme =
resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES
controller.isAppearanceLightStatusBars = isDarkTheme
controller.isAppearanceLightNavigationBars = isDarkTheme
setContent {
App()
}
}
}
@Preview
@Composable
fun AppAndroidPreview() {
App()
}

View File

@@ -6,7 +6,6 @@ import android.app.Application
import android.os.Bundle
import com.google.firebase.FirebaseApp
import com.taskttl.app.di.initKoin
import com.tencent.mmkv.MMKV
import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger
@@ -49,7 +48,6 @@ class MainApplication : Application() {
override fun onActivityDestroyed(a: Activity) {}
})
MMKV.initialize(this@MainApplication)
// 初始化 Firebase SDK
FirebaseApp.initializeApp(this@MainApplication)
// 初始化 Koin

View File

@@ -2,8 +2,14 @@ package com.taskttl.app.di
import com.taskttl.core.database.TaskTTLDatabase
import com.taskttl.core.database.getDatabaseBuilder
import com.taskttl.domain.repository.AuthRepository
import com.taskttl.domain.repository.AuthRepositoryImpl
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.bind
import org.koin.dsl.module
actual val serviceModule = module {
single<TaskTTLDatabase> { getDatabaseBuilder() }
singleOf(::AuthRepositoryImpl).bind(AuthRepository::class)
}

View File

@@ -5,6 +5,7 @@ import android.graphics.Bitmap
import android.view.ViewGroup
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.compose.foundation.layout.Box
@@ -38,7 +39,7 @@ import taskttl.composeapp.generated.resources.webview_loading_error
@OptIn(ExperimentalMaterial3Api::class)
@SuppressLint("SetJavaScriptEnabled")
@Composable
actual fun DevTTLWebView(modifier: Modifier, url: String) {
actual fun DevTTLWebView(modifier: Modifier, url: String, enableJavaScript: Boolean) {
// 状态管理
var isLoading by remember { mutableStateOf(true) }
var hasError by remember { mutableStateOf(false) }
@@ -47,8 +48,7 @@ actual fun DevTTLWebView(modifier: Modifier, url: String) {
Box(
modifier = Modifier
.fillMaxSize()
modifier = Modifier.fillMaxSize()
) {
val loadingError = stringResource(Res.string.webview_loading_error)
// 使用AndroidView加载WebView
@@ -62,15 +62,14 @@ actual fun DevTTLWebView(modifier: Modifier, url: String) {
// 配置WebView设置
settings.apply {
javaScriptEnabled = true
javaScriptEnabled = enableJavaScript
domStorageEnabled = true
loadWithOverviewMode = true
useWideViewPort = true
// 启用缓存
cacheMode = android.webkit.WebSettings.LOAD_DEFAULT
cacheMode = WebSettings.LOAD_DEFAULT
// 启用混合内容HTTP和HTTPS
mixedContentMode =
android.webkit.WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
}
@@ -78,17 +77,14 @@ actual fun DevTTLWebView(modifier: Modifier, url: String) {
override fun onPageStarted(
view: WebView?,
url: String?,
favicon: Bitmap?
favicon: Bitmap?,
) {
super.onPageStarted(view, url, favicon)
isLoading = true
hasError = false
}
override fun onPageFinished(
view: WebView?,
url: String?
) {
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
isLoading = false
}
@@ -97,7 +93,7 @@ actual fun DevTTLWebView(modifier: Modifier, url: String) {
override fun onReceivedError(
view: WebView?,
request: WebResourceRequest?,
error: WebResourceError?
error: WebResourceError?,
) {
super.onReceivedError(view, request, error)
if (request?.isForMainFrame == true) {

View File

@@ -1,14 +1,19 @@
package com.taskttl.core.notification
import android.Manifest
import android.annotation.SuppressLint
import android.app.AlarmManager
import android.app.Notification.VISIBILITY_PRIVATE
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.media.RingtoneManager
import android.os.Build
import androidx.annotation.RequiresPermission
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.PeriodicWorkRequestBuilder
@@ -16,7 +21,7 @@ import androidx.work.WorkManager
import androidx.work.workDataOf
import com.taskttl.MainApplication
import com.taskttl.R
import com.taskttl.core.alarm.AlarmReceiver
import com.taskttl.core.receiver.AlarmReceiver
import com.taskttl.core.utils.LogUtils
import com.taskttl.data.constant.Constant
import java.util.concurrent.TimeUnit
@@ -25,8 +30,8 @@ import kotlin.math.max
@SuppressLint("StaticFieldLeak")
actual object AppNotificationManager {
private val context: Context = MainApplication.instance.applicationContext
private val notificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
private val notificationManager = NotificationManagerCompat.from(context)
// context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
private const val ONE_DAY_MS = 24 * 60 * 60 * 1000L
@@ -38,7 +43,7 @@ actual object AppNotificationManager {
private fun setupNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
if (notificationManager.getNotificationChannel(Constant.CHANNEL_ID) == null) {
val channel = android.app.NotificationChannel(
val channel = NotificationChannel(
Constant.CHANNEL_ID,
"TaskTTL Notifications",
NotificationManager.IMPORTANCE_HIGH
@@ -53,6 +58,7 @@ actual object AppNotificationManager {
max(0, triggerTimeMillis - System.currentTimeMillis())
/** 调度通知,安全可后台 */
@RequiresPermission(Manifest.permission.POST_NOTIFICATIONS)
actual suspend fun scheduleNotification(payload: NotificationPayload) {
val delay = payload.delayMillis()
@@ -78,7 +84,12 @@ actual object AppNotificationManager {
}
/** AlarmManager 精确闹钟 */
/**
* AlarmManager 精确闹钟
*
* @param [context] 上下文
* @param [payload] 有效载荷
*/
fun scheduleExactAlarm(context: Context, payload: NotificationPayload) {
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !alarmManager.canScheduleExactAlarms()) {
@@ -99,7 +110,13 @@ actual object AppNotificationManager {
LogUtils.d("AlarmDebug", "Alarm set for id=${payload.id} time=${payload.triggerTimeMillis}")
}
/** 创建 Alarm PendingIntent */
/**
* 创建 Alarm PendingIntent
*
* @param [context] 上下文
* @param [payload] 有效载荷
* @return [PendingIntent]
*/
private fun createAlarmPendingIntent(
context: Context,
payload: NotificationPayload,
@@ -118,7 +135,12 @@ actual object AppNotificationManager {
)
}
/** WorkManager 长期/周期通知 */
/**
* WorkManager 长期/周期通知
*
* @param [context] 上下文
* @param [payload] 有效载荷
*/
private fun scheduleWorkManager(context: Context, payload: NotificationPayload) {
val workManager = WorkManager.getInstance(context)
val data = workDataOf(
@@ -157,20 +179,36 @@ actual object AppNotificationManager {
}
}
/** 立即显示通知 */
/**
* 立即显示通知
* 显示即时通知
* @param [context] 上下文
* @param [payload] 有效载荷
*/
@RequiresPermission(Manifest.permission.POST_NOTIFICATIONS)
fun showImmediateNotification(context: Context, payload: NotificationPayload) {
val notification = NotificationCompat.Builder(context, Constant.CHANNEL_ID)
.setContentTitle(payload.title)
.setContentText(payload.message)
.setSmallIcon(R.mipmap.ic_launcher)
.setSmallIcon(R.mipmap.ic_launcher_round)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE) // 隐私锁屏通知
.setPublicVersion(
NotificationCompat.Builder(context, Constant.CHANNEL_ID)
.setContentTitle(payload.title)
.setContentText(payload.message)
.setSmallIcon(R.mipmap.ic_launcher_round).build()
)
.setAutoCancel(true)
.setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION))
.build()
notificationManager.notify(payload.id.hashCode(), notification)
}
/** 取消通知 */
/**
* 取消通知
* @param [id] id
*/
actual suspend fun cancelNotification(id: String) {
val workManager = WorkManager.getInstance(context)
workManager.cancelAllWorkByTag(id)
@@ -186,7 +224,9 @@ actual object AppNotificationManager {
notificationManager.cancel(id.hashCode())
}
/** 取消所有通知 */
/**
* 取消所有通知
*/
actual suspend fun cancelAll() {
WorkManager.getInstance(context).cancelAllWork()
notificationManager.cancelAll()

View File

@@ -1,4 +1,4 @@
package com.taskttl.core.alarm
package com.taskttl.core.receiver
import android.content.BroadcastReceiver
import android.content.Context

View File

@@ -1,4 +1,4 @@
package com.taskttl.core.alarm
package com.taskttl.core.receiver
import android.content.BroadcastReceiver
import android.content.Context

View File

@@ -2,18 +2,18 @@ package com.taskttl.core.utils
import android.annotation.SuppressLint
import android.content.Context
import android.content.res.Configuration
import android.os.Build
import android.provider.Settings.Secure.ANDROID_ID
import android.provider.Settings.Secure.getString
import android.telephony.TelephonyManager
import com.google.android.gms.ads.identifier.AdvertisingIdClient
import com.taskttl.BuildConfig
import com.taskttl.MainApplication
import com.taskttl.core.domain.BaseReq
import com.taskttl.data.constant.Constant
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 {
@@ -21,43 +21,43 @@ 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
private const val REQUEST_TIMEOUT_MS = 2_000L
actual suspend fun getUniqueId(): String {
// 首先检查是否已经生成过唯一ID
val savedId = StorageUtils.getString(PREF_UNIQUE_ID)
val savedId = StorageUtils.getString(Constant.PREF_UNIQUE_ID)
// 如果已有保存的ID直接返回
if (savedId.isNotBlank()) {
return savedId
}
if (savedId.isNotBlank()) return savedId
// 检查网络状态如果没有网络直接生成本地ID
// if (!isNetworkAvailable()) {
// val localId = generateAppInstanceId()
// MMKVUtils.saveString(PREF_UNIQUE_ID, localId)
// MMKVUtils.saveString(Constant.PREF_UNIQUE_ID, localId)
// return localId
// }
// 尝试获取Google广告ID
val adId = try {
getGoogleAdId()
} catch (e: Exception) {
null
// 1) GAID/AAID
val gaId = getGaIdSafe()
if (isValidAdId(gaId)) return persistAndReturn(gaId!!)
// 2) ANDROID_ID
val ssaid = getAndroidIdSafe()
if (!ssaid.isNullOrBlank()) return persistAndReturn(ssaid)
// 3) UUID
val uuid = UUID.randomUUID().toString().replace("-", "")
return persistAndReturn(uuid)
}
val uniqueId = if (isValidAdId(adId)) {
adId.toString()
} else {
// 如果广告ID无效生成应用实例ID
generateAppInstanceId()
}
// 保存生成的ID
StorageUtils.saveString(PREF_UNIQUE_ID, uniqueId)
/**
* 持久化并返回
* @param [uniqueId] 唯一ID
* @return [String]
*/
private fun persistAndReturn(uniqueId: String): String {
StorageUtils.saveString(Constant.PREF_UNIQUE_ID, uniqueId)
return uniqueId
}
@@ -73,21 +73,22 @@ actual object DeviceUtils {
)
}
/**
* 获取谷歌广告id
* @return [String?]
*/
@SuppressLint("AdvertisingIdPolicy")
private suspend fun getGoogleAdId(): String? {
return withContext(Dispatchers.IO) {
private suspend fun getGaIdSafe(): String? = 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) {
} catch (_: Throwable) {
null
}
}
}
/**
* 验证是否是有效广告id
@@ -95,50 +96,27 @@ actual object DeviceUtils {
* @return [Boolean]
*/
private fun isValidAdId(adId: String?): Boolean {
return !adId.isNullOrBlank() && !adId.matches(Regex("^0+$")) && adId != "00000000-0000-0000-0000-000000000000"
if (adId.isNullOrBlank()) return false
if (adId == "00000000-0000-0000-0000-000000000000") return false
if (adId.matches(Regex("^0+$"))) return false
return true
}
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 {
/**
* 获取安卓ID
* @return [String]
*/
@SuppressLint("HardwareIds")
private fun getAndroidIdSafe(): String? {
// 优先 ANDROID_IDO+ 已是 app-scoped 且稳定)
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)
val androidId = getString(appContext.contentResolver, ANDROID_ID)
if (androidId.isNullOrBlank() || androidId == "9774d56d682e549c") null else androidId
} catch (_: Throwable) {
null
}
}
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()
}
fun getSimOrNetworkCountry(): String {
val tm = appContext.getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager
return when {

View File

@@ -1,80 +0,0 @@
package com.taskttl.core.utils
import com.tencent.mmkv.MMKV
import kotlinx.serialization.json.Json
actual object StorageUtils {
val mmkv: MMKV
get() = MMKV.defaultMMKV()
val json = Json {
ignoreUnknownKeys = true
isLenient = true
prettyPrint = true
}
actual fun saveString(key: String, value: String) {
mmkv.encode(key, value)
}
actual fun getString(key: String, defaultValue: String): String {
val value = mmkv.decodeString(key)
if (value.isNullOrEmpty()) {
mmkv.encode(key, defaultValue)
return defaultValue
}
return value
}
actual fun saveInt(key: String, value: Int) {
mmkv.encode(key, value)
}
actual fun getInt(key: String, defaultValue: Int): Int {
return mmkv.decodeInt(key, defaultValue)
}
actual fun saveLong(key: String, value: Long) {
mmkv.encode(key, value)
}
actual fun getLong(key: String, defaultValue: Long): Long {
return mmkv.decodeLong(key, defaultValue)
}
actual fun saveBoolean(key: String, value: Boolean) {
mmkv.encode(key, value)
}
actual fun getBoolean(key: String, defaultValue: Boolean): Boolean {
return mmkv.decodeBool(key, defaultValue)
}
actual inline fun <reified T : Any> saveObject(key: String, value: T) {
val data = json.encodeToString(value)
mmkv.encode(key, data)
}
actual inline fun <reified T : Any> getObject(key: String): T? {
val data = mmkv.decodeString(key) ?: return null
return try {
json.decodeFromString<T>(data)
} catch (e: Exception) {
null
}
}
actual fun contains(key: String): Boolean {
return mmkv.contains(key)
}
actual fun remove(key: String) {
mmkv.removeValueForKey(key)
}
actual fun clear() {
mmkv.clearAll()
}
}

View File

@@ -1,106 +0,0 @@
package com.taskttl.domain.repository
import android.credentials.GetCredentialException
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.credentials.CredentialManager
import androidx.credentials.CustomCredential
import androidx.credentials.GetCredentialRequest
import androidx.credentials.GetCredentialResponse
import com.facebook.login.LoginManager
import com.google.android.libraries.identity.googleid.GetSignInWithGoogleOption
import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
import com.google.android.libraries.identity.googleid.GoogleIdTokenParsingException
import com.taskttl.MainApplication
import com.taskttl.core.utils.JsonUtils
import com.taskttl.core.utils.LogUtils
import com.taskttl.data.source.remote.dto.response.AuthResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlin.coroutines.resume
actual class AuthRepository {
private val WEB_CLIENT_ID =
"649192447921-rtn0jklurc7cr4oalh9gh3684mnlklce.apps.googleusercontent.com"
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
actual suspend fun loginWithGoogle(): AuthResult = withContext(Dispatchers.Main) {
val activity = MainApplication.currentActivity()
?: return@withContext AuthResult.Error("未找到当前活动实例")
return@withContext try {
val credentialManager = CredentialManager.create(activity)
val signInWithGoogleOption =
GetSignInWithGoogleOption.Builder(WEB_CLIENT_ID).build()
val request = GetCredentialRequest.Builder()
.addCredentialOption(signInWithGoogleOption)
.build()
val response = credentialManager.getCredential(context = activity, request = request)
handleSignInWithGoogleOption(response)
// 提取 token 信息
val googleIdCredential = response.credential
val token = googleIdCredential.data.getString("idToken") ?: ""
val userId = googleIdCredential.data.getString("id") ?: ""
AuthResult.Success(userId = userId, token = token)
} catch (e: GetCredentialException) {
e.message?.let { LogUtils.e("DevTTL", it) }
AuthResult.Error("获取凭据失败: ${e.message}")
} catch (e: Exception) {
e.message?.let { LogUtils.e("DevTTL", it) }
AuthResult.Error("Google 登录失败: ${e.message}")
}
}
actual suspend fun loginWithFacebook(): AuthResult = suspendCancellableCoroutine { cont ->
val activity = MainApplication.currentActivity() ?: return@suspendCancellableCoroutine
try {
LoginManager.getInstance().logInWithReadPermissions(activity, listOf("email"))
// TODO: handle Facebook callback
cont.resume(AuthResult.Success("demo_user", "facebook_token"))
} catch (e: Exception) {
cont.resume(AuthResult.Error(e.message ?: "Facebook 登录失败"))
}
}
/**
* 使用谷歌选项处理登录
* @param [result] 结果
*/
fun handleSignInWithGoogleOption(result: GetCredentialResponse) {
val credential = result.credential
when (credential) {
is CustomCredential -> {
if (credential.type == GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL) {
try {
val googleIdTokenCredential =
GoogleIdTokenCredential.createFrom(credential.data)
LogUtils.w(
"DevTTL",
JsonUtils.default.encodeToString(googleIdTokenCredential)
)
} catch (e: GoogleIdTokenParsingException) {
LogUtils.e("DevTTL", "Received an invalid google id token response", e)
}
} else {
// Catch any unrecognized credential type here.
LogUtils.e("DevTTL", "Unexpected type of credential")
}
}
else -> {
// Catch any unrecognized credential type here.
LogUtils.e("DevTTL", "Unexpected type of credential")
}
}
}
}

View File

@@ -0,0 +1,92 @@
package com.taskttl.domain.repository
import android.credentials.GetCredentialException
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.credentials.CredentialManager
import androidx.credentials.CustomCredential
import androidx.credentials.GetCredentialRequest
import com.facebook.login.LoginManager
import com.google.android.libraries.identity.googleid.GetSignInWithGoogleOption
import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
import com.taskttl.MainApplication
import com.taskttl.core.utils.LogUtils
import com.taskttl.data.constant.Constant
import com.taskttl.data.constant.ProviderEnum
import com.taskttl.data.source.remote.dto.response.Account
import com.taskttl.data.source.remote.dto.response.AuthResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlin.coroutines.resume
/**
* 身份验证存储库impl
* @author DevTTL
* @date 2025/10/30
* @constructor 创建[AuthRepositoryImpl]
*/
class AuthRepositoryImpl() : AuthRepository {
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
override suspend fun loginWithGoogle(): AuthResult = withContext(Dispatchers.Main) {
val activity = MainApplication.currentActivity()
?: return@withContext AuthResult.Error("Context is null")
val googleIdOption = GetSignInWithGoogleOption
.Builder(Constant.WEB_CLIENT_ID)
.build()
val request: GetCredentialRequest = GetCredentialRequest
.Builder()
.addCredentialOption(googleIdOption)
.build()
val credentialManager = CredentialManager.create(activity)
return@withContext try {
val result = credentialManager.getCredential(context = activity, request = request)
val credential = result.credential
val isGoogleCred =
credential is CustomCredential &&
credential.type == GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL
if (isGoogleCred) {
val tokenResult = GoogleIdTokenCredential.createFrom(credential.data)
val account =
Account(
id = tokenResult.id,
idToken = tokenResult.idToken,
provider = ProviderEnum.GOOGLE
)
AuthResult.Success(account)
} else {
AuthResult.Canceled
}
} catch (e: GetCredentialException) {
e.message?.let { LogUtils.e("DevTTL", it) }
AuthResult.Error("获取凭据失败: ${e.message}")
} catch (e: Exception) {
e.message?.let { LogUtils.e("DevTTL", it) }
AuthResult.Error("Google 登录失败: ${e.message}")
}
}
override suspend fun loginWithFacebook(): AuthResult = suspendCancellableCoroutine { cont ->
val activity = MainApplication.currentActivity() ?: return@suspendCancellableCoroutine
try {
LoginManager.getInstance().logInWithReadPermissions(activity, listOf("email"))
// TODO: handle Facebook callback
val account = Account(
id = "",
idToken = "demo_user",
provider = ProviderEnum.FACEBOOK,
)
cont.resume(AuthResult.Success(account))
} catch (e: Exception) {
cont.resume(AuthResult.Error(e.message ?: "Facebook 登录失败"))
}
}
}

View File

@@ -0,0 +1,16 @@
package com.taskttl.presentation.common.foundation
// 获取磁盘缓存路径
import android.content.Context
import com.taskttl.MainApplication
import okio.Path
import okio.Path.Companion.toOkioPath
import java.io.File
/**
* 获取磁盘缓存路径
* @return [Path?]
*/
actual fun provideDiskCachePath(): Path? {
val context: Context = MainApplication.instance.applicationContext
return File(context.cacheDir, "coil_disk_cache").toOkioPath()
}

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.SplashScreenBase" parent="Theme.SplashScreen">
<!-- ##必填##在Splash结束后要套用的主题 请将原本Activtiy设定的Theme放在这边-->
<item name="postSplashScreenTheme">@android:style/Theme.Material.Light.NoActionBar</item>
<!-- 选填 Splash画面的背景颜色 -->
<item name="windowSplashScreenBackground">#FF5B6FF8</item>
<!-- 选填 显示在Splash画面上的Icon 可以用 一般的Icon、AnimationDrawable、AnimatedVectorDrawable -->
<item name="windowSplashScreenAnimatedIcon">@mipmap/ic_launcher_round</item>
<!-- 选填 如果你的Icon是有动画的可以透过这个来改完整的动画时间 -->
<item name="android:windowSplashScreenAnimationDuration">1000</item>
<!-- 选填 Icon圆圈圈后面的背景色 如果你的Icon与原背景太过相近可以透过这个调背景色 -->
<item name="android:windowSplashScreenIconBackgroundColor">#FF8B5FBF</item>
<!-- 选填 显示在Splash画面上底部的Icon -->
<item name="android:windowSplashScreenBrandingImage">@mipmap/ic_launcher_round</item>
</style>
</resources>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Splash" parent="Theme.SplashScreen">
<!-- ##必填##在Splash结束后要套用的主题 请将原本Activtiy设定的Theme放在这边-->
<item name="postSplashScreenTheme">@android:style/Theme.Material.Light.NoActionBar</item>
<!-- 选填 Splash画面的背景颜色 -->
<item name="windowSplashScreenBackground">#FF5B6FF8</item>
<item name="windowSplashScreenIconBackgroundColor">#FF5B6FF8</item>
<!-- 选填 显示在Splash画面上的Icon 可以用 一般的Icon、AnimationDrawable、AnimatedVectorDrawable -->
<item name="windowSplashScreenAnimatedIcon">@mipmap/ic_launcher_round</item>
</style>
</resources>

View File

@@ -6,17 +6,17 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import com.taskttl.core.manager.ThemeMode
import com.taskttl.navigation.AppNav
import com.taskttl.presentation.common.foundation.GlobalImageLoader
import com.taskttl.ui.theme.AppTheme
import org.jetbrains.compose.ui.tooling.preview.Preview
import org.koin.compose.viewmodel.koinViewModel
/**
* 应用
*/
@Preview
@Composable
fun App(viewModel: AppViewModel = koinViewModel()) {
GlobalImageLoader()
val state by viewModel.state.collectAsState()

View File

@@ -3,6 +3,16 @@ package com.taskttl.app
import com.taskttl.core.base.BaseState
import com.taskttl.core.manager.ThemeMode
/**
* 应用程序状态
* @author DevTTL
* @date 2025/10/30
* @constructor 创建[AppState]
* @param [isLoading] 正在加载
* @param [isProcessing] 正在处理
* @param [error] 错误
* @param [themeMode] 主题模式
*/
data class AppState(
override val isLoading: Boolean = false,
override val isProcessing: Boolean = false,
@@ -18,6 +28,13 @@ data class AppState(
*/
sealed class AppIntent {
/**
* 加载应用
* @author DevTTL
* @date 2025/11/07
*/
object LoadApp: AppIntent()
/**
* 加载主题
* @author DevTTL
@@ -26,4 +43,10 @@ sealed class AppIntent {
object LoadTheme : AppIntent()
}
/**
* app效果
* @author DevTTL
* @date 2025/10/30
* @constructor 创建[AppEffect]
*/
sealed class AppEffect {}

View File

@@ -1,8 +1,14 @@
package com.taskttl.app
import androidx.compose.runtime.Stable
import androidx.lifecycle.viewModelScope
import com.taskttl.core.base.BaseViewModel
import com.taskttl.core.domain.constant.PointEvent
import com.taskttl.core.manager.ThemeManager
import com.taskttl.core.utils.StorageUtils
import com.taskttl.data.constant.Constant
import com.taskttl.data.source.remote.api.TaskTTLApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
/**
@@ -12,6 +18,7 @@ import kotlinx.coroutines.launch
* @constructor 创建[AppViewModel]
* @param [themeManager] 主题管理器
*/
@Stable
class AppViewModel(private val themeManager: ThemeManager) :
BaseViewModel<AppState, AppIntent, AppEffect>(AppState()) {
@@ -19,15 +26,28 @@ class AppViewModel(private val themeManager: ThemeManager) :
viewModelScope.launch {
themeManager.themeMode.collect { mode -> updateState { copy(themeMode = mode) } }
}
// handleIntent(AppIntent.LoadTheme)
handleIntent(AppIntent.LoadApp)
}
public override fun handleIntent(intent: AppIntent) {
when (intent) {
is AppIntent.LoadApp -> loadApp()
is AppIntent.LoadTheme -> loadTheme()
}
}
/**
* 加载应用
*/
private fun loadApp() {
viewModelScope.launch(Dispatchers.Default) {
val uniqueId = StorageUtils.getString(Constant.PREF_UNIQUE_ID)
if (!uniqueId.isNotBlank()) {
TaskTTLApi.postPoint(PointEvent.AppLaunch)
}
}
}
private fun loadTheme() {
viewModelScope.launch {
updateState { copy(themeMode = themeManager.themeMode.value) }

View File

@@ -9,11 +9,11 @@ import com.taskttl.data.mapper.TaskMapper
import com.taskttl.data.repository.CategoryRepositoryImpl
import com.taskttl.data.repository.CountdownRepositoryImpl
import com.taskttl.data.repository.TaskRepositoryImpl
import com.taskttl.domain.repository.AuthRepository
import com.taskttl.domain.repository.CategoryRepository
import com.taskttl.domain.repository.CountdownRepository
import com.taskttl.domain.repository.TaskRepository
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.bind
import org.koin.dsl.module
@@ -29,9 +29,7 @@ val dataModule = module {
single { CountdownMapper() }
single { CategoryMapper() }
single<TaskRepository> { TaskRepositoryImpl(get(), get()) }
single<CountdownRepository> { CountdownRepositoryImpl(get(), get()) }
single<CategoryRepository> { CategoryRepositoryImpl(get(), get(), get(), get()) }
singleOf(::AuthRepository)
singleOf(::TaskRepositoryImpl).bind(TaskRepository::class)
singleOf(::CountdownRepositoryImpl).bind(CountdownRepository::class)
singleOf(::CategoryRepositoryImpl).bind(CategoryRepository::class)
}

View File

@@ -28,7 +28,9 @@ import org.koin.dsl.module
*/
fun initKoin(config: (KoinApplication.() -> Unit)? = null) {
startKoin {
// 先执行用户自定义配置
config?.invoke(this)
// 再加载模块
modules(repositoryModule, viewModelModule, serviceModule, dataModule)
}
}

View File

@@ -9,4 +9,4 @@ import androidx.compose.ui.Modifier
* @param [url] 网址
*/
@Composable
expect fun DevTTLWebView(modifier: Modifier, url: String)
expect fun DevTTLWebView(modifier: Modifier, url: String,enableJavaScript: Boolean = true)

View File

@@ -27,7 +27,6 @@ class ThemeManager() {
private fun loadThemeFromPreferences() {
val modeString = StorageUtils.getString(Constant.KEY_DARK_MODE, ThemeMode.SYSTEM.name)
_themeMode.value = ThemeMode.fromName(modeString)
}
/**

View File

@@ -10,6 +10,12 @@ object ApiConfig {
// const val BASE_URL = "http://10.0.0.5:8888/api/v1"
const val BASE_URL = "https://api.taskttl.com/api/v1"
/** 登录地址 */
const val LOGIN_URL = "$BASE_URL/login"
/** 三方登录地址 */
const val THIRD_PARTY_LOGIN_URL = "$BASE_URL/thirdPartyLogin"
/** 反馈地址 */
const val FEEDBACK_URL = "$BASE_URL/feedback"

View File

@@ -1,17 +1,26 @@
package com.taskttl.core.utils
import com.russhwolf.settings.Settings
import com.russhwolf.settings.contains
import com.russhwolf.settings.get
import com.russhwolf.settings.set
/**
* 存储工具
* @author admin
* @date 2025/08/11
*/
expect object StorageUtils {
object StorageUtils {
val settings: Settings = Settings()
/**
* 保存字符串值
* @param key 键
* @param value 值
*/
fun saveString(key: String, value: String)
fun saveString(key: String, value: String) {
settings.putString(key,value)
}
/**
* 获取字符串值
@@ -19,14 +28,18 @@ expect object StorageUtils {
* @param defaultValue 默认值
* @return 存储的字符串值或默认值
*/
fun getString(key: String, defaultValue: String = ""): String
fun getString(key: String, defaultValue: String = ""): String {
return settings.getString(key,defaultValue)
}
/**
* 保存整数值
* @param key 键
* @param value 值
*/
fun saveInt(key: String, value: Int)
fun saveInt(key: String, value: Int) {
settings.putInt(key,value)
}
/**
* 获取整数值
@@ -34,7 +47,9 @@ expect object StorageUtils {
* @param defaultValue 默认值
* @return 存储的整数值或默认值
*/
fun getInt(key: String, defaultValue: Int = 0): Int
fun getInt(key: String, defaultValue: Int = 0): Int {
return settings.getInt(key,defaultValue)
}
/**
@@ -42,7 +57,9 @@ expect object StorageUtils {
* @param [key] 键
* @param [value] 值
*/
fun saveLong(key: String, value: Long)
fun saveLong(key: String, value: Long) {
settings.putLong(key,value)
}
/**
* 获取长整数值
@@ -50,14 +67,18 @@ expect object StorageUtils {
* @param defaultValue 默认值
* @return 存储的长整数值或默认值
*/
fun getLong(key: String, defaultValue: Long): Long
fun getLong(key: String, defaultValue: Long): Long {
return settings.getLong(key,defaultValue)
}
/**
* 保存布尔值
* @param key 键
* @param value 值
*/
fun saveBoolean(key: String, value: Boolean)
fun saveBoolean(key: String, value: Boolean) {
settings.putBoolean(key,value)
}
/**
* 获取布尔值
@@ -65,37 +86,49 @@ expect object StorageUtils {
* @param defaultValue 默认值
* @return 存储的布尔值或默认值
*/
fun getBoolean(key: String, defaultValue: Boolean = false): Boolean
fun getBoolean(key: String, defaultValue: Boolean = false): Boolean {
return settings.getBoolean(key,defaultValue)
}
/**
* 保存对象
* @param key 键
* @param value 对象
*/
inline fun <reified T : Any> saveObject(key: String, value: T)
inline fun <reified T : Any> saveObject(key: String, value: T) {
settings[key] = value
}
/**
* 获取对象
* @param key 键
* @return 存储的对象或null
*/
inline fun <reified T : Any> getObject(key: String): T?
inline fun <reified T : Any> getObject(key: String): T? {
return settings[key]
}
/**
* 包含
* @param [key] 钥匙
* @return [Boolean]
*/
fun contains(key: String): Boolean
fun contains(key: String): Boolean {
return settings.contains(key)
}
/**
* 删除键值对
* @param key 键
*/
fun remove(key: String)
fun remove(key: String) {
settings.remove(key)
}
/**
* 清除所有数据
*/
fun clear()
fun clear() {
settings.clear()
}
}

View File

@@ -6,9 +6,15 @@ package com.taskttl.data.constant
* @date 2025/10/24
*/
object Constant {
/** 唯一ID */
const val PREF_UNIQUE_ID = "PREF_UNIQUE_ID"
/** 频道id */
const val CHANNEL_ID = "taskttl_channel"
/** 按键暗模式 */
const val KEY_DARK_MODE = "dark_mode"
const val WEB_CLIENT_ID =
"649192447921-rtn0jklurc7cr4oalh9gh3684mnlklce.apps.googleusercontent.com"
}

View File

@@ -0,0 +1,14 @@
package com.taskttl.data.constant
/**
* 提供者枚举
* @author DevTTL
* @date 2025/11/13
* @constructor 创建[ProviderEnum]
*/
enum class ProviderEnum {
GOOGLE,
FACEBOOK,
APPLE,
GITHUB
}

View File

@@ -7,7 +7,10 @@ import com.taskttl.core.network.KtorClient
import com.taskttl.core.utils.DeviceUtils
import com.taskttl.core.utils.LogUtils
import com.taskttl.data.source.remote.dto.request.FeedbackReq
import com.taskttl.data.source.remote.dto.request.LoginReq
import com.taskttl.data.source.remote.dto.request.PointReq
import com.taskttl.data.source.remote.dto.response.Account
import com.taskttl.data.source.remote.dto.response.AuthResult
import com.taskttl.data.source.remote.dto.response.FeedbackResp
import org.jetbrains.compose.resources.getString
import taskttl.composeapp.generated.resources.Res
@@ -15,6 +18,20 @@ import taskttl.composeapp.generated.resources.feedback_error
object TaskTTLApi {
/**
* 第三方登录
* @param [account] 账户
* @return [FeedbackResp]
*/
suspend fun thirdPartyLogin(account: Account): FeedbackResp {
try {
val loginReq = LoginReq(account.id,account.idToken,account.provider.name)
return KtorClient.postJson(ApiConfig.THIRD_PARTY_LOGIN_URL,loginReq.toJson())
} catch (_: Exception) {
throw Exception(getString(Res.string.feedback_error))
}
}
/**
* 发布反馈
* @param [feedbackReq] 反馈请求
@@ -25,7 +42,6 @@ object TaskTTLApi {
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))
}
}

View File

@@ -0,0 +1,24 @@
package com.taskttl.data.source.remote.dto.request
import com.taskttl.core.domain.BaseReqWith
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* 登录请求
* @author DevTTL
* @date 2025/11/13
* @constructor 创建[LoginReq]
* @param [id] id
* @param [idToken] id令牌
* @param [provider] 提供者
*/
@Serializable
data class LoginReq(
@SerialName("id")
val id: String,
@SerialName("idToken")
val idToken: String,
@SerialName("provider")
val provider: String,
) : BaseReqWith()

View File

@@ -1,7 +1,26 @@
package com.taskttl.data.source.remote.dto.response
import com.taskttl.data.constant.ProviderEnum
sealed class AuthResult {
data class Success(val userId: String, val token: String) : AuthResult()
data class Error(val message: String) : AuthResult()
data class Success(val account: Account) : AuthResult()
data class Error(val message: String?) : AuthResult()
data object Canceled : AuthResult()
}
/**
* 账户
* @author DevTTL
* @date 2025/11/13
* @constructor 创建[Account]
* @param [id] id
* @param [idToken] id令牌
* @param [provider] 提供者
*/
data class Account(
val id: String,
val idToken: String,
val provider: ProviderEnum,
) {
val isEmpty: Boolean = idToken.isEmpty() || id.isEmpty()
}

View File

@@ -2,7 +2,23 @@ package com.taskttl.domain.repository
import com.taskttl.data.source.remote.dto.response.AuthResult
expect class AuthRepository() {
/**
* 身份验证存储库
* @author DevTTL
* @date 2025/10/30
* @constructor 创建[AuthRepository]
*/
interface AuthRepository {
/**
* 使用谷歌登录
* @return [AuthResult]
*/
suspend fun loginWithGoogle(): AuthResult
/**
* 使用脸书登录
* @return [AuthResult]
*/
suspend fun loginWithFacebook(): AuthResult
}

View File

@@ -1,5 +1,5 @@
package com.taskttl.navigation
// 应用导航
import androidx.compose.runtime.Composable
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable

View File

@@ -1,5 +1,5 @@
package com.taskttl.navigation
// 主导航
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
@@ -88,7 +88,6 @@ fun MainNav() {
navController = mainNavController,
startDestination = Routes.Main.Task
) {
// 任务
composable<Routes.Main.Task> {
TaskScreen(navController = mainNavController)
}
@@ -114,7 +113,6 @@ fun MainNav() {
)
}
// 倒数日
composable<Routes.Main.Countdown> {
CountdownScreen(navController = mainNavController)
}
@@ -147,11 +145,9 @@ fun MainNav() {
StatisticsScreen(navController = mainNavController)
}
// 设置界面
composable<Routes.Main.Settings> {
SettingsScreen(navController = mainNavController)
}
// 分类管理
composable<Routes.Main.Settings.CategoryManagement> { backStackEntry ->
CategoryScreen(
navController = mainNavController,
@@ -159,14 +155,12 @@ fun MainNav() {
onNavigateBack = { mainNavController.popBackStack() }
)
}
// 添加分类
composable<Routes.Main.Settings.AddCategory> {
CategoryEditScreen(
categoryId = null,
onNavigateBack = { mainNavController.popBackStack() }
)
}
// 编辑分类
composable<Routes.Main.Settings.EditCategory> { backStackEntry ->
val editCategory: Routes.Main.Settings.EditCategory = backStackEntry.toRoute()
CategoryEditScreen(
@@ -174,28 +168,23 @@ fun MainNav() {
onNavigateBack = { mainNavController.popBackStack() }
)
}
// 数据管理
composable<Routes.Main.Settings.DataManagement> {
DataManagementScreen(onNavigateBack = { mainNavController.popBackStack() })
}
// 反馈页面
composable<Routes.Main.Settings.Feedback> {
FeedbackScreen(onNavigateBack = { mainNavController.popBackStack() })
}
// 隐私
composable<Routes.Main.Settings.Privacy> {
PrivacyScreen(
onNavigateBack = { mainNavController.popBackStack() }
)
}
// 关于页面
composable<Routes.Main.Settings.About> {
AboutScreen(
onNavigateBack = { mainNavController.popBackStack() }
)
}
// 登录页面
composable<Routes.Main.Settings.Login> {
LoginScreen(
onNavigateBack = { mainNavController.popBackStack() }

View File

@@ -3,7 +3,7 @@ package com.taskttl.navigation
import kotlinx.serialization.Serializable
/**
* 路线
* 路
* @author DevTTL
* @date 2025/06/25
* @constructor 创建[Routes]
@@ -15,6 +15,11 @@ sealed interface Routes {
data object Splash : Routes
/**
* 引导
* @author DevTTL
* @date 2025/11/07
*/
@Serializable
data object Onboarding : Routes
@@ -22,62 +27,160 @@ sealed interface Routes {
@Serializable
data object Main : Routes {
/**
* 任务
* @author DevTTL
* @date 2025/11/07
*/
@Serializable
data object Task : Routes {
/**
* 添加任务
* @author DevTTL
* @date 2025/11/07
*/
@Serializable
data object AddTask : Routes
/**
* 编辑任务
* @author DevTTL
* @date 2025/11/07
* @constructor 创建[EditTask]
* @param [taskId] 任务id
*/
@Serializable
data class EditTask(val taskId: String) : Routes
/**
* 任务详情
* @author DevTTL
* @date 2025/11/07
* @constructor 创建[TaskDetail]
* @param [taskId] 任务id
*/
@Serializable
data class TaskDetail(val taskId: String) : Routes
}
/**
* 倒计时
* @author DevTTL
* @date 2025/11/07
*/
@Serializable
data object Countdown : Routes {
/**
* 添加倒计时
* @author DevTTL
* @date 2025/11/07
*/
@Serializable
data object AddCountdown : Routes
/**
* 编辑倒计时
* @author DevTTL
* @date 2025/11/07
* @constructor 创建[EditCountdown]
* @param [countdownId] 倒计时id
*/
@Serializable
data class EditCountdown(val countdownId: String) : Routes
/**
* 倒计时详情
* @author DevTTL
* @date 2025/11/07
* @constructor 创建[CountdownDetail]
* @param [countdownId] 倒计时id
*/
@Serializable
data class CountdownDetail(val countdownId: String) : Routes
}
/**
* 统计
* @author DevTTL
* @date 2025/11/07
*/
@Serializable
data object Statistics : Routes
/**
* 设置
* @author DevTTL
* @date 2025/11/07
*/
@Serializable
data object Settings : Routes {
/**
* 类别管理
* @author DevTTL
* @date 2025/11/07
*/
@Serializable
data object CategoryManagement : Routes
/**
* 添加类别
* @author DevTTL
* @date 2025/11/07
*/
@Serializable
data object AddCategory : Routes
/**
* 编辑类别
* @author DevTTL
* @date 2025/11/07
* @constructor 创建[EditCategory]
* @param [categoryId] 类别id
*/
@Serializable
data class EditCategory(val categoryId: String) : Routes
/**
* 数据管理
* @author DevTTL
* @date 2025/11/07
*/
@Serializable
data object DataManagement : Routes
/**
* 反馈
* @author DevTTL
* @date 2025/11/07
*/
@Serializable
data object Feedback : Routes
/**
* 隐私
* @author DevTTL
* @date 2025/11/07
*/
@Serializable
data object Privacy : Routes
/**
* 关于
* @author DevTTL
* @date 2025/11/07
*/
@Serializable
data object About : Routes
/**
* 登录
* @author DevTTL
* @date 2025/11/07
*/
@Serializable
data object Login : Routes
}
}
}

View File

@@ -37,10 +37,8 @@ import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.jetbrains.compose.ui.tooling.preview.Preview
import taskttl.composeapp.generated.resources.Res
import taskttl.composeapp.generated.resources.loading
@@ -118,14 +116,3 @@ private fun RotatingGradientRing(
)
}
}
@Preview
@Composable
private fun PreviewTaskTTLLoading() {
var show by remember { mutableStateOf(true) }
LaunchedEffect(Unit) {
delay(3000)
show = false
}
LoadingOverlay(visible = show)
}

View File

@@ -0,0 +1,37 @@
package com.taskttl.presentation.common.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import coil3.compose.AsyncImage
import coil3.request.CachePolicy
import coil3.request.ImageRequest
import coil3.request.crossfade
import org.jetbrains.compose.resources.painterResource
import taskttl.composeapp.generated.resources.Res
import taskttl.composeapp.generated.resources.ic_launcher
@Composable
fun NetworkImage(
url: String,
contentDescription: String? = null,
modifier: Modifier = Modifier,
contentScale: ContentScale = ContentScale.Crop,
) {
val context = coil3.compose.LocalPlatformContext.current
AsyncImage(
modifier = modifier,
model = ImageRequest.Builder(context)
.data(url)
// 缓存策略:启用内存 + 磁盘缓存
.memoryCachePolicy(CachePolicy.ENABLED)
.diskCachePolicy(CachePolicy.ENABLED)
// 可选:淡入动画
.crossfade(true)
.build(),
contentDescription = contentDescription,
contentScale = contentScale,
placeholder = painterResource(Res.drawable.ic_launcher),
error = painterResource(Res.drawable.ic_launcher)
)
}

View File

@@ -0,0 +1,50 @@
package com.taskttl.presentation.common.foundation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import coil3.ImageLoader
import coil3.SingletonImageLoader
import coil3.compose.LocalPlatformContext
import coil3.disk.DiskCache
import coil3.memory.MemoryCache
import coil3.request.crossfade
import okio.Path
/**
* 全局图像加载器
*/
@Composable
fun GlobalImageLoader() {
val platformContext = LocalPlatformContext.current
remember(platformContext) {
val imageLoader = ImageLoader.Builder(platformContext)
// 内存缓存
.memoryCache {
MemoryCache.Builder()
.maxSizePercent(platformContext, 0.25) // 占总内存25%
.build()
}
// 磁盘缓存(如果当前平台支持)
.diskCache {
provideDiskCachePath()?.let { path ->
DiskCache.Builder()
.directory(path)
.maxSizePercent(0.02)
// .maxSizeBytes(128L * 1024 * 1024) // 128MB
.build()
}
}
// 淡入动画
.crossfade(true)
.build()
// 设置为全局单例
SingletonImageLoader.setSafe { imageLoader }
}
}
/**
* 获取磁盘缓存路径
* @return [Path?]
*/
expect fun provideDiskCachePath(): Path?

View File

@@ -1,6 +1,7 @@
package com.taskttl.presentation.features.auth
import com.taskttl.core.base.BaseState
import com.taskttl.presentation.features.task.list.TaskEffect
/**
@@ -39,6 +40,10 @@ sealed class AuthIntent {
* @date 2025/10/26
*/
object LoginWithFacebook: AuthIntent()
object ClearError: AuthIntent()
object Logout : AuthIntent()
}
/**
@@ -47,4 +52,13 @@ sealed class AuthIntent {
* @date 2025/10/26
* @constructor 创建[AuthEffect]
*/
sealed class AuthEffect {}
sealed class AuthEffect {
/**
* 显示消息
* @author admin
* @date 2025/09/27
* @constructor 创建[ShowMessage]
* @param [message] 消息
*/
data class ShowMessage(val message: String) : AuthEffect()
}

View File

@@ -1,7 +1,11 @@
package com.taskttl.presentation.features.auth
import androidx.compose.runtime.Stable
import androidx.lifecycle.viewModelScope
import com.taskttl.core.base.BaseViewModel
import com.taskttl.core.utils.LogUtils
import com.taskttl.data.source.remote.api.TaskTTLApi
import com.taskttl.data.source.remote.dto.response.AuthResult
import com.taskttl.domain.repository.AuthRepository
import kotlinx.coroutines.launch
@@ -11,27 +15,76 @@ import kotlinx.coroutines.launch
* @date 2025/10/26
* @constructor 创建[AuthViewModel]
*/
@Stable
class AuthViewModel(private val authRepository: AuthRepository) :
BaseViewModel<AuthState, AuthIntent, AuthEffect>(AuthState()) {
public override fun handleIntent(intent: AuthIntent) {
when (intent) {
AuthIntent.LoginWithGoogle -> {
// TODO: 调用 Google 登录逻辑
println("Google 登录触发")
viewModelScope.launch {
authRepository.loginWithGoogle()
is AuthIntent.LoginWithGoogle -> onLoginWithGoogle()
is AuthIntent.LoginWithFacebook -> onLoginWithFacebook()
is AuthIntent.ClearError -> clearError()
is AuthIntent.Logout -> {}
}
}
AuthIntent.LoginWithFacebook -> {
// TODO: 调用 Facebook 登录逻辑
println("Facebook 登录触发")
private fun setLoading(loading: Boolean) {
updateState { copy(isLoading = loading, isProcessing = false, error = null) }
}
/**
* 清除错误
*/
private fun clearError() {
updateState { copy(error = null) }
}
/**
* 使用谷歌登录
*/
private fun onLoginWithGoogle() {
LogUtils.e("DevTTL", "Google 登录触发")
viewModelScope.launch {
authRepository.loginWithFacebook()
when (val result = authRepository.loginWithGoogle()) {
is AuthResult.Success -> {
if (result.account.isEmpty) {
updateState { copy(isLoading = false, error = "Login canceled") }
return@launch
}
TaskTTLApi.thirdPartyLogin(result.account)
updateState { copy(isLoading = false, error = null) }
// sendEvent(AuthEffect.NavigateToHome)
}
is AuthResult.Canceled -> {
updateState { copy(isLoading = false, error = "Login canceled") }
sendEvent(AuthEffect.ShowMessage("Login canceled"))
}
is AuthResult.Error -> {
updateState {
copy(
isLoading = false,
error = result.message ?: "Unknown error"
)
}
sendEvent(AuthEffect.ShowMessage(result.message ?: "Unknown error"))
}
}
}
}
/**
* 使用脸书登录
*/
private fun onLoginWithFacebook() {
LogUtils.e("DevTTL", "Facebook 登录触发")
viewModelScope.launch {
val result = authRepository.loginWithFacebook()
}
}
}

View File

@@ -1,21 +1,14 @@
package com.taskttl.presentation.features.auth
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -28,42 +21,76 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Facebook
import androidx.compose.material.icons.filled.Language
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
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 coil3.compose.AsyncImage
import com.taskttl.core.utils.ToastUtils
import com.taskttl.navigation.Routes
import com.taskttl.presentation.common.components.AppHeader
import kotlinx.coroutines.launch
import com.taskttl.presentation.common.components.ErrorDialog
import com.taskttl.presentation.features.task.list.TaskEffect
import com.taskttl.presentation.features.task.list.TaskIntent
import org.jetbrains.compose.resources.DrawableResource
import org.jetbrains.compose.resources.InternalResourceApi
import org.jetbrains.compose.resources.painterResource
import org.koin.compose.viewmodel.koinViewModel
import taskttl.composeapp.generated.resources.Res
import taskttl.composeapp.generated.resources.app_name
/**
* 登录屏幕
* @param [onNavigateBack] 返回导航
* @param [viewModel] 视图模型
*/
@OptIn(ExperimentalMaterial3Api::class, InternalResourceApi::class)
@Composable
fun LoginScreen(
onNavigateBack: () -> Unit,
viewModel: AuthViewModel = koinViewModel(),
) {
val state by viewModel.state.collectAsState()
LaunchedEffect(Unit) {
viewModel.effects.collect { effect ->
when (effect) {
is AuthEffect.ShowMessage -> {
ToastUtils.show(effect.message)
}
// is TaskEffect.NavigateToTaskDetail -> {
// navController.navigate(Routes.Main.Task.TaskDetail(effect.taskId))
// }
else -> {}
}
}
}
state.error?.let { error ->
ErrorDialog(
errorMessage = state.error,
onDismiss = { viewModel.handleIntent(AuthIntent.ClearError) }
)
}
Box(
modifier = Modifier.fillMaxSize()
) {
@@ -76,74 +103,19 @@ fun LoginScreen(
onBackClick = { onNavigateBack.invoke() }
)
// --- 动画状态 ---
val logoAlpha = remember { Animatable(0f) }
val logoScale = rememberInfiniteTransition().animateFloat(
initialValue = 0.95f,
targetValue = 1.05f,
animationSpec = infiniteRepeatable(
animation = tween(2000, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse
)
)
Spacer(modifier = Modifier.height(48.dp))
val cardOffset = remember { Animatable(200f) }
val cardAlpha = remember { Animatable(0f) }
val googleButtonAlpha = remember { Animatable(0f) }
val facebookButtonAlpha = remember { Animatable(0f) }
val copyrightAlpha = remember { Animatable(0f) }
// --- 启动动画顺序 ---
LaunchedEffect(Unit) {
// Logo 渐显
logoAlpha.animateTo(1f, tween(500))
// 卡片滑入
launch { cardOffset.animateTo(0f, tween(700, easing = FastOutSlowInEasing)) }
launch { cardAlpha.animateTo(1f, tween(700)) }
// 按钮依次出现
googleButtonAlpha.animateTo(1f, tween(400))
facebookButtonAlpha.animateTo(1f, tween(400))
// 版权淡入
copyrightAlpha.animateTo(1f, tween(500))
}
Column(
modifier = Modifier.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceBetween
) {
// --- 登录卡片 ---
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 24.dp)
.graphicsLayer {
translationY = cardOffset.value
alpha = cardAlpha.value
},
shape = RoundedCornerShape(24.dp),
elevation = CardDefaults.cardElevation(8.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Logo
// 应用 Logo
AsyncImage(
model = Res.getUri("drawable/ic_launcher.png"),
contentDescription = null,
modifier = Modifier
.size(120.dp)
.graphicsLayer {
alpha = logoAlpha.value
scaleX = logoScale.value
scaleY = logoScale.value
},
modifier = Modifier.size(120.dp),
contentScale = ContentScale.Fit
)
@@ -165,27 +137,27 @@ fun LoginScreen(
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(24.dp))
Spacer(modifier = Modifier.height(32.dp))
AnimatedLoginButton(
// Google 登录按钮
LoginButton(
icon = Icons.Default.Language,
text = "使用 Google 登录",
backgroundColor = Color(0xFFDB4437),
alpha = googleButtonAlpha.value,
onClick = { viewModel.handleIntent(AuthIntent.LoginWithGoogle) }
)
Spacer(modifier = Modifier.height(16.dp))
AnimatedLoginButton(
// Facebook 登录按钮
LoginButton(
icon = Icons.Default.Facebook,
text = "使用 Facebook 登录",
backgroundColor = Color(0xFF1877F2),
alpha = facebookButtonAlpha.value,
onClick = { viewModel.handleIntent(AuthIntent.LoginWithFacebook) }
)
}
}
Spacer(modifier = Modifier.weight(1f))
Text(
text = "© DevTTL Team. All rights reserved.",
@@ -194,42 +166,28 @@ fun LoginScreen(
modifier = Modifier.padding(bottom = 8.dp)
)
}
}
}
}
@Composable
private fun AnimatedLoginButton(
private fun LoginButton(
icon: ImageVector,
text: String,
backgroundColor: Color,
alpha: Float = 1f,
onClick: () -> Unit,
) {
var pressed by remember { mutableStateOf(false) }
val scale by animateFloatAsState(if (pressed) 0.95f else 1f, tween(100))
Row(
modifier = Modifier
.fillMaxWidth()
.height(52.dp)
.graphicsLayer { scaleX = scale; scaleY = scale; this.alpha = alpha }
.background(color = backgroundColor, shape = RoundedCornerShape(12.dp))
.clickable(
onClick = onClick,
onClickLabel = text,
interactionSource = remember { MutableInteractionSource() },
indication = LocalIndication.current
)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
pressed = true
tryAwaitRelease()
pressed = false
}
)
}
indication = null
) { onClick() }
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
@@ -250,3 +208,38 @@ private fun AnimatedLoginButton(
)
}
}
@Composable
private fun AuthOutlineButton(
text: String,
onClick: () -> Unit,
iconRes: DrawableResource,
modifier: Modifier = Modifier,
) {
OutlinedButton(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 24.dp),
contentPadding = PaddingValues(16.dp),
border = BorderStroke(width = 1.dp, color = Color.Black),
onClick = onClick
) {
Row(
modifier = Modifier.align(Alignment.CenterVertically),
) {
Image(
painter = painterResource(iconRes),
modifier = Modifier.size(24.dp),
contentDescription = "",
)
Text(
text = text,
modifier = Modifier
.padding(start = 12.dp)
.align(Alignment.CenterVertically),
fontSize = 16.sp,
color = Color.Black,
)
}
}
}

View File

@@ -1,5 +1,6 @@
package com.taskttl.presentation.features.category.list
import androidx.compose.runtime.Stable
import androidx.lifecycle.viewModelScope
import com.taskttl.core.base.BaseViewModel
import com.taskttl.domain.model.Category
@@ -28,6 +29,7 @@ import taskttl.composeapp.generated.resources.category_update_success
* @constructor 创建[CategoryViewModel]
* @param [categoryRepository] 类别存储库
*/
@Stable
class CategoryViewModel(private val categoryRepository: CategoryRepository) :
BaseViewModel<CategoryState, CategoryIntent, CategoryEffect>(CategoryState()) {

View File

@@ -64,7 +64,6 @@ import com.taskttl.presentation.common.components.CategoryFilter
import com.taskttl.presentation.common.components.ErrorDialog
import com.taskttl.presentation.common.components.LoadingOverlay
import org.jetbrains.compose.resources.stringResource
import org.jetbrains.compose.ui.tooling.preview.Preview
import org.koin.compose.viewmodel.koinViewModel
import taskttl.composeapp.generated.resources.Res
import taskttl.composeapp.generated.resources.delete
@@ -77,7 +76,6 @@ import taskttl.composeapp.generated.resources.text_no_countdowns
import taskttl.composeapp.generated.resources.title_countdown
@Composable
@Preview
fun CountdownScreen(
navController: NavHostController,
viewModel: CountdownViewModel = koinViewModel(),

View File

@@ -1,5 +1,6 @@
package com.taskttl.presentation.features.countdown.list
import androidx.compose.runtime.Stable
import androidx.lifecycle.viewModelScope
import com.taskttl.core.base.BaseViewModel
import com.taskttl.domain.model.Category
@@ -26,6 +27,7 @@ import taskttl.composeapp.generated.resources.countdown_update_success
* @constructor 创建[CountdownViewModel]
* @param [countdownRepository] 倒计时存储库
*/
@Stable
class CountdownViewModel(
private val countdownRepository: CountdownRepository,
private val categoryRepository: CategoryRepository,

View File

@@ -37,7 +37,6 @@ import com.taskttl.domain.model.OnboardingPage
import com.taskttl.navigation.Routes
import kotlinx.coroutines.flow.collectLatest
import org.jetbrains.compose.resources.stringResource
import org.jetbrains.compose.ui.tooling.preview.Preview
import org.koin.compose.viewmodel.koinViewModel
import taskttl.composeapp.generated.resources.Res
import taskttl.composeapp.generated.resources.continue_text
@@ -49,7 +48,6 @@ import taskttl.composeapp.generated.resources.skip_text
* @param [navigatorToRoute] 导航到路线
* @param [viewModel] 视图模型
*/
@Preview
@Composable
fun OnboardingScreen(
navigatorToRoute: (Routes) -> Unit,

View File

@@ -23,7 +23,25 @@ data class OnboardingState(
* @constructor 创建[OnboardingIntent]
*/
sealed class OnboardingIntent {
/**
* 初始化
* @author DevTTL
* @date 2025/11/07
*/
object Initialization: OnboardingIntent()
/**
* 下一页
* @author DevTTL
* @date 2025/11/07
*/
object NextPage : OnboardingIntent()
/**
* 标记引导完成
* @author DevTTL
* @date 2025/11/07
*/
object MarkOnboardingCompleted : OnboardingIntent()
}

View File

@@ -1,9 +1,16 @@
package com.taskttl.presentation.features.onboarding
import androidx.compose.runtime.Stable
import androidx.lifecycle.viewModelScope
import com.taskttl.core.base.BaseViewModel
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.source.remote.api.TaskTTLApi
import com.taskttl.domain.repository.CategoryRepository
import com.taskttl.domain.repository.OnboardingRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
/**
@@ -14,18 +21,37 @@ import kotlinx.coroutines.launch
* @param [onboardingRepository] 引导存储库
* @param [categoryRepository] 类别存储库
*/
@Stable
class OnboardingViewModel(
private val onboardingRepository: OnboardingRepository,
private val categoryRepository: CategoryRepository,
) : BaseViewModel<OnboardingState, OnboardingIntent, OnboardingEffect>(initialState = OnboardingState()) {
init {
processIntent(OnboardingIntent.Initialization)
}
override fun handleIntent(intent: OnboardingIntent) {
when (intent) {
is OnboardingIntent.Initialization -> initialization()
is OnboardingIntent.NextPage -> nextPage()
is OnboardingIntent.MarkOnboardingCompleted -> markOnboardingCompleted()
}
}
private fun initialization() {
viewModelScope.launch(Dispatchers.Default) {
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)
}
}
}
/**
* 下一页
*/

View File

@@ -155,31 +155,9 @@ fun AboutScreen(
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// 技术栈
// Card(
// modifier = Modifier.fillMaxWidth()
// ) {
// Column(
// modifier = Modifier.padding(16.dp)
// ) {
// Text(
// text = stringResource(Res.string.tech_stack),
// style = MaterialTheme.typography.titleMedium,
// fontWeight = FontWeight.Medium
// )
//
// Spacer(modifier = Modifier.height(12.dp))
//
// TechStackItem("Kotlin Multiplatform", Res.string.tech_stack_kmp)
// TechStackItem("Jetpack Compose", Res.string.tech_stack_compose)
// TechStackItem("Room Database", Res.string.tech_stack_room)
// TechStackItem("Koin", Res.string.tech_stack_koin)
// TechStackItem("Ktor", Res.string.tech_stack_ktor)
// TechStackItem("MVI Architecture", Res.string.tech_stack_mvi)
// }
// }
// Spacer(modifier = Modifier.height(16.dp))
// 开发者信息
Card(
modifier = Modifier.fillMaxWidth(),
@@ -202,7 +180,9 @@ fun AboutScreen(
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// 联系方式
Card(
modifier = Modifier.fillMaxWidth(),
@@ -277,26 +257,6 @@ private fun AboutInfoRow(
}
}
@Composable
private fun TechStackItem(
name: String,
descriptionRes: StringResource,
) {
Column {
Text(
text = name,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium
)
Text(
text = stringResource(descriptionRes),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
}
}
@Composable
private fun ContactItem(
icon: ImageVector,

View File

@@ -1,5 +1,6 @@
package com.taskttl.presentation.features.settings.feedback
import androidx.compose.runtime.Stable
import androidx.lifecycle.viewModelScope
import com.taskttl.core.base.BaseViewModel
import com.taskttl.data.source.remote.api.TaskTTLApi
@@ -16,6 +17,7 @@ import taskttl.composeapp.generated.resources.feedback_success
* @date 2025/10/12
* @constructor 创建[FeedbackViewModel]
*/
@Stable
class FeedbackViewModel() :
BaseViewModel<FeedbackState, FeedbackIntent, FeedbackEffect>(FeedbackState()) {

View File

@@ -23,6 +23,8 @@ import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
@@ -48,10 +50,10 @@ import com.taskttl.core.permission.NotificationPermissionManager
import com.taskttl.navigation.Routes
import com.taskttl.presentation.common.components.AppHeader
import com.taskttl.presentation.common.components.ThemeModeDialog
import com.taskttl.presentation.features.settings.main.common.UserInfoCard
import kotlinx.coroutines.delay
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.jetbrains.compose.ui.tooling.preview.Preview
import org.koin.compose.viewmodel.koinViewModel
import taskttl.composeapp.generated.resources.Res
import taskttl.composeapp.generated.resources.section_data_management
@@ -80,7 +82,6 @@ import taskttl.composeapp.generated.resources.title_app_settings
* @param [navController] 导航控制器
*/
@Composable
@Preview
fun SettingsScreen(
navController: NavHostController,
viewModel: SettingsViewModel = koinViewModel(),
@@ -120,85 +121,23 @@ fun SettingsScreen(
.verticalScroll(rememberScrollState())
.padding(16.dp)
) {
// 用户信息卡片
Column(
modifier = Modifier
.fillMaxWidth()
.background(
brush = Brush.linearGradient(
colors = listOf(
MaterialTheme.colorScheme.primary,
MaterialTheme.colorScheme.secondary
)
),
shape = RoundedCornerShape(16.dp)
)
.padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Row(
modifier = Modifier.clickable { navController.navigate(Routes.Main.Settings.Login) },
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(60.dp)
.background(Color.White.copy(alpha = 0.2f), shape = CircleShape),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.Person,
contentDescription = "用户头像",
tint = Color.White,
modifier = Modifier.size(40.dp)
// 用户信息卡片(登录前/后通用组件)
UserInfoCard(
isLoggedIn = true,
userName = "DevTTL",
userSubtitle = "111111",
onClick = { navController.navigate(Routes.Main.Settings.Login) }
)
// AsyncImage(
// model = Res.getUri("drawable/ic_launcher.png"),
// contentDescription = null,
// modifier = Modifier
// .size(120.dp),
// contentScale = ContentScale.Fit
// )
}
Spacer(modifier = Modifier.width(12.dp))
Column(
modifier = Modifier.weight(1f).height(60.dp),
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.SpaceBetween
) {
Text(
"登录或注册",
color = Color.White,
fontWeight = FontWeight.Medium,
fontSize = 18.sp
)
Text(
"加入TaskTTL",
color = Color.White.copy(alpha = 0.9f),
fontSize = 14.sp
)
}
Spacer(modifier = Modifier.height(12.dp))
Card(
modifier = Modifier.fillMaxWidth().height(80.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
)
) {
// Column(
// modifier = Modifier.weight(1f).height(60.dp),
// horizontalAlignment = Alignment.End,
// verticalArrangement = Arrangement.SpaceBetween
// ) {
// Text(
// "昵称",
// color = Color.White,
// fontWeight = FontWeight.Medium,
// fontSize = 18.sp
// )
// Text(
// "已使用 30 天 · 完成 156 个任务",
// color = Color.White.copy(alpha = 0.9f),
// fontSize = 14.sp
// )
// }
}
}
Spacer(modifier = Modifier.height(24.dp))
@@ -252,7 +191,7 @@ fun SettingsScreen(
)
Spacer(modifier = Modifier.height(16.dp))
// // 社交分享
// 社交分享
// SectionTitle(Icons.Default.Share, Res.string.section_social_share)
// SettingItem(
// titleRes = Res.string.setting_share_achievement,
@@ -378,7 +317,11 @@ fun SettingItem(
)
descriptionRes.let {
Spacer(modifier = Modifier.height(2.dp))
Text(stringResource(it), fontSize = 14.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(
stringResource(it),
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}

View File

@@ -1,5 +1,6 @@
package com.taskttl.presentation.features.settings.main
import androidx.compose.runtime.Stable
import androidx.lifecycle.viewModelScope
import com.taskttl.core.base.BaseViewModel
import com.taskttl.core.manager.ThemeManager
@@ -15,6 +16,7 @@ import kotlinx.coroutines.launch
* @date 2025/10/12
* @constructor 创建[SettingsViewModel]
*/
@Stable
class SettingsViewModel(private val themeManager: ThemeManager) :
BaseViewModel<SettingsState, SettingsIntent, SettingsEffect>(SettingsState()) {

View File

@@ -0,0 +1,126 @@
package com.taskttl.presentation.features.settings.main.common
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import com.taskttl.presentation.common.components.NetworkImage
import taskttl.composeapp.generated.resources.Res
/**
* 用户信息卡片
* 支持:
* - 未登录:展示登录引导
* - 已登录:展示头像首字母、昵称、副标题
*/
@Composable
fun UserInfoCard(
isLoggedIn: Boolean,
userName: String?,
userSubtitle: String?,
onClick: () -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth()
.padding(16.dp)
.clickable { onClick() },
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
// 左侧文本
Column(
modifier = Modifier.weight(1f).height(60.dp),
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.SpaceBetween
) {
if (isLoggedIn && !userName.isNullOrBlank()) {
Text(
text = userName,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)
Text(
text = userSubtitle ?: "欢迎回来,继续保持高效!",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 14.sp
)
} else {
Text(
text = "登录或注册",
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)
Text(
text = "加入 TaskTTL跨设备同步你的任务与倒计时",
color = MaterialTheme.colorScheme.onSurface,
fontSize = 14.sp
)
}
}
if (!isLoggedIn) {
Icon(
imageVector = Icons.Default.ChevronRight,
contentDescription = null,
tint = Color.White.copy(alpha = 0.9f)
)
}
Spacer(modifier = Modifier.width(12.dp))
// 头像/占位
Box(
modifier = Modifier
.size(60.dp).clip(CircleShape)
.background(Color.White.copy(alpha = 0.2f), shape = CircleShape),
contentAlignment = Alignment.Center
) {
if (isLoggedIn && !userName.isNullOrBlank()) {
NetworkImage(
url = Res.getUri("drawable/ic_launcher.png"),
contentDescription = null,
modifier = Modifier.size(60.dp),
contentScale = ContentScale.Fit
)
} else {
Icon(
imageVector = Icons.Default.Person,
contentDescription = "用户头像",
tint = Color.White,
modifier = Modifier.size(40.dp)
)
}
}
}
}

View File

@@ -34,7 +34,6 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.taskttl.navigation.Routes
import org.jetbrains.compose.resources.stringResource
import org.jetbrains.compose.ui.tooling.preview.Preview
import org.koin.compose.viewmodel.koinViewModel
import taskttl.composeapp.generated.resources.Res
import taskttl.composeapp.generated.resources.app_name
@@ -45,7 +44,6 @@ import taskttl.composeapp.generated.resources.app_name_remark
* @param [navigatorToRoute] 导航到路线
* @param [viewModel] 视图模型
*/
@Preview
@Composable
fun SplashScreen(
navigatorToRoute: (Routes) -> Unit,

View File

@@ -16,7 +16,12 @@ data class SplashState(
) : BaseState()
sealed class SplashIntent {
object LoadApp : SplashIntent()
/**
* 装载状态
* @author DevTTL
* @date 2025/11/07
*/
object LoadingStatus : SplashIntent()
}

View File

@@ -1,47 +1,53 @@
package com.taskttl.presentation.features.splash
import androidx.compose.runtime.Stable
import androidx.lifecycle.viewModelScope
import com.taskttl.core.base.BaseViewModel
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.source.remote.api.TaskTTLApi
import com.taskttl.domain.repository.OnboardingRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* 启动页视图模型
* @author admin
* @date 2025/08/11
* @constructor 创建[SplashViewModel]
* @param [settings] 挡
*/
@Stable
class SplashViewModel(
private val onboardingRepository: OnboardingRepository,
) : BaseViewModel<SplashState, SplashIntent, SplashEffect>(SplashState()) {
init {
processIntent(SplashIntent.LoadApp)
processIntent(SplashIntent.LoadingStatus)
}
/**
* 处理意图
* @param [intent] 意图
*/
override fun handleIntent(intent: SplashIntent) {
when (intent) {
is SplashIntent.LoadApp -> loadApp()
is SplashIntent.LoadingStatus -> loadingStatus()
}
}
private fun loadApp() {
/**
* 装载状态
*/
private fun loadingStatus() {
viewModelScope.launch {
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)
}
delay(200)
val hasLaunched = onboardingRepository.isLaunchedBefore()
LogUtils.e("DevTTL",hasLaunched.toString())
withContext(Dispatchers.Main) {
if (hasLaunched) {
sendEvent(SplashEffect.NavigateToOnboarding)
} else {
@@ -50,3 +56,4 @@ class SplashViewModel(
}
}
}
}

View File

@@ -1,177 +0,0 @@
package com.taskttl.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProgressIndicatorDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.taskttl.domain.model.CategoryStatistics
import org.jetbrains.compose.resources.stringResource
import taskttl.composeapp.generated.resources.Res
import taskttl.composeapp.generated.resources.active
import taskttl.composeapp.generated.resources.completed
import taskttl.composeapp.generated.resources.completion_rate
import taskttl.composeapp.generated.resources.total_countdowns
import taskttl.composeapp.generated.resources.total_tasks
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CategoryStatisticsCard(
statistics: CategoryStatistics,
categoryColor: Long,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(32.dp)
.clip(CircleShape)
.background(Color(categoryColor))
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = statistics.categoryName,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium
)
}
Spacer(modifier = Modifier.height(16.dp))
// 任务统计
if (statistics.totalTasks > 0) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Column {
Text(
text = stringResource(Res.string.total_tasks),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "${statistics.totalTasks}",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
}
Column {
Text(
text = stringResource(Res.string.completed),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "${statistics.completedTasks}",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
}
Column {
Text(
text = stringResource(Res.string.completion_rate),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "${(statistics.completionRate * 100).toInt()}%",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = if (statistics.completionRate >= 0.8f)
MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurface
)
}
}
Spacer(modifier = Modifier.height(12.dp))
// 进度条
LinearProgressIndicator(
progress = { statistics.completionRate },
modifier = Modifier
.fillMaxWidth()
.height(8.dp)
.clip(RoundedCornerShape(4.dp)),
color = Color(categoryColor),
trackColor = ProgressIndicatorDefaults.linearTrackColor,
strokeCap = ProgressIndicatorDefaults.LinearStrokeCap,
)
}
// 倒数日统计
if (statistics.totalCountdowns > 0) {
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Column {
Text(
text = stringResource(Res.string.total_countdowns),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "${statistics.totalCountdowns}",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium
)
}
Column {
Text(
text = stringResource(Res.string.active),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "${statistics.activeCountdowns}",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary
)
}
}
}
}
}
}

View File

@@ -52,7 +52,6 @@ import com.taskttl.presentation.features.task.list.TaskIntent
import com.taskttl.presentation.features.task.list.TaskViewModel
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.jetbrains.compose.ui.tooling.preview.Preview
import org.koin.compose.viewmodel.koinViewModel
import taskttl.composeapp.generated.resources.Res
import taskttl.composeapp.generated.resources.category_countdown
@@ -66,7 +65,6 @@ import taskttl.composeapp.generated.resources.title_statistics
import taskttl.composeapp.generated.resources.total_tasks
@Composable
@Preview
fun StatisticsScreen(
navController: NavHostController,
taskViewModel: TaskViewModel = koinViewModel(),

View File

@@ -1,4 +1,5 @@
package com.taskttl.presentation.features.task.detail
// 任务编辑
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@@ -56,13 +57,13 @@ import com.taskttl.presentation.features.task.list.TaskIntent
import com.taskttl.presentation.features.task.list.TaskViewModel
import com.taskttl.ui.components.CategoryCard
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.number
import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime
import org.jetbrains.compose.resources.stringResource
import org.jetbrains.compose.ui.tooling.preview.Preview
import org.koin.compose.viewmodel.koinViewModel
import taskttl.composeapp.generated.resources.Res
import taskttl.composeapp.generated.resources.hint_tags
@@ -79,13 +80,18 @@ import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
/**
* 任务编辑器屏幕
* @param [taskId] 任务id
* @param [onNavigateBack] 返回导航
* @param [viewModel] 视图模型
*/
@OptIn(ExperimentalMaterial3Api::class, ExperimentalTime::class, ExperimentalUuidApi::class)
@Composable
@Preview
fun TaskEditorScreen(
taskId: String? = null,
onNavigateBack: () -> Unit,
viewModel: TaskViewModel = koinViewModel()
viewModel: TaskViewModel = koinViewModel(),
) {
LaunchedEffect(Unit) {
@@ -306,15 +312,18 @@ fun TaskEditorScreen(
fun DueDateSelector(
dueDate: LocalDateTime?,
onDateSelected: (LocalDateTime?) -> Unit,
onClick: () -> Unit
onClick: () -> Unit,
) {
val now = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault())
val today = now.date
val tomorrow = today.plus(1, DateTimeUnit.DAY)
val dateFormatter = { date: LocalDateTime ->
"${date.year}-${date.month.number.toString().padStart(2, '0')}-${date.day.toString().padStart(2, '0')} " +
"${date.hour.toString().padStart(2, '0')}:${date.minute.toString().padStart(2, '0')}"
"${date.year}-${date.month.number.toString().padStart(2, '0')}-${
date.day.toString().padStart(2, '0')
} " + "${date.hour.toString().padStart(2, '0')}:${
date.minute.toString().padStart(2, '0')
}"
}
Column(modifier = Modifier.fillMaxWidth()) {
@@ -334,7 +343,7 @@ fun DueDateSelector(
Button(
onClick = {
onDateSelected(
LocalDateTime(date.year, date.month, date.dayOfMonth, now.hour, now.minute)
LocalDateTime(date.year, date.month, date.day, now.hour, now.minute)
)
},
modifier = Modifier.weight(1f),
@@ -349,10 +358,8 @@ fun DueDateSelector(
// 其他时间按钮占剩余空间
Button(
onClick = onClick,
modifier = Modifier.fillMaxWidth(), // 占满剩余空间
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(MaterialTheme.colorScheme.surfaceVariant)
) {
Text("其他时间")
}
@@ -362,7 +369,11 @@ fun DueDateSelector(
Box(
modifier = Modifier
.fillMaxWidth()
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, MaterialTheme.shapes.small)
.border(
1.dp,
MaterialTheme.colorScheme.outlineVariant,
MaterialTheme.shapes.small
)
.padding(horizontal = 12.dp, vertical = 8.dp)
) {
Row(

View File

@@ -1,4 +1,5 @@
package com.taskttl.presentation.features.task.editor
// 任务详细信息屏幕
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
@@ -54,6 +55,13 @@ import taskttl.composeapp.generated.resources.label_none
import taskttl.composeapp.generated.resources.text_task_not_found
import taskttl.composeapp.generated.resources.title_task_info
/**
* 任务详细信息屏幕
* @param [taskId] 任务id
* @param [onNavigateBack] 返回导航
* @param [onNavigateToEdit] 导航到编辑
* @param [viewModel] 视图模型
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TaskDetailScreen(

View File

@@ -1,5 +1,5 @@
package com.taskttl.presentation.features.task.list
// 任务屏幕
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -43,7 +43,6 @@ import com.taskttl.presentation.common.components.LoadingOverlay
import com.taskttl.presentation.common.components.SearchBar
import com.taskttl.presentation.features.task.list.components.TaskCardItem
import org.jetbrains.compose.resources.stringResource
import org.jetbrains.compose.ui.tooling.preview.Preview
import org.koin.compose.viewmodel.koinViewModel
import taskttl.composeapp.generated.resources.Res
import taskttl.composeapp.generated.resources.label_show_completed
@@ -53,8 +52,12 @@ import taskttl.composeapp.generated.resources.text_no_tasks
import taskttl.composeapp.generated.resources.title_add_task
import taskttl.composeapp.generated.resources.title_task
/**
* 任务屏幕
* @param [navController] 导航控制器
* @param [viewModel] 视图模型
*/
@Composable
@Preview
fun TaskScreen(
navController: NavHostController,
viewModel: TaskViewModel = koinViewModel(),

View File

@@ -1,8 +1,9 @@
package com.taskttl.presentation.features.task.list
// 任务状态
import com.taskttl.core.base.BaseState
import com.taskttl.domain.model.Category
import com.taskttl.domain.model.Task
import kotlinx.serialization.Serializable
/**
* 任务状态

View File

@@ -1,5 +1,6 @@
package com.taskttl.presentation.features.task.list
import androidx.compose.runtime.Stable
import androidx.lifecycle.viewModelScope
import com.taskttl.core.base.BaseViewModel
import com.taskttl.core.notification.AppNotificationManager
@@ -34,6 +35,7 @@ import kotlin.time.ExperimentalTime
* @constructor 创建[TaskViewModel]
* @param [taskRepository] 任务仓库
*/
@Stable
class TaskViewModel(
private val taskRepository: TaskRepository,
private val categoryRepository: CategoryRepository,

View File

@@ -1,4 +1,5 @@
package com.taskttl.presentation.features.task.list.components
// 任务卡片
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable

View File

@@ -23,6 +23,25 @@
"other_platform_oauth_client": []
}
}
},
{
"client_info": {
"mobilesdk_app_id": "1:324197572456:android:89abdcdd4e5892872440dd",
"android_client_info": {
"package_name": "com.taskttl.debug"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyBPQBhVV-aMHpvyT-S3VgjXN__Dj0Z-Jes"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"

View File

@@ -0,0 +1,9 @@
package com.taskttl.app.di
import com.taskttl.core.database.TaskTTLDatabase
import com.taskttl.core.database.getDatabaseBuilder
import org.koin.dsl.module
actual val serviceModule = module {
single<TaskTTLDatabase> { getDatabaseBuilder() }
}

View File

@@ -0,0 +1,8 @@
package com.taskttl.core.common
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@Composable
actual fun DevTTLWebView(modifier: Modifier, url: String, enableJavaScript: Boolean) {
}

View File

@@ -0,0 +1,14 @@
package com.taskttl.core.database
import androidx.room.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import kotlinx.coroutines.Dispatchers
import java.io.File
actual fun getDatabaseBuilder(): TaskTTLDatabase {
val dbFile = File(System.getProperty("java.io.tmpdir"), "taskttl_dababase.db")
return Room.databaseBuilder<TaskTTLDatabase>(name = dbFile.absolutePath)
.setDriver(BundledSQLiteDriver())
.setQueryCoroutineContext(Dispatchers.IO)
.build()
}

View File

@@ -2,10 +2,12 @@ package com.taskttl.core.permission
actual object ExactAlarmPermissionManager {
actual fun requestPermission(): Boolean {
return false
TODO("Not yet implemented")
}
actual fun verifyPermission(): Boolean {
return false
TODO("Not yet implemented")
}

View File

@@ -2,10 +2,12 @@ package com.taskttl.core.permission
actual object NotificationPermissionManager {
actual fun requestPermission(): Boolean {
return false
TODO("Not yet implemented")
}
actual fun verifyPermission(): Boolean {
return false
TODO("Not yet implemented")
}

View File

@@ -1,8 +0,0 @@
package com.taskttl.core.ui
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@Composable
actual fun DevTTLWebView(modifier: Modifier, url: String) {
}

View File

@@ -4,10 +4,19 @@ import com.taskttl.core.domain.BaseReq
actual object DeviceUtils {
actual suspend fun getUniqueId(): String {
TODO("Not yet implemented")
return "123"
}
actual suspend fun getDeviceInfo(): BaseReq {
TODO("Not yet implemented")
// TODO
return BaseReq(
appName = "TaskTTL",
versionCode = 999,
appId = 100,
uniqueId = getUniqueId(),
deviceInfo = "0",
deviceVersion = "11",
language = Localization.getDeviceLanguage()
)
}
}

View File

@@ -1,10 +1,13 @@
package com.taskttl.core.utils
import java.util.Locale
actual object Localization {
actual fun applyLanguage(iso: String) {
}
actual fun getDeviceLanguage(): String {
TODO("Not yet implemented")
return Locale.getDefault().language
}
}

View File

@@ -1,48 +0,0 @@
package com.taskttl.core.utils
actual object StorageUtils {
actual fun saveString(key: String, value: String) {
}
actual fun getString(key: String, defaultValue: String): String {
TODO("Not yet implemented")
}
actual fun saveInt(key: String, value: Int) {
}
actual fun getInt(key: String, defaultValue: Int): Int {
TODO("Not yet implemented")
}
actual fun saveLong(key: String, value: Long) {
}
actual fun getLong(key: String, defaultValue: Long): Long {
TODO("Not yet implemented")
}
actual fun saveBoolean(key: String, value: Boolean) {
}
actual fun getBoolean(key: String, defaultValue: Boolean): Boolean {
TODO("Not yet implemented")
}
actual inline fun <reified T : Any> saveObject(key: String, value: T) {
}
actual inline fun <reified T : Any> getObject(key: String): T? {
TODO("Not yet implemented")
}
actual fun contains(key: String): Boolean {
TODO("Not yet implemented")
}
actual fun remove(key: String) {
}
actual fun clear() {
}
}

View File

@@ -1,9 +0,0 @@
package com.taskttl.data.di
import com.taskttl.data.local.database.TaskTTLDatabase
import com.taskttl.data.local.database.getDatabaseBuilder
import org.koin.dsl.module
actual val serviceModule = module {
single<TaskTTLDatabase> { getDatabaseBuilder() }
}

View File

@@ -1,11 +0,0 @@
package com.taskttl.data.local.database
import androidx.room.Room
import java.io.File
actual fun getDatabaseBuilder(): TaskTTLDatabase {
val dbFile = File(System.getProperty("java.io.tmpdir"), "taskttl_dababase.db")
return Room.databaseBuilder<TaskTTLDatabase>(
name = dbFile.absolutePath,
).build()
}

View File

@@ -1,13 +0,0 @@
package com.taskttl.data.repository
import com.taskttl.data.source.remote.dto.response.AuthResult
actual class AuthRepository actual constructor() {
actual suspend fun loginWithGoogle(): AuthResult {
TODO("Not yet implemented")
}
actual suspend fun loginWithFacebook(): AuthResult {
TODO("Not yet implemented")
}
}

View File

@@ -0,0 +1,14 @@
package com.taskttl.domain.repository
import com.taskttl.data.source.remote.dto.response.AuthResult
class AuthRepositoryImpl : AuthRepository {
override suspend fun loginWithGoogle(): AuthResult {
TODO("Not yet implemented")
}
override suspend fun loginWithFacebook(): AuthResult {
TODO("Not yet implemented")
}
}

View File

@@ -2,8 +2,14 @@ package com.taskttl
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import com.taskttl.app.App
import com.taskttl.app.di.initKoin
fun main() = application {
initKoin {
}
Window(
onCloseRequest = ::exitApplication,
alwaysOnTop = true,

View File

@@ -0,0 +1,12 @@
package com.taskttl.presentation.common.foundation
import okio.Path
import okio.Path.Companion.toOkioPath
import java.io.File
actual fun provideDiskCachePath(): Path? {
val userHome = System.getProperty("user.home")
val cacheDir = File(userHome, ".cache/myapp")
if (!cacheDir.exists()) cacheDir.mkdirs()
return cacheDir.toOkioPath()
}

View File

@@ -1,21 +1,22 @@
[versions]
agp = "8.13.0"
agp = "8.13.1"
androidx-activity = "1.11.0"
androidx-appcompat = "1.7.1"
androidx-constraintlayout = "2.2.1"
androidx-core = "1.17.0"
androidx-espresso = "3.7.0"
androidx-lifecycle = "2.9.5"
androidx-lifecycle = "2.9.6"
androidx-testExt = "1.3.0"
composeHotReload = "1.0.0-rc02"
composeMultiplatform = "1.9.1"
composeMultiplatform = "1.10.0-beta01"
junit = "4.13.2"
kotlin = "2.2.21"
kotlinx-coroutines = "1.10.2"
splashscreen = "1.2.0"
navigationCompose = "2.9.1"
koin = "4.1.1"
ktor = "3.3.1"
ktor = "3.3.2"
coil3 = "3.3.0"
kotlinx-datetime = "0.7.1"
icons = "1.7.3"
@@ -23,12 +24,12 @@ icons = "1.7.3"
google = "4.4.4"
credentials = "1.6.0-beta03"
googleid = "1.1.1"
firebase = "34.4.0"
firebase = "34.6.0"
facebook = "18.1.3"
playServicesAds = "18.2.0"
kotlinx-serialization = "1.9.0"
mmkv = "2.2.4"
settings = "1.3.0"
sqlite = "2.6.1"
room = "2.8.3"
@@ -63,9 +64,13 @@ androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "a
androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "androidx-constraintlayout" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
androidx-lifecycle-viewmodel = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel", version.ref = "androidx-lifecycle" }
androidx-lifecycle-savedstate = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate", version.ref = "androidx-lifecycle" }
androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
# 启动兼容
androidx-splashscreen = {module = "androidx.core:core-splashscreen" , version.ref = "splashscreen"}
# 导航
navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
@@ -80,7 +85,7 @@ 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-java = {module = "io.ktor:ktor-client-java", version.ref = "ktor" }
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
@@ -107,8 +112,9 @@ android-facebook-android-sdk = { module = "com.facebook.android:facebook-android
# JSON
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
# 安卓MMKV
android-mmkv = { module = "com.tencent:mmkv", version.ref = "mmkv" }
# Settings
multiplatform-settings = {module = "com.russhwolf:multiplatform-settings",version.ref="settings"}
multiplatform-settings-no-arg = {module = "com.russhwolf:multiplatform-settings-no-arg", version.ref="settings"}
# Room数据库
androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "sqlite" }
@@ -123,7 +129,6 @@ android-play-services-ads-identifier = { module = "com.google.android.gms:play-s
androidx-work = { module = "androidx.work:work-runtime-ktx", version.ref = "work" }
# 三方登录
#androidx-login-google = { module = "com.google.android.gms:play-services-auth", version.ref = "google-login" }
androidx-login-credentials = { module = "androidx.credentials:credentials", version.ref = "credentials" }
androidx-login-credentials-auth = { module = "androidx.credentials:credentials-play-services-auth", version.ref = "credentials" }
androidx-login-googleid = {module = "com.google.android.libraries.identity.googleid:googleid",version.ref="googleid"}

View File

@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.0-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME