Compare commits

...

2 Commits

Author SHA1 Message Date
5f07605a06 更新 2025-10-19 21:51:36 +08:00
c7d738ccce 通知 2025-10-19 21:51:00 +08:00
33 changed files with 1071 additions and 67 deletions

View File

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

View File

@@ -1,12 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:tools="http://schemas.android.com/tools"
xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- 添加网络权限 -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- 添加广告ID权限 -->
<uses-permission android:name="com.google.android.gms.permission.AD_ID"
<uses-permission
android:name="com.google.android.gms.permission.AD_ID"
tools:ignore="AdvertisingIdPolicy" />
<!--通知权限-->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!--Android 12+ 精确闹钟-->
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application
android:name=".MainApplication"
@@ -17,9 +23,9 @@
android:supportsRtl="true"
android:theme="@android:style/Theme.Material.Light.NoActionBar">
<activity
android:exported="true"
android:name=".MainActivity"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|mnc|colorMode|density|fontScale|fontWeightAdjustment|keyboard|layoutDirection|locale|mcc|navigation|smallestScreenSize|touchscreen|uiMode"
android:name=".MainActivity">
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@@ -39,6 +45,50 @@
</intent-filter>
</activity>
<activity
android:name="com.facebook.FacebookActivity"
android:configChanges="keyboard|keyboardHidden|screenLayout|screenSize|orientation" />
<activity
android:name="com.facebook.CustomTabActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="fb${facebookAppId}" />
</intent-filter>
</activity>
<!-- AlarmReceiver -->
<receiver
android:name=".core.alarm.AlarmReceiver"
android:enabled="true"
android:exported="true"
tools:ignore="ExportedReceiver">
<intent-filter>
<action android:name="com.taskttl.ALARM_TRIGGER" />
</intent-filter>
</receiver>
<receiver
android:name=".core.alarm.BootReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
<!-- Facebook -->
<meta-data
android:name="com.facebook.sdk.ApplicationId"
android:value="${facebookAppId}" />
<meta-data
android:name="com.facebook.sdk.ClientToken"
android:value="${facebookClientToken}" />
</application>
<queries>

View File

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

View File

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

View File

@@ -0,0 +1,67 @@
package com.taskttl.core.alarm
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.taskttl.core.notification.NotificationManager
import com.taskttl.core.notification.NotificationPayload
import com.taskttl.core.notification.NotificationRepeatType
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class AlarmReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != "com.taskttl.ALARM_TRIGGER") return
val id = intent.getStringExtra("id") ?: return
val title = intent.getStringExtra("title") ?: "TaskTTL"
val message = intent.getStringExtra("message") ?: ""
// 使用协程调用统一通知方法
CoroutineScope(Dispatchers.Main).launch {
NotificationManager.scheduleNotification(
NotificationPayload(
id = id,
title = title,
message = message,
triggerTimeMillis = System.currentTimeMillis(),
repeatType = NotificationRepeatType.NONE
)
)
}
// val id = intent.getStringExtra("id") ?: return
// val title = intent.getStringExtra("title") ?: "TaskTTL"
// val message = intent.getStringExtra("message") ?: ""
//
// val channelId = "taskttl_channel"
//
// // 确保通道存在
// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager
// if (nm.getNotificationChannel(channelId) == null) {
// val channel = android.app.NotificationChannel(
// channelId,
// "TaskTTL Notifications",
// android.app.NotificationManager.IMPORTANCE_HIGH
// )
// channel.description = "TaskTTL 提醒通知"
// nm.createNotificationChannel(channel)
// }
// }
//
// // 构建通知
// val builder = NotificationCompat.Builder(context, channelId)
// .setContentTitle(title)
// .setContentText(message)
// .setSmallIcon(R.mipmap.ic_launcher)
// .setAutoCancel(true)
// .setPriority(NotificationCompat.PRIORITY_HIGH)
//
// val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager
// nm.notify(id.hashCode(), builder.build())
}
}

View File

@@ -0,0 +1,34 @@
package com.taskttl.core.alarm
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.taskttl.core.notification.NotificationPayload
class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == Intent.ACTION_BOOT_COMPLETED) {
// 重新注册所有闹钟(可从数据库恢复)
// 这里假设你有一个持久化存储保存了待触发通知列表
// 例如 Room / SharedPreferences / 数据库
// val pendingNotifications = loadPendingNotifications(context)
//
// // 使用协程重新注册通知
// CoroutineScope(Dispatchers.Main).launch {
// pendingNotifications.forEach { payload ->
// NotificationManager.scheduleNotification(payload)
// }
// }
}
}
/**
* 从存储中获取设备重启后需要重新注册的通知
*/
private fun loadPendingNotifications(context: Context): List<NotificationPayload> {
// TODO: 这里你需要实现真实数据恢复逻辑
// 示例:返回一个空列表
return emptyList()
}
}

View File

@@ -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<String, Any?>) {
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)
}
}

View File

@@ -0,0 +1,197 @@
package com.taskttl.core.notification
import android.Manifest
import android.app.Activity
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.media.RingtoneManager
import android.os.Build
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.work.*
import com.taskttl.MainApplication
import com.taskttl.R
import com.taskttl.core.alarm.AlarmReceiver
import com.taskttl.core.utils.LogUtils
import java.util.concurrent.TimeUnit
import kotlin.math.max
actual object NotificationManager {
private val channelId = "taskttl_channel"
init {
setupNotificationChannel()
}
private fun getActivity(): Activity = MainApplication.currentActivity()
?: throw IllegalStateException("No activity available")
/** 创建通知通道Android 8+ */
private fun setupNotificationChannel() {
val activity = MainApplication.currentActivity() ?: return
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val nm = activity.getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager
if (nm.getNotificationChannel(channelId) == null) {
val channel = android.app.NotificationChannel(
channelId,
"TaskTTL Notifications",
android.app.NotificationManager.IMPORTANCE_HIGH
).apply { description = "TaskTTL 提醒通知" }
nm.createNotificationChannel(channel)
LogUtils.d("DevTTL_NotificationTest", "Notification channel created")
}
}
}
/** 调度通知 */
actual suspend fun scheduleNotification(payload: NotificationPayload) {
val activity = getActivity()
// Android 13+ 权限检查
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(activity, Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED
) {
ActivityCompat.requestPermissions(
activity,
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
1001
)
return
}
val delay = max(0, payload.triggerTimeMillis - System.currentTimeMillis())
if (delay == 0L) {
showImmediateNotification(payload)
return
}
if (delay <= 24 * 60 * 60 * 1000L && payload.repeatType == NotificationRepeatType.NONE) {
scheduleExactAlarm(payload)
} else {
scheduleWorkManager(payload)
}
}
/** AlarmManager 精确闹钟 */
private fun scheduleExactAlarm(payload: NotificationPayload) {
val activity = getActivity()
val alarmManager = activity.getSystemService(Context.ALARM_SERVICE) as AlarmManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !alarmManager.canScheduleExactAlarms()) {
scheduleWorkManager(payload) // 回退 WorkManager
return
}
val intent = Intent(activity, AlarmReceiver::class.java).apply {
action = "com.taskttl.ALARM_TRIGGER"
putExtra("id", payload.id)
putExtra("title", payload.title)
putExtra("message", payload.message)
}
val pendingIntent = PendingIntent.getBroadcast(
activity,
payload.id.hashCode(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
alarmManager.setExactAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP,
payload.triggerTimeMillis,
pendingIntent
)
}
/** WorkManager 长期/周期通知 */
private fun scheduleWorkManager(payload: NotificationPayload) {
val activity = getActivity()
val data = workDataOf(
"id" to payload.id,
"title" to payload.title,
"message" to payload.message,
"repeatType" to payload.repeatType.name
)
val workManager = WorkManager.getInstance(activity)
if (payload.repeatType == NotificationRepeatType.NONE) {
val delay = max(0, payload.triggerTimeMillis - System.currentTimeMillis())
val request = OneTimeWorkRequestBuilder<NotificationWorker>()
.setInitialDelay(delay, TimeUnit.MILLISECONDS)
.setInputData(data)
.addTag(payload.id)
.build()
workManager.enqueue(request)
} else {
val interval = when (payload.repeatType) {
NotificationRepeatType.DAILY -> 24 * 60 * 60 * 1000L
NotificationRepeatType.WEEKLY -> 7 * 24 * 60 * 60 * 1000L
NotificationRepeatType.MONTHLY -> 30 * 24 * 60 * 60 * 1000L
else -> 24 * 60 * 60 * 1000L
}
val request = PeriodicWorkRequestBuilder<NotificationWorker>(interval, TimeUnit.MILLISECONDS)
.setInputData(data)
.addTag(payload.id)
.build()
workManager.enqueueUniquePeriodicWork(
payload.id,
ExistingPeriodicWorkPolicy.UPDATE,
request
)
}
}
/** 立即显示通知 */
private fun showImmediateNotification(payload: NotificationPayload) {
val activity = getActivity()
val nm = activity.getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager
val notification = NotificationCompat.Builder(activity, channelId)
.setContentTitle(payload.title)
.setContentText(payload.message)
.setSmallIcon(R.mipmap.ic_launcher)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
// .setDefaults(NotificationCompat.DEFAULT_SOUND)
.setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION))
.build()
nm.notify(payload.id.hashCode(), notification)
}
/** 取消通知 */
actual suspend fun cancelNotification(id: String) {
val activity = getActivity()
WorkManager.getInstance(activity).cancelAllWorkByTag(id)
val alarmManager = activity.getSystemService(Context.ALARM_SERVICE) as AlarmManager
val intent = Intent(activity, AlarmReceiver::class.java)
val pendingIntent = PendingIntent.getBroadcast(
activity,
id.hashCode(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
alarmManager.cancel(pendingIntent)
val nm = activity.getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager
nm.cancel(id.hashCode())
}
actual suspend fun cancelAll() {
val activity = getActivity()
WorkManager.getInstance(activity).cancelAllWork()
val nm = activity.getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager
nm.cancelAll()
}
}

View File

@@ -0,0 +1,123 @@
package com.taskttl.core.notification
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.util.Log
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import com.taskttl.MainApplication
import androidx.core.content.edit
actual object NotificationPermissionManager {
private const val REQUEST_CODE = 2025
private var callback: NotificationPermissionCallback? = null
/**
* 在 Activity 的 onRequestPermissionsResult 中调用
*/
fun handlePermissionResult(requestCode: Int, grantResults: IntArray) {
if (requestCode == REQUEST_CODE) {
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
callback?.onGranted()
} else {
callback?.onDenied()
}
}
}
// expect/actual 适配Android 需要 Activity 参数
actual fun requestPermission(callback: NotificationPermissionCallback) {
// Android 13 以下无需权限
if (Build.VERSION.SDK_INT < 33) {
callback.onGranted()
return
}
val activity = MainApplication.currentActivity()
if (activity == null) {
Log.w("NotificationPermission", "No current activity found")
callback.onDenied()
return
}
this.callback = callback
val prefs = activity.getSharedPreferences("app_prefs", Context.MODE_PRIVATE)
val permission = Manifest.permission.POST_NOTIFICATIONS
val granted = ContextCompat.checkSelfPermission(
activity, permission
) == PackageManager.PERMISSION_GRANTED
if (granted) {
callback.onGranted()
} else {
val isFirstRequest = prefs.getBoolean("notification_first_request", true)
val shouldShowRationale =
ActivityCompat.shouldShowRequestPermissionRationale(activity, permission)
if (!shouldShowRationale && !isFirstRequest) {
openNotificationSettings()
callback.onDenied()
} else {
ActivityCompat.requestPermissions(activity, arrayOf(permission), REQUEST_CODE)
}
}
prefs.edit { putBoolean("notification_first_request", false) }
}
actual fun verifyPermission(): Boolean {
// Android 13 以下无需权限
if (Build.VERSION.SDK_INT < 33) {
return true
}
val activity = MainApplication.currentActivity()
if (activity == null) {
Log.w("NotificationPermission", "No current activity found")
return false
}
val permission = Manifest.permission.POST_NOTIFICATIONS
val granted = ContextCompat.checkSelfPermission(
activity, permission
) == PackageManager.PERMISSION_GRANTED
return granted
}
/**
* 引导用户到系统设置页关闭通知权限
*/
actual fun disablePermission() {
openNotificationSettings()
}
private fun openNotificationSettings() {
val context = MainApplication.currentActivity() ?: MainApplication.instance
val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// API 26 及以上,打开通知设置页
Intent().apply {
action = android.provider.Settings.ACTION_APP_NOTIFICATION_SETTINGS
putExtra(android.provider.Settings.EXTRA_APP_PACKAGE, context.packageName)
}
} else {
// API 26 以下,打开应用详情页
Intent().apply {
action = android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS
data = "package:${context.packageName}".toUri()
}
}
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
try {
context.startActivity(intent)
Log.d("NotificationPermission", "打开通知设置页")
} catch (e: Exception) {
Log.e("NotificationPermission", "无法打开通知设置页: ${e.message}")
}
}
}

View File

@@ -0,0 +1,54 @@
package com.taskttl.core.notification
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.media.RingtoneManager
import androidx.core.app.NotificationCompat
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.taskttl.MainActivity
import com.taskttl.R
class NotificationWorker(
context: Context,
params: WorkerParameters
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val id = inputData.getString("id") ?: return Result.failure()
val title = inputData.getString("title") ?: "TaskTTL"
val message = inputData.getString("message") ?: ""
sendNotification(id, title, message)
return Result.success()
}
private fun sendNotification(id: String, title: String, message: String) {
val context = applicationContext
val channelId = "taskttl_channel"
val intent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
val pendingIntent = PendingIntent.getActivity(
context,
id.hashCode(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val builder = NotificationCompat.Builder(context, channelId)
.setContentTitle(title)
.setContentText(message)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION))
.setPriority(NotificationCompat.PRIORITY_HIGH)
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager
nm.notify(id.hashCode(), builder.build())
}
}

View File

@@ -215,9 +215,9 @@
<string name="setting_privacy_rate">应用评价</string>
<string name="setting_privacy_rate_desc">如果喜欢,欢迎在商店留下五星好评</string>
<string name="setting_about_app">关于应用</string>
<string name="app_version_code">100001</string>
<string name="app_version_name">1.0.1</string>
<string name="setting_about_app_desc">版本 1.0.1</string>
<string name="app_version_code">100002</string>
<string name="app_version_name">1.0.2</string>
<string name="setting_about_app_desc">版本 1.0.2</string>
<!-- 反馈与帮助 -->
<string name="title_feedback">意见反馈</string>

View File

@@ -216,9 +216,9 @@
<string name="setting_privacy_rate">App Review</string>
<string name="setting_privacy_rate_desc">If you like it, please leave a five-star review in the store</string>
<string name="setting_about_app">About the App</string>
<string name="app_version_code">100001</string>
<string name="app_version_name">1.0.1</string>
<string name="setting_about_app_desc">Version 1.0.1</string>
<string name="app_version_code">100002</string>
<string name="app_version_name">1.0.2</string>
<string name="setting_about_app_desc">Version 1.0.2</string>
<!-- Feedback & Help -->
<string name="title_feedback">Feedback</string>

View File

@@ -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<String> = emptyList(),
) {
/**
* 参数
* @return [Map<String, Any?>]
*/
abstract fun params(): Map<String, Any?>
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)
}
}

View File

@@ -0,0 +1,8 @@
package com.taskttl.core.analytics
/**
* 统一事件接口
*/
expect object FacebookEventTracker {
fun logEvent(event: FacebookEvent, params: Map<String, Any?> = emptyMap())
}

View File

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

View File

@@ -0,0 +1,27 @@
package com.taskttl.core.notification
/**
* 通知管理器
* @author admin
* @date 2025/10/16
* @constructor 创建[NotificationManager]
*/
expect object NotificationManager {
/**
* 日程通知
* @param [payload] 有效载荷
*/
suspend fun scheduleNotification(payload: NotificationPayload)
/**
* 取消通知
* @param [id] ID
*/
suspend fun cancelNotification(id: String)
/**
* 全部取消
*/
suspend fun cancelAll()
}

View File

@@ -0,0 +1,20 @@
package com.taskttl.core.notification
/**
* 通知有效载荷
* @author admin
* @date 2025/10/16
* @constructor 创建[NotificationPayload]
* @param [id] ID
* @param [title] 标题
* @param [message] 消息
* @param [triggerTimeMillis] 触发时间毫秒
* @param [repeatType] 重复类型
*/
data class NotificationPayload(
val id: String,
val title: String,
val message: String,
val triggerTimeMillis: Long,
val repeatType: NotificationRepeatType = NotificationRepeatType.NONE,
)

View File

@@ -0,0 +1,31 @@
package com.taskttl.core.notification
/**
* 通知权限回调接口
*/
interface NotificationPermissionCallback {
/**
* 授予
*/
fun onGranted()
/**
* 被否认
*/
fun onDenied()
}
/**
* 跨平台通知权限处理器
*/
expect object NotificationPermissionManager {
/**
* 请求通知权限
* @return 是否允许
*/
fun requestPermission(callback: NotificationPermissionCallback)
fun verifyPermission(): Boolean
fun disablePermission()
}

View File

@@ -0,0 +1,14 @@
package com.taskttl.core.notification
/**
* 通知重复类型
* @author admin
* @date 2025/10/16
* @constructor 创建[NotificationRepeatType]
*/
enum class NotificationRepeatType {
NONE,
DAILY,
WEEKLY,
MONTHLY
}

View File

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

View File

@@ -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

View File

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

View File

@@ -39,6 +39,7 @@ data class TaskState(
* @constructor 创建[TaskIntent]
*/
sealed class TaskIntent {
/**
* 加载任务
* @author admin

View File

@@ -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

View File

@@ -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) }
}
/**
* 清除错误
*/

View File

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

View File

@@ -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) {

View File

@@ -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

View File

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

View File

@@ -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<String, Any?>) {
val mutableDict = mutableMapOf<Any?, Any?>()
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<Any?, Any?>
AppEvents.shared.logEvent(event.eventName, parameters = eventParams)
}
}

View File

@@ -0,0 +1,43 @@
package com.taskttl.core.notification
import platform.UserNotifications.*
actual class NotificationManager {
actual suspend fun scheduleNotification(payload: NotificationPayload) {
val content = UNMutableNotificationContent().apply {
title = payload.title
body = payload.message
sound = UNNotificationSound.defaultSound()
}
val interval = (payload.triggerTimeMillis - getCurrentTimeMillis()) / 1000.0
val trigger = UNTimeIntervalNotificationTrigger.triggerWithTimeInterval(
interval,
repeats = payload.repeatType != NotificationRepeatType.NONE
)
val request = UNNotificationRequest.requestWithIdentifier(
payload.id,
content,
trigger
)
UNUserNotificationCenter.currentNotificationCenter()
.addNotificationRequest(request) { error ->
error?.let { println("❌ Notification Error: ${it.localizedDescription}") }
}
}
actual suspend fun cancelNotification(id: String) {
UNUserNotificationCenter.currentNotificationCenter()
.removePendingNotificationRequestsWithIdentifiers(listOf(id))
}
actual suspend fun cancelAll() {
UNUserNotificationCenter.currentNotificationCenter()
.removeAllPendingNotificationRequests()
}
private fun getCurrentTimeMillis(): Long =
(platform.Foundation.NSDate().timeIntervalSince1970 * 1000).toLong()
}

View File

@@ -0,0 +1,14 @@
package com.taskttl.core.notification
import platform.UserNotifications.*
actual object NotificationPermissionManager {
actual fun requestPermission(callback: NotificationPermissionCallback) {
val center = UNUserNotificationCenter.currentNotificationCenter()
center.requestAuthorizationWithOptions(
UNAuthorizationOptionAlert or UNAuthorizationOptionSound or UNAuthorizationOptionBadge
) { granted, _ ->
if (granted) callback.onGranted() else callback.onDenied()
}
}
}

View File

@@ -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" }