From 5f07605a066801211b1c4f2c36fc737261b24e3e Mon Sep 17 00:00:00 2001 From: devttl Date: Sun, 19 Oct 2025 21:51:36 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- composeApp/build.gradle.kts | 5 +- .../src/androidMain/AndroidManifest.xml | 60 +++++++- .../kotlin/com/taskttl/MainActivity.kt | 17 +++ .../kotlin/com/taskttl/MainApplication.kt | 20 +-- .../analytics/FacebookEventTracker.android.kt | 25 ++++ .../composeResources/values-zh/strings.xml | 6 +- .../composeResources/values/strings.xml | 6 +- .../taskttl/core/analytics/FacebookEvent.kt | 128 ++++++++++++++++++ .../core/analytics/FacebookEventTracker.kt | 8 ++ .../core/analytics/FacebookEventValidator.kt | 25 ++++ .../com/taskttl/core/utils/DateUtils.kt | 8 ++ .../kotlin/com/taskttl/data/di/KoinModels.kt | 1 + .../com/taskttl/data/state/SettingsState.kt | 13 ++ .../com/taskttl/data/state/TaskState.kt | 1 + .../data/viewmodel/CountdownViewModel.kt | 4 + .../data/viewmodel/SettingsViewModel.kt | 24 ++++ .../taskttl/data/viewmodel/TaskViewModel.kt | 51 +++++-- .../onboarding/OnboardingScreen.kt | 13 ++ .../presentation/settings/SettingsScreen.kt | 53 ++++---- .../taskttl/presentation/task/TaskScreen.kt | 2 - .../analytics/FacebookEventTracker.ios.kt | 24 ++++ gradle/libs.versions.toml | 20 ++- 22 files changed, 447 insertions(+), 67 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/taskttl/core/analytics/FacebookEventTracker.android.kt create mode 100644 composeApp/src/commonMain/kotlin/com/taskttl/core/analytics/FacebookEvent.kt create mode 100644 composeApp/src/commonMain/kotlin/com/taskttl/core/analytics/FacebookEventTracker.kt create mode 100644 composeApp/src/commonMain/kotlin/com/taskttl/core/analytics/FacebookEventValidator.kt create mode 100644 composeApp/src/iosMain/kotlin/com/taskttl/core/analytics/FacebookEventTracker.ios.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index a1c7b31..2c0f824 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -62,7 +62,7 @@ kotlin { implementation(project.dependencies.platform(libs.firebase.bom)) implementation(libs.firebase.analytics) // facebook - // implementation(libs.android.facebook.android.sdk) + implementation(libs.android.facebook.android.sdk) // mmkv implementation(libs.android.mmkv) @@ -72,6 +72,9 @@ kotlin { // admob implementation(libs.android.play.services.ads.identifier) + + // work + implementation(libs.androidx.work) } commonMain.dependencies { implementation(compose.runtime) diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index 1e6bea8..b31c1ba 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -1,12 +1,18 @@ - + - + + + + + + android:exported="true"> @@ -39,6 +45,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/composeApp/src/androidMain/kotlin/com/taskttl/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/taskttl/MainActivity.kt index 283c938..e452de0 100644 --- a/composeApp/src/androidMain/kotlin/com/taskttl/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/taskttl/MainActivity.kt @@ -1,17 +1,34 @@ package com.taskttl +import android.content.res.Configuration 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.view.WindowCompat +import androidx.core.view.WindowInsetsControllerCompat class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() + // 设置全屏显示 + WindowCompat.setDecorFitsSystemWindows(window, true) + + // 设置状态栏图标颜色 + val windowInsetsController = WindowInsetsControllerCompat(window, window.decorView) + // windowInsetsController.hide(WindowInsetsCompat.Type.statusBars() or WindowInsetsCompat.Type.navigationBars()) + // windowInsetsController.systemBarsBehavior = + // WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + + // 使用系统原生方法检测暗色主题 + val isDarkTheme = resources.configuration.uiMode and + Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES + windowInsetsController.isAppearanceLightStatusBars = !isDarkTheme + setContent { App() } diff --git a/composeApp/src/androidMain/kotlin/com/taskttl/MainApplication.kt b/composeApp/src/androidMain/kotlin/com/taskttl/MainApplication.kt index 86186d9..f71d337 100644 --- a/composeApp/src/androidMain/kotlin/com/taskttl/MainApplication.kt +++ b/composeApp/src/androidMain/kotlin/com/taskttl/MainApplication.kt @@ -1,5 +1,6 @@ package com.taskttl +import android.annotation.SuppressLint import android.app.Activity import android.app.Application import android.os.Bundle @@ -12,16 +13,15 @@ import org.koin.android.ext.koin.androidLogger class MainApplication : Application() { companion object { + @SuppressLint("StaticFieldLeak") + @Volatile + private var _currentActivity: Activity? = null + lateinit var instance: Application + + fun currentActivity(): Activity? = _currentActivity } - @Volatile - var currentActivity: Activity? = null - private set - - - - init { instance = this } @@ -32,12 +32,12 @@ class MainApplication : Application() { registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks { override fun onActivityResumed(activity: Activity) { - currentActivity = activity + _currentActivity = activity } override fun onActivityPaused(activity: Activity) { - if (currentActivity == activity) { - currentActivity = null + if (_currentActivity == activity) { + _currentActivity = null } } diff --git a/composeApp/src/androidMain/kotlin/com/taskttl/core/analytics/FacebookEventTracker.android.kt b/composeApp/src/androidMain/kotlin/com/taskttl/core/analytics/FacebookEventTracker.android.kt new file mode 100644 index 0000000..c9fe5bc --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/taskttl/core/analytics/FacebookEventTracker.android.kt @@ -0,0 +1,25 @@ +package com.taskttl.core.analytics + +import android.os.Bundle +import com.facebook.FacebookSdk +import com.facebook.appevents.AppEventsLogger + +actual object FacebookEventTracker { + + private val logger: AppEventsLogger by lazy { + AppEventsLogger.newLogger(FacebookSdk.getApplicationContext()) + } + + actual fun logEvent(event: FacebookEvent, params: Map) { + val bundle = Bundle() + params.forEach { (key, value) -> + when (value) { + is String -> bundle.putString(key, value) + is Double -> bundle.putDouble(key, value) + is Int -> bundle.putInt(key, value) + is Boolean -> bundle.putBoolean(key, value) + } + } + logger.logEvent(event.eventName, bundle) + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/values-zh/strings.xml b/composeApp/src/commonMain/composeResources/values-zh/strings.xml index 584468e..4408a34 100644 --- a/composeApp/src/commonMain/composeResources/values-zh/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-zh/strings.xml @@ -215,9 +215,9 @@ 应用评价 如果喜欢,欢迎在商店留下五星好评 关于应用 - 100001 - 1.0.1 - 版本 1.0.1 + 100002 + 1.0.2 + 版本 1.0.2 意见反馈 diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 1f53858..c243ae4 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -216,9 +216,9 @@ App Review If you like it, please leave a five-star review in the store About the App - 100001 - 1.0.1 - Version 1.0.1 + 100002 + 1.0.2 + Version 1.0.2 Feedback diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/core/analytics/FacebookEvent.kt b/composeApp/src/commonMain/kotlin/com/taskttl/core/analytics/FacebookEvent.kt new file mode 100644 index 0000000..d95e56f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/taskttl/core/analytics/FacebookEvent.kt @@ -0,0 +1,128 @@ +package com.taskttl.core.analytics + +/** + * Facebook 标准事件定义(官方事件名 + 必填字段) + * https://developers.facebook.com/docs/app-events/reference + */ +sealed class FacebookEvent( + val eventName: String, + val requiredKeys: List = emptyList(), +) { + /** + * 参数 + * @return [Map] + */ + abstract fun params(): Map + + data class Login( + val method: String? = null + ) : FacebookEvent("fb_mobile_login", listOf("method")) { + override fun params() = mapOf("method" to method) + } + + // 内容类 / 电商类 + data class ViewContent( + val contentId: String, + val contentType: String? = null, + val value: Double? = null, + val currency: String? = null + ) : FacebookEvent("fb_mobile_content_view", listOf("content_id")) { + override fun params() = mapOf( + "content_id" to contentId, + "content_type" to contentType, + "value" to value, + "currency" to currency + ) + } + + // 游戏 / 广告类 + data class AchieveLevel(val level: Int) : + FacebookEvent("fb_mobile_level_achieved", listOf("level")) { + override fun params() = mapOf("level" to level) + } + + /** + * 广告次数 + * @author admin + * @date 2025/10/19 + * @constructor 创建[AdImpression] + * @param [adPlatform] 广告平台 如 "AdMob", "ironSource", "AppLovin" 等 + * @param [adSource] 广告来源或网络,如 "admob_banner", "admob_interstitial" + * @param [adFormat] 广告类型,如 "banner", "interstitial", "rewarded_video" + * @param [adPlacementId] 广告位 ID + * @param [value] 收益金额 + * @param [currency] 收益货币(ISO 代码,如 "USD") + * @param [placement] 自定义广告位名称 + */ + data class AdImpression( + val adPlatform: String, + val adSource: String, + val adFormat: String, + val adPlacementId: String, + val currency: String, + val value: Double, + val placement: String? = null + ) : FacebookEvent( + "ad_impression", + listOf("ad_platform", "ad_format", "ad_placement_id", "currency", "value") + ) { + override fun params() = mapOf( + "ad_platform" to adPlatform, + "ad_source" to adSource, + "ad_format" to adFormat, + "ad_placement_id" to adPlacementId, + "currency" to currency, + "value" to value, + "placement" to placement + ) + } + + data class AddToCart( + val contentId: String, + val value: Double, + val currency: String + ) : FacebookEvent("fb_mobile_add_to_cart", listOf("content_id", "value", "currency")) { + override fun params() = mapOf( + "content_id" to contentId, + "value" to value, + "currency" to currency + ) + } + + data class Purchase( + val value: Double, + val currency: String, + val contentId: String? = null + ) : FacebookEvent("fb_mobile_purchase", listOf("value", "currency")) { + override fun params() = mapOf( + "value" to value, + "currency" to currency, + "content_id" to contentId + ) + } + + + /** + * 订阅 / 试用类 + * @author admin + * @date 2025/10/19 + * @constructor 创建[StartTrial] + * @param [trialName] 试验名称 + */ + data class StartTrial(val trialName: String? = null) : + FacebookEvent("start_trial") { + override fun params() = mapOf("trial_name" to trialName) + } + + /** + * 订阅 + * @author admin + * @date 2025/10/19 + * @constructor 创建[Subscribe] + * @param [plan] 计划 + */ + data class Subscribe(val plan: String? = null) : + FacebookEvent("subscribe") { + override fun params() = mapOf("plan" to plan) + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/core/analytics/FacebookEventTracker.kt b/composeApp/src/commonMain/kotlin/com/taskttl/core/analytics/FacebookEventTracker.kt new file mode 100644 index 0000000..a9a96f6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/taskttl/core/analytics/FacebookEventTracker.kt @@ -0,0 +1,8 @@ +package com.taskttl.core.analytics + +/** + * 统一事件接口 + */ +expect object FacebookEventTracker { + fun logEvent(event: FacebookEvent, params: Map = emptyMap()) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/core/analytics/FacebookEventValidator.kt b/composeApp/src/commonMain/kotlin/com/taskttl/core/analytics/FacebookEventValidator.kt new file mode 100644 index 0000000..6cab11e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/taskttl/core/analytics/FacebookEventValidator.kt @@ -0,0 +1,25 @@ +package com.taskttl.core.analytics + +/** + * facebook事件验证器 + * @author admin + * @date 2025/10/19 + */ +object FacebookEventValidator { + + /** + * 验证 + * @param [event] 事件 + * @return [String?] + */ + fun validate(event: FacebookEvent): String? { + val params = event.params() + val missing = event.requiredKeys.filter { key -> + val v = params[key] + v == null || (v is String && v.isBlank()) + } + return if (missing.isNotEmpty()) { + "⚠️ Missing required fields for ${event.eventName}: ${missing.joinToString()}" + } else null + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/core/utils/DateUtils.kt b/composeApp/src/commonMain/kotlin/com/taskttl/core/utils/DateUtils.kt index 4610599..fe9eaca 100644 --- a/composeApp/src/commonMain/kotlin/com/taskttl/core/utils/DateUtils.kt +++ b/composeApp/src/commonMain/kotlin/com/taskttl/core/utils/DateUtils.kt @@ -81,4 +81,12 @@ object DateUtils { CountdownTime(days, hours, minutes, seconds, isExpired = false) } } + + fun LocalDateTime.toEpochMillis(): Long { + return this.toInstant(TimeZone.currentSystemDefault()).toEpochMilliseconds() + } + + fun Long.toLocalDateTime(): LocalDateTime { + return Instant.fromEpochMilliseconds(this).toLocalDateTime(TimeZone.currentSystemDefault()) + } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/data/di/KoinModels.kt b/composeApp/src/commonMain/kotlin/com/taskttl/data/di/KoinModels.kt index 35fc2ab..cf2647a 100644 --- a/composeApp/src/commonMain/kotlin/com/taskttl/data/di/KoinModels.kt +++ b/composeApp/src/commonMain/kotlin/com/taskttl/data/di/KoinModels.kt @@ -1,5 +1,6 @@ package com.taskttl.data.di +import com.taskttl.core.notification.NotificationManager import com.taskttl.data.repository.OnboardingRepository import com.taskttl.data.repository.SettingsRepository import com.taskttl.data.repository.impl.OnboardingRepositoryImpl diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/data/state/SettingsState.kt b/composeApp/src/commonMain/kotlin/com/taskttl/data/state/SettingsState.kt index 5c49f50..14255be 100644 --- a/composeApp/src/commonMain/kotlin/com/taskttl/data/state/SettingsState.kt +++ b/composeApp/src/commonMain/kotlin/com/taskttl/data/state/SettingsState.kt @@ -1,5 +1,6 @@ package com.taskttl.data.state +import com.taskttl.core.notification.NotificationPermissionManager import com.taskttl.core.viewmodel.BaseUiState /** @@ -14,6 +15,7 @@ data class SettingsState( override val isLoading: Boolean = false, override val isProcessing: Boolean = false, override val error: String? = null, + val isNotification: Boolean = NotificationPermissionManager.verifyPermission(), ) : BaseUiState() /** @@ -38,6 +40,17 @@ sealed class SettingsIntent { * @param [url] 网址 */ class OpenUrl(val url: String) : SettingsIntent() + + object RequestPermission : SettingsIntent() + + /** + * 更新通知状态 + * @author DevTTL + * @date 2025/10/17 + * @constructor 创建[UpdateNotificationStatus] + * @param [status] 状态 + */ + class UpdateNotificationStatus(val status: Boolean) : SettingsIntent() } diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/data/state/TaskState.kt b/composeApp/src/commonMain/kotlin/com/taskttl/data/state/TaskState.kt index f16b440..687bfa0 100644 --- a/composeApp/src/commonMain/kotlin/com/taskttl/data/state/TaskState.kt +++ b/composeApp/src/commonMain/kotlin/com/taskttl/data/state/TaskState.kt @@ -39,6 +39,7 @@ data class TaskState( * @constructor 创建[TaskIntent] */ sealed class TaskIntent { + /** * 加载任务 * @author admin diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/data/viewmodel/CountdownViewModel.kt b/composeApp/src/commonMain/kotlin/com/taskttl/data/viewmodel/CountdownViewModel.kt index d753b53..ca2c5d4 100644 --- a/composeApp/src/commonMain/kotlin/com/taskttl/data/viewmodel/CountdownViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/taskttl/data/viewmodel/CountdownViewModel.kt @@ -1,6 +1,10 @@ package com.taskttl.data.viewmodel import androidx.lifecycle.viewModelScope +import com.taskttl.core.notification.NotificationManager +import com.taskttl.core.notification.NotificationPayload +import com.taskttl.core.notification.NotificationRepeatType +import com.taskttl.core.utils.DateUtils.toEpochMillis import com.taskttl.core.viewmodel.BaseViewModel import com.taskttl.data.local.model.Category import com.taskttl.data.local.model.CategoryType diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/data/viewmodel/SettingsViewModel.kt b/composeApp/src/commonMain/kotlin/com/taskttl/data/viewmodel/SettingsViewModel.kt index a42e0f1..5bf968b 100644 --- a/composeApp/src/commonMain/kotlin/com/taskttl/data/viewmodel/SettingsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/taskttl/data/viewmodel/SettingsViewModel.kt @@ -1,7 +1,10 @@ package com.taskttl.data.viewmodel import androidx.lifecycle.viewModelScope +import com.taskttl.core.notification.NotificationPermissionCallback +import com.taskttl.core.notification.NotificationPermissionManager import com.taskttl.core.utils.ExternalAppLauncher +import com.taskttl.core.utils.LogUtils import com.taskttl.core.viewmodel.BaseViewModel import com.taskttl.data.state.SettingsEffect import com.taskttl.data.state.SettingsIntent @@ -22,6 +25,8 @@ class SettingsViewModel() : when (intent) { is SettingsIntent.OpenAppRating -> openAppRating() is SettingsIntent.OpenUrl -> openUrl(intent.url) + is SettingsIntent.RequestPermission -> requestPermission() + is SettingsIntent.UpdateNotificationStatus -> updateNotificationStatus(intent.status) } } @@ -37,6 +42,25 @@ class SettingsViewModel() : } } + private fun requestPermission() { + viewModelScope.launch { + if (NotificationPermissionManager.verifyPermission()) { + NotificationPermissionManager.disablePermission() + } else { + NotificationPermissionManager.requestPermission( + object : NotificationPermissionCallback { + override fun onGranted() = updateState { copy(isNotification = true) } + override fun onDenied() = LogUtils.e("DevTTL", "❌ Android 通知权限被拒绝") + } + ) + } + } + } + + private fun updateNotificationStatus(status: Boolean) { + updateState { copy(isNotification = status) } + } + /** * 清除错误 */ diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/data/viewmodel/TaskViewModel.kt b/composeApp/src/commonMain/kotlin/com/taskttl/data/viewmodel/TaskViewModel.kt index 8b8d487..800d600 100644 --- a/composeApp/src/commonMain/kotlin/com/taskttl/data/viewmodel/TaskViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/taskttl/data/viewmodel/TaskViewModel.kt @@ -1,7 +1,10 @@ package com.taskttl.data.viewmodel import androidx.lifecycle.viewModelScope -import com.taskttl.core.utils.LogUtils +import com.taskttl.core.notification.NotificationManager +import com.taskttl.core.notification.NotificationPayload +import com.taskttl.core.notification.NotificationRepeatType +import com.taskttl.core.utils.DateUtils.toEpochMillis import com.taskttl.core.viewmodel.BaseViewModel import com.taskttl.data.local.model.Category import com.taskttl.data.local.model.CategoryType @@ -24,6 +27,8 @@ import taskttl.composeapp.generated.resources.task_status_update_failed import taskttl.composeapp.generated.resources.task_status_update_success import taskttl.composeapp.generated.resources.task_update_failed import taskttl.composeapp.generated.resources.task_update_success +import kotlin.time.Clock +import kotlin.time.ExperimentalTime /** * 任务视图模型 @@ -87,10 +92,7 @@ class TaskViewModel( try { launch { categoryRepository.getCategoriesByType(CategoryType.TASK) - .collect { categories -> - LogUtils.e("DevTTL", categories.toString()) - updateState { copy(categories = categories) } - } + .collect { categories -> updateState { copy(categories = categories) } } } launch { taskRepository.getAllTasks().collect { tasks -> @@ -104,7 +106,6 @@ class TaskViewModel( } } } catch (e: Exception) { - LogUtils.e("DevTTL", e.message.toString()) val errStr = getString(Res.string.task_load_failed) updateState { copy(isLoading = false, error = e.message ?: errStr) } } @@ -131,19 +132,36 @@ class TaskViewModel( * 添加任务 * @param [task] 任务 */ + @OptIn(ExperimentalTime::class) private fun addTask(task: Task) { viewModelScope.launch { try { if (state.value.isProcessing) return@launch updateState { copy(isLoading = false, isProcessing = true) } + // 1️⃣ 插入任务 taskRepository.insertTask(task) + + // 2️⃣ 创建通知 + task.dueDate?.let { + NotificationManager.scheduleNotification( + NotificationPayload( + id = task.id, + title = task.title, + message = task.description, + triggerTimeMillis = task.dueDate.toEpochMillis(), + repeatType = NotificationRepeatType.NONE + ) + ) + } + + // 3️⃣ UI 提示 sendEvent(TaskEffect.ShowMessage(getString(Res.string.task_add_success))) sendEvent(TaskEffect.NavigateBack) } catch (e: Exception) { val errStr = getString(Res.string.task_add_failed) updateState { copy(error = e.message ?: errStr) } } finally { - updateState { copy(isLoading = false,isProcessing = false) } + updateState { copy(isLoading = false, isProcessing = false) } } } } @@ -158,13 +176,27 @@ class TaskViewModel( if (state.value.isProcessing) return@launch updateState { copy(isLoading = false, isProcessing = true) } taskRepository.updateTask(task) + + NotificationManager.cancelNotification(task.id) + task.dueDate?.let { + NotificationManager.scheduleNotification( + NotificationPayload( + id = task.id, + title = task.title, + message = task.description, + triggerTimeMillis = task.dueDate.toEpochMillis(), + repeatType = NotificationRepeatType.NONE + ) + ) + } + sendEvent(TaskEffect.ShowMessage(getString(Res.string.task_update_success))) sendEvent(TaskEffect.NavigateBack) } catch (e: Exception) { val errStr = getString(Res.string.task_update_failed) updateState { copy(error = e.message ?: errStr) } } finally { - updateState { copy(isLoading = false,isProcessing = false) } + updateState { copy(isLoading = false, isProcessing = false) } } } } @@ -179,12 +211,13 @@ class TaskViewModel( if (state.value.isProcessing) return@launch updateState { copy(isLoading = false, isProcessing = true) } taskRepository.deleteTask(taskId) + NotificationManager.cancelNotification(taskId) sendEvent(TaskEffect.ShowMessage(getString(Res.string.task_delete_success))) } catch (e: Exception) { val errStr = getString(Res.string.task_delete_failed) updateState { copy(error = e.message ?: errStr) } } finally { - updateState { copy(isLoading = false,isProcessing = false) } + updateState { copy(isLoading = false, isProcessing = false) } } } } diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/presentation/onboarding/OnboardingScreen.kt b/composeApp/src/commonMain/kotlin/com/taskttl/presentation/onboarding/OnboardingScreen.kt index cd2c276..44d0019 100644 --- a/composeApp/src/commonMain/kotlin/com/taskttl/presentation/onboarding/OnboardingScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/taskttl/presentation/onboarding/OnboardingScreen.kt @@ -32,7 +32,10 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.taskttl.core.notification.NotificationPermissionCallback +import com.taskttl.core.notification.NotificationPermissionManager import com.taskttl.core.routes.Routes +import com.taskttl.core.utils.LogUtils import com.taskttl.data.local.model.OnboardingPage import com.taskttl.data.state.OnboardingEffect import com.taskttl.data.state.OnboardingIntent @@ -63,6 +66,16 @@ fun OnboardingScreen( val pagerState = rememberPagerState(0, initialPageOffsetFraction = 0f, pageCount = { onboardingPages.size }) + LaunchedEffect(Unit) { + kotlinx.coroutines.delay(300) + NotificationPermissionManager.requestPermission( + object : NotificationPermissionCallback { + override fun onGranted() = LogUtils.e("DevTTL", "✅ Android 通知权限已授予") + override fun onDenied() = LogUtils.e("DevTTL", "❌ Android 通知权限被拒绝") + } + ) + } + LaunchedEffect(Unit) { viewModel.effects.collectLatest { event -> when (event) { diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/presentation/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/taskttl/presentation/settings/SettingsScreen.kt index 26b47b1..06aa4e5 100644 --- a/composeApp/src/commonMain/kotlin/com/taskttl/presentation/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/taskttl/presentation/settings/SettingsScreen.kt @@ -21,17 +21,15 @@ import androidx.compose.material.icons.automirrored.filled.Help import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Settings -import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Storage import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Switch 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 @@ -40,8 +38,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.navigation.NavHostController +import com.taskttl.core.notification.NotificationPermissionManager import com.taskttl.core.routes.Routes -import com.taskttl.core.utils.ExternalAppLauncher import com.taskttl.data.state.SettingsIntent import com.taskttl.data.viewmodel.SettingsViewModel import com.taskttl.ui.components.AppHeader @@ -53,29 +51,18 @@ import taskttl.composeapp.generated.resources.Res import taskttl.composeapp.generated.resources.section_data_management import taskttl.composeapp.generated.resources.section_general_settings import taskttl.composeapp.generated.resources.section_help_feedback -import taskttl.composeapp.generated.resources.section_social_share import taskttl.composeapp.generated.resources.setting_about_app import taskttl.composeapp.generated.resources.setting_about_app_desc import taskttl.composeapp.generated.resources.setting_category_management import taskttl.composeapp.generated.resources.setting_category_management_desc -import taskttl.composeapp.generated.resources.setting_dark_mode -import taskttl.composeapp.generated.resources.setting_dark_mode_desc -import taskttl.composeapp.generated.resources.setting_data_management -import taskttl.composeapp.generated.resources.setting_data_management_desc import taskttl.composeapp.generated.resources.setting_feedback import taskttl.composeapp.generated.resources.setting_feedback_desc -import taskttl.composeapp.generated.resources.setting_invite_friend -import taskttl.composeapp.generated.resources.setting_invite_friend_desc -import taskttl.composeapp.generated.resources.setting_language -import taskttl.composeapp.generated.resources.setting_language_desc import taskttl.composeapp.generated.resources.setting_privacy_policy import taskttl.composeapp.generated.resources.setting_privacy_policy_desc import taskttl.composeapp.generated.resources.setting_privacy_rate import taskttl.composeapp.generated.resources.setting_privacy_rate_desc import taskttl.composeapp.generated.resources.setting_push_notification import taskttl.composeapp.generated.resources.setting_push_notification_desc -import taskttl.composeapp.generated.resources.setting_share_achievement -import taskttl.composeapp.generated.resources.setting_share_achievement_desc import taskttl.composeapp.generated.resources.title_app_settings /** @@ -86,8 +73,21 @@ import taskttl.composeapp.generated.resources.title_app_settings @Preview fun SettingsScreen( navController: NavHostController, - viewModel: SettingsViewModel = koinViewModel() + viewModel: SettingsViewModel = koinViewModel(), ) { + val state by viewModel.state.collectAsState() + + // 轮询监听权限状态变化 + LaunchedEffect(Unit) { + while (true) { + val current = NotificationPermissionManager.verifyPermission() + if (state.isNotification != current) { + viewModel.handleIntent(SettingsIntent.UpdateNotificationStatus(current)) + } + kotlinx.coroutines.delay(2000) + } + } + Box( modifier = Modifier .fillMaxSize() @@ -151,16 +151,15 @@ fun SettingsScreen( // 通用设置 - // SectionTitle(Icons.Default.Settings, Res.string.section_general_settings) + SectionTitle(Icons.Default.Settings, Res.string.section_general_settings) - // var notificationEnabled by remember { mutableStateOf(true) } - // SettingItem( - // titleRes = Res.string.setting_push_notification, - // descriptionRes = Res.string.setting_push_notification_desc, - // showSwitch = true, - // switchState = notificationEnabled, - // onSwitchChanged = { notificationEnabled = it } - // ) + SettingItem( + titleRes = Res.string.setting_push_notification, + descriptionRes = Res.string.setting_push_notification_desc, + showSwitch = true, + switchState = state.isNotification, + onSwitchChanged = { viewModel.handleIntent(SettingsIntent.RequestPermission) } + ) // var darkMode by remember { mutableStateOf(false) } // @@ -291,7 +290,7 @@ fun SettingItem( onSwitchChanged: ((Boolean) -> Unit)? = null, showArrow: Boolean = false, modifier: Modifier = Modifier, - onClick: (() -> Unit)? = null + onClick: (() -> Unit)? = null, ) { Row( modifier = modifier diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/presentation/task/TaskScreen.kt b/composeApp/src/commonMain/kotlin/com/taskttl/presentation/task/TaskScreen.kt index bb17c4f..61eea8f 100644 --- a/composeApp/src/commonMain/kotlin/com/taskttl/presentation/task/TaskScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/taskttl/presentation/task/TaskScreen.kt @@ -55,7 +55,6 @@ import com.taskttl.core.routes.Routes import com.taskttl.core.ui.ActionButtonListItem import com.taskttl.core.ui.ErrorDialog import com.taskttl.core.ui.LoadingOverlay - import com.taskttl.core.utils.ToastUtils import com.taskttl.data.local.model.Task import com.taskttl.data.state.TaskEffect @@ -245,7 +244,6 @@ fun TaskScreen( } } - // 悬浮按钮 FloatingActionButton( onClick = { navController.navigate(Routes.Main.Task.AddTask) }, diff --git a/composeApp/src/iosMain/kotlin/com/taskttl/core/analytics/FacebookEventTracker.ios.kt b/composeApp/src/iosMain/kotlin/com/taskttl/core/analytics/FacebookEventTracker.ios.kt new file mode 100644 index 0000000..8f50f76 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/taskttl/core/analytics/FacebookEventTracker.ios.kt @@ -0,0 +1,24 @@ +package com.taskttl.core.analytics + +import platform.Foundation.NSNumber +import platform.Foundation.NSString +import platform.Foundation.create +import platform.FBSDKCoreKit.* + +actual object FacebookEventTracker { + actual fun logEvent(event: FacebookEvent, params: Map) { + val mutableDict = mutableMapOf() + params.forEach { (key, value) -> + mutableDict[key] = when (value) { + is String -> value as NSString + is Double -> NSNumber(value) + is Int -> NSNumber(value) + is Boolean -> NSNumber(value) + else -> null + } + } + + val eventParams = if (mutableDict.isEmpty()) null else mutableDict as Map + AppEvents.shared.logEvent(event.eventName, parameters = eventParams) + } +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c975786..315c759 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,15 +5,15 @@ androidx-appcompat = "1.7.1" androidx-constraintlayout = "2.2.1" androidx-core = "1.17.0" androidx-espresso = "3.7.0" -androidx-lifecycle = "2.9.4" +androidx-lifecycle = "2.9.5" androidx-testExt = "1.3.0" composeHotReload = "1.0.0-rc02" -composeMultiplatform = "1.9.0" +composeMultiplatform = "1.9.1" junit = "4.13.2" kotlin = "2.2.20" kotlinx-coroutines = "1.10.2" -navigationCompose = "2.9.0" +navigationCompose = "2.9.1" koin = "4.1.1" ktor = "3.3.1" coil3 = "3.3.0" @@ -32,16 +32,18 @@ sqlite = "2.6.1" room = "2.8.2" ksp = "2.2.20-2.0.2" +work = "2.10.5" + # 环境 android-compileSdk = "36" android-minSdk = "24" android-targetSdk = "36" -android-versionCode = "100001" -android-versionName = "1.0.1" +android-versionCode = "100002" +android-versionName = "1.0.2" -android-facebookAppId = "1203530117944408" -android-facebookClientToken = "1ee2da9430c1a589e8aa623bfaaaa586" +android-facebookAppId = "801567145847535" +android-facebookClientToken = "15db47cd9d8d35ccaa49f43e30beefaf" [libraries] @@ -111,6 +113,10 @@ androidx-room-sqlite-wrapper = { module = "androidx.room:room-sqlite-wrapper", v # 谷歌Ads android-play-services-ads-identifier = { module = "com.google.android.gms:play-services-ads-identifier", version.ref = "playServicesAds" } +# 安卓任务 +androidx-work = {module="androidx.work:work-runtime-ktx", version.ref="work"} + + [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } androidLibrary = { id = "com.android.library", version.ref = "agp" }