feat: 首页与应用壳模块

新增:
- StatusBarView 自定义状态栏(圆点+信号条+电池壳,按原型图V3)
- ViewPager2 左右滑动(设置页/主页,默认主页)
- 主页:时钟+日期+快捷区3卡片(对接statisticsNew API)
- 设置页:圆形头像+用户信息+设备信息+调试模式
- TaskApi 接口(统计+考勤)
- HomePagerAdapter(View方式,避免Fragment嵌套)
- 页面指示器+快捷区卡片背景drawable

修改:
- HomeFragment 重写为ViewPager2容器
- NetworkModule 添加TaskApi提供者
- styles.xml 添加ConfigRow/Label/Value样式

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
dongliang
2026-04-27 18:37:06 +09:30
parent e6d5d6fe8a
commit e07a2242a9
17 changed files with 824 additions and 203 deletions

View File

@@ -0,0 +1,14 @@
package com.xiaoqu.watch.data.task
import com.google.gson.annotations.SerializedName
/**
* 考勤状态信息
* 对应 watchTask/myCurrentAttendance API 返回
*/
data class AttendanceInfo(
/** 工作状态 (0=工作中) */
@SerializedName("workStatus") val workStatus: Int = 0,
/** 考勤状态 (3=未上班) */
@SerializedName("workAtStatus") val workAtStatus: Int = 0
)

View File

@@ -0,0 +1,16 @@
package com.xiaoqu.watch.data.task
import com.google.gson.annotations.SerializedName
/**
* 首页任务统计数据
* 对应 watchTask/statisticsNew API 返回
*/
data class TaskStatistics(
/** 接单池(待抢单) */
@SerializedName("waitForTask") val waitForTask: Int = 0,
/** 待打卡 */
@SerializedName("treatTask") val treatTask: Int = 0,
/** 待完成 */
@SerializedName("incompleteTask") val incompleteTask: Int = 0
)

View File

@@ -6,6 +6,7 @@ import com.xiaoqu.watch.network.EnvConfig
import com.xiaoqu.watch.network.SignatureInterceptor
import com.xiaoqu.watch.network.UnbindInterceptor
import com.xiaoqu.watch.network.api.CommonApi
import com.xiaoqu.watch.network.api.TaskApi
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@@ -62,4 +63,10 @@ object NetworkModule {
fun provideCommonApi(retrofit: Retrofit): CommonApi {
return retrofit.create(CommonApi::class.java)
}
@Provides
@Singleton
fun provideTaskApi(retrofit: Retrofit): TaskApi {
return retrofit.create(TaskApi::class.java)
}
}

View File

@@ -0,0 +1,20 @@
package com.xiaoqu.watch.network.api
import com.xiaoqu.watch.data.task.AttendanceInfo
import com.xiaoqu.watch.data.task.TaskStatistics
import com.xiaoqu.watch.network.ApiResponse
import retrofit2.http.GET
/**
* 任务相关 API 接口
*/
interface TaskApi {
/** 首页任务统计(接单池/待打卡/待完成) */
@GET("watchTask/statisticsNew")
suspend fun getStatistics(): ApiResponse<TaskStatistics>
/** 查询当前考勤状态 */
@GET("watchTask/myCurrentAttendance")
suspend fun getAttendance(): ApiResponse<AttendanceInfo>
}

View File

@@ -4,53 +4,58 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.TextView
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import com.xiaoqu.watch.BuildConfig
import com.xiaoqu.watch.R
import com.xiaoqu.watch.data.prefs.DevicePrefs
import com.xiaoqu.watch.data.prefs.UserPrefs
import com.xiaoqu.watch.databinding.FragmentHomeBinding
import com.xiaoqu.watch.device.nfc.NfcController
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 com.xiaoqu.watch.network.ApiResult
import com.xiaoqu.watch.network.api.TaskApi
import com.xiaoqu.watch.network.safeApiCall
import com.xiaoqu.watch.ui.common.BaseFragment
import com.xiaoqu.watch.ui.widget.NavBarHelper
import com.xiaoqu.watch.ui.widget.QuTipDialog
import com.xiaoqu.watch.ui.widget.StatusBarView
import com.xiaoqu.watch.util.DateUtil
import com.xiaoqu.watch.util.NetworkUtil
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.delay
import timber.log.Timber
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
/**
* 首页 Fragment
* 当前为硬件验证 demo 页面,展示系统状态 + 硬件控制测试按钮
* 首页 FragmentViewPager2 容器)
* Page 0 = 设置页Page 1 = 主页(默认显示)
*/
@AndroidEntryPoint
class HomeFragment : BaseFragment<FragmentHomeBinding>() {
@Inject lateinit var devicePrefs: DevicePrefs
@Inject lateinit var userPrefs: UserPrefs
@Inject lateinit var screenController: ScreenController
@Inject lateinit var nfcController: NfcController
@Inject lateinit var vibrationController: VibrationController
@Inject lateinit var eventBus: EventBus
@Inject lateinit var taskApi: TaskApi
/** 提示弹窗 */
private lateinit var tipDialog: QuTipDialog
// ===== 主页 View 引用 =====
private lateinit var mainStatusBar: StatusBarView
private lateinit var tvClock: TextView
private lateinit var tvDate: TextView
private lateinit var tvPoolNum: TextView
private lateinit var tvPunchNum: TextView
private lateinit var tvCompleteNum: TextView
/** NFC 是否正在扫描 */
private var nfcScanning = false
// ===== 设置页 View 引用 =====
private lateinit var configStatusBar: StatusBarView
private lateinit var tvAvatarLetter: TextView
private lateinit var tvUserName: TextView
private lateinit var tvUserPhone: TextView
/** 当前电量和充电状态 */
private var batteryLevel = -1
private var batteryCharging = false
// ===== 调试模式 =====
private var debugTapCount = 0
private var lastTapTime = 0L
override fun createBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentHomeBinding {
return FragmentHomeBinding.inflate(inflater, container, false)
@@ -59,158 +64,206 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// 设置 NavBar 为首页模式initDevicePrefs 和 MQTT 已在 SplashFragment 完成)
NavBarHelper.setupHomePage(binding.root)
// 创建两个页面 View
val inflater = LayoutInflater.from(requireContext())
val configPage = inflater.inflate(R.layout.page_config, null)
val mainPage = inflater.inflate(R.layout.page_main, null)
// 初始化弹窗
val dialogContainer = requireActivity().findViewById<FrameLayout>(R.id.dialog_container)
tipDialog = QuTipDialog(dialogContainer)
// 绑定 View 引用
bindMainViews(mainPage)
bindConfigViews(configPage)
// 显示状态信息
updateStatus()
// 设置 ViewPager2
val adapter = HomePagerAdapter(listOf(configPage, mainPage))
binding.viewPager.adapter = adapter
binding.viewPager.setCurrentItem(1, false) // 默认显示主页
// 绑定测试按钮
setupButtons()
// 初始化页面数据
initMainPage()
initConfigPage()
// 启动时间更新定时器(每秒刷新 NavBar 时间)
startTimeUpdater()
// 启动时钟定时器
startClockUpdater()
// 监听系统状态事件
observeSystemEvents()
// 加载任务统计数据
fetchStatistics()
// 监听 MQTT 事件
observeEvents()
}
override fun onDestroyView() {
super.onDestroyView()
// 停止 NFC 扫描
if (nfcScanning) {
nfcController.stopScan()
nfcScanning = false
// ===== 主页 =====
/** 绑定主页 View 引用 */
private fun bindMainViews(page: View) {
mainStatusBar = page.findViewById(R.id.statusBar)
tvClock = page.findViewById(R.id.tvClock)
tvDate = page.findViewById(R.id.tvDate)
tvPoolNum = page.findViewById(R.id.tvPoolNum)
tvPunchNum = page.findViewById(R.id.tvPunchNum)
tvCompleteNum = page.findViewById(R.id.tvCompleteNum)
// 快捷区卡片点击 → 跳转任务列表(后续实现)
page.findViewById<View>(R.id.cardPool)?.setOnClickListener {
Timber.d("点击接单池")
// TODO: navigate to TaskListFragment with tableStatus=2
}
page.findViewById<View>(R.id.cardPunch)?.setOnClickListener {
Timber.d("点击待打卡")
// TODO: navigate to TaskListFragment with tableStatus=3
}
page.findViewById<View>(R.id.cardComplete)?.setOnClickListener {
Timber.d("点击待完成")
// TODO: navigate to TaskListFragment with tableStatus=4
}
}
/** 更新状态信息显示try-catch 保护,防止系统关机时 DeadSystemException */
private fun updateStatus() {
try {
val dateInfo = DateUtil.getDateInfo()
val sb = StringBuilder()
sb.appendLine("${dateInfo.date} ${dateInfo.week} ${dateInfo.time}")
sb.appendLine("设备: ${devicePrefs.brand} ${devicePrefs.model}")
sb.appendLine("网络: ${NetworkUtil.getNetworkTypeName(requireContext())}")
sb.appendLine("绑定: ${if (userPrefs.isBound) "是" else "否"}")
// 电量信息(等收到广播后更新)
if (batteryLevel >= 0) {
sb.appendLine("电量: ${batteryLevel}% ${if (batteryCharging) "(充电中)" else ""}")
}
sb.appendLine("屏幕: ${if (screenController.isScreenOn()) "亮" else "灭"}")
sb.appendLine("NFC: ${if (nfcController.isOpen()) "开" else "关"}")
sb.appendLine("MQTT: 已启动")
binding.tvStatus.text = sb.toString()
} catch (e: Exception) {
Timber.w(e, "更新状态信息异常")
}
/** 初始化主页数据 */
private fun initMainPage() {
updateClock()
}
/** 绑定测试按钮 */
private fun setupButtons() {
// 熄屏测试:熄屏 3 秒后自动亮屏
binding.btnScreenOff.setOnClickListener {
screenController.turnOff()
binding.root.postDelayed({
screenController.turnOn()
updateStatus()
}, 3000)
}
// 振动测试:执行 planId=4打卡成功方案
binding.btnVibrate.setOnClickListener {
val pattern = VibrationDefaults.getPattern(4)
if (pattern != null) {
vibrationController.executePattern(pattern)
}
}
// NFC 读卡测试:开启/关闭切换
binding.btnNfcScan.setOnClickListener {
if (nfcScanning) {
// 停止扫描
nfcController.stopScan()
nfcController.close()
nfcScanning = false
binding.btnNfcScan.text = "NFC 读卡测试"
updateStatus()
} else {
// 开始扫描
nfcController.open()
nfcController.startScan { nfcId ->
// 读到卡号,显示提示
tipDialog.show(
status = QuTipDialog.Status.SUCCESS,
title = "读到NFC卡",
desc = "卡号: $nfcId",
back = true, step = 0, countdown = 3
)
}
nfcScanning = true
binding.btnNfcScan.text = "停止 NFC 扫描"
updateStatus()
}
}
// 提示弹窗测试
binding.btnShowTip.setOnClickListener {
tipDialog.show(
status = QuTipDialog.Status.SUCCESS,
title = "操作成功",
desc = "这是一个提示弹窗 demo",
back = true, step = 0, countdown = 3
)
}
/** 更新时钟和日期 */
private fun updateClock() {
val info = DateUtil.getDateInfo()
tvClock.text = DateUtil.formatTimeShort()
tvDate.text = "${info.month}${info.day}${info.week}"
}
/** 每秒更新 NavBar 时间显示,跟随 Fragment 生命周期自动取消 */
private fun startTimeUpdater() {
/** 每秒更新时钟 */
private fun startClockUpdater() {
viewLifecycleOwner.lifecycleScope.launch {
while (isActive) {
NavBarHelper.updateTime(binding.root)
updateClock()
delay(1000)
}
}
}
/** 监听系统状态事件电量、蓝牙<EFBFBD><EFBFBD><EFBFBD>更新 NavBar 和状态显示 */
private fun observeSystemEvents() {
/** 从 API 获取任务统计数据 */
private fun fetchStatistics() {
viewLifecycleOwner.lifecycleScope.launch {
val result = safeApiCall { taskApi.getStatistics() }
if (result is ApiResult.Success && result.data != null) {
val data = result.data
tvPoolNum.text = data.waitForTask.toString()
tvPunchNum.text = data.treatTask.toString()
tvCompleteNum.text = data.incompleteTask.toString()
}
}
}
// ===== 设置页 =====
/** 绑定设置页 View 引用 */
private fun bindConfigViews(page: View) {
configStatusBar = page.findViewById(R.id.statusBar)
tvAvatarLetter = page.findViewById(R.id.tvAvatarLetter)
tvUserName = page.findViewById(R.id.tvUserName)
tvUserPhone = page.findViewById(R.id.tvUserPhone)
// 头像点击 → 调试模式检测
page.findViewById<View>(R.id.userBlock)?.setOnClickListener {
checkDebugMode()
}
}
/** 初始化设置页数据 */
private fun initConfigPage() {
// 用户信息
val userName = userPrefs.userName
tvAvatarLetter.text = if (userName.isNotEmpty()) userName.first().toString() else "?"
tvUserName.text = userName
// 手机号脱敏138****8000
val mobile = userPrefs.mobile
tvUserPhone.text = if (mobile.length >= 7) {
"${mobile.substring(0, 3)}****${mobile.substring(mobile.length - 4)}"
} else {
mobile
}
// 设备信息
val configPage = (binding.viewPager.adapter as? HomePagerAdapter)?.let { return@let null }
// 通过 View 直接查找configPage 是 ViewPager2 的第一个子 View
binding.viewPager.post {
val recyclerView = binding.viewPager.getChildAt(0) as? androidx.recyclerview.widget.RecyclerView
val configView = recyclerView?.findViewHolderForAdapterPosition(0)?.itemView
configView?.let { fillDeviceInfo(it) }
}
}
/** 填充设备信息 */
private fun fillDeviceInfo(configView: View) {
configView.findViewById<TextView>(R.id.tvModel)?.text =
"${devicePrefs.brand} ${devicePrefs.model}"
configView.findViewById<TextView>(R.id.tvOsVersion)?.text =
"Android ${devicePrefs.osVersion}"
configView.findViewById<TextView>(R.id.tvImei)?.text = run {
val imei = devicePrefs.imei
if (imei.length > 6) "${imei.substring(0, 3)}***${imei.substring(imei.length - 3)}"
else imei
}
configView.findViewById<TextView>(R.id.tvAppVersion)?.text =
"v${BuildConfig.VERSION_NAME}"
}
/** 调试模式500ms 内连续点击 6 次触发 */
private fun checkDebugMode() {
val now = System.currentTimeMillis()
if (now - lastTapTime > 500) {
debugTapCount = 1
} else {
debugTapCount++
}
lastTapTime = now
if (debugTapCount >= 6) {
debugTapCount = 0
Timber.d("调试模式已开启")
android.widget.Toast.makeText(requireContext(), "调试模式已开启", android.widget.Toast.LENGTH_SHORT).show()
// TODO: 打开调试页面
}
}
// ===== 事件监听 =====
/** 监听 MQTT 和系统状态事件 */
private fun observeEvents() {
viewLifecycleOwner.lifecycleScope.launch {
eventBus.events.collect { event ->
when (event) {
// 电量变化:更新 NavBar 电量图标 + 状态文字
// 电量变化:更新两个状态栏
is AppEvent.BatteryChanged -> {
batteryLevel = event.level
batteryCharging = event.isCharging
NavBarHelper.updateBattery(binding.root, event.level, event.isCharging)
updateStatus()
mainStatusBar.updateBattery(event.level, event.isCharging)
configStatusBar.updateBattery(event.level, event.isCharging)
}
// 蓝牙开关:更新 NavBar 蓝牙图标
// 蓝牙状态变化
is AppEvent.BluetoothStateChanged -> {
NavBarHelper.updateBluetooth(binding.root, event.isOn)
updateStatus()
mainStatusBar.updateBluetooth(event.isOn)
configStatusBar.updateBluetooth(event.isOn)
}
// 蓝牙连接/断开
is AppEvent.BluetoothDeviceConnected -> updateStatus()
is AppEvent.BluetoothDeviceDisconnected -> updateStatus()
// MQTT 连接/断连/消息
is AppEvent.MqttConnected -> updateStatus()
is AppEvent.MqttDisconnected -> updateStatus()
// MQTT 消息
is AppEvent.MqttMessageReceived -> {
Timber.d("MQTT消息: type=${event.type}")
// messageType=3: 解绑 → 清除用户数据,导航到绑定页
if (event.type == 3) {
userPrefs.clear()
findMainNavController().navigate(R.id.action_home_to_bind)
return@collect
when (event.type) {
0 -> {
// 新任务消息 → 刷新统计数据
Timber.d("首页: 收到新任务消息")
fetchStatistics()
}
3 -> {
// 解绑 → 清除数据 → 跳绑定页
Timber.d("首页: 收到解绑消息")
userPrefs.clear()
findNavController().navigate(R.id.action_home_to_bind)
}
4 -> {
// 工作状态变更
Timber.d("首页: 收到工作状态变更")
// TODO: 更新考勤状态,控制蓝牙扫描
}
}
updateStatus()
}
else -> {} // 其他事件不处理
else -> {}
}
}
}

View File

@@ -0,0 +1,29 @@
package com.xiaoqu.watch.ui.home
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
/**
* 首页 ViewPager2 适配器
* 两个页面Page 0 = 设置页Page 1 = 主页
* 使用 View非 Fragment避免嵌套导航问题
*/
class HomePagerAdapter(
private val pages: List<View>
) : RecyclerView.Adapter<HomePagerAdapter.PageViewHolder>() {
class PageViewHolder(val view: View) : RecyclerView.ViewHolder(view)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PageViewHolder {
return PageViewHolder(pages[viewType])
}
override fun onBindViewHolder(holder: PageViewHolder, position: Int) {
// View 已在创建时设置好,无需额外绑定
}
override fun getItemCount(): Int = pages.size
override fun getItemViewType(position: Int): Int = position
}

View File

@@ -0,0 +1,146 @@
package com.xiaoqu.watch.ui.widget
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.RectF
import android.util.AttributeSet
import android.view.View
import com.xiaoqu.watch.R
/**
* 自定义状态栏 View按原型图 V3 样式)
* 左侧:蓝牙圆点 + 4G 信号条
* 右侧:电池壳 + 填充条
*
* 使用方式:在布局中引入,通过 update* 方法更新状态
*/
class StatusBarView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
// ===== 状态数据 =====
private var bluetoothOn = true
private var signalLevel = 4 // 0-4
private var batteryLevel = 75 // 0-100
private var isCharging = false
// ===== 画笔 =====
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
// ===== 颜色常量 =====
private val colorGreen = context.getColor(R.color.success)
private val colorOrange = context.getColor(R.color.warning)
private val colorRed = context.getColor(R.color.error)
private val colorWhite = 0xFFFFFFFF.toInt()
private val colorDim = 0x26FFFFFF // 白色 15% 透明度
private val colorBorder = 0x59FFFFFF // 白色 35% 透明度
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val h = height.toFloat()
val centerY = h / 2
// ===== 左侧:蓝牙圆点 + 信号条 =====
drawBluetoothDot(canvas, 8f, centerY)
drawSignalBars(canvas, 22f, centerY)
// ===== 右侧:电池 =====
drawBattery(canvas, width - 48f, centerY)
}
/** 绘制蓝牙状态圆点 */
private fun drawBluetoothDot(canvas: Canvas, x: Float, centerY: Float) {
val radius = 5f
paint.style = Paint.Style.FILL
paint.color = if (bluetoothOn) colorGreen else colorDim
canvas.drawCircle(x, centerY, radius, paint)
}
/** 绘制 4G 信号条4 个递增高度的竖条) */
private fun drawSignalBars(canvas: Canvas, startX: Float, centerY: Float) {
val barWidth = 3f
val gap = 2f
val maxHeight = 16f
val heights = floatArrayOf(5f, 8f, 12f, maxHeight)
for (i in 0..3) {
paint.style = Paint.Style.FILL
paint.color = if (i < signalLevel) colorWhite else colorDim
val x = startX + i * (barWidth + gap)
val barH = heights[i]
val top = centerY + maxHeight / 2 - barH
val rect = RectF(x, top, x + barWidth, centerY + maxHeight / 2)
canvas.drawRoundRect(rect, 1.5f, 1.5f, paint)
}
}
/** 绘制电池图标(壳 + 填充条 + 凸起) */
private fun drawBattery(canvas: Canvas, startX: Float, centerY: Float) {
val shellW = 28f
val shellH = 13f
val shellTop = centerY - shellH / 2
val cornerR = 3.5f
// 电池壳边框
paint.style = Paint.Style.STROKE
paint.strokeWidth = 1.5f
paint.color = colorBorder
val shellRect = RectF(startX, shellTop, startX + shellW, shellTop + shellH)
canvas.drawRoundRect(shellRect, cornerR, cornerR, paint)
// 电池填充条
paint.style = Paint.Style.FILL
paint.color = when {
isCharging -> colorGreen
batteryLevel <= 10 -> colorRed
batteryLevel <= 20 -> colorOrange
else -> colorGreen
}
val fillPadding = 2.5f
val fillMaxW = shellW - fillPadding * 2
val fillW = fillMaxW * batteryLevel / 100f
val fillRect = RectF(
startX + fillPadding,
shellTop + fillPadding,
startX + fillPadding + fillW,
shellTop + shellH - fillPadding
)
canvas.drawRoundRect(fillRect, 2f, 2f, paint)
// 电池凸起(右侧小矩形)
paint.color = colorBorder
val nubW = 2.5f
val nubH = 6f
val nubRect = RectF(
startX + shellW + 1f,
centerY - nubH / 2,
startX + shellW + 1f + nubW,
centerY + nubH / 2
)
canvas.drawRoundRect(nubRect, 1f, 1f, paint)
}
// ===== 更新方法 =====
/** 更新蓝牙状态 */
fun updateBluetooth(isOn: Boolean) {
bluetoothOn = isOn
invalidate()
}
/** 更新信号强度 (0-4) */
fun updateSignal(level: Int) {
signalLevel = level.coerceIn(0, 4)
invalidate()
}
/** 更新电池状态 */
fun updateBattery(level: Int, charging: Boolean) {
batteryLevel = level.coerceIn(0, 100)
isCharging = charging
invalidate()
}
}