diff --git a/app/src/main/java/com/xiaoqu/watch/app/MainActivity.kt b/app/src/main/java/com/xiaoqu/watch/app/MainActivity.kt index c12bd61..6068819 100644 --- a/app/src/main/java/com/xiaoqu/watch/app/MainActivity.kt +++ b/app/src/main/java/com/xiaoqu/watch/app/MainActivity.kt @@ -7,8 +7,16 @@ import android.view.View import androidx.activity.OnBackPressedCallback import androidx.appcompat.app.AppCompatActivity import com.xiaoqu.watch.databinding.ActivityMainBinding +import com.xiaoqu.watch.event.AppEvent +import com.xiaoqu.watch.event.EventBus +import com.xiaoqu.watch.service.manager.NotificationManager import com.xiaoqu.watch.service.manager.SystemStateMonitor +import com.xiaoqu.watch.ui.widget.NotificationBannerView import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject import kotlin.math.abs @@ -24,6 +32,16 @@ class MainActivity : AppCompatActivity() { /** 系统状态监听器(电量、蓝牙状态) */ @Inject lateinit var systemStateMonitor: SystemStateMonitor + /** 消息通知管理器 */ + @Inject lateinit var notificationManager: NotificationManager + /** 事件总线 */ + @Inject lateinit var eventBus: EventBus + + /** 通知横幅 */ + private lateinit var notificationBanner: NotificationBannerView + + /** Activity 协程作用域 */ + private val activityScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -47,13 +65,43 @@ class MainActivity : AppCompatActivity() { // 注册系统状态监听(电量、蓝牙) systemStateMonitor.register() + // 初始化通知横幅 + notificationBanner = binding.notificationBanner + + // 监听 MQTT 新任务消息,显示横幅 + observeMqttMessages() + Timber.d("MainActivity created") } override fun onDestroy() { super.onDestroy() - // 取消系统状态监听 systemStateMonitor.unregister() + notificationBanner.destroy() + } + + // ===== MQTT 新任务 → 通知横幅 ===== + + /** 监听 MQTT 消息,type=1 时通知横幅 */ + private fun observeMqttMessages() { + activityScope.launch { + eventBus.events.collect { event -> + when (event) { + is AppEvent.MqttMessageReceived -> { + if (event.type == 1) { + // 交给 NotificationManager 处理(去抖+震动+亮屏+事件) + val handled = notificationManager.onNewTaskMessage(event.rawJson) + if (handled) { + // 显示横幅 + notificationBanner.show(notificationManager.pendingCount) + } + } + } + // 横幅点击由 HomeFragment 处理跳转 + else -> {} + } + } + } } // ===== 下拉手势检测(Activity 级别,不可能被子 View 拦截) ===== diff --git a/app/src/main/java/com/xiaoqu/watch/event/AppEvent.kt b/app/src/main/java/com/xiaoqu/watch/event/AppEvent.kt index 5683688..ab4569e 100644 --- a/app/src/main/java/com/xiaoqu/watch/event/AppEvent.kt +++ b/app/src/main/java/com/xiaoqu/watch/event/AppEvent.kt @@ -24,6 +24,10 @@ sealed class AppEvent { data class BluetoothDeviceConnected(val deviceName: String) : AppEvent() data class BluetoothDeviceDisconnected(val deviceName: String) : AppEvent() + // 消息通知 + /** 新任务到达(携带任务 ID 列表,横幅+红点用) */ + data class NewTaskArrived(val taskIds: List, val count: Int) : AppEvent() + // MQTT 相关 data object MqttConnected : AppEvent() data object MqttDisconnected : AppEvent() diff --git a/app/src/main/java/com/xiaoqu/watch/service/manager/NotificationManager.kt b/app/src/main/java/com/xiaoqu/watch/service/manager/NotificationManager.kt new file mode 100644 index 0000000..5cc0cf4 --- /dev/null +++ b/app/src/main/java/com/xiaoqu/watch/service/manager/NotificationManager.kt @@ -0,0 +1,169 @@ +package com.xiaoqu.watch.service.manager + +import com.xiaoqu.watch.data.task.TaskStatistics +import com.xiaoqu.watch.device.screen.ScreenController +import com.xiaoqu.watch.device.sensor.VibrationController +import com.xiaoqu.watch.device.sensor.VibrationDefaults +import com.xiaoqu.watch.event.AppEvent +import com.xiaoqu.watch.event.EventBus +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import org.json.JSONObject +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +/** + * 消息通知管理器 + * + * 职责: + * - 处理 MQTT type=1 新任务消息(去抖 + 解析任务 ID) + * - 震动 + 亮屏反馈 + * - 管理未读任务 ID 列表(内存,不持久化) + * - 对比统计数字变化,确定红点卡片 + */ +@Singleton +class NotificationManager @Inject constructor( + private val vibrationController: VibrationController, + private val screenController: ScreenController, + private val eventBus: EventBus +) { + companion object { + /** 去抖间隔(毫秒) */ + private const val DEBOUNCE_MS = 1000L + /** 新消息震动方案 */ + private const val PLAN_NEW_MESSAGE = 2 + } + + /** 未读的新任务 ID 列表 */ + private val _pendingTaskIds = mutableListOf() + val pendingTaskIds: List get() = _pendingTaskIds.toList() + + /** 未读任务数量 */ + val pendingCount: Int get() = _pendingTaskIds.size + + /** 上次消息处理时间(去抖用) */ + private var lastMessageTime = 0L + + /** 上次统计数据(对比红点用) */ + var lastStats: TaskStatistics? = null + + /** 协程作用域 */ + private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + + /** + * 处理 MQTT type=1 新任务消息 + * @param rawJson MQTT 消息原始 JSON + * @return true=已处理,false=被去抖过滤 + */ + fun onNewTaskMessage(rawJson: String): Boolean { + // 1. 去抖:1s 内重复消息忽略 + val now = System.currentTimeMillis() + if (now - lastMessageTime < DEBOUNCE_MS) { + Timber.d("通知: 去抖过滤 (距上条 ${now - lastMessageTime}ms)") + return false + } + lastMessageTime = now + + // 2. 解析任务 ID + val taskIds = parseTaskIds(rawJson) + if (taskIds.isEmpty()) { + Timber.w("通知: 消息中无有效任务 ID") + return false + } + + // 3. 加入未读列表(去重) + for (id in taskIds) { + if (id !in _pendingTaskIds) { + _pendingTaskIds.add(id) + } + } + Timber.d("通知: 收到 ${taskIds.size} 个新任务, 当前未读 ${_pendingTaskIds.size}") + + // 4. 震动 + 亮屏 + val pattern = VibrationDefaults.getPattern(PLAN_NEW_MESSAGE) + if (pattern != null) { + vibrationController.executePattern(pattern) + } + screenController.turnOn() + + // 5. 发送事件通知 + scope.launch { + eventBus.emit(AppEvent.NewTaskArrived(taskIds, _pendingTaskIds.size)) + } + + return true + } + + /** 消费所有未读消息(用户已查看列表) */ + fun consumeAll() { + _pendingTaskIds.clear() + Timber.d("通知: 已清空全部未读") + } + + /** 按 ID 消费(任务完成时) */ + fun consumeByTaskId(taskId: String) { + _pendingTaskIds.remove(taskId) + Timber.d("通知: 已消费任务 $taskId, 剩余 ${_pendingTaskIds.size}") + } + + /** + * 对比统计数字变化,返回需要加红点的卡片 tableStatus 集合 + * @param oldStats 旧统计 + * @param newStats 新统计 + * @return 数字增加的 tableStatus 集合(2=接单池, 3=待打卡, 4=待完成) + */ + fun diffStats(oldStats: TaskStatistics?, newStats: TaskStatistics): Set { + if (oldStats == null) return emptySet() + val result = mutableSetOf() + if (newStats.waitForTask > oldStats.waitForTask) result.add(2) + if (newStats.treatTask > oldStats.treatTask) result.add(3) + if (newStats.incompleteTask > oldStats.incompleteTask) result.add(4) + return result + } + + /** + * 从 MQTT JSON 中解析任务 ID 列表 + * 消息格式待实际验证,先按旧版推断(消息对象含 id 字段或 taskArr 数组) + */ + private fun parseTaskIds(rawJson: String): List { + return try { + val json = JSONObject(rawJson) + + // 尝试方式1:消息本身就是一个任务(含 id 字段) + val directId = json.optString("id", "") + if (directId.isNotEmpty()) { + return listOf(directId) + } + + // 尝试方式2:消息含 taskArr 数组 + val taskArr = json.optJSONArray("taskArr") + if (taskArr != null) { + val ids = mutableListOf() + for (i in 0 until taskArr.length()) { + val taskId = taskArr.optJSONObject(i)?.optString("id", "") ?: "" + if (taskId.isNotEmpty()) ids.add(taskId) + } + return ids + } + + // 尝试方式3:消息含 data 数组 + val dataArr = json.optJSONArray("data") + if (dataArr != null) { + val ids = mutableListOf() + for (i in 0 until dataArr.length()) { + val taskId = dataArr.optJSONObject(i)?.optString("id", "") ?: "" + if (taskId.isNotEmpty()) ids.add(taskId) + } + return ids + } + + emptyList() + } catch (e: Exception) { + Timber.w(e, "通知: 解析任务 ID 失败") + emptyList() + } + } +} diff --git a/app/src/main/java/com/xiaoqu/watch/ui/home/HomeFragment.kt b/app/src/main/java/com/xiaoqu/watch/ui/home/HomeFragment.kt index ace6ee6..a57efde 100644 --- a/app/src/main/java/com/xiaoqu/watch/ui/home/HomeFragment.kt +++ b/app/src/main/java/com/xiaoqu/watch/ui/home/HomeFragment.kt @@ -48,6 +48,7 @@ class HomeFragment : BaseFragment() { @Inject lateinit var eventBus: EventBus @Inject lateinit var taskApi: TaskApi @Inject lateinit var bluetoothScanManager: com.xiaoqu.watch.service.manager.BluetoothScanManager + @Inject lateinit var notificationManager: com.xiaoqu.watch.service.manager.NotificationManager /** 考勤打卡 ViewModel */ private val punchViewModel: PunchViewModel by viewModels() @@ -64,6 +65,10 @@ class HomeFragment : BaseFragment() { private lateinit var tvPoolNum: TextView private lateinit var tvPunchNum: TextView private lateinit var tvCompleteNum: TextView + // 红点角标 + private lateinit var dotPool: View + private lateinit var dotPunch: View + private lateinit var dotComplete: View // ===== 设置页 View 引用 ===== private lateinit var tvAvatarLetter: TextView @@ -258,14 +263,22 @@ class HomeFragment : BaseFragment() { tvPunchNum = page.findViewById(R.id.tvPunchNum) tvCompleteNum = page.findViewById(R.id.tvCompleteNum) - // 快捷区卡片点击 → 跳转任务列表(传 tableStatus 参数) + // 红点角标 + dotPool = page.findViewById(R.id.dotPool) + dotPunch = page.findViewById(R.id.dotPunch) + dotComplete = page.findViewById(R.id.dotComplete) + + // 快捷区卡片点击 → 跳转任务列表 + 清除红点 page.findViewById(R.id.cardPool)?.setOnClickListener { + dotPool.visibility = View.GONE navigateToTaskList(2) } page.findViewById(R.id.cardPunch)?.setOnClickListener { + dotPunch.visibility = View.GONE navigateToTaskList(3) } page.findViewById(R.id.cardComplete)?.setOnClickListener { + dotComplete.visibility = View.GONE navigateToTaskList(4) } } @@ -296,15 +309,31 @@ class HomeFragment : BaseFragment() { } } - /** 从 API 获取任务统计数据 */ - private fun fetchStatistics() { + /** + * 从 API 获取任务统计数据 + * @param checkDots 是否对比红点(新任务到达时 true) + */ + private fun fetchStatistics(checkDots: Boolean = false) { viewLifecycleOwner.lifecycleScope.launch { val result = safeApiCall { taskApi.getStatistics() } if (result is ApiResult.Success && result.data != null) { val data = result.data + + // 对比红点 + if (checkDots) { + val changedCards = notificationManager.diffStats(notificationManager.lastStats, data) + if (2 in changedCards) dotPool.visibility = View.VISIBLE + if (3 in changedCards) dotPunch.visibility = View.VISIBLE + if (4 in changedCards) dotComplete.visibility = View.VISIBLE + } + + // 更新数字 tvPoolNum.text = data.waitForTask.toString() tvPunchNum.text = data.treatTask.toString() tvCompleteNum.text = data.incompleteTask.toString() + + // 保存为下次对比基准 + notificationManager.lastStats = data } } } @@ -388,12 +417,18 @@ class HomeFragment : BaseFragment() { is AppEvent.BluetoothStateChanged -> { statusBar.updateBluetooth(event.isOn) } + // 新任务到达 → 刷新统计 + 红点 + is AppEvent.NewTaskArrived -> { + Timber.d("首页: 新任务到达 (${event.count} 条)") + fetchStatistics(checkDots = true) + setupBannerClick(event.taskIds) + } // MQTT 消息 is AppEvent.MqttMessageReceived -> { when (event.type) { 0 -> { - // 新任务消息 → 刷新统计数据 - Timber.d("首页: 收到新任务消息") + // 日常动态 → 刷新统计 + Timber.d("首页: 收到日常动态") fetchStatistics() } 3 -> { @@ -470,11 +505,38 @@ class HomeFragment : BaseFragment() { /** 跳转到任务列表(传 tableStatus 参数) */ private fun navigateToTaskList(tableStatus: Int) { - // 防止重复导航 val currentDest = findNavController().currentDestination?.id if (currentDest != R.id.homeFragment) return val bundle = bundleOf("tableStatus" to tableStatus) findNavController().navigate(R.id.action_home_to_taskList, bundle) } + + /** + * 设置通知横幅点击回调 + * 1 个任务 → 跳任务详情;多个 → 跳任务列表 + */ + private fun setupBannerClick(taskIds: List) { + val mainActivity = activity as? com.xiaoqu.watch.app.MainActivity ?: return + mainActivity.notificationBanner.onClick = { + val currentDest = findNavController().currentDestination?.id + if (currentDest == R.id.homeFragment) { + if (taskIds.size == 1) { + // 1 个任务 → 直接跳详情 + val taskId = taskIds.first().toLongOrNull() ?: 0L + val bundle = bundleOf("taskId" to taskId) + findNavController().navigate(R.id.action_home_to_taskDetail, bundle) + } else { + // 多个任务 → 跳任务列表(传 taskIds) + val bundle = bundleOf("tableStatus" to 2) // 默认接单池 + findNavController().navigate(R.id.action_home_to_taskList, bundle) + } + // 清除所有红点和未读 + dotPool.visibility = View.GONE + dotPunch.visibility = View.GONE + dotComplete.visibility = View.GONE + notificationManager.consumeAll() + } + } + } } diff --git a/app/src/main/java/com/xiaoqu/watch/ui/widget/NotificationBannerView.kt b/app/src/main/java/com/xiaoqu/watch/ui/widget/NotificationBannerView.kt new file mode 100644 index 0000000..c47dc7d --- /dev/null +++ b/app/src/main/java/com/xiaoqu/watch/ui/widget/NotificationBannerView.kt @@ -0,0 +1,138 @@ +package com.xiaoqu.watch.ui.widget + +import android.content.Context +import android.os.CountDownTimer +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.animation.DecelerateInterpolator +import android.widget.LinearLayout +import android.widget.TextView +import com.xiaoqu.watch.R +import timber.log.Timber + +/** + * 通知横幅(Activity 层,所有页面之上) + * + * 功能: + * - 从顶部滑入显示"有 N 条新任务" + * - 10s 倒计时自动收起 + * - 点击触发回调 + * - 新消息到达时更新数字并重置倒计时 + */ +class NotificationBannerView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : LinearLayout(context, attrs, defStyleAttr) { + + companion object { + /** 自动收起倒计时(秒) */ + private const val COUNTDOWN_SECONDS = 10 + /** 动画时长(毫秒) */ + private const val ANIM_DURATION = 250L + } + + private val tvBannerText: TextView + private val tvBannerHint: TextView + + /** 横幅是否正在显示 */ + var isShowing = false + private set + + /** 点击回调 */ + var onClick: (() -> Unit)? = null + + /** 倒计时器 */ + private var countDownTimer: CountDownTimer? = null + + init { + LayoutInflater.from(context).inflate(R.layout.view_notification_banner, this, true) + visibility = GONE + + tvBannerText = findViewById(R.id.tvBannerText) + tvBannerHint = findViewById(R.id.tvBannerHint) + + // 点击横幅 + setOnClickListener { + onClick?.invoke() + dismiss() + } + } + + /** + * 显示横幅 + * @param count 新任务数量 + */ + fun show(count: Int) { + if (count <= 0) return + + // 更新文字 + tvBannerText.text = "有 ${count} 条新任务" + + if (isShowing) { + // 已显示 → 只更新数字和重置倒计时 + resetCountdown() + Timber.d("通知横幅: 更新数字为 $count") + return + } + + // 首次显示:滑入动画 + isShowing = true + visibility = VISIBLE + translationY = -height.toFloat().coerceAtLeast(100f) + animate() + .translationY(0f) + .setDuration(ANIM_DURATION) + .setInterpolator(DecelerateInterpolator()) + .start() + + // 启动倒计时 + resetCountdown() + Timber.d("通知横幅: 显示 (${count} 条新任务)") + } + + /** 收起横幅 */ + fun dismiss() { + if (!isShowing) return + isShowing = false + + // 取消倒计时 + countDownTimer?.cancel() + countDownTimer = null + + // 滑出动画 + animate() + .translationY(-height.toFloat().coerceAtLeast(100f)) + .setDuration(ANIM_DURATION) + .setInterpolator(DecelerateInterpolator()) + .withEndAction { + visibility = GONE + } + .start() + + Timber.d("通知横幅: 收起") + } + + /** 重置倒计时(新消息到达或首次显示) */ + private fun resetCountdown() { + countDownTimer?.cancel() + countDownTimer = object : CountDownTimer( + COUNTDOWN_SECONDS * 1000L, 1000L + ) { + override fun onTick(millisUntilFinished: Long) { + val seconds = (millisUntilFinished / 1000) + 1 + tvBannerHint.text = "点击查看 · ${seconds}s" + } + + override fun onFinish() { + dismiss() + } + }.start() + } + + /** 清理资源 */ + fun destroy() { + countDownTimer?.cancel() + countDownTimer = null + } +} diff --git a/app/src/main/res/drawable/bg_red_dot.xml b/app/src/main/res/drawable/bg_red_dot.xml new file mode 100644 index 0000000..f6aadc4 --- /dev/null +++ b/app/src/main/res/drawable/bg_red_dot.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index aa906a9..cff7b2d 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -19,7 +19,14 @@ app:defaultNavHost="true" app:navGraph="@navigation/nav_main" /> - + + + + - - + + + - - + + + + + + + + - - + + + + + + + + + + + diff --git a/app/src/main/res/layout/view_notification_banner.xml b/app/src/main/res/layout/view_notification_banner.xml new file mode 100644 index 0000000..f16d6e3 --- /dev/null +++ b/app/src/main/res/layout/view_notification_banner.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + diff --git a/app/src/main/res/navigation/nav_main.xml b/app/src/main/res/navigation/nav_main.xml index b6948a5..6563041 100644 --- a/app/src/main/res/navigation/nav_main.xml +++ b/app/src/main/res/navigation/nav_main.xml @@ -28,6 +28,9 @@ + +