Переглянути джерело

Initial commit: DTS Monitor Android App v1.0

- Kotlin + Material Design 3
- 4 个功能页面: 实时温度/报警管理/历史数据/设置
- REST API 客户端对接 DTS Monitor Web 服务
- MPAndroidChart 温度曲线图表
- OkHttp + Gson 网络请求
- 支持 Android 7.0+ (API 24+)
steven_roc 3 днів тому
коміт
768fe1ef95
48 змінених файлів з 2283 додано та 0 видалено
  1. 15 0
      .gitignore
  2. 77 0
      README.md
  3. 59 0
      app/build.gradle
  4. 3 0
      app/proguard-rules.pro
  5. 27 0
      app/src/main/AndroidManifest.xml
  6. 47 0
      app/src/main/java/com/dtsmonitor/app/MainActivity.kt
  7. 83 0
      app/src/main/java/com/dtsmonitor/app/api/DtsApiService.kt
  8. 75 0
      app/src/main/java/com/dtsmonitor/app/api/Models.kt
  9. 41 0
      app/src/main/java/com/dtsmonitor/app/ui/alarm/AlarmAdapter.kt
  10. 183 0
      app/src/main/java/com/dtsmonitor/app/ui/alarm/AlarmFragment.kt
  11. 158 0
      app/src/main/java/com/dtsmonitor/app/ui/history/HistoryFragment.kt
  12. 54 0
      app/src/main/java/com/dtsmonitor/app/ui/history/SnapshotAdapter.kt
  13. 132 0
      app/src/main/java/com/dtsmonitor/app/ui/realtime/RealtimeFragment.kt
  14. 97 0
      app/src/main/java/com/dtsmonitor/app/ui/settings/SettingsFragment.kt
  15. 19 0
      app/src/main/java/com/dtsmonitor/app/util/Preferences.kt
  16. 17 0
      app/src/main/res/drawable/ic_launcher_foreground.xml
  17. 31 0
      app/src/main/res/layout/activity_main.xml
  18. 185 0
      app/src/main/res/layout/fragment_alarm.xml
  19. 62 0
      app/src/main/res/layout/fragment_history.xml
  20. 121 0
      app/src/main/res/layout/fragment_realtime.xml
  21. 123 0
      app/src/main/res/layout/fragment_settings.xml
  22. 77 0
      app/src/main/res/layout/item_alarm.xml
  23. 52 0
      app/src/main/res/layout/item_snapshot.xml
  24. 19 0
      app/src/main/res/menu/bottom_nav_menu.xml
  25. 5 0
      app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
  26. 5 0
      app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
  27. BIN
      app/src/main/res/mipmap-hdpi/ic_launcher.png
  28. BIN
      app/src/main/res/mipmap-hdpi/ic_launcher_round.png
  29. BIN
      app/src/main/res/mipmap-mdpi/ic_launcher.png
  30. BIN
      app/src/main/res/mipmap-mdpi/ic_launcher_round.png
  31. BIN
      app/src/main/res/mipmap-xhdpi/ic_launcher.png
  32. BIN
      app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
  33. BIN
      app/src/main/res/mipmap-xxhdpi/ic_launcher.png
  34. BIN
      app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
  35. BIN
      app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
  36. BIN
      app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
  37. 14 0
      app/src/main/res/values/colors.xml
  38. 38 0
      app/src/main/res/values/strings.xml
  39. 44 0
      app/src/main/res/values/themes.xml
  40. 8 0
      app/src/main/res/xml/network_security_config.xml
  41. 24 0
      build.gradle
  42. 4 0
      gradle.properties
  43. BIN
      gradle/wrapper/gradle-wrapper.jar
  44. 7 0
      gradle/wrapper/gradle-wrapper.properties
  45. 249 0
      gradlew
  46. 92 0
      gradlew.bat
  47. 34 0
      scripts/git_auto_sync.sh
  48. 2 0
      settings.gradle

+ 15 - 0
.gitignore

@@ -0,0 +1,15 @@
+.gradle/
+build/
+app/build/
+captures/
+.externalNativeBuild/
+.cxx/
+.idea/
+*.iml
+*.iws
+*.ipr
+.DS_Store
+local.properties
+scripts/.git_credentials
+*.jks
+*.keystore

+ 77 - 0
README.md

@@ -0,0 +1,77 @@
+# DTS Monitor Android
+
+DTS 光纤温度传感器监控系统 Android 客户端
+
+## 功能
+
+- 🌡️ **实时温度监控** - 实时温度曲线、最高/最低/平均温度、状态指示
+- 🚨 **报警管理** - 报警统计(今日/本周/本月/累计)、报警记录列表、时间筛选
+- 📊 **历史数据** - 温度快照列表、历史温度曲线查看
+- ⚙️ **设置** - 服务器地址配置,支持保存
+
+## 技术栈
+
+- **语言**: Kotlin
+- **最低支持**: Android 7.0 (API 24)
+- **目标版本**: Android 13 (API 33)
+- **UI**: Material Design 3
+- **图表**: MPAndroidChart
+- **网络**: OkHttp + Gson
+- **构建**: Gradle 8.4
+
+## 项目结构
+
+```
+app/src/main/java/com/dtsmonitor/app/
+├── MainActivity.kt          # 主Activity,底部导航管理
+├── ui/
+│   ├── realtime/            # 实时温度监控页
+│   ├── alarm/               # 报警管理页
+│   ├── history/             # 历史数据页
+│   └── settings/            # 设置页
+├── api/
+│   ├── DtsApiService.kt     # REST API 客户端
+│   └── Models.kt            # 数据模型
+└── util/
+    └── Preferences.kt       # SharedPreferences 工具
+```
+
+## 编译
+
+### 环境要求
+
+- JDK 11+
+- Android SDK (API 33)
+- Gradle 8.4
+
+### 编译步骤
+
+```bash
+export ANDROID_HOME=/path/to/android-sdk
+./gradlew assembleDebug
+```
+
+APK 输出: `app/build/outputs/apk/debug/app-debug.apk`
+
+## 配置
+
+首次运行后,进入「设置」页面输入 DTS Monitor 服务器地址(如 `192.168.1.100:8086`),保存后返回其他页面即可自动连接。
+
+## 对接 API
+
+| 端点 | 说明 |
+|------|------|
+| GET /api/realtime | 实时温度数据 |
+| GET /api/status | 系统状态 |
+| GET /api/alarm | 报警记录(分页) |
+| GET /api/snapshots | 温度快照列表 |
+| GET /api/history?time=xxx | 历史温度数据 |
+
+## 仓库
+
+- **Gogs**: https://git.zhonjin.com:40717/steven_roc/dts_monitor_android
+- **服务端**: https://git.zhonjin.com:40717/steven_roc/dts_monitor
+
+## License
+
+MIT

+ 59 - 0
app/build.gradle

@@ -0,0 +1,59 @@
+plugins {
+    id 'com.android.application'
+    id 'org.jetbrains.kotlin.android'
+}
+
+android {
+    compileSdk 33
+    namespace 'com.dtsmonitor.app'
+
+    defaultConfig {
+        applicationId "com.dtsmonitor.app"
+        minSdk 24
+        targetSdk 33
+        versionCode 1
+        versionName "1.0"
+    }
+
+    buildTypes {
+        release {
+            minifyEnabled false
+            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
+        }
+    }
+
+    compileOptions {
+        sourceCompatibility JavaVersion.VERSION_11
+        targetCompatibility JavaVersion.VERSION_11
+    }
+
+    kotlinOptions {
+        jvmTarget = '11'
+    }
+
+    buildFeatures {
+        viewBinding true
+    }
+}
+
+dependencies {
+    implementation 'androidx.core:core-ktx:1.10.1'
+    implementation 'androidx.appcompat:appcompat:1.6.1'
+    implementation 'com.google.android.material:material:1.9.0'
+    implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
+    implementation 'androidx.fragment:fragment-ktx:1.6.1'
+    implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1'
+    implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.6.1'
+    implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
+    implementation 'androidx.recyclerview:recyclerview:1.3.1'
+
+    // OkHttp + Gson
+    implementation 'com.squareup.okhttp3:okhttp:4.11.0'
+    implementation 'com.google.code.gson:gson:2.10.1'
+
+    // MPAndroidChart
+    implementation 'com.github.PhilJay:MPAndroidChart:v3.1.0'
+
+    // Coroutines
+    implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
+}

+ 3 - 0
app/proguard-rules.pro

@@ -0,0 +1,3 @@
+# Add project specific ProGuard rules here.
+-keep class com.dtsmonitor.app.api.** { *; }
+-keep class com.github.mikephil.charting.** { *; }

+ 27 - 0
app/src/main/AndroidManifest.xml

@@ -0,0 +1,27 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+
+    <uses-permission android:name="android.permission.INTERNET" />
+    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
+
+    <application
+        android:allowBackup="true"
+        android:icon="@mipmap/ic_launcher"
+        android:label="@string/app_name"
+        android:roundIcon="@mipmap/ic_launcher_round"
+        android:supportsRtl="true"
+        android:theme="@style/Theme.DTSMonitor"
+        android:networkSecurityConfig="@xml/network_security_config"
+        android:usesCleartextTraffic="true">
+        <activity
+            android:name=".MainActivity"
+            android:exported="true"
+            android:windowSoftInputMode="adjustResize">
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+                <category android:name="android.intent.category.LAUNCHER" />
+            </intent-filter>
+        </activity>
+    </application>
+
+</manifest>

+ 47 - 0
app/src/main/java/com/dtsmonitor/app/MainActivity.kt

@@ -0,0 +1,47 @@
+package com.dtsmonitor.app
+
+import android.os.Bundle
+import androidx.appcompat.app.AppCompatActivity
+import androidx.fragment.app.Fragment
+import com.dtsmonitor.app.databinding.ActivityMainBinding
+import com.dtsmonitor.app.ui.alarm.AlarmFragment
+import com.dtsmonitor.app.ui.history.HistoryFragment
+import com.dtsmonitor.app.ui.realtime.RealtimeFragment
+import com.dtsmonitor.app.ui.settings.SettingsFragment
+
+class MainActivity : AppCompatActivity() {
+
+    private lateinit var binding: ActivityMainBinding
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        binding = ActivityMainBinding.inflate(layoutInflater)
+        setContentView(binding.root)
+
+        setupBottomNavigation()
+
+        if (savedInstanceState == null) {
+            loadFragment(RealtimeFragment())
+        }
+    }
+
+    private fun setupBottomNavigation() {
+        binding.bottomNavigation.setOnItemSelectedListener { item ->
+            val fragment: Fragment = when (item.itemId) {
+                R.id.nav_realtime -> RealtimeFragment()
+                R.id.nav_alarm -> AlarmFragment()
+                R.id.nav_history -> HistoryFragment()
+                R.id.nav_settings -> SettingsFragment()
+                else -> return@setOnItemSelectedListener false
+            }
+            loadFragment(fragment)
+            true
+        }
+    }
+
+    private fun loadFragment(fragment: Fragment) {
+        supportFragmentManager.beginTransaction()
+            .replace(R.id.fragment_container, fragment)
+            .commit()
+    }
+}

+ 83 - 0
app/src/main/java/com/dtsmonitor/app/api/DtsApiService.kt

@@ -0,0 +1,83 @@
+package com.dtsmonitor.app.api
+
+import com.google.gson.Gson
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import java.util.concurrent.TimeUnit
+
+class DtsApiService(private var baseUrl: String) {
+
+    private val client = OkHttpClient.Builder()
+        .connectTimeout(10, TimeUnit.SECONDS)
+        .readTimeout(15, TimeUnit.SECONDS)
+        .writeTimeout(10, TimeUnit.SECONDS)
+        .build()
+
+    private val gson = Gson()
+
+    fun updateBaseUrl(url: String) {
+        baseUrl = url.trimEnd('/')
+    }
+
+    private fun buildUrl(path: String): String {
+        val base = if (baseUrl.startsWith("http")) baseUrl else "http://$baseUrl"
+        return "$base$path"
+    }
+
+    suspend fun getRealtime(): Result<RealtimeResponse> = withContext(Dispatchers.IO) {
+        executeRequest("/api/realtime")
+    }
+
+    suspend fun getStatus(): Result<StatusResponse> = withContext(Dispatchers.IO) {
+        executeRequest("/api/status")
+    }
+
+    suspend fun getConfig(): Result<ConfigResponse> = withContext(Dispatchers.IO) {
+        executeRequest("/api/config")
+    }
+
+    suspend fun getAlarms(page: Int = 1, size: Int = 50): Result<AlarmResponse> = withContext(Dispatchers.IO) {
+        executeRequest("/api/alarm?page=$page&size=$size")
+    }
+
+    suspend fun getSnapshots(page: Int = 1, size: Int = 20): Result<SnapshotResponse> = withContext(Dispatchers.IO) {
+        executeRequest("/api/snapshots?page=$page&size=$size")
+    }
+
+    suspend fun getHistory(time: String): Result<HistoryResponse> = withContext(Dispatchers.IO) {
+        executeRequest("/api/history?time=$time")
+    }
+
+    private inline fun <reified T> executeRequest(path: String): Result<T> {
+        return try {
+            val url = buildUrl(path)
+            val request = Request.Builder()
+                .url(url)
+                .get()
+                .build()
+
+            val response = client.newCall(request).execute()
+
+            if (response.isSuccessful) {
+                val body = response.body?.string()
+                if (body != null) {
+                    val result = gson.fromJson(body, T::class.java)
+                    Result.success(result)
+                } else {
+                    Result.failure(Exception("Empty response body"))
+                }
+            } else {
+                Result.failure(Exception("HTTP ${response.code}: ${response.message}"))
+            }
+        } catch (e: Exception) {
+            Result.failure(e)
+        }
+    }
+
+    fun getWebUrl(): String {
+        val base = if (baseUrl.startsWith("http")) baseUrl else "http://$baseUrl"
+        return base
+    }
+}

+ 75 - 0
app/src/main/java/com/dtsmonitor/app/api/Models.kt

@@ -0,0 +1,75 @@
+package com.dtsmonitor.app.api
+
+import com.google.gson.annotations.SerializedName
+
+data class RealtimeResponse(
+    @SerializedName("status") val status: String,
+    @SerializedName("distances") val distances: List<Double>,
+    @SerializedName("temperatures") val temperatures: List<Double>,
+    @SerializedName("timestamp") val timestamp: String,
+    @SerializedName("num_points") val numPoints: Int,
+    @SerializedName("max_temp") val maxTemp: Double,
+    @SerializedName("min_temp") val minTemp: Double,
+    @SerializedName("avg_temp") val avgTemp: Double,
+    @SerializedName("max_temp_distance") val maxTempDistance: Double,
+    @SerializedName("alarm_count") val alarmCount: Int
+)
+
+data class StatusResponse(
+    @SerializedName("status") val status: String,
+    @SerializedName("tcp_connected") val tcpConnected: Boolean,
+    @SerializedName("data_valid") val dataValid: Boolean,
+    @SerializedName("last_update") val lastUpdate: String,
+    @SerializedName("num_points") val numPoints: Int,
+    @SerializedName("alarm_count") val alarmCount: Int,
+    @SerializedName("alarm_threshold") val alarmThreshold: Double,
+    @SerializedName("store_interval") val storeInterval: Int,
+    @SerializedName("retention_days") val retentionDays: Int
+)
+
+data class ConfigResponse(
+    @SerializedName("status") val status: String,
+    @SerializedName("device_ip") val deviceIp: String,
+    @SerializedName("cmd_port") val cmdPort: Int,
+    @SerializedName("data_port") val dataPort: Int,
+    @SerializedName("target_channel") val targetChannel: Int
+)
+
+data class AlarmResponse(
+    @SerializedName("status") val status: String,
+    @SerializedName("total") val total: Int,
+    @SerializedName("page") val page: Int,
+    @SerializedName("page_size") val pageSize: Int,
+    @SerializedName("records") val records: List<AlarmRecord>
+)
+
+data class AlarmRecord(
+    @SerializedName("id") val id: Int,
+    @SerializedName("timestamp") val timestamp: String,
+    @SerializedName("channel") val channel: Int,
+    @SerializedName("distance") val distance: Double,
+    @SerializedName("temperature") val temperature: Double,
+    @SerializedName("alarm_type") val alarmType: String
+)
+
+data class SnapshotResponse(
+    @SerializedName("status") val status: String,
+    @SerializedName("total") val total: Int,
+    @SerializedName("page") val page: Int,
+    @SerializedName("page_size") val pageSize: Int,
+    @SerializedName("snapshots") val snapshots: List<SnapshotRecord>
+)
+
+data class SnapshotRecord(
+    @SerializedName("timestamp") val timestamp: Double,
+    @SerializedName("time_str") val timeStr: String
+)
+
+data class HistoryResponse(
+    @SerializedName("status") val status: String,
+    @SerializedName("channel") val channel: Int,
+    @SerializedName("time") val time: String,
+    @SerializedName("num_points") val numPoints: Int,
+    @SerializedName("distances") val distances: List<Double>,
+    @SerializedName("temperatures") val temperatures: List<Double>
+)

+ 41 - 0
app/src/main/java/com/dtsmonitor/app/ui/alarm/AlarmAdapter.kt

@@ -0,0 +1,41 @@
+package com.dtsmonitor.app.ui.alarm
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.ListAdapter
+import androidx.recyclerview.widget.RecyclerView
+import com.dtsmonitor.app.api.AlarmRecord
+import com.dtsmonitor.app.databinding.ItemAlarmBinding
+
+class AlarmAdapter : ListAdapter<AlarmRecord, AlarmAdapter.AlarmViewHolder>(AlarmDiffCallback()) {
+
+    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AlarmViewHolder {
+        val binding = ItemAlarmBinding.inflate(LayoutInflater.from(parent.context), parent, false)
+        return AlarmViewHolder(binding)
+    }
+
+    override fun onBindViewHolder(holder: AlarmViewHolder, position: Int) {
+        holder.bind(getItem(position))
+    }
+
+    class AlarmViewHolder(private val binding: ItemAlarmBinding) : RecyclerView.ViewHolder(binding.root) {
+        fun bind(record: AlarmRecord) {
+            binding.tvAlarmType.text = record.alarmType
+            binding.tvAlarmTime.text = record.timestamp
+            binding.tvAlarmChannel.text = "通道: ${record.channel}"
+            binding.tvAlarmDistance.text = "距离: ${String.format("%.1f", record.distance)}m"
+            binding.tvAlarmTemp.text = "${String.format("%.1f", record.temperature)}℃"
+        }
+    }
+
+    class AlarmDiffCallback : DiffUtil.ItemCallback<AlarmRecord>() {
+        override fun areItemsTheSame(oldItem: AlarmRecord, newItem: AlarmRecord): Boolean {
+            return oldItem.id == newItem.id
+        }
+
+        override fun areContentsTheSame(oldItem: AlarmRecord, newItem: AlarmRecord): Boolean {
+            return oldItem == newItem
+        }
+    }
+}

+ 183 - 0
app/src/main/java/com/dtsmonitor/app/ui/alarm/AlarmFragment.kt

@@ -0,0 +1,183 @@
+package com.dtsmonitor.app.ui.alarm
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.fragment.app.Fragment
+import androidx.recyclerview.widget.LinearLayoutManager
+import com.dtsmonitor.app.api.AlarmRecord
+import com.dtsmonitor.app.api.DtsApiService
+import com.dtsmonitor.app.databinding.FragmentAlarmBinding
+import com.dtsmonitor.app.util.Preferences
+import kotlinx.coroutines.*
+import java.time.LocalDate
+import java.time.LocalDateTime
+import java.time.format.DateTimeFormatter
+
+class AlarmFragment : Fragment() {
+
+    private var _binding: FragmentAlarmBinding? = null
+    private val binding get() = _binding!!
+
+    private lateinit var apiService: DtsApiService
+    private lateinit var preferences: Preferences
+    private lateinit var alarmAdapter: AlarmAdapter
+    private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
+
+    private var allAlarms: List<AlarmRecord> = emptyList()
+    private var currentFilter: FilterType = FilterType.ALL
+
+    enum class FilterType { TODAY, WEEK, MONTH, ALL }
+
+    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
+        _binding = FragmentAlarmBinding.inflate(inflater, container, false)
+        return binding.root
+    }
+
+    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+        super.onViewCreated(view, savedInstanceState)
+
+        preferences = Preferences(requireContext())
+        apiService = DtsApiService(preferences.serverAddress)
+
+        setupRecyclerView()
+        setupFilters()
+        setupSwipeRefresh()
+        loadAlarms()
+    }
+
+    private fun setupRecyclerView() {
+        alarmAdapter = AlarmAdapter()
+        binding.rvAlarms.apply {
+            layoutManager = LinearLayoutManager(requireContext())
+            adapter = alarmAdapter
+        }
+    }
+
+    private fun setupFilters() {
+        binding.btnFilterToday.setOnClickListener { setFilter(FilterType.TODAY) }
+        binding.btnFilterWeek.setOnClickListener { setFilter(FilterType.WEEK) }
+        binding.btnFilterMonth.setOnClickListener { setFilter(FilterType.MONTH) }
+        binding.btnFilterAll.setOnClickListener { setFilter(FilterType.ALL) }
+    }
+
+    private fun setupSwipeRefresh() {
+        binding.swipeRefresh.setColorSchemeResources(android.R.color.holo_blue_light)
+        binding.swipeRefresh.setOnRefreshListener {
+            loadAlarms()
+        }
+    }
+
+    private fun setFilter(filter: FilterType) {
+        currentFilter = filter
+        updateFilterButtons()
+        applyFilter()
+    }
+
+    private fun updateFilterButtons() {
+        val buttons = listOf(
+            binding.btnFilterToday to FilterType.TODAY,
+            binding.btnFilterWeek to FilterType.WEEK,
+            binding.btnFilterMonth to FilterType.MONTH,
+            binding.btnFilterAll to FilterType.ALL
+        )
+        buttons.forEach { (btn, type) ->
+            if (type == currentFilter) {
+                btn.setBackgroundColor(resources.getColor(android.R.color.holo_blue_light, null))
+            } else {
+                btn.setBackgroundColor(resources.getColor(android.R.color.transparent, null))
+            }
+        }
+    }
+
+    private fun loadAlarms() {
+        apiService.updateBaseUrl(preferences.serverAddress)
+        coroutineScope.launch {
+            val result = apiService.getAlarms(page = 1, size = 200)
+            result.onSuccess { response ->
+                if (isAdded) {
+                    allAlarms = response.records
+                    updateStats()
+                    applyFilter()
+                    binding.swipeRefresh.isRefreshing = false
+                }
+            }.onFailure {
+                if (isAdded) {
+                    binding.swipeRefresh.isRefreshing = false
+                }
+            }
+        }
+    }
+
+    private fun updateStats() {
+        val now = LocalDate.now()
+        val todayCount = allAlarms.count { record ->
+            try {
+                val date = LocalDateTime.parse(record.timestamp, DateTimeFormatter.ISO_LOCAL_DATE_TIME).toLocalDate()
+                date == now
+            } catch (e: Exception) { false }
+        }
+        val weekStart = now.minusDays(now.dayOfWeek.value.toLong() - 1)
+        val weekCount = allAlarms.count { record ->
+            try {
+                val date = LocalDateTime.parse(record.timestamp, DateTimeFormatter.ISO_LOCAL_DATE_TIME).toLocalDate()
+                !date.isBefore(weekStart)
+            } catch (e: Exception) { false }
+        }
+        val monthStart = now.withDayOfMonth(1)
+        val monthCount = allAlarms.count { record ->
+            try {
+                val date = LocalDateTime.parse(record.timestamp, DateTimeFormatter.ISO_LOCAL_DATE_TIME).toLocalDate()
+                !date.isBefore(monthStart)
+            } catch (e: Exception) { false }
+        }
+
+        binding.tvTodayCount.text = todayCount.toString()
+        binding.tvWeekCount.text = weekCount.toString()
+        binding.tvMonthCount.text = monthCount.toString()
+        binding.tvTotalCount.text = allAlarms.size.toString()
+    }
+
+    private fun applyFilter() {
+        val filtered = when (currentFilter) {
+            FilterType.TODAY -> {
+                val now = LocalDate.now()
+                allAlarms.filter { record ->
+                    try {
+                        val date = LocalDateTime.parse(record.timestamp, DateTimeFormatter.ISO_LOCAL_DATE_TIME).toLocalDate()
+                        date == now
+                    } catch (e: Exception) { false }
+                }
+            }
+            FilterType.WEEK -> {
+                val now = LocalDate.now()
+                val weekStart = now.minusDays(now.dayOfWeek.value.toLong() - 1)
+                allAlarms.filter { record ->
+                    try {
+                        val date = LocalDateTime.parse(record.timestamp, DateTimeFormatter.ISO_LOCAL_DATE_TIME).toLocalDate()
+                        !date.isBefore(weekStart)
+                    } catch (e: Exception) { false }
+                }
+            }
+            FilterType.MONTH -> {
+                val now = LocalDate.now()
+                val monthStart = now.withDayOfMonth(1)
+                allAlarms.filter { record ->
+                    try {
+                        val date = LocalDateTime.parse(record.timestamp, DateTimeFormatter.ISO_LOCAL_DATE_TIME).toLocalDate()
+                        !date.isBefore(monthStart)
+                    } catch (e: Exception) { false }
+                }
+            }
+            FilterType.ALL -> allAlarms
+        }
+        alarmAdapter.submitList(filtered)
+    }
+
+    override fun onDestroyView() {
+        super.onDestroyView()
+        coroutineScope.cancel()
+        _binding = null
+    }
+}

+ 158 - 0
app/src/main/java/com/dtsmonitor/app/ui/history/HistoryFragment.kt

@@ -0,0 +1,158 @@
+package com.dtsmonitor.app.ui.history
+
+import android.graphics.Color
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.core.content.ContextCompat
+import androidx.fragment.app.Fragment
+import androidx.recyclerview.widget.LinearLayoutManager
+import com.dtsmonitor.app.R
+import com.dtsmonitor.app.api.DtsApiService
+import com.dtsmonitor.app.api.SnapshotRecord
+import com.dtsmonitor.app.databinding.FragmentHistoryBinding
+import com.dtsmonitor.app.util.Preferences
+import com.github.mikephil.charting.components.XAxis
+import com.github.mikephil.charting.data.Entry
+import com.github.mikephil.charting.data.LineData
+import com.github.mikephil.charting.data.LineDataSet
+import kotlinx.coroutines.*
+
+class HistoryFragment : Fragment() {
+
+    private var _binding: FragmentHistoryBinding? = null
+    private val binding get() = _binding!!
+
+    private lateinit var apiService: DtsApiService
+    private lateinit var preferences: Preferences
+    private lateinit var snapshotAdapter: SnapshotAdapter
+    private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
+
+    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
+        _binding = FragmentHistoryBinding.inflate(inflater, container, false)
+        return binding.root
+    }
+
+    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+        super.onViewCreated(view, savedInstanceState)
+
+        preferences = Preferences(requireContext())
+        apiService = DtsApiService(preferences.serverAddress)
+
+        setupRecyclerView()
+        setupChart()
+        setupSwipeRefresh()
+        loadSnapshots()
+    }
+
+    private fun setupRecyclerView() {
+        snapshotAdapter = SnapshotAdapter { snapshot ->
+            loadHistoryChart(snapshot.timeStr)
+        }
+        binding.rvSnapshots.apply {
+            layoutManager = LinearLayoutManager(requireContext())
+            adapter = snapshotAdapter
+        }
+    }
+
+    private fun setupChart() {
+        binding.lineChart.apply {
+            setTouchEnabled(true)
+            setPinchZoom(true)
+            description.text = ""
+            legend.textColor = Color.WHITE
+            xAxis.apply {
+                textColor = Color.GRAY
+                setDrawGridLines(true)
+                gridColor = Color.DKGRAY
+                position = XAxis.XAxisPosition.BOTTOM
+            }
+            axisLeft.apply {
+                textColor = Color.GRAY
+                setDrawGridLines(true)
+                gridColor = Color.DKGRAY
+            }
+            axisRight.isEnabled = false
+            setBackgroundColor(ContextCompat.getColor(requireContext(), R.color.bg_card))
+            setDrawGridBackground(false)
+        }
+    }
+
+    private fun setupSwipeRefresh() {
+        binding.swipeRefresh.setColorSchemeResources(android.R.color.holo_blue_light)
+        binding.swipeRefresh.setOnRefreshListener {
+            loadSnapshots()
+        }
+    }
+
+    private fun loadSnapshots() {
+        apiService.updateBaseUrl(preferences.serverAddress)
+        coroutineScope.launch {
+            val result = apiService.getSnapshots(page = 1, size = 20)
+            result.onSuccess { response ->
+                if (isAdded) {
+                    snapshotAdapter.submitList(response.snapshots)
+                    binding.swipeRefresh.isRefreshing = false
+                }
+            }.onFailure {
+                if (isAdded) {
+                    binding.swipeRefresh.isRefreshing = false
+                }
+            }
+        }
+    }
+
+    private fun loadHistoryChart(timeStr: String) {
+        apiService.updateBaseUrl(preferences.serverAddress)
+        binding.cardChart.visibility = View.VISIBLE
+
+        coroutineScope.launch {
+            val result = apiService.getHistory(timeStr)
+            result.onSuccess { response ->
+                if (isAdded) {
+                    renderChart(response.distances, response.temperatures, timeStr)
+                }
+            }.onFailure {
+                if (isAdded) {
+                    binding.cardChart.visibility = View.GONE
+                }
+            }
+        }
+    }
+
+    private fun renderChart(distances: List<Double>, temperatures: List<Double>, label: String) {
+        val entries = mutableListOf<Entry>()
+        for (i in distances.indices) {
+            if (i < temperatures.size) {
+                entries.add(Entry(distances[i].toFloat(), temperatures[i].toFloat()))
+            }
+        }
+
+        val dataSet = LineDataSet(entries, "温度 (℃) - $label").apply {
+            color = ContextCompat.getColor(requireContext(), R.color.accent)
+            setDrawCircles(false)
+            lineWidth = 1.5f
+            setDrawFilled(true)
+            fillDrawable = android.graphics.drawable.GradientDrawable(
+                android.graphics.drawable.GradientDrawable.Orientation.TOP_BOTTOM,
+                intArrayOf(
+                    Color.parseColor("#4000d4ff"),
+                    Color.parseColor("#0000d4ff")
+                )
+            )
+            mode = LineDataSet.Mode.CUBIC_BEZIER
+            valueTextSize = 0f
+            setDrawValues(false)
+        }
+
+        binding.lineChart.data = LineData(dataSet)
+        binding.lineChart.invalidate()
+    }
+
+    override fun onDestroyView() {
+        super.onDestroyView()
+        coroutineScope.cancel()
+        _binding = null
+    }
+}

+ 54 - 0
app/src/main/java/com/dtsmonitor/app/ui/history/SnapshotAdapter.kt

@@ -0,0 +1,54 @@
+package com.dtsmonitor.app.ui.history
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.ListAdapter
+import androidx.recyclerview.widget.RecyclerView
+import com.dtsmonitor.app.api.SnapshotRecord
+import com.dtsmonitor.app.databinding.ItemSnapshotBinding
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+class SnapshotAdapter(private val onItemClick: (SnapshotRecord) -> Unit) :
+    ListAdapter<SnapshotRecord, SnapshotAdapter.SnapshotViewHolder>(SnapshotDiffCallback()) {
+
+    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SnapshotViewHolder {
+        val binding = ItemSnapshotBinding.inflate(LayoutInflater.from(parent.context), parent, false)
+        return SnapshotViewHolder(binding)
+    }
+
+    override fun onBindViewHolder(holder: SnapshotViewHolder, position: Int) {
+        holder.bind(getItem(position))
+    }
+
+    inner class SnapshotViewHolder(private val binding: ItemSnapshotBinding) :
+        RecyclerView.ViewHolder(binding.root) {
+
+        init {
+            binding.root.setOnClickListener {
+                val position = bindingAdapterPosition
+                if (position != RecyclerView.NO_POSITION) {
+                    onItemClick(getItem(position))
+                }
+            }
+        }
+
+        fun bind(record: SnapshotRecord) {
+            binding.tvSnapshotTime.text = record.timeStr
+            val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
+            binding.tvSnapshotTimestamp.text = sdf.format(Date(record.timestamp.toLong() * 1000))
+        }
+    }
+
+    class SnapshotDiffCallback : DiffUtil.ItemCallback<SnapshotRecord>() {
+        override fun areItemsTheSame(oldItem: SnapshotRecord, newItem: SnapshotRecord): Boolean {
+            return oldItem.timestamp == newItem.timestamp
+        }
+
+        override fun areContentsTheSame(oldItem: SnapshotRecord, newItem: SnapshotRecord): Boolean {
+            return oldItem == newItem
+        }
+    }
+}

+ 132 - 0
app/src/main/java/com/dtsmonitor/app/ui/realtime/RealtimeFragment.kt

@@ -0,0 +1,132 @@
+package com.dtsmonitor.app.ui.realtime
+
+import android.annotation.SuppressLint
+import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.webkit.WebSettings
+import android.webkit.WebView
+import android.webkit.WebViewClient
+import androidx.core.content.ContextCompat
+import androidx.fragment.app.Fragment
+import com.dtsmonitor.app.R
+import com.dtsmonitor.app.api.DtsApiService
+import com.dtsmonitor.app.databinding.FragmentRealtimeBinding
+import com.dtsmonitor.app.util.Preferences
+import kotlinx.coroutines.*
+
+class RealtimeFragment : Fragment() {
+
+    private var _binding: FragmentRealtimeBinding? = null
+    private val binding get() = _binding!!
+
+    private lateinit var apiService: DtsApiService
+    private lateinit var preferences: Preferences
+    private val handler = Handler(Looper.getMainLooper())
+    private val refreshInterval = 5000L
+    private var refreshJob: Job? = null
+    private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
+
+    private lateinit var webView: WebView
+
+    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
+        _binding = FragmentRealtimeBinding.inflate(inflater, container, false)
+        return binding.root
+    }
+
+    @SuppressLint("SetJavaScriptEnabled")
+    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+        super.onViewCreated(view, savedInstanceState)
+
+        preferences = Preferences(requireContext())
+        apiService = DtsApiService(preferences.serverAddress)
+
+        setupWebView()
+        startPeriodicRefresh()
+    }
+
+    @SuppressLint("SetJavaScriptEnabled")
+    private fun setupWebView() {
+        webView = binding.webviewRealtime
+        webView.settings.apply {
+            javaScriptEnabled = true
+            domStorageEnabled = true
+            loadWithOverviewMode = true
+            useWideViewPort = true
+            cacheMode = WebSettings.LOAD_DEFAULT
+            mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
+        }
+        webView.webViewClient = object : WebViewClient() {
+            override fun onPageFinished(view: WebView?, url: String?) {
+                super.onPageFinished(view, url)
+            }
+        }
+        webView.setBackgroundColor(ContextCompat.getColor(requireContext(), R.color.bg_primary))
+        loadWebView()
+    }
+
+    private fun loadWebView() {
+        val url = apiService.getWebUrl()
+        webView.loadUrl(url)
+    }
+
+    private fun startPeriodicRefresh() {
+        refreshJob?.cancel()
+        refreshJob = coroutineScope.launch {
+            while (isActive) {
+                refreshStatus()
+                delay(refreshInterval)
+            }
+        }
+    }
+
+    private fun refreshStatus() {
+        apiService.updateBaseUrl(preferences.serverAddress)
+        coroutineScope.launch {
+            val result = apiService.getRealtime()
+            result.onSuccess { response ->
+                if (isAdded) {
+                    updateStatusBar(response.maxTemp, response.minTemp, response.avgTemp, true)
+                }
+            }.onFailure {
+                if (isAdded) {
+                    updateStatusBar(0.0, 0.0, 0.0, false)
+                }
+            }
+        }
+    }
+
+    private fun updateStatusBar(maxTemp: Double, minTemp: Double, avgTemp: Double, connected: Boolean) {
+        if (!isAdded) return
+
+        binding.tvStatus.text = if (connected) "已连接" else "未连接"
+        binding.tvStatus.setTextColor(
+            ContextCompat.getColor(requireContext(), if (connected) R.color.success else R.color.alarm_color)
+        )
+
+        binding.tvMaxTemp.text = if (connected) String.format("%.1f℃", maxTemp) else "--℃"
+        binding.tvMinTemp.text = if (connected) String.format("%.1f℃", minTemp) else "--℃"
+        binding.tvAvgTemp.text = if (connected) String.format("%.1f℃", avgTemp) else "--℃"
+    }
+
+    override fun onResume() {
+        super.onResume()
+        webView.onResume()
+        startPeriodicRefresh()
+    }
+
+    override fun onPause() {
+        super.onPause()
+        webView.onPause()
+        refreshJob?.cancel()
+    }
+
+    override fun onDestroyView() {
+        super.onDestroyView()
+        refreshJob?.cancel()
+        _binding = null
+    }
+}

+ 97 - 0
app/src/main/java/com/dtsmonitor/app/ui/settings/SettingsFragment.kt

@@ -0,0 +1,97 @@
+package com.dtsmonitor.app.ui.settings
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.Toast
+import androidx.fragment.app.Fragment
+import com.dtsmonitor.app.api.DtsApiService
+import com.dtsmonitor.app.databinding.FragmentSettingsBinding
+import com.dtsmonitor.app.util.Preferences
+import kotlinx.coroutines.*
+
+class SettingsFragment : Fragment() {
+
+    private var _binding: FragmentSettingsBinding? = null
+    private val binding get() = _binding!!
+
+    private lateinit var preferences: Preferences
+    private lateinit var apiService: DtsApiService
+    private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
+
+    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
+        _binding = FragmentSettingsBinding.inflate(inflater, container, false)
+        return binding.root
+    }
+
+    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+        super.onViewCreated(view, savedInstanceState)
+
+        preferences = Preferences(requireContext())
+        apiService = DtsApiService(preferences.serverAddress)
+
+        binding.etServerAddress.setText(preferences.serverAddress)
+
+        binding.btnSave.setOnClickListener {
+            val address = binding.etServerAddress.text.toString().trim()
+            if (address.isNotEmpty()) {
+                preferences.serverAddress = address
+                apiService.updateBaseUrl(address)
+                Toast.makeText(requireContext(), "设置已保存", Toast.LENGTH_SHORT).show()
+                loadStatus()
+            }
+        }
+
+        loadStatus()
+    }
+
+    private fun loadStatus() {
+        apiService.updateBaseUrl(preferences.serverAddress)
+        binding.tvConnectionStatus.text = "加载中..."
+
+        coroutineScope.launch {
+            val statusResult = apiService.getStatus()
+            val configResult = apiService.getConfig()
+
+            var statusText = ""
+            statusResult.onSuccess { response ->
+                statusText = buildString {
+                    appendLine("TCP连接: ${if (response.tcpConnected) "正常" else "断开"}")
+                    appendLine("数据有效: ${if (response.dataValid) "是" else "否"}")
+                    appendLine("最后更新: ${response.lastUpdate}")
+                    appendLine("测量点数: ${response.numPoints}")
+                    appendLine("当前报警数: ${response.alarmCount}")
+                    appendLine("报警阈值: ${response.alarmThreshold}℃")
+                    appendLine("存储间隔: ${response.storeInterval}秒")
+                    append("保留天数: ${response.retentionDays}天")
+                }
+            }.onFailure {
+                statusText = "无法连接到服务器\n${it.message}"
+            }
+
+            var configText = ""
+            configResult.onSuccess { response ->
+                configText = buildString {
+                    appendLine("设备IP: ${response.deviceIp}")
+                    appendLine("命令端口: ${response.cmdPort}")
+                    appendLine("数据端口: ${response.dataPort}")
+                    append("目标通道: ${response.targetChannel}")
+                }
+            }.onFailure {
+                configText = "无法获取设备信息"
+            }
+
+            if (isAdded) {
+                binding.tvConnectionStatus.text = statusText
+                binding.tvDeviceInfo.text = configText
+            }
+        }
+    }
+
+    override fun onDestroyView() {
+        super.onDestroyView()
+        coroutineScope.cancel()
+        _binding = null
+    }
+}

+ 19 - 0
app/src/main/java/com/dtsmonitor/app/util/Preferences.kt

@@ -0,0 +1,19 @@
+package com.dtsmonitor.app.util
+
+import android.content.Context
+import android.content.SharedPreferences
+
+class Preferences(context: Context) {
+
+    private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+
+    var serverAddress: String
+        get() = prefs.getString(KEY_SERVER_ADDRESS, DEFAULT_SERVER) ?: DEFAULT_SERVER
+        set(value) = prefs.edit().putString(KEY_SERVER_ADDRESS, value).apply()
+
+    companion object {
+        private const val PREFS_NAME = "dts_monitor_prefs"
+        private const val KEY_SERVER_ADDRESS = "server_address"
+        private const val DEFAULT_SERVER = "192.168.1.100:8086"
+    }
+}

+ 17 - 0
app/src/main/res/drawable/ic_launcher_foreground.xml

@@ -0,0 +1,17 @@
+<?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">
+    <!-- DTS icon: a thermometer/temperature sensor symbol -->
+    <path
+        android:fillColor="#00d4ff"
+        android:pathData="M54,20 C58.4,20 62,23.6 62,28 L62,60 C64.8,62.2 66,65.5 66,69 C66,75.6 60.6,81 54,81 C47.4,81 42,75.6 42,69 C42,65.5 43.2,62.2 46,60 L46,28 C46,23.6 49.6,20 54,20 Z" />
+    <path
+        android:fillColor="#0f1923"
+        android:pathData="M54,63 C57.3,63 60,65.7 60,69 C60,72.3 57.3,75 54,75 C50.7,75 48,72.3 48,69 C48,65.7 50.7,63 54,63 Z" />
+    <path
+        android:fillColor="#ff4757"
+        android:pathData="M52,40 L56,40 L56,63 L52,63 Z" />
+</vector>

+ 31 - 0
app/src/main/res/layout/activity_main.xml

@@ -0,0 +1,31 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.constraintlayout.widget.ConstraintLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:background="@color/bg_primary">
+
+    <FrameLayout
+        android:id="@+id/fragment_container"
+        android:layout_width="0dp"
+        android:layout_height="0dp"
+        app:layout_constraintTop_toTopOf="parent"
+        app:layout_constraintBottom_toTopOf="@id/bottom_navigation"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintEnd_toEndOf="parent" />
+
+    <com.google.android.material.bottomnavigation.BottomNavigationView
+        android:id="@+id/bottom_navigation"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:background="@color/nav_bg"
+        app:itemIconTint="@color/text_secondary"
+        app:itemTextColor="@color/text_secondary"
+        app:itemActiveIndicatorStyle="@null"
+        app:labelVisibilityMode="labeled"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintEnd_toEndOf="parent" />
+
+</androidx.constraintlayout.widget.ConstraintLayout>

+ 185 - 0
app/src/main/res/layout/fragment_alarm.xml

@@ -0,0 +1,185 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:orientation="vertical"
+    android:background="@color/bg_primary"
+    android:padding="16dp">
+
+    <!-- Stats Cards -->
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:orientation="horizontal"
+        android:layout_marginBottom="16dp">
+
+        <com.google.android.material.card.MaterialCardView
+            android:layout_width="0dp"
+            android:layout_height="wrap_content"
+            android:layout_weight="1"
+            android:layout_marginEnd="4dp"
+            style="@style/CardStyle">
+            <LinearLayout
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:orientation="vertical"
+                android:gravity="center">
+                <TextView
+                    android:id="@+id/tv_today_count"
+                    style="@style/StatValueText"
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content"
+                    android:text="0" />
+                <TextView
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content"
+                    style="@style/StatLabelText"
+                    android:text="今日" />
+            </LinearLayout>
+        </com.google.android.material.card.MaterialCardView>
+
+        <com.google.android.material.card.MaterialCardView
+            android:layout_width="0dp"
+            android:layout_height="wrap_content"
+            android:layout_weight="1"
+            android:layout_marginStart="4dp"
+            android:layout_marginEnd="4dp"
+            style="@style/CardStyle">
+            <LinearLayout
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:orientation="vertical"
+                android:gravity="center">
+                <TextView
+                    android:id="@+id/tv_week_count"
+                    style="@style/StatValueText"
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content"
+                    android:text="0" />
+                <TextView
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content"
+                    style="@style/StatLabelText"
+                    android:text="本周" />
+            </LinearLayout>
+        </com.google.android.material.card.MaterialCardView>
+
+        <com.google.android.material.card.MaterialCardView
+            android:layout_width="0dp"
+            android:layout_height="wrap_content"
+            android:layout_weight="1"
+            android:layout_marginStart="4dp"
+            android:layout_marginEnd="4dp"
+            style="@style/CardStyle">
+            <LinearLayout
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:orientation="vertical"
+                android:gravity="center">
+                <TextView
+                    android:id="@+id/tv_month_count"
+                    style="@style/StatValueText"
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content"
+                    android:text="0" />
+                <TextView
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content"
+                    style="@style/StatLabelText"
+                    android:text="本月" />
+            </LinearLayout>
+        </com.google.android.material.card.MaterialCardView>
+
+        <com.google.android.material.card.MaterialCardView
+            android:layout_width="0dp"
+            android:layout_height="wrap_content"
+            android:layout_weight="1"
+            android:layout_marginStart="4dp"
+            style="@style/CardStyle">
+            <LinearLayout
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:orientation="vertical"
+                android:gravity="center">
+                <TextView
+                    android:id="@+id/tv_total_count"
+                    style="@style/StatValueText"
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content"
+                    android:text="0"
+                    android:textColor="@color/alarm_color" />
+                <TextView
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content"
+                    style="@style/StatLabelText"
+                    android:text="累计" />
+            </LinearLayout>
+        </com.google.android.material.card.MaterialCardView>
+    </LinearLayout>
+
+    <!-- Filter Chips -->
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:orientation="horizontal"
+        android:layout_marginBottom="8dp">
+
+        <com.google.android.material.button.MaterialButton
+            android:id="@+id/btn_filter_today"
+            style="@style/Widget.Material3.Button.TonalButton"
+            android:layout_width="wrap_content"
+            android:layout_height="36dp"
+            android:text="今日"
+            android:textSize="12sp"
+            android:layout_marginEnd="8dp" />
+
+        <com.google.android.material.button.MaterialButton
+            android:id="@+id/btn_filter_week"
+            style="@style/Widget.Material3.Button.OutlinedButton"
+            android:layout_width="wrap_content"
+            android:layout_height="36dp"
+            android:text="本周"
+            android:textSize="12sp"
+            android:layout_marginEnd="8dp" />
+
+        <com.google.android.material.button.MaterialButton
+            android:id="@+id/btn_filter_month"
+            style="@style/Widget.Material3.Button.OutlinedButton"
+            android:layout_width="wrap_content"
+            android:layout_height="36dp"
+            android:text="本月"
+            android:textSize="12sp"
+            android:layout_marginEnd="8dp" />
+
+        <com.google.android.material.button.MaterialButton
+            android:id="@+id/btn_filter_all"
+            style="@style/Widget.Material3.Button.OutlinedButton"
+            android:layout_width="wrap_content"
+            android:layout_height="36dp"
+            android:text="全部"
+            android:textSize="12sp" />
+    </LinearLayout>
+
+    <!-- Alarm List -->
+    <TextView
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:text="报警列表"
+        android:textColor="@color/text_primary"
+        android:textSize="16sp"
+        android:textStyle="bold"
+        android:layout_marginBottom="8dp" />
+
+    <androidx.swiperefreshlayout.widget.SwipeRefreshLayout
+        android:id="@+id/swipe_refresh"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent">
+
+        <androidx.recyclerview.widget.RecyclerView
+            android:id="@+id/rv_alarms"
+            android:layout_width="match_parent"
+            android:layout_height="match_parent" />
+    </androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
+
+</LinearLayout>

+ 62 - 0
app/src/main/res/layout/fragment_history.xml

@@ -0,0 +1,62 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:orientation="vertical"
+    android:background="@color/bg_primary"
+    android:padding="16dp">
+
+    <!-- Snapshots Section -->
+    <TextView
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:text="快照列表"
+        android:textColor="@color/text_primary"
+        android:textSize="16sp"
+        android:textStyle="bold"
+        android:layout_marginBottom="8dp" />
+
+    <androidx.swiperefreshlayout.widget.SwipeRefreshLayout
+        android:id="@+id/swipe_refresh"
+        android:layout_width="match_parent"
+        android:layout_height="0dp"
+        android:layout_weight="1">
+
+        <androidx.recyclerview.widget.RecyclerView
+            android:id="@+id/rv_snapshots"
+            android:layout_width="match_parent"
+            android:layout_height="match_parent" />
+    </androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
+
+    <!-- History Chart Section -->
+    <com.google.android.material.card.MaterialCardView
+        android:id="@+id/card_chart"
+        android:layout_width="match_parent"
+        android:layout_height="300dp"
+        android:layout_marginTop="16dp"
+        style="@style/CardStyle"
+        android:visibility="gone">
+
+        <LinearLayout
+            android:layout_width="match_parent"
+            android:layout_height="match_parent"
+            android:orientation="vertical">
+
+            <TextView
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:text="历史温度曲线"
+                android:textColor="@color/text_primary"
+                android:textSize="14sp"
+                android:textStyle="bold"
+                android:layout_marginBottom="8dp" />
+
+            <com.github.mikephil.charting.charts.LineChart
+                android:id="@+id/line_chart"
+                android:layout_width="match_parent"
+                android:layout_height="match_parent" />
+        </LinearLayout>
+    </com.google.android.material.card.MaterialCardView>
+
+</LinearLayout>

+ 121 - 0
app/src/main/res/layout/fragment_realtime.xml

@@ -0,0 +1,121 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:orientation="vertical"
+    android:background="@color/bg_primary">
+
+    <!-- Status Bar -->
+    <LinearLayout
+        android:id="@+id/status_bar"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:orientation="horizontal"
+        android:background="@color/status_bar_bg"
+        android:padding="12dp">
+
+        <LinearLayout
+            android:layout_width="0dp"
+            android:layout_height="wrap_content"
+            android:layout_weight="1"
+            android:orientation="vertical"
+            android:gravity="center">
+
+            <TextView
+                android:id="@+id/tv_status"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:text="未连接"
+                android:textColor="@color/alarm_color"
+                android:textSize="14sp"
+                android:textStyle="bold" />
+
+            <TextView
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:text="状态"
+                android:textColor="@color/text_secondary"
+                android:textSize="11sp" />
+        </LinearLayout>
+
+        <LinearLayout
+            android:layout_width="0dp"
+            android:layout_height="wrap_content"
+            android:layout_weight="1"
+            android:orientation="vertical"
+            android:gravity="center">
+
+            <TextView
+                android:id="@+id/tv_max_temp"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:text="--℃"
+                android:textColor="@color/alarm_color"
+                android:textSize="14sp"
+                android:textStyle="bold" />
+
+            <TextView
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:text="最高"
+                android:textColor="@color/text_secondary"
+                android:textSize="11sp" />
+        </LinearLayout>
+
+        <LinearLayout
+            android:layout_width="0dp"
+            android:layout_height="wrap_content"
+            android:layout_weight="1"
+            android:orientation="vertical"
+            android:gravity="center">
+
+            <TextView
+                android:id="@+id/tv_min_temp"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:text="--℃"
+                android:textColor="@color/accent"
+                android:textSize="14sp"
+                android:textStyle="bold" />
+
+            <TextView
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:text="最低"
+                android:textColor="@color/text_secondary"
+                android:textSize="11sp" />
+        </LinearLayout>
+
+        <LinearLayout
+            android:layout_width="0dp"
+            android:layout_height="wrap_content"
+            android:layout_weight="1"
+            android:orientation="vertical"
+            android:gravity="center">
+
+            <TextView
+                android:id="@+id/tv_avg_temp"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:text="--℃"
+                android:textColor="@color/success"
+                android:textSize="14sp"
+                android:textStyle="bold" />
+
+            <TextView
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:text="平均"
+                android:textColor="@color/text_secondary"
+                android:textSize="11sp" />
+        </LinearLayout>
+    </LinearLayout>
+
+    <!-- WebView -->
+    <WebView
+        android:id="@+id/webview_realtime"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent" />
+
+</LinearLayout>

+ 123 - 0
app/src/main/res/layout/fragment_settings.xml

@@ -0,0 +1,123 @@
+<?xml version="1.0" encoding="utf-8"?>
+<ScrollView
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:background="@color/bg_primary">
+
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:orientation="vertical"
+        android:padding="16dp">
+
+        <!-- Server Configuration -->
+        <com.google.android.material.card.MaterialCardView
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            style="@style/CardStyle"
+            android:layout_marginBottom="16dp">
+
+            <LinearLayout
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:orientation="vertical">
+
+                <TextView
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content"
+                    android:text="服务器配置"
+                    android:textColor="@color/accent"
+                    android:textSize="18sp"
+                    android:textStyle="bold"
+                    android:layout_marginBottom="16dp" />
+
+                <com.google.android.material.textfield.TextInputLayout
+                    android:layout_width="match_parent"
+                    android:layout_height="wrap_content"
+                    android:hint="服务器地址"
+                    style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
+                    android:layout_marginBottom="16dp">
+
+                    <com.google.android.material.textfield.TextInputEditText
+                        android:id="@+id/et_server_address"
+                        android:layout_width="match_parent"
+                        android:layout_height="wrap_content"
+                        android:inputType="textUri"
+                        android:textColor="@color/text_primary"
+                        android:textColorHint="@color/text_secondary" />
+                </com.google.android.material.textfield.TextInputLayout>
+
+                <com.google.android.material.button.MaterialButton
+                    android:id="@+id/btn_save"
+                    android:layout_width="match_parent"
+                    android:layout_height="wrap_content"
+                    android:text="保存配置"
+                    android:backgroundTint="@color/accent"
+                    android:textColor="@color/bg_primary" />
+            </LinearLayout>
+        </com.google.android.material.card.MaterialCardView>
+
+        <!-- Status Info -->
+        <com.google.android.material.card.MaterialCardView
+            android:id="@+id/card_status"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            style="@style/CardStyle"
+            android:layout_marginBottom="16dp">
+
+            <LinearLayout
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:orientation="vertical">
+
+                <TextView
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content"
+                    android:text="连接状态"
+                    android:textColor="@color/accent"
+                    android:textSize="18sp"
+                    android:textStyle="bold"
+                    android:layout_marginBottom="16dp" />
+
+                <TextView
+                    android:id="@+id/tv_connection_status"
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content"
+                    android:textColor="@color/text_secondary"
+                    android:textSize="14sp" />
+            </LinearLayout>
+        </com.google.android.material.card.MaterialCardView>
+
+        <!-- Device Info -->
+        <com.google.android.material.card.MaterialCardView
+            android:id="@+id/card_device_info"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            style="@style/CardStyle">
+
+            <LinearLayout
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:orientation="vertical">
+
+                <TextView
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content"
+                    android:text="设备信息"
+                    android:textColor="@color/accent"
+                    android:textSize="18sp"
+                    android:textStyle="bold"
+                    android:layout_marginBottom="16dp" />
+
+                <TextView
+                    android:id="@+id/tv_device_info"
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content"
+                    android:textColor="@color/text_secondary"
+                    android:textSize="14sp" />
+            </LinearLayout>
+        </com.google.android.material.card.MaterialCardView>
+
+    </LinearLayout>
+</ScrollView>

+ 77 - 0
app/src/main/res/layout/item_alarm.xml

@@ -0,0 +1,77 @@
+<?xml version="1.0" encoding="utf-8"?>
+<com.google.android.material.card.MaterialCardView
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    style="@style/CardStyle"
+    android:layout_marginBottom="8dp">
+
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:orientation="horizontal"
+        android:gravity="center_vertical"
+        android:padding="12dp">
+
+        <!-- Alarm Type Badge -->
+        <TextView
+            android:id="@+id/tv_alarm_type"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:background="@color/alarm_color"
+            android:paddingStart="8dp"
+            android:paddingEnd="8dp"
+            android:paddingTop="4dp"
+            android:paddingBottom="4dp"
+            android:textColor="@color/text_primary"
+            android:textSize="11sp"
+            android:textStyle="bold" />
+
+        <!-- Alarm Details -->
+        <LinearLayout
+            android:layout_width="0dp"
+            android:layout_height="wrap_content"
+            android:layout_weight="1"
+            android:orientation="vertical"
+            android:layout_marginStart="12dp">
+
+            <TextView
+                android:id="@+id/tv_alarm_time"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:textColor="@color/text_primary"
+                android:textSize="14sp" />
+
+            <LinearLayout
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:orientation="horizontal"
+                android:layout_marginTop="4dp">
+
+                <TextView
+                    android:id="@+id/tv_alarm_channel"
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content"
+                    android:textColor="@color/text_secondary"
+                    android:textSize="12sp"
+                    android:layout_marginEnd="16dp" />
+
+                <TextView
+                    android:id="@+id/tv_alarm_distance"
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content"
+                    android:textColor="@color/text_secondary"
+                    android:textSize="12sp"
+                    android:layout_marginEnd="16dp" />
+
+                <TextView
+                    android:id="@+id/tv_alarm_temp"
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content"
+                    android:textColor="@color/alarm_color"
+                    android:textSize="12sp"
+                    android:textStyle="bold" />
+            </LinearLayout>
+        </LinearLayout>
+    </LinearLayout>
+</com.google.android.material.card.MaterialCardView>

+ 52 - 0
app/src/main/res/layout/item_snapshot.xml

@@ -0,0 +1,52 @@
+<?xml version="1.0" encoding="utf-8"?>
+<com.google.android.material.card.MaterialCardView
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    style="@style/CardStyle"
+    android:layout_marginBottom="8dp">
+
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:orientation="horizontal"
+        android:gravity="center_vertical"
+        android:padding="12dp">
+
+        <ImageView
+            android:layout_width="24dp"
+            android:layout_height="24dp"
+            android:src="@android:drawable/ic_menu_recent_history"
+            android:tint="@color/accent"
+            android:contentDescription="snapshot icon" />
+
+        <LinearLayout
+            android:layout_width="0dp"
+            android:layout_height="wrap_content"
+            android:layout_weight="1"
+            android:orientation="vertical"
+            android:layout_marginStart="12dp">
+
+            <TextView
+                android:id="@+id/tv_snapshot_time"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:textColor="@color/text_primary"
+                android:textSize="14sp" />
+
+            <TextView
+                android:id="@+id/tv_snapshot_timestamp"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:textColor="@color/text_secondary"
+                android:textSize="12sp" />
+        </LinearLayout>
+
+        <ImageView
+            android:layout_width="24dp"
+            android:layout_height="24dp"
+            android:src="@android:drawable/ic_menu_view"
+            android:tint="@color/accent"
+            android:contentDescription="view chart" />
+    </LinearLayout>
+</com.google.android.material.card.MaterialCardView>

+ 19 - 0
app/src/main/res/menu/bottom_nav_menu.xml

@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="utf-8"?>
+<menu xmlns:android="http://schemas.android.com/apk/res/android">
+    <item
+        android:id="@+id/nav_realtime"
+        android:icon="@android:drawable/ic_menu_compass"
+        android:title="@string/nav_realtime" />
+    <item
+        android:id="@+id/nav_alarm"
+        android:icon="@android:drawable/ic_dialog_alert"
+        android:title="@string/nav_alarm" />
+    <item
+        android:id="@+id/nav_history"
+        android:icon="@android:drawable/ic_menu_recent_history"
+        android:title="@string/nav_history" />
+    <item
+        android:id="@+id/nav_settings"
+        android:icon="@android:drawable/ic_menu_preferences"
+        android:title="@string/nav_settings" />
+</menu>

+ 5 - 0
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml

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

+ 5 - 0
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml

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

BIN
app/src/main/res/mipmap-hdpi/ic_launcher.png


BIN
app/src/main/res/mipmap-hdpi/ic_launcher_round.png


BIN
app/src/main/res/mipmap-mdpi/ic_launcher.png


BIN
app/src/main/res/mipmap-mdpi/ic_launcher_round.png


BIN
app/src/main/res/mipmap-xhdpi/ic_launcher.png


BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_round.png


BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher.png


BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png


BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher.png


BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png


+ 14 - 0
app/src/main/res/values/colors.xml

@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <color name="bg_primary">#0f1923</color>
+    <color name="bg_card">#1a2733</color>
+    <color name="accent">#00d4ff</color>
+    <color name="alarm_color">#ff4757</color>
+    <color name="text_primary">#FFFFFF</color>
+    <color name="text_secondary">#B0BEC5</color>
+    <color name="success">#2ed573</color>
+    <color name="warning">#ffa502</color>
+    <color name="divider">#2a3a4a</color>
+    <color name="nav_bg">#0a1118</color>
+    <color name="status_bar_bg">#1a2733</color>
+</resources>

+ 38 - 0
app/src/main/res/values/strings.xml

@@ -0,0 +1,38 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <string name="app_name">DTS Monitor</string>
+    <string name="nav_realtime">实时</string>
+    <string name="nav_alarm">报警</string>
+    <string name="nav_history">历史</string>
+    <string name="nav_settings">设置</string>
+    <string name="server_address">服务器地址</string>
+    <string name="server_hint">例如: 192.168.1.100:8086</string>
+    <string name="save">保存</string>
+    <string name="refresh">刷新</string>
+    <string name="loading">加载中...</string>
+    <string name="no_data">暂无数据</string>
+    <string name="error_loading">加载失败</string>
+    <string name="alarm_stats_title">报警统计</string>
+    <string name="today">今日</string>
+    <string name="this_week">本周</string>
+    <string name="this_month">本月</string>
+    <string name="total">累计</string>
+    <string name="alarm_list">报警列表</string>
+    <string name="snapshot_list">快照列表</string>
+    <string name="history_chart">历史温度曲线</string>
+    <string name="max_temp">最高温度</string>
+    <string name="min_temp">最低温度</string>
+    <string name="avg_temp">平均温度</string>
+    <string name="connected">已连接</string>
+    <string name="disconnected">未连接</string>
+    <string name="settings_saved">设置已保存</string>
+    <string name="channel_label">通道</string>
+    <string name="distance_label">距离</string>
+    <string name="temp_label">温度</string>
+    <string name="time_label">时间</string>
+    <string name="filter_all">全部</string>
+    <string name="filter_today">今日</string>
+    <string name="filter_week">本周</string>
+    <string name="filter_month">本月</string>
+    <string name="no_server_configured">请先在设置页配置服务器地址</string>
+</resources>

+ 44 - 0
app/src/main/res/values/themes.xml

@@ -0,0 +1,44 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <style name="Theme.DTSMonitor" parent="Theme.Material3.Dark.NoActionBar">
+        <item name="colorPrimary">@color/accent</item>
+        <item name="colorPrimaryContainer">@color/bg_card</item>
+        <item name="colorOnPrimary">@color/text_primary</item>
+        <item name="colorSecondary">@color/accent</item>
+        <item name="colorError">@color/alarm_color</item>
+        <item name="android:colorBackground">@color/bg_primary</item>
+        <item name="colorSurface">@color/bg_card</item>
+        <item name="colorOnSurface">@color/text_primary</item>
+        <item name="android:statusBarColor">@color/bg_primary</item>
+        <item name="android:navigationBarColor">@color/nav_bg</item>
+    </style>
+
+    <style name="CardStyle" parent="Widget.Material3.CardView.Filled">
+        <item name="cardBackgroundColor">@color/bg_card</item>
+        <item name="cardCornerRadius">12dp</item>
+        <item name="cardElevation">0dp</item>
+        <item name="contentPadding">16dp</item>
+    </style>
+
+    <style name="StatValueText">
+        <item name="android:textSize">24sp</item>
+        <item name="android:textColor">@color/accent</item>
+        <item name="android:textStyle">bold</item>
+        <item name="android:fontFamily">sans-serif-medium</item>
+    </style>
+
+    <style name="StatLabelText">
+        <item name="android:textSize">12sp</item>
+        <item name="android:textColor">@color/text_secondary</item>
+    </style>
+
+    <style name="AlarmBadge">
+        <item name="android:textSize">12sp</item>
+        <item name="android:paddingStart">8dp</item>
+        <item name="android:paddingEnd">8dp</item>
+        <item name="android:paddingTop">4dp</item>
+        <item name="android:paddingBottom">4dp</item>
+        <item name="android:background">@color/alarm_color</item>
+        <item name="android:textColor">@color/text_primary</item>
+    </style>
+</resources>

+ 8 - 0
app/src/main/res/xml/network_security_config.xml

@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="utf-8"?>
+<network-security-config>
+    <base-config cleartextTrafficPermitted="true">
+        <trust-anchors>
+            <certificates src="system" />
+        </trust-anchors>
+    </base-config>
+</network-security-config>

+ 24 - 0
build.gradle

@@ -0,0 +1,24 @@
+buildscript {
+    ext.kotlin_version = '1.8.22'
+    repositories {
+        google()
+        mavenCentral()
+        maven { url 'https://jitpack.io' }
+    }
+    dependencies {
+        classpath 'com.android.tools.build:gradle:7.4.2'
+        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
+    }
+}
+
+allprojects {
+    repositories {
+        google()
+        mavenCentral()
+        maven { url 'https://jitpack.io' }
+    }
+}
+
+task clean(type: Delete) {
+    delete rootProject.buildDir
+}

+ 4 - 0
gradle.properties

@@ -0,0 +1,4 @@
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+android.useAndroidX=true
+android.nonTransitiveRClass=true
+kotlin.code.style=official

BIN
gradle/wrapper/gradle-wrapper.jar


+ 7 - 0
gradle/wrapper/gradle-wrapper.properties

@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists

+ 249 - 0
gradlew

@@ -0,0 +1,249 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original 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 POSIX generated by Gradle.
+#
+#   Important for running:
+#
+#   (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+#       noncompliant, but you have some other compliant shell such as ksh or
+#       bash, then to run this script, type that shell name before the whole
+#       command line, like:
+#
+#           ksh Gradle
+#
+#       Busybox and similar reduced shells will NOT work, because this script
+#       requires all of these POSIX shell features:
+#         * functions;
+#         * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+#           «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+#         * compound commands having a testable exit status, especially «case»;
+#         * various built-in commands including «command», «set», and «ulimit».
+#
+#   Important for patching:
+#
+#   (2) This script targets any POSIX shell, so it avoids extensions provided
+#       by Bash, Ksh, etc; in particular arrays are avoided.
+#
+#       The "traditional" practice of packing multiple parameters into a
+#       space-separated string is a well documented source of bugs and security
+#       problems, so this is (mostly) avoided, by progressively accumulating
+#       options in "$@", and eventually passing that to Java.
+#
+#       Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+#       and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+#       see the in-line comments for details.
+#
+#       There are tweaks for specific operating systems such as AIX, CygWin,
+#       Darwin, MinGW, and NonStop.
+#
+#   (3) This script is generated from the Groovy template
+#       https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+#       within the Gradle project.
+#
+#       You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+    APP_HOME=${app_path%"${app_path##*/}"}  # leaves a trailing /; empty if no leading path
+    [ -h "$app_path" ]
+do
+    ls=$( ls -ld "$app_path" )
+    link=${ls#*' -> '}
+    case $link in             #(
+      /*)   app_path=$link ;; #(
+      *)    app_path=$APP_HOME$link ;;
+    esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+    echo "$*"
+} >&2
+
+die () {
+    echo
+    echo "$*"
+    echo
+    exit 1
+} >&2
+
+# 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  ;; #(
+  MSYS* | 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
+    if ! command -v java >/dev/null 2>&1
+    then
+        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
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+    case $MAX_FD in #(
+      max*)
+        # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+        # shellcheck disable=SC2039,SC3045
+        MAX_FD=$( ulimit -H -n ) ||
+            warn "Could not query maximum file descriptor limit"
+    esac
+    case $MAX_FD in  #(
+      '' | soft) :;; #(
+      *)
+        # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+        # shellcheck disable=SC2039,SC3045
+        ulimit -n "$MAX_FD" ||
+            warn "Could not set maximum file descriptor limit to $MAX_FD"
+    esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+#   * args from the command line
+#   * the main class name
+#   * -classpath
+#   * -D...appname settings
+#   * --module-path (only if needed)
+#   * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+    APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+    CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+    JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+    # Now convert the arguments - kludge to limit ourselves to /bin/sh
+    for arg do
+        if
+            case $arg in                                #(
+              -*)   false ;;                            # don't mess with options #(
+              /?*)  t=${arg#/} t=/${t%%/*}              # looks like a POSIX filepath
+                    [ -e "$t" ] ;;                      #(
+              *)    false ;;
+            esac
+        then
+            arg=$( cygpath --path --ignore --mixed "$arg" )
+        fi
+        # Roll the args list around exactly as many times as the number of
+        # args, so each arg winds up back in the position where it started, but
+        # possibly modified.
+        #
+        # NB: a `for` loop captures its iteration list before it begins, so
+        # changing the positional parameters here affects neither the number of
+        # iterations, nor the values presented in `arg`.
+        shift                   # remove old arg
+        set -- "$@" "$arg"      # push replacement arg
+    done
+fi
+
+
+# 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"'
+
+# Collect all arguments for the java command:
+#   * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+#     and any embedded shellness will be escaped.
+#   * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+#     treated as '${Hostname}' itself on the command line.
+
+set -- \
+        "-Dorg.gradle.appname=$APP_BASE_NAME" \
+        -classpath "$CLASSPATH" \
+        org.gradle.wrapper.GradleWrapperMain \
+        "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+    die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+#   readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+#   set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+        printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+        xargs -n1 |
+        sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+        tr '\n' ' '
+    )" '"$@"'
+
+exec "$JAVACMD" "$@"

+ 92 - 0
gradlew.bat

@@ -0,0 +1,92 @@
+@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=.
+@rem This is normally unused
+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% equ 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% equ 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!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega

+ 34 - 0
scripts/git_auto_sync.sh

@@ -0,0 +1,34 @@
+#!/bin/bash
+# DTS Monitor Android Git 自动同步脚本
+# 用法: bash scripts/git_auto_sync.sh [commit_message]
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
+cd "$PROJECT_ROOT"
+
+GOGS_URL="https://git.zhonjin.com:40717/steven_roc/dts_monitor_android.git"
+REMOTE_NAME="gogs"
+
+CREDENTIALS_FILE="$SCRIPT_DIR/.git_credentials"
+if [ ! -f "$CREDENTIALS_FILE" ]; then
+  echo "❌ 凭证文件不存在: $CREDENTIALS_FILE"
+  exit 1
+fi
+TOKEN=$(cat "$CREDENTIALS_FILE")
+
+REMOTE_URL="https://steven_roc:${TOKEN}@git.zhonjin.com:40717/steven_roc/dts_monitor_android.git"
+
+if ! git remote get-url "$REMOTE_NAME" &>/dev/null; then
+  git remote add "$REMOTE_NAME" "$REMOTE_URL"
+  echo "✅ 添加远程: $REMOTE_NAME"
+else
+  git remote set-url "$REMOTE_NAME" "$REMOTE_URL"
+fi
+
+COMMIT_MSG="${1:-Auto sync: $(date '+%Y-%m-%d %H:%M:%S')}"
+git add -A
+git diff --cached --quiet && { echo "无变更"; exit 0; }
+git commit -m "$COMMIT_MSG"
+git push "$REMOTE_NAME" master 2>&1 && echo "✅ 推送成功" || {
+  git push "$REMOTE_NAME" HEAD:main 2>&1 && echo "✅ 推送成功(main)" || echo "❌ 推送失败"
+}

+ 2 - 0
settings.gradle

@@ -0,0 +1,2 @@
+rootProject.name = 'DTSMonitor'
+include ':app'