feat: 小趣手表APP Android原生重构 - 基础框架搭建

已完成的模块:
1. 项目脚手架 - Gradle配置、28个包目录、核心基类
2. 权限管理 - 确认定制ROM已预授权所有权限
3. 工具类 - DateUtil/DeviceUtil/NetworkUtil/Md5Util
4. 设备信息 - DevicePrefs/UserPrefs (SharedPreferences)
5. 网络层 - OkHttp+Retrofit+MD5签名拦截器+解绑拦截器
6. 基础UI组件 - NavBarView/QuTipDialog/QuConfirmDialog/ActionButton/iconfont

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
dongliang
2026-04-27 11:26:50 +09:30
commit a397985954
89 changed files with 3211 additions and 0 deletions

19
.gitignore vendored Normal file
View File

@@ -0,0 +1,19 @@
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties
# JVM crash logs
hs_err_*.log
replay_*.log

3
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml

6
.idea/AndroidProjectSystem.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidProjectSystem">
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
</component>
</project>

6
.idea/compiler.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" />
</component>
</project>

10
.idea/deploymentTargetSelector.xml generated Normal file
View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
</SelectionState>
</selectionStates>
</component>
</project>

19
.idea/gradle.xml generated Normal file
View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
</set>
</option>
</GradleProjectSettings>
</option>
</component>
</project>

10
.idea/migrations.xml generated Normal file
View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectMigrations">
<option name="MigrateToGradleLocalJavaHome">
<set>
<option value="$PROJECT_DIR$" />
</set>
</option>
</component>
</project>

10
.idea/misc.xml generated Normal file
View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>

17
.idea/runConfigurations.xml generated Normal file
View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RunConfigurationProducerService">
<option name="ignoredProducers">
<set>
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
</set>
</option>
</component>
</project>

1
app/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

109
app/build.gradle.kts Normal file
View File

@@ -0,0 +1,109 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
}
android {
namespace = "com.xiaoqu.watch"
compileSdk = 36
defaultConfig {
applicationId = "com.xiaoqu.watch"
minSdk = 27
targetSdk = 27
versionCode = 1
versionName = "2.0.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
debug {
// TODO: 内网测试时改回 http://192.168.1.181:8091/
buildConfigField("String", "SERVICE_URL", "\"https://app.updatexiaoqu.com:9443/\"")
buildConfigField("String", "MQTT_URL", "\"mqtt.ququbranch.com:8085/mqtt\"")
}
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
buildConfigField("String", "SERVICE_URL", "\"https://app.updatexiaoqu.com:9443/\"")
buildConfigField("String", "MQTT_URL", "\"mqtt.ququbranch.com:8085/mqtt\"")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = "11"
}
buildFeatures {
viewBinding = true
buildConfig = true
}
}
dependencies {
// Core
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.material)
implementation(libs.androidx.activity)
implementation(libs.androidx.fragment)
implementation(libs.androidx.constraintlayout)
implementation(libs.androidx.viewpager2)
implementation(libs.androidx.swiperefreshlayout)
// Lifecycle
implementation(libs.androidx.lifecycle.viewmodel)
implementation(libs.androidx.lifecycle.livedata)
implementation(libs.androidx.lifecycle.runtime)
// Navigation
implementation(libs.androidx.navigation.fragment)
implementation(libs.androidx.navigation.ui)
// Hilt
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
// Coroutines
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.coroutines.android)
// Room
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler)
// Network
implementation(libs.retrofit)
implementation(libs.retrofit.converter.gson)
implementation(libs.okhttp)
implementation(libs.okhttp.logging)
implementation(libs.gson)
// MQTT
implementation(libs.paho.mqtt)
// Logging
implementation(libs.timber)
// QR Code
implementation(libs.zxing.core)
// Test
testImplementation(libs.junit)
testImplementation(libs.mockk)
testImplementation(libs.kotlinx.coroutines.test)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
}

21
app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@@ -0,0 +1,24 @@
package com.xiaoqu.watch
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.xiaoqu.watch", appContext.packageName)
}
}

View File

@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- 网络 -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<!-- 蓝牙 -->
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<!-- NFC -->
<uses-permission android:name="android.permission.NFC" />
<!-- 定位(蓝牙扫描需要) -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<!-- 振动 -->
<uses-permission android:name="android.permission.VIBRATE" />
<!-- 开机自启 -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- WakeLock -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- 手机状态(获取 IMEI -->
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<!-- 相机(二维码扫描备用) -->
<uses-permission android:name="android.permission.CAMERA" />
<application
android:name=".app.WatchApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.XqWatch">
<!-- 主 ActivityLauncher 模式) -->
<activity
android:name=".app.MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:screenOrientation="portrait"
android:configChanges="orientation|screenSize|keyboardHidden">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.HOME" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<!-- 开机自启广播接收器 -->
<receiver
android:name=".app.BootReceiver"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
</application>
</manifest>

Binary file not shown.

View File

@@ -0,0 +1,23 @@
package com.xiaoqu.watch.app
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import timber.log.Timber
/**
* 开机自启广播接收器
* 收到 BOOT_COMPLETED 后启动 MainActivity
*/
class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == Intent.ACTION_BOOT_COMPLETED) {
Timber.d("Boot completed, launching MainActivity")
val launchIntent = Intent(context, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(launchIntent)
}
}
}

View File

@@ -0,0 +1,53 @@
package com.xiaoqu.watch.app
import android.content.pm.ActivityInfo
import android.os.Bundle
import android.view.View
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AppCompatActivity
import com.xiaoqu.watch.databinding.ActivityMainBinding
import dagger.hilt.android.AndroidEntryPoint
import timber.log.Timber
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 固定竖屏
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
// 全屏(定制系统无状态栏,此为防御性设置)
window.decorView.systemUiVisibility = (
View.SYSTEM_UI_FLAG_FULLSCREEN
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
// 拦截物理返回键
setupBackButton()
Timber.d("MainActivity created")
}
/**
* 物理返回键拦截:
* - 已绑定用户 → 开启 NFC 打卡模式(后续模块实现)
* - 未绑定 → 无操作
* - 所有情况阻止默认页面回退
*/
private fun setupBackButton() {
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
// TODO: 后续模块实现 NFC 打卡触发
Timber.d("Back button pressed - intercepted")
}
})
}
}

View File

@@ -0,0 +1,27 @@
package com.xiaoqu.watch.app
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
import timber.log.Timber
@HiltAndroidApp
class WatchApplication : Application() {
override fun onCreate() {
super.onCreate()
// 1. Timber 初始化
Timber.plant(Timber.DebugTree())
// 2-9 其他初始化在后续模块中逐步添加
// LogManager.init()
// CrashHandler.init()
// DeviceRepository.collectDeviceInfo()
// DeviceMonitor.start()
// ClockManager.start()
// WakeLockHelper.acquire()
// OtaManager.checkOnStartup()
Timber.d("WatchApplication initialized")
}
}

View File

@@ -0,0 +1,89 @@
package com.xiaoqu.watch.data.prefs
import android.content.Context
import android.content.SharedPreferences
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* 设备信息存储(解绑时不清除)
* 对应旧版 uni.setStorage('watchInfo', {...})
*/
@Singleton
class DevicePrefs @Inject constructor(
@ApplicationContext context: Context
) {
private val sp: SharedPreferences =
context.getSharedPreferences("device_prefs", Context.MODE_PRIVATE)
var imei: String
get() = sp.getString(KEY_IMEI, "") ?: ""
set(value) = sp.edit().putString(KEY_IMEI, value).apply()
var serial: String
get() = sp.getString(KEY_SERIAL, "") ?: ""
set(value) = sp.edit().putString(KEY_SERIAL, value).apply()
var bluetoothName: String
get() = sp.getString(KEY_BT_NAME, "") ?: ""
set(value) = sp.edit().putString(KEY_BT_NAME, value).apply()
var bluetoothMac: String
get() = sp.getString(KEY_BT_MAC, "") ?: ""
set(value) = sp.edit().putString(KEY_BT_MAC, value).apply()
var brand: String
get() = sp.getString(KEY_BRAND, "") ?: ""
set(value) = sp.edit().putString(KEY_BRAND, value).apply()
var model: String
get() = sp.getString(KEY_MODEL, "") ?: ""
set(value) = sp.edit().putString(KEY_MODEL, value).apply()
var osVersion: String
get() = sp.getString(KEY_OS_VERSION, "") ?: ""
set(value) = sp.edit().putString(KEY_OS_VERSION, value).apply()
var totalMemory: Long
get() = sp.getLong(KEY_TOTAL_MEMORY, 0)
set(value) = sp.edit().putLong(KEY_TOTAL_MEMORY, value).apply()
/** 是否已初始化过设备信息 */
val isInitialized: Boolean
get() = imei.isNotEmpty()
/** 从 DeviceUtil 批量写入设备信息 */
fun saveDeviceInfo(
imei: String,
serial: String,
bluetoothName: String,
bluetoothMac: String,
brand: String,
model: String,
osVersion: String,
totalMemory: Long
) {
sp.edit()
.putString(KEY_IMEI, imei)
.putString(KEY_SERIAL, serial)
.putString(KEY_BT_NAME, bluetoothName)
.putString(KEY_BT_MAC, bluetoothMac)
.putString(KEY_BRAND, brand)
.putString(KEY_MODEL, model)
.putString(KEY_OS_VERSION, osVersion)
.putLong(KEY_TOTAL_MEMORY, totalMemory)
.apply()
}
companion object {
private const val KEY_IMEI = "imei"
private const val KEY_SERIAL = "serial"
private const val KEY_BT_NAME = "bluetooth_name"
private const val KEY_BT_MAC = "bluetooth_mac"
private const val KEY_BRAND = "brand"
private const val KEY_MODEL = "model"
private const val KEY_OS_VERSION = "os_version"
private const val KEY_TOTAL_MEMORY = "total_memory"
}
}

View File

@@ -0,0 +1,61 @@
package com.xiaoqu.watch.data.prefs
import android.content.Context
import android.content.SharedPreferences
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* 用户绑定信息存储(解绑时清除)
* 对应旧版 uni.setStorage('bondUser', {...})
*/
@Singleton
class UserPrefs @Inject constructor(
@ApplicationContext context: Context
) {
private val sp: SharedPreferences =
context.getSharedPreferences("user_prefs", Context.MODE_PRIVATE)
var userId: Long
get() = sp.getLong(KEY_USER_ID, 0)
set(value) = sp.edit().putLong(KEY_USER_ID, value).apply()
var mobile: String
get() = sp.getString(KEY_MOBILE, "") ?: ""
set(value) = sp.edit().putString(KEY_MOBILE, value).apply()
var userName: String
get() = sp.getString(KEY_USER_NAME, "") ?: ""
set(value) = sp.edit().putString(KEY_USER_NAME, value).apply()
var headUrl: String
get() = sp.getString(KEY_HEAD_URL, "") ?: ""
set(value) = sp.edit().putString(KEY_HEAD_URL, value).apply()
/** 是否已绑定用户 */
val isBound: Boolean
get() = userId > 0
/** 保存用户绑定信息 */
fun saveUser(userId: Long, mobile: String, userName: String, headUrl: String) {
sp.edit()
.putLong(KEY_USER_ID, userId)
.putString(KEY_MOBILE, mobile)
.putString(KEY_USER_NAME, userName)
.putString(KEY_HEAD_URL, headUrl)
.apply()
}
/** 解绑时清除所有用户数据 */
fun clear() {
sp.edit().clear().apply()
}
companion object {
private const val KEY_USER_ID = "user_id"
private const val KEY_MOBILE = "mobile"
private const val KEY_USER_NAME = "user_name"
private const val KEY_HEAD_URL = "head_url"
}
}

View File

@@ -0,0 +1,65 @@
package com.xiaoqu.watch.di
import com.google.gson.Gson
import com.google.gson.GsonBuilder
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 dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideGson(): Gson {
return GsonBuilder().create()
}
@Provides
@Singleton
fun provideOkHttpClient(
signatureInterceptor: SignatureInterceptor,
unbindInterceptor: UnbindInterceptor
): OkHttpClient {
val logging = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}
return OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.writeTimeout(15, TimeUnit.SECONDS)
.addInterceptor(signatureInterceptor)
.addInterceptor(unbindInterceptor)
.addInterceptor(logging)
.build()
}
@Provides
@Singleton
fun provideRetrofit(okHttpClient: OkHttpClient, gson: Gson): Retrofit {
return Retrofit.Builder()
.baseUrl(EnvConfig.serviceUrl)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
}
@Provides
@Singleton
fun provideCommonApi(retrofit: Retrofit): CommonApi {
return retrofit.create(CommonApi::class.java)
}
}

View File

@@ -0,0 +1,20 @@
package com.xiaoqu.watch.event
/**
* 全局事件定义(替代旧版 uni.$emit/$on 事件总线)
*/
sealed class AppEvent {
// 任务相关
data class TaskUpdated(val taskId: Long) : AppEvent()
data object TaskListRefresh : AppEvent()
data object HomeRefresh : AppEvent()
data object NewMessage : AppEvent()
data object PunchTaskListRefresh : AppEvent()
data class TabChanged(val index: Int) : AppEvent()
data class NfcCardRead(val nfcId: String) : AppEvent()
// 系统相关
data object DeviceUnbound : AppEvent()
data object BindSuccess : AppEvent()
data class WorkStateChanged(val isWorking: Boolean) : AppEvent()
}

View File

@@ -0,0 +1,22 @@
package com.xiaoqu.watch.event
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import javax.inject.Inject
import javax.inject.Singleton
/**
* 全局事件总线SharedFlow 实现)
* 替代旧版 uni.$emit/$on
*/
@Singleton
class EventBus @Inject constructor() {
private val _events = MutableSharedFlow<AppEvent>(replay = 0)
val events: SharedFlow<AppEvent> = _events.asSharedFlow()
suspend fun emit(event: AppEvent) {
_events.emit(event)
}
}

View File

@@ -0,0 +1,21 @@
package com.xiaoqu.watch.network
import com.google.gson.annotations.SerializedName
/**
* 服务端统一响应格式
* { "code": 0, "data": {...}, "err": "错误信息" }
*/
data class ApiResponse<T>(
@SerializedName("code") val code: Int = 0,
@SerializedName("data") val data: T? = null,
@SerializedName("err") val err: String? = null
) {
val isSuccess: Boolean get() = code == 0
val isUnbound: Boolean get() = code == CODE_UNBOUND
companion object {
/** 解绑状态码 */
const val CODE_UNBOUND = 104
}
}

View File

@@ -0,0 +1,11 @@
package com.xiaoqu.watch.network
/**
* 统一的 API 返回结果封装
* 调用方用 when 表达式处理,编译器强制覆盖所有情况
*/
sealed class ApiResult<out T> {
data class Success<T>(val data: T) : ApiResult<T>()
data class Error(val code: Int, val message: String) : ApiResult<Nothing>()
data class NetworkError(val exception: Throwable) : ApiResult<Nothing>()
}

View File

@@ -0,0 +1,13 @@
package com.xiaoqu.watch.network
import com.xiaoqu.watch.BuildConfig
/**
* 环境配置
* debug 构建 → develop 环境
* release 构建 → production 环境
*/
object EnvConfig {
val serviceUrl: String = BuildConfig.SERVICE_URL
val mqttUrl: String = BuildConfig.MQTT_URL
}

View File

@@ -0,0 +1,22 @@
package com.xiaoqu.watch.network
import timber.log.Timber
/**
* 安全 API 调用封装
* 将 Retrofit suspend 调用转换为 ApiResult
*/
suspend fun <T> safeApiCall(call: suspend () -> ApiResponse<T>): ApiResult<T> {
return try {
val response = call()
if (response.isSuccess) {
@Suppress("UNCHECKED_CAST")
ApiResult.Success(response.data as T)
} else {
ApiResult.Error(response.code, response.err ?: "未知错误")
}
} catch (e: Exception) {
Timber.w(e, "网络请求异常")
ApiResult.NetworkError(e)
}
}

View File

@@ -0,0 +1,41 @@
package com.xiaoqu.watch.network
import com.xiaoqu.watch.data.prefs.DevicePrefs
import com.xiaoqu.watch.data.prefs.UserPrefs
import com.xiaoqu.watch.util.Md5Util
import okhttp3.Interceptor
import okhttp3.Response
import javax.inject.Inject
import javax.inject.Singleton
/**
* MD5 签名拦截器
* 自动为每个请求添加 signature、imei、timeStamp 请求头
*
* 签名算法(与旧版一致):
* signature = MD5(MD5(imei).upper() + MD5(timeStamp + phone).upper()).upper()
*/
@Singleton
class SignatureInterceptor @Inject constructor(
private val devicePrefs: DevicePrefs,
private val userPrefs: UserPrefs
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val imei = devicePrefs.imei
val phone = userPrefs.mobile
val timeStamp = System.currentTimeMillis().toString()
val signature = Md5Util.md5Upper(
Md5Util.md5Upper(imei) + Md5Util.md5Upper(timeStamp + phone)
)
val request = chain.request().newBuilder()
.addHeader("signature", signature)
.addHeader("imei", imei)
.addHeader("timeStamp", timeStamp)
.build()
return chain.proceed(request)
}
}

View File

@@ -0,0 +1,51 @@
package com.xiaoqu.watch.network
import com.google.gson.Gson
import com.xiaoqu.watch.data.prefs.UserPrefs
import com.xiaoqu.watch.event.AppEvent
import com.xiaoqu.watch.event.EventBus
import kotlinx.coroutines.runBlocking
import okhttp3.Interceptor
import okhttp3.Response
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
/**
* 解绑拦截器
* 检测 API 返回 code=104 时,自动清除用户数据并发送解绑事件
*/
@Singleton
class UnbindInterceptor @Inject constructor(
private val userPrefs: UserPrefs,
private val eventBus: EventBus,
private val gson: Gson
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val response = chain.proceed(chain.request())
if (response.isSuccessful) {
try {
// 窥视响应体(不消耗)
val source = response.body?.source() ?: return response
source.request(Long.MAX_VALUE)
val buffer = source.buffer.clone()
val json = buffer.readUtf8()
val apiResponse = gson.fromJson(json, ApiResponse::class.java)
if (apiResponse?.isUnbound == true) {
Timber.w("收到 code=104执行自动解绑")
userPrefs.clear()
runBlocking {
eventBus.emit(AppEvent.DeviceUnbound)
}
}
} catch (e: Exception) {
Timber.w(e, "解绑检测异常")
}
}
return response
}
}

View File

@@ -0,0 +1,34 @@
package com.xiaoqu.watch.network.api
import com.xiaoqu.watch.network.ApiResponse
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Query
/**
* 通用 API 接口(绑定、版本检查、设备状态)
* 其他模块的 API 在各自模块开发时添加
*/
interface CommonApi {
/** 绑定确认 */
@POST("watch/bindWatchConfirm")
suspend fun bindWatchConfirm(@Body params: Map<String, Any>): ApiResponse<Any>
/** 根据 IMEI 查询手表信息 */
@GET("watch/getWatchByImei")
suspend fun getWatchByImei(@Query("imei") imei: String): ApiResponse<Any>
/** 检查版本更新 */
@GET("newAppVersion/queryWatch")
suspend fun checkVersion(@Query("imei") imei: String): ApiResponse<Any>
/** 上报设备状态电量、蓝牙、NFC等 */
@POST("watch/setWatchStatusByImeiFormWatch")
suspend fun reportDeviceStatus(@Body params: Map<String, Any>): ApiResponse<Any>
/** 上报日志 */
@POST("watchTestLog")
suspend fun reportLog(@Body params: Map<String, Any>): ApiResponse<Any>
}

View File

@@ -0,0 +1,15 @@
package com.xiaoqu.watch.ui.bind
import android.view.LayoutInflater
import android.view.ViewGroup
import com.xiaoqu.watch.databinding.FragmentBindBinding
import com.xiaoqu.watch.ui.common.BaseFragment
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class BindFragment : BaseFragment<FragmentBindBinding>() {
override fun createBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentBindBinding {
return FragmentBindBinding.inflate(inflater, container, false)
}
}

View File

@@ -0,0 +1,66 @@
package com.xiaoqu.watch.ui.common
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.navigation.NavController
import androidx.viewbinding.ViewBinding
/**
* 所有 Fragment 的基类
* 提供 ViewBinding 管理、圆角 SafeArea padding、导航工具方法
*/
abstract class BaseFragment<VB : ViewBinding> : Fragment() {
private var _binding: VB? = null
protected val binding get() = _binding!!
/**
* 子类实现:创建 ViewBinding
*/
abstract fun createBinding(inflater: LayoutInflater, container: ViewGroup?): VB
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = createBinding(inflater, container)
// 圆角屏 SafeArea padding顶部由 NavBar 自行处理,底部和左右由各布局自行设置)
// NavBar 已移入各 Fragment 布局root 不再统一加 padding
return binding.root
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null // 防止内存泄漏
}
/**
* 获取主 NavController支持 ViewPager2 内的 Fragment 向上查找)
*/
protected fun findMainNavController(): NavController {
var fragment: Fragment? = this
while (fragment != null) {
try {
return androidx.navigation.fragment.NavHostFragment.findNavController(fragment)
} catch (e: IllegalStateException) {
fragment = fragment.parentFragment
}
}
throw IllegalStateException("No NavController found")
}
/**
* 设置标题栏返回按钮(物理返回键被拦截,页面返回靠标题栏按钮)
*/
protected fun setupBackButton(backButton: View) {
backButton.setOnClickListener {
findMainNavController().popBackStack()
}
}
}

View File

@@ -0,0 +1,53 @@
package com.xiaoqu.watch.ui.common
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.xiaoqu.watch.network.ApiResult
import kotlinx.coroutines.launch
/**
* 所有 ViewModel 的基类
* 提供通用的 Loading/Error 状态管理和安全调用方法
*/
open class BaseViewModel : ViewModel() {
private val _loading = MutableLiveData(false)
val loading: LiveData<Boolean> = _loading
private val _error = MutableLiveData<String?>()
val error: LiveData<String?> = _error
/**
* 安全调用:自动管理 loading 和 error 状态
*/
protected fun <T> launch(
block: suspend () -> ApiResult<T>,
onSuccess: (T) -> Unit,
onError: ((String) -> Unit)? = null
) {
viewModelScope.launch {
_loading.value = true
_error.value = null
when (val result = block()) {
is ApiResult.Success -> onSuccess(result.data)
is ApiResult.Error -> {
_error.value = result.message
onError?.invoke(result.message)
}
is ApiResult.NetworkError -> {
val msg = "网络连接异常"
_error.value = msg
onError?.invoke(msg)
}
}
_loading.value = false
}
}
/**
* 子类覆盖,实现重新加载(配合 StateLayout 重试按钮)
*/
open fun retry() {}
}

View File

@@ -0,0 +1,121 @@
package com.xiaoqu.watch.ui.home
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
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.ui.common.BaseFragment
import com.xiaoqu.watch.ui.widget.NavBarHelper
import com.xiaoqu.watch.ui.widget.QuConfirmDialog
import com.xiaoqu.watch.ui.widget.QuTipDialog
import com.xiaoqu.watch.util.DateUtil
import com.xiaoqu.watch.util.DeviceUtil
import com.xiaoqu.watch.util.NetworkUtil
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
/**
* 首页 Fragment
* 当前为 UI 组件 demo 页面,展示 NavBar、按钮样式、弹窗组件
*/
@AndroidEntryPoint
class HomeFragment : BaseFragment<FragmentHomeBinding>() {
@Inject lateinit var devicePrefs: DevicePrefs
@Inject lateinit var userPrefs: UserPrefs
/** 提示弹窗(挂载到 Activity 的 dialog_container */
private lateinit var tipDialog: QuTipDialog
/** 确认弹窗 */
private lateinit var confirmDialog: QuConfirmDialog
override fun createBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentHomeBinding {
return FragmentHomeBinding.inflate(inflater, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// 初始化设备信息
initDevicePrefs()
// 设置 NavBar 为首页模式(状态图标 + 时间 + 电量)
NavBarHelper.setupHomePage(binding.root)
// 初始化弹窗(使用 Activity 的全局弹窗容器)
val dialogContainer = requireActivity().findViewById<FrameLayout>(R.id.dialog_container)
tipDialog = QuTipDialog(dialogContainer)
confirmDialog = QuConfirmDialog(dialogContainer)
// 显示 demo 信息
showDemoInfo()
// 绑定按钮事件
setupButtons()
}
/** 首次启动时初始化设备信息到 device_prefs */
private fun initDevicePrefs() {
if (!devicePrefs.isInitialized) {
val info = DeviceUtil.getDeviceInfo(requireContext())
devicePrefs.saveDeviceInfo(
imei = info.imei,
serial = info.serial,
bluetoothName = info.bluetoothName,
bluetoothMac = info.bluetoothMac,
brand = info.brand,
model = info.model,
osVersion = info.osVersion,
totalMemory = info.totalMemory
)
}
}
/** 显示设备和网络基本信息 */
private fun showDemoInfo() {
val dateInfo = DateUtil.getDateInfo()
val sb = StringBuilder()
sb.appendLine("${dateInfo.date} ${dateInfo.week}")
sb.appendLine("设备: ${devicePrefs.brand} ${devicePrefs.model}")
sb.appendLine("网络: ${NetworkUtil.getNetworkTypeName(requireContext())}")
sb.appendLine("绑定: ${if (userPrefs.isBound) "是" else "否"}")
binding.tvDemoInfo.text = sb.toString()
}
/** 绑定按钮点击事件 */
private fun setupButtons() {
// 显示提示弹窗成功状态3 秒倒计时后自动关闭)
binding.btnShowTip.setOnClickListener {
tipDialog.show(
status = QuTipDialog.Status.SUCCESS,
title = "操作成功",
desc = "这是一个提示弹窗 demo",
back = true,
step = 0, // 只关闭,不返回
countdown = 3
)
}
// 显示确认弹窗
binding.btnShowConfirm.setOnClickListener {
confirmDialog.showText(
text = "确认执行此操作?",
onConfirm = {
// 确认后显示成功提示
tipDialog.show(
status = QuTipDialog.Status.SUCCESS,
title = "已确认",
back = true,
step = 0,
countdown = 2
)
}
)
}
}
}

View File

@@ -0,0 +1,15 @@
package com.xiaoqu.watch.ui.info
import android.view.LayoutInflater
import android.view.ViewGroup
import com.xiaoqu.watch.databinding.FragmentInfoBinding
import com.xiaoqu.watch.ui.common.BaseFragment
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class InfoFragment : BaseFragment<FragmentInfoBinding>() {
override fun createBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentInfoBinding {
return FragmentInfoBinding.inflate(inflater, container, false)
}
}

View File

@@ -0,0 +1,15 @@
package com.xiaoqu.watch.ui.punch
import android.view.LayoutInflater
import android.view.ViewGroup
import com.xiaoqu.watch.databinding.FragmentPunchBinding
import com.xiaoqu.watch.ui.common.BaseFragment
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class PunchFragment : BaseFragment<FragmentPunchBinding>() {
override fun createBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentPunchBinding {
return FragmentPunchBinding.inflate(inflater, container, false)
}
}

View File

@@ -0,0 +1,15 @@
package com.xiaoqu.watch.ui.task
import android.view.LayoutInflater
import android.view.ViewGroup
import com.xiaoqu.watch.databinding.FragmentTaskDetailBinding
import com.xiaoqu.watch.ui.common.BaseFragment
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class TaskDetailFragment : BaseFragment<FragmentTaskDetailBinding>() {
override fun createBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentTaskDetailBinding {
return FragmentTaskDetailBinding.inflate(inflater, container, false)
}
}

View File

@@ -0,0 +1,15 @@
package com.xiaoqu.watch.ui.task
import android.view.LayoutInflater
import android.view.ViewGroup
import com.xiaoqu.watch.databinding.FragmentTaskListBinding
import com.xiaoqu.watch.ui.common.BaseFragment
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class TaskListFragment : BaseFragment<FragmentTaskListBinding>() {
override fun createBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentTaskListBinding {
return FragmentTaskListBinding.inflate(inflater, container, false)
}
}

View File

@@ -0,0 +1,103 @@
package com.xiaoqu.watch.ui.widget
/**
* iconfont 图标 Unicode 映射
* 对应旧版 static/style/iconfont.css 中的图标编码
* 使用方式TextView.text = IconFont.BLUETOOTH并设置 typeface 为 iconfont.ttf
*/
object IconFont {
// ===== 导航栏状态图标 =====
/** 蓝牙已连接 */
const val BLUETOOTH = "\ue6da"
/** 蓝牙未连接 */
const val BLUETOOTH_OFF = "\ue6db"
/** 4G 已连接 */
const val SIGNAL_4G = "\ue6dc"
/** 4G 未连接 */
const val SIGNAL_4G_OFF = "\ue6d9"
/** NFC */
const val NFC = "\ue6d8"
// ===== 电量图标6 级) =====
/** 电量 0-10% */
const val BATTERY_10 = "\ue6d0"
/** 电量 11-20% */
const val BATTERY_20 = "\ue6cd"
/** 电量 21-40% */
const val BATTERY_40 = "\ue6d1"
/** 电量 41-60% */
const val BATTERY_60 = "\ue6cc"
/** 电量 61-99% */
const val BATTERY_80 = "\ue6cf"
/** 电量 100% */
const val BATTERY_100 = "\ue6ce"
/** 充电中 */
const val CHARGING = "\ue6ca"
/** 电量极低 */
const val BATTERY_LOW = "\ue7a4"
// ===== 提示弹窗图标 =====
/** 成功(✓) */
const val SUCCESS = "\ue600"
/** 错误(×) */
const val ERROR = "\ue623"
/** 警告(! */
const val WARNING = "\ue685"
/** 定位打卡 */
const val LOCATION = "\ue6c0"
// ===== 导航图标 =====
/** 返回 */
const val BACK = "\ue6bd"
// ===== 功能图标 =====
/** 未读消息 */
const val UNREAD_MESSAGE = "\ue6e3"
/** 邮件 */
const val MAIL = "\uebca"
/** 设置 */
const val SETTINGS = "\ue8b8"
/** 硬件信息 */
const val HARDWARE_INFO = "\ue6c9"
/** 查看 */
const val VIEW = "\ue6d2"
/** 日历/选择时间 */
const val CALENDAR = "\ue6c4"
/** 调试模式 */
const val DEBUG = "\ue6c1"
/** 蓝牙定位数据 */
const val BLE_DATA = "\ue6c3"
/** 蓝牙连接记录 */
const val BLE_LOG = "\ue6c5"
/** 蓝牙通信日志 */
const val BLE_COMM_LOG = "\ue6c7"
/** 计步数据 */
const val STEP_DATA = "\ue6c8"
/** 存储空间 */
const val STORAGE = "\ue6bf"
/** 喇叭/声音 */
const val SOUND = "\ue601"
/** WiFi */
const val WIFI = "\ue604"
/** 账号 */
const val ACCOUNT = "\ue60d"
/** 日志 */
const val LOG = "\ue69a"
/**
* 根据电量百分比返回对应的电量图标
* @param level 电量百分比 0-100
* @param isCharging 是否充电中
*/
fun getBatteryIcon(level: Int, isCharging: Boolean): String {
if (isCharging) return CHARGING
return when {
level <= 10 -> BATTERY_10
level <= 20 -> BATTERY_20
level <= 40 -> BATTERY_40
level <= 60 -> BATTERY_60
level <= 99 -> BATTERY_80
else -> BATTERY_100
}
}
}

View File

@@ -0,0 +1,110 @@
package com.xiaoqu.watch.ui.widget
import android.graphics.Typeface
import android.view.View
import android.widget.TextView
import com.xiaoqu.watch.R
import com.xiaoqu.watch.util.DateUtil
/**
* NavBar 辅助工具
* 帮助 Fragment 配置导航栏的显示模式(首页模式 / 子页面模式)
*
* 使用方式:
* - 首页NavBarHelper.setupHomePage(binding.root)
* - 子页面NavBarHelper.setupSubPage(binding.root, "标题") { navBack() }
*/
object NavBarHelper {
/**
* 设置为首页模式:左=状态图标,中=时间,右=电量
* @param rootView 包含 layout_nav_bar 的根视图
*/
fun setupHomePage(rootView: View) {
// 加载 iconfont 字体
val typeface = Typeface.createFromAsset(rootView.context.assets, "font/iconfont.ttf")
// 左侧:显示状态图标,隐藏返回按钮
rootView.findViewById<View>(R.id.statusIcons)?.visibility = View.VISIBLE
rootView.findViewById<View>(R.id.btnBack)?.visibility = View.GONE
// 状态图标设置字体
applyIconFont(rootView, R.id.iconBluetooth, typeface)
applyIconFont(rootView, R.id.icon4G, typeface)
applyIconFont(rootView, R.id.iconNfc, typeface)
// 中间:显示时间
rootView.findViewById<TextView>(R.id.navTitle)?.text = DateUtil.formatTimeShort()
// 右侧:电量图标设置字体
applyIconFont(rootView, R.id.iconBattery, typeface)
}
/**
* 设置为子页面模式:左=返回按钮,中=标题,右=电量
* @param rootView 包含 layout_nav_bar 的根视图
* @param title 页面标题
* @param onBackClick 返回按钮点击回调
*/
fun setupSubPage(rootView: View, title: String, onBackClick: () -> Unit) {
val typeface = Typeface.createFromAsset(rootView.context.assets, "font/iconfont.ttf")
// 左侧:隐藏状态图标,显示返回按钮
rootView.findViewById<View>(R.id.statusIcons)?.visibility = View.GONE
val btnBack = rootView.findViewById<TextView>(R.id.btnBack)
btnBack?.visibility = View.VISIBLE
btnBack?.typeface = typeface
btnBack?.setOnClickListener { onBackClick() }
// 中间:显示标题
rootView.findViewById<TextView>(R.id.navTitle)?.text = title
// 右侧:电量图标设置字体
applyIconFont(rootView, R.id.iconBattery, typeface)
}
/**
* 更新电量图标
* @param rootView 包含 layout_nav_bar 的根视图
* @param level 电量百分比 0-100
* @param isCharging 是否充电中
*/
fun updateBattery(rootView: View, level: Int, isCharging: Boolean) {
val iconBattery = rootView.findViewById<TextView>(R.id.iconBattery) ?: return
iconBattery.text = IconFont.getBatteryIcon(level, isCharging)
// 充电中显示绿色,低电量显示红色,其他白色
val colorRes = when {
isCharging -> R.color.charge_green
level <= 10 -> R.color.disconnect_red
else -> R.color.text_primary
}
iconBattery.setTextColor(iconBattery.context.getColor(colorRes))
}
/**
* 更新蓝牙状态图标
*/
fun updateBluetooth(rootView: View, isConnected: Boolean) {
val icon = rootView.findViewById<TextView>(R.id.iconBluetooth) ?: return
icon.text = if (isConnected) IconFont.BLUETOOTH else IconFont.BLUETOOTH_OFF
icon.setTextColor(icon.context.getColor(
if (isConnected) R.color.text_primary else R.color.text_secondary
))
}
/**
* 更新 4G 状态图标
*/
fun update4G(rootView: View, isConnected: Boolean) {
val icon = rootView.findViewById<TextView>(R.id.icon4G) ?: return
icon.text = if (isConnected) IconFont.SIGNAL_4G else IconFont.SIGNAL_4G_OFF
icon.setTextColor(icon.context.getColor(
if (isConnected) R.color.text_primary else R.color.text_secondary
))
}
/** 为 TextView 设置 iconfont 字体 */
private fun applyIconFont(rootView: View, viewId: Int, typeface: Typeface) {
rootView.findViewById<TextView>(viewId)?.typeface = typeface
}
}

View File

@@ -0,0 +1,109 @@
package com.xiaoqu.watch.ui.widget
import android.graphics.Typeface
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.TextView
import com.xiaoqu.watch.R
/**
* 确认弹窗(对应旧版 qu-confirm.vue
* 半透明遮罩 + 内容区 + 底部取消(×)/确认(✓)图标按钮
*
* 使用方式:
* ```
* val confirm = QuConfirmDialog(binding.dialogContainer)
* confirm.show(
* contentView = myContentView, // 或传 contentText
* onConfirm = { /* 确认操作 */ },
* onCancel = { /* 取消操作 */ }
* )
* ```
*/
class QuConfirmDialog(
private val container: FrameLayout
) {
private var confirmView: View? = null
/**
* 显示确认弹窗(自定义内容 View
* @param contentView 内容区域的自定义 View
* @param onConfirm 确认按钮回调
* @param onCancel 取消按钮回调(默认关闭弹窗)
*/
fun show(
contentView: View? = null,
onConfirm: () -> Unit,
onCancel: (() -> Unit)? = null
) {
dismiss()
val view = LayoutInflater.from(container.context)
.inflate(R.layout.dialog_confirm, container, false)
confirmView = view
// 加载 iconfont 字体
val typeface = Typeface.createFromAsset(container.context.assets, "font/iconfont.ttf")
// 设置内容区域
if (contentView != null) {
val contentContainer = view.findViewById<FrameLayout>(R.id.confirmContent)
contentContainer.addView(contentView)
}
// 取消按钮
val btnCancel = view.findViewById<TextView>(R.id.btnCancel)
btnCancel.typeface = typeface
btnCancel.setOnClickListener {
dismiss()
onCancel?.invoke()
}
// 确认按钮
val btnConfirm = view.findViewById<TextView>(R.id.btnConfirm)
btnConfirm.typeface = typeface
btnConfirm.setOnClickListener {
dismiss()
onConfirm()
}
// 点击遮罩不关闭(手表误触概率高)
view.findViewById<View>(R.id.confirmOverlay).setOnClickListener { /* 拦截点击 */ }
// 显示弹窗
container.addView(view)
container.visibility = View.VISIBLE
}
/**
* 显示确认弹窗(纯文字内容)
* @param text 提示文字
* @param onConfirm 确认按钮回调
* @param onCancel 取消按钮回调
*/
fun showText(
text: String,
onConfirm: () -> Unit,
onCancel: (() -> Unit)? = null
) {
val textView = TextView(container.context).apply {
this.text = text
setTextColor(context.getColor(R.color.text_primary))
textSize = 15f
gravity = android.view.Gravity.CENTER
}
show(contentView = textView, onConfirm = onConfirm, onCancel = onCancel)
}
/** 关闭弹窗 */
fun dismiss() {
confirmView?.let { container.removeView(it) }
confirmView = null
container.visibility = View.GONE
}
/** 弹窗是否正在显示 */
val isShowing: Boolean get() = confirmView != null
}

View File

@@ -0,0 +1,148 @@
package com.xiaoqu.watch.ui.widget
import android.graphics.Typeface
import android.os.CountDownTimer
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.TextView
import com.xiaoqu.watch.R
/**
* 提示弹窗(对应旧版 qu-tip.vue
* 显示状态图标(成功/警告/错误)+ 标题 + 可选描述 + 倒计时自动关闭/返回
*
* 使用方式:
* ```
* val tip = QuTipDialog(binding.dialogContainer)
* tip.show(
* status = QuTipDialog.Status.SUCCESS,
* title = "打卡成功",
* desc = "已记录考勤",
* back = true,
* step = 1, // 0=只关闭1=返回上一页
* countdown = 3,
* onBack = { findMainNavController().popBackStack() }
* )
* ```
*/
class QuTipDialog(
private val container: FrameLayout
) {
/** 提示状态类型 */
enum class Status { SUCCESS, WARNING, ERROR, LOCATION }
private var tipView: View? = null
private var timer: CountDownTimer? = null
/**
* 显示提示弹窗
* @param status 状态类型(成功/警告/错误/定位)
* @param title 标题文字
* @param desc 描述文字(可选)
* @param back 是否显示倒计时返回按钮
* @param step 倒计时结束后行为0=只关闭弹窗,>0=触发 onBack 回调
* @param countdown 倒计时秒数(默认 3 秒)
* @param onBack 返回回调step > 0 时触发)
*/
fun show(
status: Status,
title: String,
desc: String? = null,
back: Boolean = true,
step: Int = 1,
countdown: Int = 3,
onBack: (() -> Unit)? = null
) {
// 先移除旧弹窗
dismiss()
// 加载布局
val view = LayoutInflater.from(container.context)
.inflate(R.layout.dialog_tip, container, false)
tipView = view
// 加载 iconfont 字体
val typeface = Typeface.createFromAsset(container.context.assets, "font/iconfont.ttf")
// 设置状态图标和颜色
val iconView = view.findViewById<TextView>(R.id.tipIcon)
iconView.typeface = typeface
when (status) {
Status.SUCCESS -> {
iconView.text = IconFont.SUCCESS
iconView.setTextColor(container.context.getColor(R.color.success))
}
Status.WARNING -> {
iconView.text = IconFont.WARNING
iconView.setTextColor(container.context.getColor(R.color.warning))
}
Status.ERROR -> {
iconView.text = IconFont.ERROR
iconView.setTextColor(container.context.getColor(R.color.error))
}
Status.LOCATION -> {
iconView.text = IconFont.LOCATION
iconView.setTextColor(container.context.getColor(R.color.success))
}
}
// 设置标题
view.findViewById<TextView>(R.id.tipTitle).text = title
// 设置描述(可选)
val descView = view.findViewById<TextView>(R.id.tipDesc)
if (desc != null) {
descView.text = desc
descView.visibility = View.VISIBLE
}
// 倒计时返回按钮
val backBtn = view.findViewById<TextView>(R.id.tipBackBtn)
if (back) {
backBtn.visibility = View.VISIBLE
backBtn.typeface = typeface
// 启动倒计时
timer = object : CountDownTimer(countdown * 1000L, 1000L) {
override fun onTick(millisUntilFinished: Long) {
val seconds = (millisUntilFinished / 1000) + 1
backBtn.text = "${IconFont.BACK} ${seconds}s"
}
override fun onFinish() {
dismiss()
// step > 0 时触发返回回调
if (step > 0) {
onBack?.invoke()
}
}
}.start()
// 点击立即返回(不等倒计时)
backBtn.setOnClickListener {
dismiss()
if (step > 0) {
onBack?.invoke()
}
}
}
// 显示弹窗
container.addView(view)
container.visibility = View.VISIBLE
}
/** 关闭弹窗 */
fun dismiss() {
timer?.cancel()
timer = null
tipView?.let { container.removeView(it) }
tipView = null
container.visibility = View.GONE
}
/** 弹窗是否正在显示 */
val isShowing: Boolean get() = tipView != null
}

View File

@@ -0,0 +1,78 @@
package com.xiaoqu.watch.util
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale
/**
* 日期格式化工具
* 对应旧版 commonUtil.js 的 getDateTime()
*/
object DateUtil {
private val weekNames = arrayOf("周日", "周一", "周二", "周三", "周四", "周五", "周六")
private val formatDateTime = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.CHINA)
private val formatDate = SimpleDateFormat("MM月dd日", Locale.CHINA)
private val formatTime = SimpleDateFormat("HH:mm:ss", Locale.CHINA)
private val formatTimeShort = SimpleDateFormat("HH:mm", Locale.CHINA)
/** 完整日期时间2026-04-24 20:15:30 */
fun formatDateTime(timestamp: Long = System.currentTimeMillis()): String {
return formatDateTime.format(Date(timestamp))
}
/** 月日04月24日 */
fun formatDate(timestamp: Long = System.currentTimeMillis()): String {
return formatDate.format(Date(timestamp))
}
/** 时分秒20:15:30 */
fun formatTime(timestamp: Long = System.currentTimeMillis()): String {
return formatTime.format(Date(timestamp))
}
/** 时分20:15 */
fun formatTimeShort(timestamp: Long = System.currentTimeMillis()): String {
return formatTimeShort.format(Date(timestamp))
}
/** 星期:周四 */
fun getWeekDay(timestamp: Long = System.currentTimeMillis()): String {
val calendar = Calendar.getInstance()
calendar.timeInMillis = timestamp
return weekNames[calendar.get(Calendar.DAY_OF_WEEK) - 1]
}
/**
* 获取完整日期信息(对应旧版 getDateTime() 返回对象)
*/
fun getDateInfo(timestamp: Long = System.currentTimeMillis()): DateInfo {
val calendar = Calendar.getInstance()
calendar.timeInMillis = timestamp
return DateInfo(
date = formatDate(timestamp),
time = formatTime(timestamp),
week = weekNames[calendar.get(Calendar.DAY_OF_WEEK) - 1],
year = calendar.get(Calendar.YEAR),
month = calendar.get(Calendar.MONTH) + 1,
day = calendar.get(Calendar.DAY_OF_MONTH),
hour = calendar.get(Calendar.HOUR_OF_DAY),
minute = calendar.get(Calendar.MINUTE),
second = calendar.get(Calendar.SECOND)
)
}
data class DateInfo(
val date: String,
val time: String,
val week: String,
val year: Int,
val month: Int,
val day: Int,
val hour: Int,
val minute: Int,
val second: Int
)
}

View File

@@ -0,0 +1,116 @@
package com.xiaoqu.watch.util
import android.annotation.SuppressLint
import android.app.ActivityManager
import android.bluetooth.BluetoothAdapter
import android.content.Context
import android.os.Build
import android.telephony.TelephonyManager
import timber.log.Timber
/**
* 设备信息工具
* 对应旧版 deviceInfoUtil.js 的 getDevice()
*/
object DeviceUtil {
/** 设备品牌 */
fun getBrand(): String = Build.BRAND
/** 设备型号 */
fun getModel(): String = Build.MODEL
/** 系统版本 */
fun getOsVersion(): String = Build.VERSION.RELEASE
/** 设备序列号 */
@SuppressLint("HardwareIds")
fun getSerial(): String {
return try {
Build.SERIAL ?: ""
} catch (e: Exception) {
Timber.w(e, "获取序列号失败")
""
}
}
/** 设备 IMEI需要 READ_PHONE_STATE 权限) */
@SuppressLint("HardwareIds", "MissingPermission")
fun getImei(context: Context): String {
return try {
val tm = context.getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager
@Suppress("DEPRECATION")
tm.deviceId ?: ""
} catch (e: Exception) {
Timber.w(e, "获取IMEI失败")
""
}
}
/** 蓝牙适配器名称 */
@SuppressLint("HardwareIds")
fun getBluetoothName(): String {
return try {
BluetoothAdapter.getDefaultAdapter()?.name ?: ""
} catch (e: Exception) {
Timber.w(e, "获取蓝牙名称失败")
""
}
}
/** 蓝牙 MAC 地址 */
@SuppressLint("HardwareIds")
fun getBluetoothMac(): String {
return try {
BluetoothAdapter.getDefaultAdapter()?.address ?: ""
} catch (e: Exception) {
Timber.w(e, "获取蓝牙MAC失败")
""
}
}
/** 总内存MB */
fun getTotalMemory(context: Context): Long {
val memInfo = ActivityManager.MemoryInfo()
val am = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
am.getMemoryInfo(memInfo)
return memInfo.totalMem / (1024 * 1024)
}
/** 可用内存MB */
fun getAvailableMemory(context: Context): Long {
val memInfo = ActivityManager.MemoryInfo()
val am = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
am.getMemoryInfo(memInfo)
return memInfo.availMem / (1024 * 1024)
}
/**
* 获取完整设备信息(对应旧版 watchInfo 对象)
*/
fun getDeviceInfo(context: Context): DeviceInfo {
return DeviceInfo(
brand = getBrand(),
model = getModel(),
osVersion = getOsVersion(),
serial = getSerial(),
imei = getImei(context),
bluetoothName = getBluetoothName(),
bluetoothMac = getBluetoothMac(),
totalMemory = getTotalMemory(context),
availableMemory = getAvailableMemory(context)
)
}
data class DeviceInfo(
val brand: String,
val model: String,
val osVersion: String,
val serial: String,
val imei: String,
val bluetoothName: String,
val bluetoothMac: String,
val totalMemory: Long,
val availableMemory: Long
)
}

View File

@@ -0,0 +1,22 @@
package com.xiaoqu.watch.util
import java.security.MessageDigest
/**
* MD5 哈希工具
* 对应旧版 md5.js 的 hex_md5(),用于 API 请求签名
*/
object Md5Util {
/** 计算字符串的 MD5 哈希值(小写 hex */
fun md5(input: String): String {
val digest = MessageDigest.getInstance("MD5")
val bytes = digest.digest(input.toByteArray())
return bytes.joinToString("") { "%02x".format(it) }
}
/** 计算字符串的 MD5 哈希值(大写 hex */
fun md5Upper(input: String): String {
return md5(input).uppercase()
}
}

View File

@@ -0,0 +1,49 @@
package com.xiaoqu.watch.util
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkInfo
/**
* 网络状态检测工具
* 对应旧版 systemUtil.js 的 getNetWorkAvailable()
*/
object NetworkUtil {
/** 网络是否可用 */
@Suppress("DEPRECATION")
fun isNetworkAvailable(context: Context): Boolean {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val activeNetwork: NetworkInfo? = cm.activeNetworkInfo
return activeNetwork?.isConnected == true
}
/** 是否连接 WiFi */
@Suppress("DEPRECATION")
fun isWifiConnected(context: Context): Boolean {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val wifiInfo = cm.getNetworkInfo(ConnectivityManager.TYPE_WIFI)
return wifiInfo?.isConnected == true
}
/** 是否连接移动数据4G */
@Suppress("DEPRECATION")
fun isMobileConnected(context: Context): Boolean {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val mobileInfo = cm.getNetworkInfo(ConnectivityManager.TYPE_MOBILE)
return mobileInfo?.isConnected == true
}
/** 获取当前网络类型描述 */
@Suppress("DEPRECATION")
fun getNetworkTypeName(context: Context): String {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val activeNetwork: NetworkInfo? = cm.activeNetworkInfo
return when {
activeNetwork == null || !activeNetwork.isConnected -> "无网络"
activeNetwork.type == ConnectivityManager.TYPE_WIFI -> "WiFi"
activeNetwork.type == ConnectivityManager.TYPE_MOBILE -> "移动数据"
else -> "其他"
}
}
}

View File

@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>

View File

@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

Binary file not shown.

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- MainActivity 布局NavBar 已移入各 Fragment 自行管理
只保留 NavHostFragment主内容和 dialog_container全局弹窗层 -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/main"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/background"
tools:context=".app.MainActivity">
<!-- Layer 1: 主内容区NavHostFragment -->
<androidx.fragment.app.FragmentContainerView
android:id="@+id/nav_host_fragment"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:defaultNavHost="true"
app:navGraph="@navigation/nav_main" />
<!-- Layer 2: 全局弹窗层QuTipDialog / QuConfirmDialog 动态添加,默认隐藏) -->
<FrameLayout
android:id="@+id/dialog_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone" />
</FrameLayout>

View File

@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- QuConfirmDialog 布局:确认弹窗
结构:半透明遮罩 + 居中内容区 + 底部取消(×)/确认(✓)图标按钮 -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/confirmOverlay"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/overlay">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center"
android:orientation="vertical"
android:padding="@dimen/spacing_lg">
<!-- 内容区域(由调用方动态设置) -->
<FrameLayout
android:id="@+id/confirmContent"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="@dimen/spacing_xl" />
<!-- 底部按钮组(取消 × / 确认 ✓) -->
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:orientation="horizontal">
<!-- 取消按钮(×) -->
<TextView
android:id="@+id/btnCancel"
android:layout_width="60dp"
android:layout_height="60dp"
android:gravity="center"
android:text="\ue623"
android:textColor="@color/text_secondary"
android:textSize="36sp"
android:layout_marginEnd="@dimen/spacing_xl" />
<!-- 确认按钮(✓) -->
<TextView
android:id="@+id/btnConfirm"
android:layout_width="60dp"
android:layout_height="60dp"
android:gravity="center"
android:text="\ue600"
android:textColor="@color/success"
android:textSize="36sp" />
</LinearLayout>
</LinearLayout>
</FrameLayout>

View File

@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- QuTipDialog 布局:提示弹窗(成功/警告/错误)
结构:全屏遮罩 + 居中内容(图标 + 标题 + 描述 + 倒计时返回按钮) -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/background">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical"
android:padding="@dimen/spacing_lg">
<!-- 状态图标(成功✓ / 警告! / 错误×) -->
<TextView
android:id="@+id/tipIcon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="48sp"
android:layout_marginBottom="@dimen/spacing_md" />
<!-- 标题文字 -->
<TextView
android:id="@+id/tipTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/text_primary"
android:textSize="@dimen/text_title"
android:textStyle="bold"
android:layout_marginBottom="@dimen/spacing_sm" />
<!-- 描述文字(可选) -->
<TextView
android:id="@+id/tipDesc"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/text_secondary"
android:textSize="@dimen/text_caption"
android:gravity="center"
android:visibility="gone"
android:layout_marginBottom="@dimen/spacing_lg" />
<!-- 倒计时返回按钮(显示"返回 3s" -->
<TextView
android:id="@+id/tipBackBtn"
android:layout_width="@dimen/touch_min_size"
android:layout_height="@dimen/touch_min_size"
android:gravity="center"
android:textColor="@color/text_secondary"
android:textSize="@dimen/text_caption"
android:visibility="gone" />
</LinearLayout>
</FrameLayout>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/background">
<!-- TODO: 二维码 + 设备信息 -->
</FrameLayout>

View File

@@ -0,0 +1,94 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 首页布局NavBar + 内容区 -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/background"
android:orientation="vertical">
<!-- 顶部导航栏(首页模式:状态图标 + 时间 + 电量) -->
<include layout="@layout/layout_nav_bar" />
<!-- 内容区域 -->
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingStart="@dimen/safe_area_left"
android:paddingEnd="@dimen/safe_area_right"
android:paddingBottom="@dimen/safe_area_bottom">
<!-- demo 信息展示 -->
<TextView
android:id="@+id/tvDemoInfo"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@color/text_primary"
android:textSize="@dimen/text_small"
android:lineSpacingExtra="3dp"
android:layout_marginBottom="@dimen/spacing_md" />
<!-- 按钮 demo触发 QuTipDialog -->
<TextView
android:id="@+id/btnShowTip"
style="@style/ActionButton.Primary"
android:text="显示提示弹窗"
android:layout_marginBottom="@dimen/spacing_sm" />
<!-- 按钮 demo触发 QuConfirmDialog -->
<TextView
android:id="@+id/btnShowConfirm"
style="@style/ActionButton.Success"
android:text="显示确认弹窗"
android:layout_marginBottom="@dimen/spacing_sm" />
<!-- 按钮 demo其他样式展示 -->
<TextView
android:id="@+id/btnDanger"
style="@style/ActionButton.Danger"
android:text="危险按钮"
android:layout_marginBottom="@dimen/spacing_sm" />
<TextView
android:id="@+id/btnWarning"
style="@style/ActionButton.Warning"
android:text="警告按钮"
android:layout_marginBottom="@dimen/spacing_sm" />
<TextView
android:id="@+id/btnGrey"
style="@style/ActionButton.Grey"
android:text="灰色按钮"
android:layout_marginBottom="@dimen/spacing_sm" />
<!-- 半宽按钮并排 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:orientation="horizontal">
<TextView
style="@style/ActionButton.Grey"
android:layout_width="0dp"
android:layout_weight="1"
android:text="取消"
android:layout_marginEnd="@dimen/spacing_sm" />
<TextView
style="@style/ActionButton.Primary"
android:layout_width="0dp"
android:layout_weight="1"
android:text="确定" />
</LinearLayout>
</LinearLayout>
</ScrollView>
</LinearLayout>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/background">
<!-- TODO: 设备信息展示 -->
</FrameLayout>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/background">
<!-- TODO: 打卡操作界面 -->
</FrameLayout>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/background">
<!-- TODO: 任务详情 + 操作按钮 -->
</FrameLayout>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/background">
<!-- TODO: RecyclerView 任务列表 -->
</FrameLayout>

View File

@@ -0,0 +1,108 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- NavBarView顶部导航栏每个 Fragment 通过 <include> 引入
左/中/右三栏布局:
- 首页:左=状态图标(蓝牙/4G/NFC),中=时间,右=电量
- 子页面:左=返回按钮,中=标题,右=电量 -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/navBar"
android:layout_width="match_parent"
android:layout_height="@dimen/nav_bar_height"
android:background="@color/background"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="@dimen/safe_area_left"
android:paddingEnd="@dimen/safe_area_right">
<!-- 左侧区域(状态图标 或 返回按钮) -->
<FrameLayout
android:id="@+id/navLeft"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center_vertical">
<!-- 返回按钮(子页面用,默认隐藏) -->
<TextView
android:id="@+id/btnBack"
android:layout_width="@dimen/touch_min_size"
android:layout_height="@dimen/touch_min_size"
android:layout_gravity="center_vertical"
android:gravity="center"
android:text="\ue6bd"
android:textColor="@color/text_secondary"
android:textSize="18sp"
android:visibility="gone" />
<!-- 状态图标组(首页用,默认显示) -->
<LinearLayout
android:id="@+id/statusIcons"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:gravity="center_vertical"
android:orientation="horizontal">
<!-- 蓝牙状态 -->
<TextView
android:id="@+id/iconBluetooth"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="\ue6da"
android:textColor="@color/text_primary"
android:textSize="14sp"
android:paddingEnd="6dp" />
<!-- 4G 状态 -->
<TextView
android:id="@+id/icon4G"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="\ue6dc"
android:textColor="@color/text_primary"
android:textSize="14sp"
android:paddingEnd="6dp" />
<!-- NFC 状态 -->
<TextView
android:id="@+id/iconNfc"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="\ue6d8"
android:textColor="@color/text_primary"
android:textSize="14sp" />
</LinearLayout>
</FrameLayout>
<!-- 中间区域(时间 或 标题) -->
<TextView
android:id="@+id/navTitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="2"
android:ellipsize="end"
android:gravity="center"
android:maxLines="1"
android:textColor="@color/text_primary"
android:textSize="@dimen/text_body" />
<!-- 右侧区域(电量图标) -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center_vertical|end"
android:orientation="horizontal">
<!-- 电量图标 -->
<TextView
android:id="@+id/iconBattery"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="\ue6ce"
android:textColor="@color/success"
android:textSize="16sp" />
</LinearLayout>
</LinearLayout>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/nav_main"
app:startDestination="@id/homeFragment">
<!-- 首页(含 ViewPager2 左右滑动:设置页 / 主页) -->
<fragment
android:id="@+id/homeFragment"
android:name="com.xiaoqu.watch.ui.home.HomeFragment"
android:label="首页" />
<!-- 设备绑定页(全屏二维码) -->
<fragment
android:id="@+id/bindFragment"
android:name="com.xiaoqu.watch.ui.bind.BindFragment"
android:label="设备绑定" />
<!-- 任务列表 -->
<fragment
android:id="@+id/taskListFragment"
android:name="com.xiaoqu.watch.ui.task.TaskListFragment"
android:label="任务列表" />
<!-- 任务详情 -->
<fragment
android:id="@+id/taskDetailFragment"
android:name="com.xiaoqu.watch.ui.task.TaskDetailFragment"
android:label="任务详情" />
<!-- 打卡页 -->
<fragment
android:id="@+id/punchFragment"
android:name="com.xiaoqu.watch.ui.punch.PunchFragment"
android:label="打卡" />
<!-- 设备信息 -->
<fragment
android:id="@+id/infoFragment"
android:name="com.xiaoqu.watch.ui.info.InfoFragment"
android:label="设备信息" />
</navigation>

View File

@@ -0,0 +1,7 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Base.Theme.XqWatch" parent="Theme.Material3.DayNight.NoActionBar">
<!-- Customize your dark theme here. -->
<!-- <item name="colorPrimary">@color/my_dark_primary</item> -->
</style>
</resources>

View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
<!-- 全局背景 -->
<color name="background">#FF000000</color>
<!-- 主题色 -->
<color name="primary">#FF007AFF</color>
<color name="action_primary">#FF339AFB</color>
<!-- 状态色 -->
<color name="success">#FF1CC46B</color>
<color name="warning">#FFEB9A26</color>
<color name="error">#FFDA5050</color>
<!-- 按钮色 -->
<color name="grey_button">#FF666666</color>
<!-- 文字色 -->
<color name="text_primary">#FFFFFFFF</color>
<color name="text_secondary">#FF999999</color>
<color name="text_placeholder">#FF808080</color>
<color name="text_disabled">#FFC0C0C0</color>
<!-- 界面元素 -->
<color name="overlay">#66000000</color>
<color name="border">#FFC8C7CC</color>
<color name="card_background">#FF1A1A1A</color>
<color name="pressed">#FFF1F1F1</color>
<!-- 状态图标 -->
<color name="charge_green">#FF00FF00</color>
<color name="disconnect_red">#FF8B0000</color>
</resources>

View File

@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- SafeArea 内边距(圆角屏防裁切) -->
<dimen name="safe_area_left">12dp</dimen>
<dimen name="safe_area_top">16dp</dimen>
<dimen name="safe_area_right">12dp</dimen>
<dimen name="safe_area_bottom">16dp</dimen>
<!-- NavBar -->
<dimen name="nav_bar_height">60dp</dimen>
<!-- 字体大小 -->
<dimen name="text_title">18sp</dimen>
<dimen name="text_body">15sp</dimen>
<dimen name="text_caption">13sp</dimen>
<dimen name="text_small">11sp</dimen>
<dimen name="text_button">15sp</dimen>
<dimen name="text_button_small">13sp</dimen>
<!-- 间距 -->
<dimen name="spacing_xs">4dp</dimen>
<dimen name="spacing_sm">8dp</dimen>
<dimen name="spacing_md">12dp</dimen>
<dimen name="spacing_lg">16dp</dimen>
<dimen name="spacing_xl">24dp</dimen>
<!-- 圆角 -->
<dimen name="corner_radius_sm">4dp</dimen>
<dimen name="corner_radius_md">8dp</dimen>
<dimen name="corner_radius_lg">16dp</dimen>
<dimen name="corner_radius_button">8dp</dimen>
<!-- 按钮 -->
<dimen name="button_height">40dp</dimen>
<dimen name="button_half_width">120dp</dimen>
<!-- 触摸区域最小尺寸 -->
<dimen name="touch_min_size">40dp</dimen>
<!-- 图标 -->
<dimen name="icon_sm">16dp</dimen>
<dimen name="icon_md">24dp</dimen>
<dimen name="icon_lg">32dp</dimen>
</resources>

View File

@@ -0,0 +1,3 @@
<resources>
<string name="app_name">XqWatch</string>
</resources>

View File

@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- ===== ActionButton 基础样式 ===== -->
<style name="ActionButton">
<item name="android:layout_width">match_parent</item>
<item name="android:layout_height">@dimen/button_height</item>
<item name="android:gravity">center</item>
<item name="android:textColor">@color/white</item>
<item name="android:textSize">@dimen/text_button</item>
<item name="android:textStyle">bold</item>
</style>
<!-- 主色按钮(默认) -->
<style name="ActionButton.Primary">
<item name="android:background">@color/action_primary</item>
</style>
<!-- 成功按钮(确认/完成) -->
<style name="ActionButton.Success">
<item name="android:background">@color/success</item>
</style>
<!-- 危险按钮(取消/撤销) -->
<style name="ActionButton.Danger">
<item name="android:background">@color/error</item>
</style>
<!-- 警告按钮(抢单) -->
<style name="ActionButton.Warning">
<item name="android:background">@color/warning</item>
</style>
<!-- 灰色按钮(返回/次要操作) -->
<style name="ActionButton.Grey">
<item name="android:background">@color/grey_button</item>
</style>
<!-- 半宽按钮(两个并排时使用) -->
<style name="ActionButton.Half">
<item name="android:layout_width">@dimen/button_half_width</item>
</style>
</resources>

View File

@@ -0,0 +1,29 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- 应用主题:黑底全屏、无 ActionBar、无窗口动画 -->
<style name="Base.Theme.XqWatch" parent="Theme.Material3.DayNight.NoActionBar">
<!-- 背景色 -->
<item name="android:windowBackground">@color/background</item>
<item name="android:colorBackground">@color/background</item>
<!-- 主题色 -->
<item name="colorPrimary">@color/primary</item>
<item name="colorOnPrimary">@color/white</item>
<!-- 全屏(隐藏状态栏/导航栏) -->
<item name="android:windowFullscreen">true</item>
<item name="windowNoTitle">true</item>
<!-- 禁用窗口动画(手表性能优先) -->
<item name="android:windowAnimationStyle">@null</item>
<!-- 文字颜色 -->
<item name="android:textColorPrimary">@color/text_primary</item>
<item name="android:textColorSecondary">@color/text_secondary</item>
<item name="android:textColorHint">@color/text_placeholder</item>
<!-- 固定竖屏 -->
<item name="android:screenOrientation">portrait</item>
</style>
<style name="Theme.XqWatch" parent="Base.Theme.XqWatch" />
</resources>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older than API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>

View File

@@ -0,0 +1,17 @@
package com.xiaoqu.watch
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}

7
build.gradle.kts Normal file
View File

@@ -0,0 +1,7 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.ksp) apply false
alias(libs.plugins.hilt) apply false
}

23
gradle.properties Normal file
View File

@@ -0,0 +1,23 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. For more details, visit
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true

90
gradle/libs.versions.toml Normal file
View File

@@ -0,0 +1,90 @@
[versions]
agp = "8.7.3"
kotlin = "2.0.21"
ksp = "2.0.21-1.0.27"
hilt = "2.51.1"
coroutines = "1.8.1"
lifecycle = "2.7.0"
navigation = "2.7.7"
room = "2.6.1"
retrofit = "2.9.0"
okhttp = "4.12.0"
gson = "2.10.1"
paho = "1.2.5"
timber = "5.0.1"
zxing = "3.5.3"
swiperefresh = "1.1.0"
viewpager2 = "1.0.0"
coreKtx = "1.12.0"
appcompat = "1.6.1"
material = "1.11.0"
activity = "1.8.2"
fragment = "1.6.2"
constraintlayout = "2.1.4"
junit = "4.13.2"
junitVersion = "1.1.5"
espressoCore = "3.5.1"
mockk = "1.13.10"
coroutinesTest = "1.8.1"
[libraries]
# Core
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
androidx-activity = { group = "androidx.activity", name = "activity-ktx", version.ref = "activity" }
androidx-fragment = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "fragment" }
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
androidx-viewpager2 = { group = "androidx.viewpager2", name = "viewpager2", version.ref = "viewpager2" }
androidx-swiperefreshlayout = { group = "androidx.swiperefreshlayout", name = "swiperefreshlayout", version.ref = "swiperefresh" }
# Lifecycle
androidx-lifecycle-viewmodel = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycle" }
androidx-lifecycle-livedata = { group = "androidx.lifecycle", name = "lifecycle-livedata-ktx", version.ref = "lifecycle" }
androidx-lifecycle-runtime = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
# Navigation
androidx-navigation-fragment = { group = "androidx.navigation", name = "navigation-fragment-ktx", version.ref = "navigation" }
androidx-navigation-ui = { group = "androidx.navigation", name = "navigation-ui-ktx", version.ref = "navigation" }
# Hilt
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }
# Coroutines
kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" }
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
# Room
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
# Network
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-converter-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" }
# MQTT
paho-mqtt = { group = "org.eclipse.paho", name = "org.eclipse.paho.client.mqttv3", version.ref = "paho" }
# Logging
timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" }
# QR Code
zxing-core = { group = "com.google.zxing", name = "core", version.ref = "zxing" }
# Test
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutinesTest" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,6 @@
#Fri Apr 24 17:29:26 CST 2026
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

185
gradlew vendored Executable file
View File

@@ -0,0 +1,185 @@
#!/usr/bin/env sh
#
# Copyright 2015 the original author or authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=`expr $i + 1`
done
case $i in
0) set -- ;;
1) set -- "$args0" ;;
2) set -- "$args0" "$args1" ;;
3) set -- "$args0" "$args1" "$args2" ;;
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=`save "$@"`
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
exec "$JAVACMD" "$@"

89
gradlew.bat vendored Normal file
View File

@@ -0,0 +1,89 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

24
settings.gradle.kts Normal file
View File

@@ -0,0 +1,24 @@
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "XqWatch"
include(":app")