feat: 消息通知模块(横幅+红点+跳转)
MQTT type=1 新任务推送 → 震动+亮屏+顶部蓝色横幅+卡片红点。 新增: - NotificationManager: 去抖1s+内存存储taskIds+统计对比红点 - NotificationBannerView: Activity层横幅(滑入/10s倒计时/点击) - AppEvent.NewTaskArrived: 携带taskIds和count 集成: - MainActivity: 监听MQTT type=1→NotificationManager→横幅 - HomeFragment: 监听NewTaskArrived→刷新统计+对比红点+横幅点击跳转 - page_main.xml: 3个卡片各加红点角标(FrameLayout包裹) - nav_main.xml: 新增action_home_to_taskDetail Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,8 +7,16 @@ import android.view.View
|
|||||||
import androidx.activity.OnBackPressedCallback
|
import androidx.activity.OnBackPressedCallback
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import com.xiaoqu.watch.databinding.ActivityMainBinding
|
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.service.manager.SystemStateMonitor
|
||||||
|
import com.xiaoqu.watch.ui.widget.NotificationBannerView
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
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 timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
@@ -24,6 +32,16 @@ class MainActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
/** 系统状态监听器(电量、蓝牙状态) */
|
/** 系统状态监听器(电量、蓝牙状态) */
|
||||||
@Inject lateinit var systemStateMonitor: SystemStateMonitor
|
@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?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
@@ -47,13 +65,43 @@ class MainActivity : AppCompatActivity() {
|
|||||||
// 注册系统状态监听(电量、蓝牙)
|
// 注册系统状态监听(电量、蓝牙)
|
||||||
systemStateMonitor.register()
|
systemStateMonitor.register()
|
||||||
|
|
||||||
|
// 初始化通知横幅
|
||||||
|
notificationBanner = binding.notificationBanner
|
||||||
|
|
||||||
|
// 监听 MQTT 新任务消息,显示横幅
|
||||||
|
observeMqttMessages()
|
||||||
|
|
||||||
Timber.d("MainActivity created")
|
Timber.d("MainActivity created")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
// 取消系统状态监听
|
|
||||||
systemStateMonitor.unregister()
|
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 拦截) =====
|
// ===== 下拉手势检测(Activity 级别,不可能被子 View 拦截) =====
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ sealed class AppEvent {
|
|||||||
data class BluetoothDeviceConnected(val deviceName: String) : AppEvent()
|
data class BluetoothDeviceConnected(val deviceName: String) : AppEvent()
|
||||||
data class BluetoothDeviceDisconnected(val deviceName: String) : AppEvent()
|
data class BluetoothDeviceDisconnected(val deviceName: String) : AppEvent()
|
||||||
|
|
||||||
|
// 消息通知
|
||||||
|
/** 新任务到达(携带任务 ID 列表,横幅+红点用) */
|
||||||
|
data class NewTaskArrived(val taskIds: List<String>, val count: Int) : AppEvent()
|
||||||
|
|
||||||
// MQTT 相关
|
// MQTT 相关
|
||||||
data object MqttConnected : AppEvent()
|
data object MqttConnected : AppEvent()
|
||||||
data object MqttDisconnected : AppEvent()
|
data object MqttDisconnected : AppEvent()
|
||||||
|
|||||||
@@ -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<String>()
|
||||||
|
val pendingTaskIds: List<String> 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<Int> {
|
||||||
|
if (oldStats == null) return emptySet()
|
||||||
|
val result = mutableSetOf<Int>()
|
||||||
|
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<String> {
|
||||||
|
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<String>()
|
||||||
|
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<String>()
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,6 +48,7 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
|
|||||||
@Inject lateinit var eventBus: EventBus
|
@Inject lateinit var eventBus: EventBus
|
||||||
@Inject lateinit var taskApi: TaskApi
|
@Inject lateinit var taskApi: TaskApi
|
||||||
@Inject lateinit var bluetoothScanManager: com.xiaoqu.watch.service.manager.BluetoothScanManager
|
@Inject lateinit var bluetoothScanManager: com.xiaoqu.watch.service.manager.BluetoothScanManager
|
||||||
|
@Inject lateinit var notificationManager: com.xiaoqu.watch.service.manager.NotificationManager
|
||||||
|
|
||||||
/** 考勤打卡 ViewModel */
|
/** 考勤打卡 ViewModel */
|
||||||
private val punchViewModel: PunchViewModel by viewModels()
|
private val punchViewModel: PunchViewModel by viewModels()
|
||||||
@@ -64,6 +65,10 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
|
|||||||
private lateinit var tvPoolNum: TextView
|
private lateinit var tvPoolNum: TextView
|
||||||
private lateinit var tvPunchNum: TextView
|
private lateinit var tvPunchNum: TextView
|
||||||
private lateinit var tvCompleteNum: TextView
|
private lateinit var tvCompleteNum: TextView
|
||||||
|
// 红点角标
|
||||||
|
private lateinit var dotPool: View
|
||||||
|
private lateinit var dotPunch: View
|
||||||
|
private lateinit var dotComplete: View
|
||||||
|
|
||||||
// ===== 设置页 View 引用 =====
|
// ===== 设置页 View 引用 =====
|
||||||
private lateinit var tvAvatarLetter: TextView
|
private lateinit var tvAvatarLetter: TextView
|
||||||
@@ -258,14 +263,22 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
|
|||||||
tvPunchNum = page.findViewById(R.id.tvPunchNum)
|
tvPunchNum = page.findViewById(R.id.tvPunchNum)
|
||||||
tvCompleteNum = page.findViewById(R.id.tvCompleteNum)
|
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<View>(R.id.cardPool)?.setOnClickListener {
|
page.findViewById<View>(R.id.cardPool)?.setOnClickListener {
|
||||||
|
dotPool.visibility = View.GONE
|
||||||
navigateToTaskList(2)
|
navigateToTaskList(2)
|
||||||
}
|
}
|
||||||
page.findViewById<View>(R.id.cardPunch)?.setOnClickListener {
|
page.findViewById<View>(R.id.cardPunch)?.setOnClickListener {
|
||||||
|
dotPunch.visibility = View.GONE
|
||||||
navigateToTaskList(3)
|
navigateToTaskList(3)
|
||||||
}
|
}
|
||||||
page.findViewById<View>(R.id.cardComplete)?.setOnClickListener {
|
page.findViewById<View>(R.id.cardComplete)?.setOnClickListener {
|
||||||
|
dotComplete.visibility = View.GONE
|
||||||
navigateToTaskList(4)
|
navigateToTaskList(4)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -296,15 +309,31 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 从 API 获取任务统计数据 */
|
/**
|
||||||
private fun fetchStatistics() {
|
* 从 API 获取任务统计数据
|
||||||
|
* @param checkDots 是否对比红点(新任务到达时 true)
|
||||||
|
*/
|
||||||
|
private fun fetchStatistics(checkDots: Boolean = false) {
|
||||||
viewLifecycleOwner.lifecycleScope.launch {
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
val result = safeApiCall { taskApi.getStatistics() }
|
val result = safeApiCall { taskApi.getStatistics() }
|
||||||
if (result is ApiResult.Success && result.data != null) {
|
if (result is ApiResult.Success && result.data != null) {
|
||||||
val data = result.data
|
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()
|
tvPoolNum.text = data.waitForTask.toString()
|
||||||
tvPunchNum.text = data.treatTask.toString()
|
tvPunchNum.text = data.treatTask.toString()
|
||||||
tvCompleteNum.text = data.incompleteTask.toString()
|
tvCompleteNum.text = data.incompleteTask.toString()
|
||||||
|
|
||||||
|
// 保存为下次对比基准
|
||||||
|
notificationManager.lastStats = data
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -388,12 +417,18 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
|
|||||||
is AppEvent.BluetoothStateChanged -> {
|
is AppEvent.BluetoothStateChanged -> {
|
||||||
statusBar.updateBluetooth(event.isOn)
|
statusBar.updateBluetooth(event.isOn)
|
||||||
}
|
}
|
||||||
|
// 新任务到达 → 刷新统计 + 红点
|
||||||
|
is AppEvent.NewTaskArrived -> {
|
||||||
|
Timber.d("首页: 新任务到达 (${event.count} 条)")
|
||||||
|
fetchStatistics(checkDots = true)
|
||||||
|
setupBannerClick(event.taskIds)
|
||||||
|
}
|
||||||
// MQTT 消息
|
// MQTT 消息
|
||||||
is AppEvent.MqttMessageReceived -> {
|
is AppEvent.MqttMessageReceived -> {
|
||||||
when (event.type) {
|
when (event.type) {
|
||||||
0 -> {
|
0 -> {
|
||||||
// 新任务消息 → 刷新统计数据
|
// 日常动态 → 刷新统计
|
||||||
Timber.d("首页: 收到新任务消息")
|
Timber.d("首页: 收到日常动态")
|
||||||
fetchStatistics()
|
fetchStatistics()
|
||||||
}
|
}
|
||||||
3 -> {
|
3 -> {
|
||||||
@@ -470,11 +505,38 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
|
|||||||
|
|
||||||
/** 跳转到任务列表(传 tableStatus 参数) */
|
/** 跳转到任务列表(传 tableStatus 参数) */
|
||||||
private fun navigateToTaskList(tableStatus: Int) {
|
private fun navigateToTaskList(tableStatus: Int) {
|
||||||
// 防止重复导航
|
|
||||||
val currentDest = findNavController().currentDestination?.id
|
val currentDest = findNavController().currentDestination?.id
|
||||||
if (currentDest != R.id.homeFragment) return
|
if (currentDest != R.id.homeFragment) return
|
||||||
|
|
||||||
val bundle = bundleOf("tableStatus" to tableStatus)
|
val bundle = bundleOf("tableStatus" to tableStatus)
|
||||||
findNavController().navigate(R.id.action_home_to_taskList, bundle)
|
findNavController().navigate(R.id.action_home_to_taskList, bundle)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置通知横幅点击回调
|
||||||
|
* 1 个任务 → 跳任务详情;多个 → 跳任务列表
|
||||||
|
*/
|
||||||
|
private fun setupBannerClick(taskIds: List<String>) {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
6
app/src/main/res/drawable/bg_red_dot.xml
Normal file
6
app/src/main/res/drawable/bg_red_dot.xml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- 红点角标(新消息提示) -->
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="oval">
|
||||||
|
<solid android:color="#FFFF453A" />
|
||||||
|
</shape>
|
||||||
@@ -19,7 +19,14 @@
|
|||||||
app:defaultNavHost="true"
|
app:defaultNavHost="true"
|
||||||
app:navGraph="@navigation/nav_main" />
|
app:navGraph="@navigation/nav_main" />
|
||||||
|
|
||||||
<!-- Layer 2: 全局弹窗层(QuTipDialog / QuConfirmDialog 动态添加,默认隐藏) -->
|
<!-- Layer 2: 通知横幅(所有 Fragment 之上,MQTT 新任务时显示) -->
|
||||||
|
<com.xiaoqu.watch.ui.widget.NotificationBannerView
|
||||||
|
android:id="@+id/notificationBanner"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="top" />
|
||||||
|
|
||||||
|
<!-- Layer 3: 全局弹窗层(QuTipDialog / QuConfirmDialog 动态添加,默认隐藏) -->
|
||||||
<FrameLayout
|
<FrameLayout
|
||||||
android:id="@+id/dialog_container"
|
android:id="@+id/dialog_container"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
|
|||||||
@@ -48,13 +48,17 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:orientation="horizontal">
|
android:orientation="horizontal">
|
||||||
|
|
||||||
<!-- 接单池(蓝色)padding top14→19, bottom12→16, radius14→19 -->
|
<!-- 接单池(蓝色)+ 红点角标 -->
|
||||||
<LinearLayout
|
<FrameLayout
|
||||||
android:id="@+id/cardPool"
|
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:layout_marginEnd="5dp"
|
android:layout_marginEnd="5dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/cardPool"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
android:background="@drawable/bg_quick_blue"
|
android:background="@drawable/bg_quick_blue"
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
@@ -83,14 +87,31 @@
|
|||||||
android:layout_marginTop="5dp" />
|
android:layout_marginTop="5dp" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<!-- 待打卡(橙色) -->
|
<!-- 红点角标 -->
|
||||||
<LinearLayout
|
<View
|
||||||
android:id="@+id/cardPunch"
|
android:id="@+id/dotPool"
|
||||||
|
android:layout_width="10dp"
|
||||||
|
android:layout_height="10dp"
|
||||||
|
android:layout_gravity="top|end"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:layout_marginEnd="4dp"
|
||||||
|
android:background="@drawable/bg_red_dot"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
</FrameLayout>
|
||||||
|
|
||||||
|
<!-- 待打卡(橙色)+ 红点角标 -->
|
||||||
|
<FrameLayout
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:layout_marginStart="3dp"
|
android:layout_marginStart="3dp"
|
||||||
android:layout_marginEnd="3dp"
|
android:layout_marginEnd="3dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/cardPunch"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
android:background="@drawable/bg_quick_orange"
|
android:background="@drawable/bg_quick_orange"
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
@@ -117,13 +138,29 @@
|
|||||||
android:layout_marginTop="5dp" />
|
android:layout_marginTop="5dp" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<!-- 待完成(绿色) -->
|
<View
|
||||||
<LinearLayout
|
android:id="@+id/dotPunch"
|
||||||
android:id="@+id/cardComplete"
|
android:layout_width="10dp"
|
||||||
|
android:layout_height="10dp"
|
||||||
|
android:layout_gravity="top|end"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:layout_marginEnd="4dp"
|
||||||
|
android:background="@drawable/bg_red_dot"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
</FrameLayout>
|
||||||
|
|
||||||
|
<!-- 待完成(绿色)+ 红点角标 -->
|
||||||
|
<FrameLayout
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:layout_marginStart="5dp"
|
android:layout_marginStart="5dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/cardComplete"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
android:background="@drawable/bg_quick_green"
|
android:background="@drawable/bg_quick_green"
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
@@ -150,6 +187,18 @@
|
|||||||
android:layout_marginTop="5dp" />
|
android:layout_marginTop="5dp" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:id="@+id/dotComplete"
|
||||||
|
android:layout_width="10dp"
|
||||||
|
android:layout_height="10dp"
|
||||||
|
android:layout_gravity="top|end"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:layout_marginEnd="4dp"
|
||||||
|
android:background="@drawable/bg_red_dot"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
</FrameLayout>
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<!-- 页面指示器 margin-top:10px→13dp -->
|
<!-- 页面指示器 margin-top:10px→13dp -->
|
||||||
|
|||||||
35
app/src/main/res/layout/view_notification_banner.xml
Normal file
35
app/src/main/res/layout/view_notification_banner.xml
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- 通知横幅(Activity 层,所有页面之上) -->
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:id="@+id/bannerRoot"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="#E80A84FF"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:paddingStart="24dp"
|
||||||
|
android:paddingTop="20dp"
|
||||||
|
android:paddingEnd="24dp"
|
||||||
|
android:paddingBottom="16dp">
|
||||||
|
|
||||||
|
<!-- 主文字:"有 N 条新任务" -->
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvBannerText"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="有 1 条新任务"
|
||||||
|
android:textColor="#FFFFFFFF"
|
||||||
|
android:textSize="20sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<!-- 提示文字:"点击查看 · 8s" -->
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvBannerHint"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="点击查看 · 10s"
|
||||||
|
android:textColor="#99FFFFFF"
|
||||||
|
android:textSize="14sp" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
@@ -28,6 +28,9 @@
|
|||||||
<!-- 首页 → 任务列表 -->
|
<!-- 首页 → 任务列表 -->
|
||||||
<action android:id="@+id/action_home_to_taskList"
|
<action android:id="@+id/action_home_to_taskList"
|
||||||
app:destination="@id/taskListFragment" />
|
app:destination="@id/taskListFragment" />
|
||||||
|
<!-- 首页 → 任务详情(通知横幅点击,1个任务时直接跳详情) -->
|
||||||
|
<action android:id="@+id/action_home_to_taskDetail"
|
||||||
|
app:destination="@id/taskDetailFragment" />
|
||||||
</fragment>
|
</fragment>
|
||||||
|
|
||||||
<!-- 设备绑定页 -->
|
<!-- 设备绑定页 -->
|
||||||
|
|||||||
Reference in New Issue
Block a user