diff --git a/app/.gitignore b/app/.gitignore
new file mode 100644
index 0000000..ea8dfe7
--- /dev/null
+++ b/app/.gitignore
@@ -0,0 +1,6 @@
+.gradle/
+build/
+local.properties
+.idea/
+*.iml
+app/build/
diff --git a/app/BUILD_STATUS.txt b/app/BUILD_STATUS.txt
new file mode 100644
index 0000000..73bb222
--- /dev/null
+++ b/app/BUILD_STATUS.txt
@@ -0,0 +1,6 @@
+FallGuard project status
+
+Source project: created.
+Local APK build: NOT run in this environment because Android SDK / Gradle are not installed and outbound DNS/network access is unavailable.
+APK build workflow: .github/workflows/android.yml
+Expected debug APK output after a successful build: app/build/outputs/apk/debug/app-debug.apk
diff --git a/app/app/build.gradle.kts b/app/app/build.gradle.kts
new file mode 100644
index 0000000..c2375b6
--- /dev/null
+++ b/app/app/build.gradle.kts
@@ -0,0 +1,71 @@
+plugins {
+ id("com.android.application")
+ id("org.jetbrains.kotlin.android")
+ id("org.jetbrains.kotlin.plugin.compose")
+}
+
+android {
+ namespace = "com.zhouyi.elife"
+ compileSdk = 35
+
+ defaultConfig {
+ applicationId = "com.zhouyi.elife"
+ minSdk = 24
+ targetSdk = 35
+ versionCode = 2
+ versionName = "1.1.0"
+ }
+
+ buildTypes {
+ debug {
+ // Keep the requested package name com.zhouyi.elife even for debug APK.
+ versionNameSuffix = "-debug"
+ }
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+
+ buildFeatures {
+ compose = true
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+ packaging {
+ resources {
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ }
+ }
+}
+
+dependencies {
+ val composeBom = platform("androidx.compose:compose-bom:2024.12.01")
+ implementation(composeBom)
+ androidTestImplementation(composeBom)
+
+ implementation("androidx.core:core-ktx:1.15.0")
+ implementation("androidx.activity:activity-compose:1.10.0")
+ implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
+ implementation("androidx.compose.ui:ui")
+ implementation("androidx.compose.ui:ui-graphics")
+ implementation("androidx.compose.ui:ui-tooling-preview")
+ implementation("androidx.compose.material3:material3")
+ implementation("androidx.compose.material:material-icons-extended")
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
+
+ debugImplementation("androidx.compose.ui:ui-tooling")
+ debugImplementation("androidx.compose.ui:ui-test-manifest")
+ androidTestImplementation("androidx.test.ext:junit:1.2.1")
+ androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
+ androidTestImplementation("androidx.compose.ui:ui-test-junit4")
+}
diff --git a/app/app/proguard-rules.pro b/app/app/proguard-rules.pro
new file mode 100644
index 0000000..8b35c5f
--- /dev/null
+++ b/app/app/proguard-rules.pro
@@ -0,0 +1 @@
+# No custom ProGuard rules are required for the current app.
diff --git a/app/app/src/main/AndroidManifest.xml b/app/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..1a1dc35
--- /dev/null
+++ b/app/app/src/main/AndroidManifest.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/app/src/main/java/com/zhouyi/elife/MainActivity.kt b/app/app/src/main/java/com/zhouyi/elife/MainActivity.kt
new file mode 100644
index 0000000..def2ae0
--- /dev/null
+++ b/app/app/src/main/java/com/zhouyi/elife/MainActivity.kt
@@ -0,0 +1,2710 @@
+package com.zhouyi.elife
+
+import android.Manifest
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.media.AudioAttributes
+import android.os.Build
+import android.os.Bundle
+import android.security.keystore.KeyGenParameterSpec
+import android.security.keystore.KeyProperties
+import android.util.Base64
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.ime
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Air
+import androidx.compose.material.icons.filled.AutoAwesome
+import androidx.compose.material.icons.filled.Bolt
+import androidx.compose.material.icons.filled.CheckCircle
+import androidx.compose.material.icons.filled.DeleteOutline
+import androidx.compose.material.icons.filled.DoorFront
+import androidx.compose.material.icons.filled.Favorite
+import androidx.compose.material.icons.filled.History
+import androidx.compose.material.icons.filled.Home
+import androidx.compose.material.icons.filled.Lightbulb
+import androidx.compose.material.icons.filled.Lock
+import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.material.icons.filled.Router
+import androidx.compose.material.icons.filled.Send
+import androidx.compose.material.icons.filled.Sensors
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material.icons.filled.SmartToy
+import androidx.compose.material.icons.filled.Thermostat
+import androidx.compose.material.icons.filled.Tune
+import androidx.compose.material.icons.filled.Visibility
+import androidx.compose.material.icons.filled.VisibilityOff
+import androidx.compose.material.icons.filled.WarningAmber
+import androidx.compose.material.icons.filled.Wifi
+import androidx.compose.material.icons.filled.WifiOff
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Divider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.NavigationBar
+import androidx.compose.material3.NavigationBarItem
+import androidx.compose.material3.NavigationBarItemDefaults
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Slider
+import androidx.compose.material3.SliderDefaults
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.text.input.VisualTransformation
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.core.app.NotificationCompat
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.json.JSONArray
+import org.json.JSONObject
+import java.io.IOException
+import java.net.HttpURLConnection
+import java.net.URL
+import java.security.KeyStore
+import javax.crypto.Cipher
+import javax.crypto.KeyGenerator
+import javax.crypto.SecretKey
+import javax.crypto.spec.GCMParameterSpec
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+// ───────────────────────── 配色 ─────────────────────────
+private val PageBackground = Color(0xFFF3F6FC)
+private val Ink = Color(0xFF19243B)
+private val Muted = Color(0xFF78849A)
+private val Blue = Color(0xFF526DFF)
+private val Purple = Color(0xFF8B61E8)
+private val Green = Color(0xFF159568)
+private val Red = Color(0xFFE34D59)
+private val Amber = Color(0xFFC07A15)
+private val Hairline = Color(0xFFE8ECF5)
+private val BrandGradient = listOf(Color(0xFF6D83FF), Color(0xFF4659E8))
+
+private const val DEFAULT_SYSTEM_PROMPT =
+ "你是「智能生活」家居助手,运行在用户手机上,可以通过工具读取设备状态、控制门上灯/室内灯/风扇、修改自动化规则。" +
+ "始终使用简体中文,表达自然、简短、准确。需要实时数据时先调用 get_device_status,不得编造设备状态。" +
+ "只有用户明确提出控制或修改要求时才调用对应控制工具;执行后根据工具返回结果说明实际执行情况。" +
+ "当工具权限关闭时只能查询状态,不得声称已经控制设备。心率是模拟数据,不是医疗测量结果。" +
+ "设备报告疑似摔倒时,优先提醒用户立即确认人员情况;只有用户明确要求清除警报且确认安全时,才调用 clear_fall_alarm。"
+
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ createFallNotificationChannel()
+
+ setContent {
+ MaterialTheme(
+ colorScheme = lightColorScheme(
+ primary = Blue,
+ secondary = Color(0xFF748BFF),
+ background = PageBackground,
+ surface = Color.White,
+ onPrimary = Color.White,
+ onBackground = Ink,
+ onSurface = Ink
+ )
+ ) {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = PageBackground
+ ) {
+ SmartLifeScreen(
+ onFallDetected = { fallStatus ->
+ showFallSystemNotification(fallStatus)
+ }
+ )
+ }
+ }
+ }
+
+ // Android 13+ requires explicit notification permission.
+ if (Build.VERSION.SDK_INT >= 33 &&
+ checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
+ ) {
+ requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), REQUEST_NOTIFICATION_PERMISSION)
+ }
+ }
+
+ private fun createFallNotificationChannel() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val channel = NotificationChannel(
+ FALL_NOTIFICATION_CHANNEL_ID,
+ "摔倒警报",
+ NotificationManager.IMPORTANCE_HIGH
+ ).apply {
+ description = "智能生活检测到疑似摔倒时发出提醒"
+ enableVibration(true)
+ vibrationPattern = longArrayOf(0L, 500L, 250L, 500L, 250L, 700L)
+ setSound(
+ android.provider.Settings.System.DEFAULT_NOTIFICATION_URI,
+ AudioAttributes.Builder()
+ .setUsage(AudioAttributes.USAGE_NOTIFICATION)
+ .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
+ .build()
+ )
+ lockscreenVisibility = NotificationCompat.VISIBILITY_PUBLIC
+ }
+ getSystemService(NotificationManager::class.java)
+ ?.createNotificationChannel(channel)
+ }
+ }
+
+ private fun showFallSystemNotification(status: SmartStatus) {
+ if (Build.VERSION.SDK_INT >= 33 &&
+ checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
+ ) {
+ return
+ }
+
+ val openAppIntent = Intent(this, MainActivity::class.java).apply {
+ flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
+ }
+ val pendingIntent = PendingIntent.getActivity(
+ this,
+ FALL_NOTIFICATION_ID,
+ openAppIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+ val detail = "设备检测到疑似摔倒,请立即确认人员情况。累计记录:${status.fallCount} 次。"
+ val notification = NotificationCompat.Builder(this, FALL_NOTIFICATION_CHANNEL_ID)
+ .setSmallIcon(android.R.drawable.ic_dialog_alert)
+ .setContentTitle("智能生活:检测到摔倒!")
+ .setContentText("请立即确认人员情况。")
+ .setStyle(NotificationCompat.BigTextStyle().bigText(detail))
+ .setCategory(NotificationCompat.CATEGORY_ALARM)
+ .setPriority(NotificationCompat.PRIORITY_HIGH)
+ .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
+ .setDefaults(NotificationCompat.DEFAULT_ALL)
+ .setAutoCancel(true)
+ .setOnlyAlertOnce(false)
+ .setContentIntent(pendingIntent)
+ .build()
+
+ try {
+ getSystemService(NotificationManager::class.java)
+ ?.notify(FALL_NOTIFICATION_ID, notification)
+ } catch (_: SecurityException) {
+ // The notification permission may be disabled by the user or system.
+ }
+ }
+
+ companion object {
+ private const val REQUEST_NOTIFICATION_PERMISSION = 4701
+ private const val FALL_NOTIFICATION_CHANNEL_ID = "smartlife_fall_alerts"
+ private const val FALL_NOTIFICATION_ID = 1001
+ }
+}
+
+// ───────────────────────── 数据模型 ─────────────────────────
+private data class SmartStatus(
+ val temperature: Double? = null,
+ val humidity: Double? = null,
+ val pir: Boolean = false,
+ val doorLight: Boolean = false,
+ val roomLight: Boolean = false,
+ val fan: Boolean = false,
+ val fanAuto: Boolean = true,
+ val pirAuto: Boolean = true,
+ val fanTempThreshold: Float = 28f,
+ val fanHumidityThreshold: Float = 75f,
+ val lightTimeout: Int = 30,
+ val doorTimeout: Int = 8,
+ val rfidLast: String = "—",
+ val rfidAuthorized: Boolean = false,
+ val sensorOk: Boolean = false,
+ val fanAvailable: Boolean = false,
+ val roomLightAvailable: Boolean = false,
+ val pinStatus: String = "等待读取设备状态",
+ val uptime: Long = 0,
+ val fallen: Boolean = false,
+ val fallCount: Int = 0,
+ val fallSource: String = "",
+ val heartRateBpm: Int? = null,
+ val heartRateSimulated: Boolean = true,
+ val slaveConnected: Boolean = false,
+ val slaveIp: String = "",
+ val slaveTransport: String = "UDP/Wi-Fi",
+ val slaveFallAlarm: Boolean = false,
+ val slaveFallCount: Int = 0,
+ val wifiMode: String = "STA",
+ val ipAddress: String = "",
+ val apSsid: String = "QHZXManagement",
+ val apPassword: String = "QHZX2026"
+)
+
+private data class LocalEvent(
+ val time: String,
+ val title: String,
+ val detail: String,
+ val alert: Boolean
+)
+
+/** role: user / assistant / tool / error */
+private data class ChatMsg(val role: String, val text: String)
+
+private data class AiConfig(
+ val baseUrl: String = "https://api.openai.com/v1",
+ val apiKey: String = "",
+ val model: String = "gpt-4o-mini",
+ val systemPrompt: String = DEFAULT_SYSTEM_PROMPT,
+ val allowControl: Boolean = true
+)
+
+private enum class AppTab(val label: String, val icon: ImageVector) {
+ Home("首页", Icons.Filled.Home),
+ Auto("自动化", Icons.Filled.Tune),
+ Ai("AI 管家", Icons.Filled.AutoAwesome),
+ Settings("设置", Icons.Filled.Settings)
+}
+
+// ───────────────────────── 配置持久化 ─────────────────────────
+private class AppPrefs(context: Context) {
+ private val sp = context.getSharedPreferences("smartlife_prefs", Context.MODE_PRIVATE)
+ private val keyAlias = "smartlife_ai_key_aes_gcm"
+
+ var host: String
+ get() = sp.getString("host", "").orEmpty()
+ set(v) { sp.edit().putString("host", v).apply() }
+
+ var autoConnect: Boolean
+ get() = sp.getBoolean("auto_connect", false)
+ set(v) { sp.edit().putBoolean("auto_connect", v).apply() }
+
+ fun loadAi(): AiConfig {
+ val d = AiConfig()
+ val storedKey = sp.getString("ai_api_key", "").orEmpty()
+ val apiKey = decryptApiKey(storedKey)
+ // 兼容旧版本明文配置:首次读取后尝试迁移到 Android Keystore 加密格式。
+ if (storedKey.isNotBlank() && !storedKey.startsWith("enc:") && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ sp.edit().putString("ai_api_key", encryptApiKey(storedKey)).apply()
+ }
+ return AiConfig(
+ baseUrl = sp.getString("ai_base_url", d.baseUrl).orEmpty().ifBlank { d.baseUrl },
+ apiKey = apiKey,
+ model = sp.getString("ai_model", d.model).orEmpty().ifBlank { d.model },
+ systemPrompt = sp.getString("ai_system_prompt", d.systemPrompt).orEmpty().ifBlank { d.systemPrompt },
+ allowControl = sp.getBoolean("ai_allow_control", true)
+ )
+ }
+
+ fun saveAi(c: AiConfig) {
+ sp.edit()
+ .putString("ai_base_url", c.baseUrl.trim())
+ .putString("ai_api_key", encryptApiKey(c.apiKey.trim()))
+ .putString("ai_model", c.model.trim())
+ .putString("ai_system_prompt", c.systemPrompt)
+ .putBoolean("ai_allow_control", c.allowControl)
+ .apply()
+ }
+
+ private fun encryptApiKey(value: String): String {
+ if (value.isBlank() || Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return value
+ return try {
+ val cipher = Cipher.getInstance("AES/GCM/NoPadding")
+ cipher.init(Cipher.ENCRYPT_MODE, getOrCreateSecretKey())
+ val encrypted = cipher.doFinal(value.toByteArray(Charsets.UTF_8))
+ val payload = cipher.iv + encrypted
+ "enc:" + Base64.encodeToString(payload, Base64.NO_WRAP)
+ } catch (_: Exception) {
+ value
+ }
+ }
+
+ private fun decryptApiKey(value: String): String {
+ if (!value.startsWith("enc:")) return value
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return ""
+ return try {
+ val payload = Base64.decode(value.removePrefix("enc:"), Base64.NO_WRAP)
+ if (payload.size <= 12) return ""
+ val iv = payload.copyOfRange(0, 12)
+ val encrypted = payload.copyOfRange(12, payload.size)
+ val cipher = Cipher.getInstance("AES/GCM/NoPadding")
+ cipher.init(Cipher.DECRYPT_MODE, getOrCreateSecretKey(), GCMParameterSpec(128, iv))
+ String(cipher.doFinal(encrypted), Charsets.UTF_8)
+ } catch (_: Exception) {
+ ""
+ }
+ }
+
+ private fun getOrCreateSecretKey(): SecretKey {
+ val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
+ val existing = keyStore.getKey(keyAlias, null) as? SecretKey
+ if (existing != null) return existing
+
+ val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
+ generator.init(
+ KeyGenParameterSpec.Builder(
+ keyAlias,
+ KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
+ )
+ .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
+ .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
+ .setRandomizedEncryptionRequired(true)
+ .build()
+ )
+ return generator.generateKey()
+ }
+
+ fun clearAll() {
+ sp.edit().clear().apply()
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ try {
+ KeyStore.getInstance("AndroidKeyStore").apply {
+ load(null)
+ deleteEntry(keyAlias)
+ }
+ } catch (_: Exception) {
+ // 清理偏好设置成功即可;Keystore 清理失败不影响应用继续运行。
+ }
+ }
+ }
+}
+
+// ───────────────────────── 主界面 ─────────────────────────
+@Composable
+private fun SmartLifeScreen(
+ onFallDetected: (SmartStatus) -> Unit
+) {
+ val context = LocalContext.current
+ val prefs = remember { AppPrefs(context.applicationContext) }
+
+ var tabIndex by rememberSaveable { mutableStateOf(0) }
+ var host by rememberSaveable { mutableStateOf(prefs.host) }
+ var showFallDialog by rememberSaveable { mutableStateOf(false) }
+ var lastObservedFallen by remember { mutableStateOf(false) }
+ var monitoring by rememberSaveable { mutableStateOf(prefs.autoConnect && prefs.host.isNotBlank()) }
+ var connected by remember { mutableStateOf(false) }
+ var status by remember { mutableStateOf(SmartStatus()) }
+ var errorMessage by remember { mutableStateOf(null) }
+ var infoMessage by remember { mutableStateOf("填写设备 IP 地址以连接智能生活主控") }
+ var lastUpdated by remember { mutableStateOf("") }
+ var refreshToken by remember { mutableIntStateOf(0) }
+ var configLoadedHost by remember { mutableStateOf("") }
+
+ var pirAuto by rememberSaveable { mutableStateOf(true) }
+ var fanAuto by rememberSaveable { mutableStateOf(true) }
+ var fanTempThreshold by rememberSaveable { mutableStateOf(28f) }
+ var fanHumidityThreshold by rememberSaveable { mutableStateOf(75f) }
+ var settingsSaving by remember { mutableStateOf(false) }
+
+ var aiConfig by remember { mutableStateOf(prefs.loadAi()) }
+ var aiBusy by remember { mutableStateOf(false) }
+ var aiTesting by remember { mutableStateOf(false) }
+ var aiTestMessage by remember { mutableStateOf("") }
+ var showClearDialog by remember { mutableStateOf(false) }
+ val chat = remember { mutableStateListOf() }
+ val apiMessages = remember { mutableListOf() }
+
+ val history = remember { mutableStateListOf() }
+ val scope = rememberCoroutineScope()
+ val normalizedHost = remember(host) {
+ host.trim()
+ .removePrefix("http://")
+ .removePrefix("https://")
+ .trimEnd('/')
+ }
+
+ // ── 设备状态轮询 ──
+ LaunchedEffect(monitoring, normalizedHost, refreshToken) {
+ if (!monitoring || normalizedHost.isBlank()) {
+ connected = false
+ return@LaunchedEffect
+ }
+
+ while (isActive) {
+ try {
+ val json = withContext(Dispatchers.IO) {
+ SmartLifeApi.getJson(normalizedHost, "/api/status")
+ }
+ val fresh = SmartLifeApi.parseStatus(json)
+ val wasFallen = lastObservedFallen
+ status = fresh
+ connected = true
+ errorMessage = null
+ infoMessage = "设备数据同步正常"
+ lastUpdated = nowClock()
+
+ if (configLoadedHost != normalizedHost) {
+ pirAuto = fresh.pirAuto
+ fanAuto = fresh.fanAuto
+ fanTempThreshold = fresh.fanTempThreshold
+ fanHumidityThreshold = fresh.fanHumidityThreshold
+ configLoadedHost = normalizedHost
+ }
+
+ if (fresh.fallen && !wasFallen) {
+ showFallDialog = true
+ onFallDetected(fresh)
+ addHistory(
+ history,
+ LocalEvent(nowClock(), "检测到疑似摔倒", "主控设备报告摔倒状态,请及时确认人员情况。", true)
+ )
+ } else if (!fresh.fallen && wasFallen) {
+ addHistory(
+ history,
+ LocalEvent(nowClock(), "摔倒警报已清除", "设备当前已清除摔倒标记。", false)
+ )
+ }
+ lastObservedFallen = fresh.fallen
+ } catch (e: Exception) {
+ connected = false
+ errorMessage = when (e) {
+ is java.net.SocketTimeoutException -> "连接超时,请检查 Wi-Fi 和设备 IP。"
+ else -> "暂时无法连接,请确认主控已运行并连接同一 Wi-Fi。"
+ }
+ infoMessage = "正在等待设备响应"
+ }
+ delay(if (connected) 1_000L else 2_500L)
+ }
+ }
+
+ fun runDeviceRequest(path: String, successMessage: String) {
+ if (normalizedHost.isBlank()) {
+ errorMessage = "请先填写设备 IP 地址。"
+ return
+ }
+ scope.launch {
+ try {
+ settingsSaving = true
+ val json = withContext(Dispatchers.IO) {
+ SmartLifeApi.getJson(normalizedHost, path)
+ }
+ val fresh = SmartLifeApi.parseStatus(json)
+ status = fresh
+ if (path == "/api/reset") {
+ lastObservedFallen = fresh.fallen
+ showFallDialog = false
+ }
+ connected = true
+ errorMessage = null
+ infoMessage = successMessage
+ lastUpdated = nowClock()
+ refreshToken++
+ } catch (_: Exception) {
+ connected = false
+ errorMessage = "操作没有完成,请检查设备连接后重试。"
+ } finally {
+ settingsSaving = false
+ }
+ }
+ }
+
+ fun toggleDevice(device: String, enabled: Boolean) {
+ runDeviceRequest(
+ "/api/control?device=$device&state=${if (enabled) 1 else 0}",
+ "设备控制指令已发送"
+ )
+ if (device == "fan") {
+ fanAuto = false
+ } else if (device == "room_light") {
+ pirAuto = false
+ }
+ }
+
+ fun saveAutomation() {
+ val temperature = String.format(Locale.US, "%.1f", fanTempThreshold)
+ val humidity = String.format(Locale.US, "%.1f", fanHumidityThreshold)
+ val lt = status.lightTimeout.takeIf { it > 0 } ?: 30
+ val dt = status.doorTimeout.takeIf { it > 0 } ?: 8
+ val path = "/api/settings?pir_auto=${if (pirAuto) 1 else 0}" +
+ "&fan_auto=${if (fanAuto) 1 else 0}" +
+ "&fan_temp=$temperature" +
+ "&fan_humidity=$humidity&light_timeout=$lt&door_timeout=$dt"
+ runDeviceRequest(path, "自动化规则已保存")
+ configLoadedHost = normalizedHost
+ }
+
+ // ── AI 工具执行 ──
+ suspend fun callDevice(path: String): SmartStatus {
+ val json = withContext(Dispatchers.IO) { SmartLifeApi.getJson(normalizedHost, path) }
+ val fresh = SmartLifeApi.parseStatus(json)
+ status = fresh
+ connected = true
+ errorMessage = null
+ lastUpdated = nowClock()
+ return fresh
+ }
+
+ suspend fun executeAiTool(name: String, args: JSONObject): String {
+ if (normalizedHost.isBlank()) return toolResult(false, "尚未配置设备 IP,无法访问设备。", null)
+ if (!aiConfig.allowControl && name != "get_device_status") {
+ return toolResult(false, "AI 控制权限当前已关闭,只允许读取设备状态。", null)
+ }
+ return try {
+ when (name) {
+ "get_device_status" -> {
+ val s = callDevice("/api/status")
+ toolResult(true, "已读取最新状态", s)
+ }
+ "control_device" -> {
+ val device = args.optString("device")
+ if (device !in listOf("door_light", "room_light", "fan")) {
+ toolResult(false, "未知设备:$device", null)
+ } else {
+ val on = args.optBoolean("state", false)
+ val s = callDevice("/api/control?device=$device&state=${if (on) 1 else 0}")
+ if (device == "fan") fanAuto = false
+ if (device == "room_light") pirAuto = false
+ addHistory(history, LocalEvent(nowClock(), "AI 控制设备", "${deviceName(device)} → ${if (on) "开启" else "关闭"}", false))
+ toolResult(true, "指令已发送:${deviceName(device)} ${if (on) "开启" else "关闭"}", s)
+ }
+ }
+ "update_automation" -> {
+ // 先读取设备端最新规则,避免只修改一个字段时覆盖其他字段。
+ val current = callDevice("/api/status")
+ val p = if (args.has("pir_auto")) args.optBoolean("pir_auto") else current.pirAuto
+ val f = if (args.has("fan_auto")) args.optBoolean("fan_auto") else current.fanAuto
+ val t = (if (args.has("fan_temp_threshold")) args.optDouble("fan_temp_threshold") else current.fanTempThreshold.toDouble())
+ .coerceIn(18.0, 35.0)
+ val h = (if (args.has("fan_humidity_threshold")) args.optDouble("fan_humidity_threshold") else current.fanHumidityThreshold.toDouble())
+ .coerceIn(40.0, 90.0)
+ val lt = current.lightTimeout.takeIf { it > 0 } ?: 30
+ val dt = current.doorTimeout.takeIf { it > 0 } ?: 8
+ val path = "/api/settings?pir_auto=${if (p) 1 else 0}&fan_auto=${if (f) 1 else 0}" +
+ "&fan_temp=${String.format(Locale.US, "%.1f", t)}" +
+ "&fan_humidity=${String.format(Locale.US, "%.1f", h)}" +
+ "&light_timeout=$lt&door_timeout=$dt"
+ val s = callDevice(path)
+ pirAuto = s.pirAuto
+ fanAuto = s.fanAuto
+ fanTempThreshold = s.fanTempThreshold
+ fanHumidityThreshold = s.fanHumidityThreshold
+ configLoadedHost = normalizedHost
+ addHistory(history, LocalEvent(nowClock(), "AI 更新自动化", "人体感应=${if (p) "开" else "关"},风扇自动=${if (f) "开" else "关"},${t.toInt()}°C / ${h.toInt()}%", false))
+ toolResult(true, "自动化规则已更新", s)
+ }
+ "clear_fall_alarm" -> {
+ val current = callDevice("/api/status")
+ if (!current.fallen) {
+ toolResult(true, "设备当前没有待清除的摔倒标记", current)
+ } else {
+ val s = callDevice("/api/reset")
+ lastObservedFallen = s.fallen
+ showFallDialog = false
+ addHistory(history, LocalEvent(nowClock(), "AI 清除摔倒警报", "已按用户要求清除设备上的摔倒标记。", false))
+ toolResult(true, "摔倒标记已清除", s)
+ }
+ }
+ else -> toolResult(false, "未知工具:$name", null)
+ }
+ } catch (e: Exception) {
+ toolResult(false, "设备请求失败:${e.message ?: "未知错误"}", null)
+ }
+ }
+
+ fun sendChat(text: String) {
+ val input = text.trim()
+ if (aiBusy || input.isEmpty()) return
+ val cfg = aiConfig
+ if (cfg.apiKey.isBlank()) {
+ chat.add(ChatMsg("error", "还没有填写 API Key,请先到「设置」页配置。"))
+ return
+ }
+ if (cfg.baseUrl.isBlank() || cfg.model.isBlank()) {
+ chat.add(ChatMsg("error", "请先检查 AI 接口地址和模型名称。"))
+ return
+ }
+
+ val previousMessageCount = apiMessages.size
+ chat.add(ChatMsg("user", input))
+ apiMessages.add(JSONObject().put("role", "user").put("content", input))
+ aiBusy = true
+ scope.launch {
+ try {
+ val toolsArr = aiTools(cfg.allowControl)
+ var toolRounds = 0
+ var finished = false
+
+ while (!finished) {
+ val systemContent = buildString {
+ append(cfg.systemPrompt)
+ append("\n\n当前时间:").append(nowClock())
+ append("\n设备连接状态:").append(if (connected) "在线" else "未连接")
+ append("\n最近一次设备状态(可能已过期,需要实时数据时调用 get_device_status):")
+ append(if (connected) status.toAiJson() else "设备未连接或尚未读取")
+ append("\n工具权限:").append(
+ if (cfg.allowControl) "允许查询和控制" else "仅允许查询设备状态,不允许控制或修改规则"
+ )
+ }
+
+ val msgs = JSONArray()
+ msgs.put(JSONObject().put("role", "system").put("content", systemContent))
+ apiMessages.forEach { msgs.put(it) }
+
+ val toolsNow = if (toolRounds < 6) toolsArr else null
+ val reply = withContext(Dispatchers.IO) {
+ AiClient.chat(cfg, msgs, toolsNow)
+ }
+ apiMessages.add(reply)
+
+ val calls = reply.optJSONArray("tool_calls")
+ if (calls != null && calls.length() > 0) {
+ if (toolRounds >= 6) {
+ chat.add(ChatMsg("error", "本轮工具调用次数已达到安全上限,请把任务拆分后再试。"))
+ finished = true
+ continue
+ }
+ toolRounds++
+ for (i in 0 until calls.length()) {
+ val call = calls.getJSONObject(i)
+ val fn = call.optJSONObject("function") ?: JSONObject()
+ val name = fn.optString("name")
+ val args = try {
+ JSONObject(fn.optString("arguments", "{}").ifBlank { "{}" })
+ } catch (_: Exception) {
+ JSONObject()
+ }
+ chat.add(ChatMsg("tool", "正在执行:${toolLabel(name, args)}"))
+ val result = executeAiTool(name, args)
+ apiMessages.add(
+ JSONObject()
+ .put("role", "tool")
+ .put("tool_call_id", call.optString("id"))
+ .put("content", result)
+ )
+ }
+ } else {
+ val content = if (reply.isNull("content")) "" else reply.optString("content")
+ chat.add(ChatMsg("assistant", content.ifBlank { "(已完成)" }))
+ finished = true
+ }
+ }
+ } catch (e: Exception) {
+ // 保留本地可见的用户消息,但回滚未完成的 API 工具上下文,避免污染下一轮请求。
+ while (apiMessages.size > previousMessageCount) {
+ apiMessages.removeAt(apiMessages.lastIndex)
+ }
+ val detail = e.message?.take(220).orEmpty().ifBlank { "请检查网络、API Key、接口地址和模型是否支持工具调用。" }
+ chat.add(ChatMsg("error", "AI 请求没有完成:$detail"))
+ } finally {
+ while (apiMessages.size > 40) apiMessages.removeAt(0)
+ while (apiMessages.isNotEmpty() && apiMessages[0].optString("role") != "user") {
+ apiMessages.removeAt(0)
+ }
+ aiBusy = false
+ }
+ }
+ }
+
+ fun testAiConnection(candidate: AiConfig) {
+ if (candidate.apiKey.isBlank()) {
+ aiTestMessage = "请先填写 API Key。"
+ return
+ }
+ if (candidate.baseUrl.isBlank() || candidate.model.isBlank()) {
+ aiTestMessage = "接口地址和模型名称不能为空。"
+ return
+ }
+ if (aiTesting) return
+ aiTesting = true
+ aiTestMessage = "正在测试当前配置……"
+ scope.launch {
+ try {
+ val messages = JSONArray()
+ .put(JSONObject().put("role", "system").put("content", "你是连接测试助手。请只回复:连接成功。"))
+ .put(JSONObject().put("role", "user").put("content", "连接测试"))
+ val reply = withContext(Dispatchers.IO) {
+ AiClient.chat(candidate, messages, null)
+ }
+ val content = reply.optString("content").ifBlank { "服务端已响应" }
+ aiTestMessage = "连接成功:${content.take(70)}"
+ } catch (e: Exception) {
+ aiTestMessage = "连接失败:${e.message?.take(150) ?: "请检查配置和网络"}"
+ } finally {
+ aiTesting = false
+ }
+ }
+ }
+
+ // ── 页面骨架 ──
+ val imeOpen = WindowInsets.ime.getBottom(LocalDensity.current) > 0
+
+ Scaffold(
+ containerColor = PageBackground,
+ bottomBar = {
+ if (!(tabIndex == AppTab.Ai.ordinal && imeOpen)) {
+ BottomBar(selected = tabIndex, onSelect = { tabIndex = it })
+ }
+ }
+ ) { inner ->
+ Box(modifier = Modifier.fillMaxSize().padding(inner)) {
+ when (tabIndex) {
+ 0 -> ScrollPage {
+ AppHeader(connected = connected, monitoring = monitoring)
+ OverviewCard(
+ connected = connected,
+ status = status,
+ updatedAt = lastUpdated,
+ onRefresh = { if (monitoring) refreshToken++ }
+ )
+ if (status.fallen || status.fallCount > 0) {
+ SafetyCard(
+ fallen = status.fallen,
+ count = status.fallCount,
+ connected = connected,
+ onReset = { runDeviceRequest("/api/reset", "摔倒状态已清除") }
+ )
+ }
+ EnvironmentCard(status = status)
+ QuickControlRow(
+ status = status,
+ connected = connected,
+ onToggle = { device, on -> toggleDevice(device, on) }
+ )
+ HeartRateCard(status = status)
+ AssistantEntryCard(
+ configured = aiConfig.apiKey.isNotBlank(),
+ controlEnabled = aiConfig.allowControl,
+ connected = connected,
+ onClick = { tabIndex = AppTab.Ai.ordinal }
+ )
+ RfidCard(status = status)
+ if (status.pinStatus.isNotBlank()) {
+ val warn = status.pinStatus.contains("不可用") || status.pinStatus.contains("失败")
+ NoticeCard(
+ title = if (warn) "硬件引脚提示" else "设备状态",
+ message = status.pinStatus,
+ warning = warn
+ )
+ }
+ FooterText()
+ }
+
+ 1 -> ScrollPage {
+ PageTitle("自动化", "设备手动控制与自动化规则")
+ AutomationSummaryCard(
+ connected = connected,
+ pirAuto = pirAuto,
+ fanAuto = fanAuto,
+ temperatureThreshold = fanTempThreshold,
+ humidityThreshold = fanHumidityThreshold
+ )
+ if (!connected) {
+ NoticeCard(
+ title = "设备未连接",
+ message = "连接主控后才能控制设备和保存规则,请到「设置」页填写设备 IP。",
+ warning = true
+ )
+ }
+ AutomationCard(
+ connected = connected,
+ pirAuto = pirAuto,
+ onPirAutoChange = { pirAuto = it },
+ fanAuto = fanAuto,
+ onFanAutoChange = { fanAuto = it },
+ temperatureThreshold = fanTempThreshold,
+ onTemperatureThresholdChange = { fanTempThreshold = it },
+ humidityThreshold = fanHumidityThreshold,
+ onHumidityThresholdChange = { fanHumidityThreshold = it },
+ lightTimeout = status.lightTimeout,
+ saving = settingsSaving,
+ onSave = { saveAutomation() }
+ )
+ SectionHeading("设备控制", "手动操作会退出对应设备的自动模式")
+ DeviceControlCard(
+ title = "门上灯",
+ description = if (status.rfidAuthorized) "已识别授权卡 · 自动点亮" else "P7 · 授权 UID 匹配时自动点亮",
+ icon = Icons.Filled.DoorFront,
+ accent = Purple,
+ checked = status.doorLight,
+ enabled = connected,
+ onCheckedChange = { toggleDevice("door_light", it) }
+ )
+ DeviceControlCard(
+ title = "室内照明总开关",
+ description = if (status.roomLightAvailable) "P13 / P14 两路联动 · 一键同时开关" else "灯组引脚未全部就绪,请查看硬件提示",
+ icon = Icons.Filled.Lightbulb,
+ accent = Blue,
+ checked = status.roomLight,
+ enabled = connected && status.roomLightAvailable,
+ onCheckedChange = { toggleDevice("room_light", it) }
+ )
+ DeviceControlCard(
+ title = "风扇 / 电机",
+ description = when {
+ !status.fanAvailable -> "P5/P6 电机输出当前不可用,请查看引脚提示"
+ status.fanAuto -> "自动模式 · 达到温湿度条件时启动"
+ else -> "手动模式 · 点击开关控制"
+ },
+ icon = Icons.Filled.Air,
+ accent = Color(0xFF138FBA),
+ checked = status.fan,
+ enabled = connected && status.fanAvailable,
+ onCheckedChange = { toggleDevice("fan", it) },
+ trailingText = if (status.fanAuto) "自动" else "手动"
+ )
+ HistoryCard(history = history)
+ FooterText()
+ }
+
+ 2 -> AiPage(
+ config = aiConfig,
+ chat = chat,
+ busy = aiBusy,
+ connected = connected,
+ onSend = { sendChat(it) },
+ onClear = { chat.clear(); apiMessages.clear() },
+ onGoSettings = { tabIndex = AppTab.Settings.ordinal }
+ )
+
+ else -> ScrollPage {
+ PageTitle("设置", "连接你的设备,配置 AI 服务与应用偏好")
+ SectionHeading("设备连接", "填写主控 IP,应用会按设置自动同步状态")
+ ConnectionCard(
+ host = host,
+ onHostChange = { host = it; prefs.host = it },
+ monitoring = monitoring,
+ connected = connected,
+ infoMessage = infoMessage,
+ errorMessage = errorMessage,
+ onConnect = {
+ configLoadedHost = ""
+ monitoring = true
+ prefs.autoConnect = true
+ connected = false
+ errorMessage = null
+ infoMessage = "正在连接智能生活主控……"
+ refreshToken++
+ },
+ onPause = {
+ monitoring = false
+ prefs.autoConnect = false
+ connected = false
+ infoMessage = "已暂停轮询"
+ },
+ onRefresh = { refreshToken++ },
+ wifiMode = status.wifiMode,
+ apSsid = status.apSsid,
+ apPassword = status.apPassword,
+ apAddress = if (status.ipAddress.isNotBlank() && status.wifiMode == "AP") status.ipAddress else "192.168.4.1",
+ onUseApAddress = {
+ val addr = if (status.ipAddress.isNotBlank() && status.wifiMode == "AP") status.ipAddress else "192.168.4.1"
+ host = addr
+ prefs.host = addr
+ }
+ )
+ SectionHeading("AI 服务", "自定义 API Key、模型与工具权限")
+ AiConfigCard(
+ config = aiConfig,
+ testing = aiTesting,
+ testMessage = aiTestMessage,
+ onTest = { candidate -> testAiConnection(candidate) },
+ onSave = { newCfg ->
+ prefs.saveAi(newCfg)
+ aiConfig = newCfg
+ aiTestMessage = "配置已保存到本机。"
+ }
+ )
+ SectionHeading("数据管理", "清除本机保存的连接和 AI 配置")
+ DataCard(onClear = { showClearDialog = true })
+ FooterText()
+ }
+ }
+ }
+ }
+
+ if (showClearDialog) {
+ AlertDialog(
+ onDismissRequest = { showClearDialog = false },
+ title = { Text("清除所有本地配置?", fontWeight = FontWeight.Bold) },
+ text = { Text("将删除已保存的设备 IP、API Key 和 AI 设置,并清空聊天记录。设备上的规则不受影响。") },
+ confirmButton = {
+ Button(
+ onClick = {
+ prefs.clearAll()
+ host = ""
+ monitoring = false
+ connected = false
+ aiConfig = AiConfig()
+ aiTestMessage = ""
+ chat.clear()
+ apiMessages.clear()
+ showClearDialog = false
+ },
+ colors = ButtonDefaults.buttonColors(containerColor = Red)
+ ) { Text("清除") }
+ },
+ dismissButton = {
+ TextButton(onClick = { showClearDialog = false }) { Text("取消", color = Muted) }
+ }
+ )
+ }
+
+ if (showFallDialog) {
+ AlertDialog(
+ onDismissRequest = { showFallDialog = false },
+ icon = {
+ Icon(
+ imageVector = Icons.Filled.WarningAmber,
+ contentDescription = null,
+ tint = Red,
+ modifier = Modifier.size(42.dp)
+ )
+ },
+ title = {
+ Text(text = "检测到疑似摔倒!", color = Red, fontWeight = FontWeight.Bold)
+ },
+ text = {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text("智能生活主机/从机已上报疑似摔倒状态。请立即查看人员情况,确认是否需要帮助。")
+ Text(text = "累计记录:${status.fallCount} 次", color = Muted, fontSize = 13.sp)
+ if (status.fallSource.isNotBlank()) {
+ Text("报警来源:${status.fallSource}", color = Muted, fontSize = 12.sp)
+ }
+ Text(
+ text = "系统通知也已尝试发送。关闭此弹窗不会清除设备上的摔倒状态。",
+ color = Muted,
+ fontSize = 12.sp
+ )
+ }
+ },
+ confirmButton = {
+ Button(
+ onClick = { showFallDialog = false },
+ colors = ButtonDefaults.buttonColors(containerColor = Red)
+ ) { Text("我知道了,立即确认") }
+ },
+ dismissButton = {
+ TextButton(onClick = { showFallDialog = false }) { Text("稍后处理", color = Muted) }
+ }
+ )
+ }
+}
+
+// ───────────────────────── 通用组件 ─────────────────────────
+@Composable
+private fun ScrollPage(content: @Composable ColumnScope.() -> Unit) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = 18.dp, vertical = 18.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ content = content
+ )
+}
+
+@Composable
+private fun BottomBar(selected: Int, onSelect: (Int) -> Unit) {
+ NavigationBar(containerColor = Color.White, tonalElevation = 8.dp) {
+ AppTab.values().forEachIndexed { index, tab ->
+ NavigationBarItem(
+ selected = index == selected,
+ onClick = { onSelect(index) },
+ icon = { Icon(tab.icon, contentDescription = tab.label, modifier = Modifier.size(23.dp)) },
+ label = { Text(tab.label, fontSize = 11.sp, fontWeight = FontWeight.Medium) },
+ colors = NavigationBarItemDefaults.colors(
+ selectedIconColor = Blue,
+ selectedTextColor = Blue,
+ indicatorColor = Color(0xFFE8EDFF),
+ unselectedIconColor = Muted,
+ unselectedTextColor = Muted
+ )
+ )
+ }
+ }
+}
+
+@Composable
+private fun AppCard(
+ modifier: Modifier = Modifier,
+ containerColor: Color = Color.White,
+ content: @Composable ColumnScope.() -> Unit
+) {
+ Card(
+ modifier = modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(25.dp),
+ colors = CardDefaults.cardColors(containerColor = containerColor),
+ elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
+ border = BorderStroke(1.dp, Hairline),
+ content = content
+ )
+}
+
+@Composable
+private fun CardTitle(
+ icon: ImageVector,
+ accent: Color,
+ title: String,
+ subtitle: String,
+ trailing: (@Composable () -> Unit)? = null
+) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ MiniIcon(icon, accent)
+ Spacer(Modifier.width(11.dp))
+ Column(modifier = Modifier.weight(1f)) {
+ Text(title, color = Ink, fontSize = 16.sp, fontWeight = FontWeight.Bold)
+ Text(subtitle, color = Muted, fontSize = 11.sp)
+ }
+ trailing?.invoke()
+ }
+}
+
+@Composable
+private fun PageTitle(title: String, subtitle: String) {
+ Column(verticalArrangement = Arrangement.spacedBy(3.dp), modifier = Modifier.padding(top = 4.dp)) {
+ Text(title, color = Ink, fontSize = 26.sp, fontWeight = FontWeight.Bold)
+ Text(subtitle, color = Muted, fontSize = 13.sp)
+ }
+}
+
+@Composable
+private fun SectionHeading(title: String, subtitle: String) {
+ Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
+ Text(title, color = Ink, fontSize = 18.sp, fontWeight = FontWeight.Bold)
+ Text(subtitle, color = Muted, fontSize = 12.sp)
+ }
+}
+
+@Composable
+private fun MiniIcon(icon: ImageVector, tint: Color) {
+ Box(
+ modifier = Modifier.size(40.dp).clip(RoundedCornerShape(13.dp)).background(tint.copy(alpha = 0.12f)),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(22.dp))
+ }
+}
+
+@Composable
+private fun FooterText() {
+ Text(
+ text = "智能生活 · 局域网控制 · 设备状态每秒同步",
+ modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
+ textAlign = TextAlign.Center,
+ color = Muted,
+ fontSize = 11.sp
+ )
+}
+
+@Composable
+private fun AppHeader(connected: Boolean, monitoring: Boolean) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Box(
+ modifier = Modifier
+ .size(50.dp)
+ .clip(RoundedCornerShape(17.dp))
+ .background(Brush.linearGradient(BrandGradient)),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(Icons.Filled.Home, contentDescription = null, tint = Color.White, modifier = Modifier.size(27.dp))
+ }
+ Spacer(Modifier.width(12.dp))
+ Column(modifier = Modifier.weight(1f)) {
+ Text("智能生活", color = Ink, fontSize = 23.sp, fontWeight = FontWeight.Bold)
+ Text("Smart Life · 家居自动化", color = Muted, fontSize = 12.sp)
+ }
+ Surface(
+ shape = CircleShape,
+ color = if (connected) Color(0xFFE5F6ED) else Color(0xFFE9EDF5)
+ ) {
+ Row(
+ modifier = Modifier.padding(horizontal = 11.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Box(
+ modifier = Modifier.size(7.dp).clip(CircleShape).background(
+ if (connected) Green else Color(0xFF929DB0)
+ )
+ )
+ Spacer(Modifier.width(6.dp))
+ Text(
+ when { connected -> "在线"; monitoring -> "重连中"; else -> "未连接" },
+ color = if (connected) Green else Muted,
+ fontSize = 12.sp,
+ fontWeight = FontWeight.SemiBold
+ )
+ }
+ }
+ }
+}
+
+// ───────────────────────── 首页 ─────────────────────────
+@Composable
+private fun OverviewCard(
+ connected: Boolean,
+ status: SmartStatus,
+ updatedAt: String,
+ onRefresh: () -> Unit
+) {
+ val gradient = when {
+ status.fallen -> listOf(Color(0xFFEC5967), Color(0xFFC82F4A))
+ connected -> listOf(Color(0xFF5277FF), Color(0xFF7C5CE7))
+ else -> listOf(Color(0xFF8190AA), Color(0xFF5D6A80))
+ }
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(28.dp),
+ colors = CardDefaults.cardColors(containerColor = Color.Transparent),
+ elevation = CardDefaults.cardElevation(defaultElevation = 6.dp)
+ ) {
+ Box(modifier = Modifier.fillMaxWidth().background(Brush.linearGradient(gradient))) {
+ Box(
+ modifier = Modifier
+ .align(Alignment.TopEnd)
+ .offset(x = 46.dp, y = (-46).dp)
+ .size(150.dp)
+ .clip(CircleShape)
+ .background(Color.White.copy(alpha = 0.09f))
+ )
+ Box(
+ modifier = Modifier
+ .align(Alignment.BottomStart)
+ .offset(x = (-36).dp, y = 40.dp)
+ .size(110.dp)
+ .clip(CircleShape)
+ .background(Color.White.copy(alpha = 0.07f))
+ )
+ Column(
+ modifier = Modifier.fillMaxWidth().padding(21.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Surface(color = Color.White.copy(alpha = 0.18f), shape = RoundedCornerShape(15.dp)) {
+ Box(modifier = Modifier.padding(12.dp), contentAlignment = Alignment.Center) {
+ Icon(
+ if (status.fallen) Icons.Filled.WarningAmber else Icons.Filled.CheckCircle,
+ contentDescription = null,
+ tint = Color.White,
+ modifier = Modifier.size(29.dp)
+ )
+ }
+ }
+ Spacer(Modifier.width(12.dp))
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ when {
+ status.fallen -> "安全警报"
+ connected && status.pir -> "检测到人员活动"
+ connected -> "家居系统运行中"
+ else -> "等待主控连接"
+ },
+ color = Color.White,
+ fontSize = 22.sp,
+ fontWeight = FontWeight.Bold
+ )
+ Spacer(Modifier.height(3.dp))
+ Text(
+ when {
+ status.fallen -> "设备报告疑似摔倒,请尽快确认"
+ connected && status.pir -> "人体传感器已触发照明自动化"
+ connected -> "灯光、温湿度与 RFID 自动化已就绪"
+ else -> "到「设置」填写设备 IP 后即可监测"
+ },
+ color = Color.White.copy(alpha = 0.88f),
+ fontSize = 12.sp
+ )
+ }
+ OutlinedButton(
+ onClick = onRefresh,
+ enabled = connected,
+ shape = CircleShape,
+ border = BorderStroke(1.dp, Color.White.copy(alpha = 0.55f)),
+ colors = ButtonDefaults.outlinedButtonColors(
+ contentColor = Color.White,
+ disabledContentColor = Color.White.copy(alpha = 0.45f)
+ ),
+ contentPadding = PaddingValues(0.dp),
+ modifier = Modifier.size(42.dp)
+ ) {
+ Icon(Icons.Filled.Refresh, contentDescription = "刷新", modifier = Modifier.size(19.dp))
+ }
+ }
+ Divider(color = Color.White.copy(alpha = 0.24f))
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column {
+ Text("人体感应 P0", color = Color.White.copy(alpha = 0.75f), fontSize = 11.sp)
+ Spacer(Modifier.height(4.dp))
+ Text(
+ if (status.pir) "有人经过" else "区域空闲",
+ color = Color.White,
+ fontSize = 14.sp,
+ fontWeight = FontWeight.SemiBold
+ )
+ }
+ Column(horizontalAlignment = Alignment.End) {
+ Text("最近同步", color = Color.White.copy(alpha = 0.75f), fontSize = 11.sp)
+ Spacer(Modifier.height(4.dp))
+ Text(updatedAt.ifBlank { "暂无数据" }, color = Color.White, fontSize = 14.sp, fontWeight = FontWeight.SemiBold)
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun EnvironmentCard(status: SmartStatus) {
+ Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ EnvironmentMetric(
+ modifier = Modifier.weight(1f),
+ title = "室内温度",
+ value = status.temperature?.let { String.format(Locale.getDefault(), "%.1f°", it) } ?: "--",
+ unit = "C",
+ subtitle = "SHT20 · 风扇阈值 ${status.fanTempThreshold.toInt()}°C",
+ icon = Icons.Filled.Thermostat,
+ accent = Color(0xFFE38A34),
+ fraction = (((status.temperature ?: 0.0) - 10.0) / 30.0).toFloat().coerceIn(0f, 1f)
+ )
+ EnvironmentMetric(
+ modifier = Modifier.weight(1f),
+ title = "相对湿度",
+ value = status.humidity?.let { String.format(Locale.getDefault(), "%.0f", it) } ?: "--",
+ unit = "%",
+ subtitle = "SHT20 · 风扇阈值 ${status.fanHumidityThreshold.toInt()}%",
+ icon = Icons.Filled.Air,
+ accent = Color(0xFF258EC0),
+ fraction = (((status.humidity ?: 0.0)) / 100.0).toFloat().coerceIn(0f, 1f)
+ )
+ }
+}
+
+@Composable
+private fun EnvironmentMetric(
+ modifier: Modifier,
+ title: String,
+ value: String,
+ unit: String,
+ subtitle: String,
+ icon: ImageVector,
+ accent: Color,
+ fraction: Float
+) {
+ AppCard(modifier = modifier) {
+ Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ MiniIcon(icon, accent)
+ Spacer(Modifier.width(8.dp))
+ Text(title, color = Muted, fontSize = 12.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ }
+ Row(verticalAlignment = Alignment.Bottom) {
+ Text(value, color = Ink, fontSize = 31.sp, fontWeight = FontWeight.Bold)
+ Spacer(Modifier.width(3.dp))
+ Text(unit, color = Muted, fontSize = 13.sp, modifier = Modifier.padding(bottom = 5.dp))
+ }
+ Box(
+ modifier = Modifier.fillMaxWidth().height(5.dp).clip(CircleShape).background(accent.copy(alpha = 0.14f))
+ ) {
+ Box(
+ modifier = Modifier.fillMaxWidth(fraction).fillMaxHeight().clip(CircleShape).background(accent)
+ )
+ }
+ Text(subtitle, color = Muted, fontSize = 10.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ }
+ }
+}
+
+@Composable
+private fun QuickControlRow(
+ status: SmartStatus,
+ connected: Boolean,
+ onToggle: (String, Boolean) -> Unit
+) {
+ Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ SectionHeading("快捷控制", "点按切换开关,规则设置在「自动化」")
+ Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ QuickTile(
+ modifier = Modifier.weight(1f),
+ title = "门上灯",
+ icon = Icons.Filled.DoorFront,
+ accent = Purple,
+ active = status.doorLight,
+ enabled = connected,
+ onClick = { onToggle("door_light", !status.doorLight) }
+ )
+ QuickTile(
+ modifier = Modifier.weight(1f),
+ title = "室内灯",
+ icon = Icons.Filled.Lightbulb,
+ accent = Blue,
+ active = status.roomLight,
+ enabled = connected && status.roomLightAvailable,
+ onClick = { onToggle("room_light", !status.roomLight) }
+ )
+ QuickTile(
+ modifier = Modifier.weight(1f),
+ title = "风扇",
+ icon = Icons.Filled.Air,
+ accent = Color(0xFF138FBA),
+ active = status.fan,
+ enabled = connected && status.fanAvailable,
+ onClick = { onToggle("fan", !status.fan) }
+ )
+ }
+ }
+}
+
+@Composable
+private fun QuickTile(
+ modifier: Modifier,
+ title: String,
+ icon: ImageVector,
+ accent: Color,
+ active: Boolean,
+ enabled: Boolean,
+ onClick: () -> Unit
+) {
+ val shape = RoundedCornerShape(22.dp)
+ val brush: Brush = if (active) {
+ Brush.linearGradient(listOf(accent, accent.copy(alpha = 0.72f)))
+ } else {
+ SolidColor(Color.White)
+ }
+ Box(
+ modifier = modifier
+ .height(104.dp)
+ .alpha(if (enabled) 1f else 0.5f)
+ .clip(shape)
+ .background(brush)
+ .border(1.dp, if (active) Color.Transparent else Hairline, shape)
+ .clickable(enabled = enabled, onClick = onClick)
+ ) {
+ Column(
+ modifier = Modifier.fillMaxSize().padding(14.dp),
+ verticalArrangement = Arrangement.SpaceBetween
+ ) {
+ Box(
+ modifier = Modifier
+ .size(34.dp)
+ .clip(CircleShape)
+ .background(if (active) Color.White.copy(alpha = 0.22f) else accent.copy(alpha = 0.12f)),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(icon, contentDescription = null, tint = if (active) Color.White else accent, modifier = Modifier.size(19.dp))
+ }
+ Column {
+ Text(title, color = if (active) Color.White else Ink, fontSize = 14.sp, fontWeight = FontWeight.Bold)
+ Text(
+ if (active) "已开启" else "已关闭",
+ color = if (active) Color.White.copy(alpha = 0.85f) else Muted,
+ fontSize = 11.sp
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun AssistantEntryCard(
+ configured: Boolean,
+ controlEnabled: Boolean,
+ connected: Boolean,
+ onClick: () -> Unit
+) {
+ Card(
+ modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
+ shape = RoundedCornerShape(23.dp),
+ colors = CardDefaults.cardColors(containerColor = Color.White),
+ border = BorderStroke(1.dp, Hairline),
+ elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth().background(
+ Brush.horizontalGradient(listOf(Color(0xFFF4F1FF), Color(0xFFF0F4FF)))
+ ).padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Box(
+ modifier = Modifier.size(46.dp).clip(RoundedCornerShape(15.dp))
+ .background(Brush.linearGradient(listOf(Purple, Blue))),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(Icons.Filled.AutoAwesome, contentDescription = null, tint = Color.White, modifier = Modifier.size(24.dp))
+ }
+ Spacer(Modifier.width(12.dp))
+ Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
+ Text("AI 智能管家", color = Ink, fontSize = 15.sp, fontWeight = FontWeight.Bold)
+ Text(
+ when {
+ !configured -> "填写 API Key 后即可开始对话"
+ controlEnabled && connected -> "可查询状态,也可通过工具控制设备"
+ controlEnabled -> "AI 已配置,等待设备连接"
+ else -> "只读查询模式已开启"
+ },
+ color = Muted,
+ fontSize = 11.sp,
+ lineHeight = 15.sp
+ )
+ }
+ Surface(color = Color.White.copy(alpha = 0.88f), shape = CircleShape) {
+ Icon(Icons.Filled.Send, contentDescription = "打开 AI 管家", tint = Blue, modifier = Modifier.padding(10.dp).size(17.dp))
+ }
+ }
+ }
+}
+
+@Composable
+private fun AutomationSummaryCard(
+ connected: Boolean,
+ pirAuto: Boolean,
+ fanAuto: Boolean,
+ temperatureThreshold: Float,
+ humidityThreshold: Float
+) {
+ val enabledCount = (if (pirAuto) 1 else 0) + (if (fanAuto) 1 else 0)
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(24.dp),
+ colors = CardDefaults.cardColors(containerColor = Color.Transparent),
+ elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
+ ) {
+ Column(
+ modifier = Modifier.fillMaxWidth().background(
+ Brush.linearGradient(listOf(Color(0xFF536FFF), Color(0xFF765CE7)))
+ ).padding(18.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text("自动化控制中心", color = Color.White, fontSize = 19.sp, fontWeight = FontWeight.Bold)
+ Text("让设备根据环境自动运行", color = Color.White.copy(alpha = 0.82f), fontSize = 11.sp)
+ }
+ Surface(color = Color.White.copy(alpha = 0.18f), shape = RoundedCornerShape(11.dp)) {
+ Text(
+ if (connected) "设备在线" else "未连接",
+ modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
+ color = Color.White,
+ fontSize = 11.sp,
+ fontWeight = FontWeight.SemiBold
+ )
+ }
+ }
+ Divider(color = Color.White.copy(alpha = 0.22f))
+ Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text("已启用规则", color = Color.White.copy(alpha = 0.76f), fontSize = 10.sp)
+ Text("$enabledCount / 2", color = Color.White, fontSize = 24.sp, fontWeight = FontWeight.Bold)
+ }
+ Column(modifier = Modifier.weight(1f)) {
+ Text("风扇启动条件", color = Color.White.copy(alpha = 0.76f), fontSize = 10.sp)
+ Text("${temperatureThreshold.toInt()}°C · ${humidityThreshold.toInt()}%", color = Color.White, fontSize = 16.sp, fontWeight = FontWeight.Bold)
+ }
+ }
+ Text(
+ if (enabledCount == 2) "人体感应照明和温湿度风扇均已开启。"
+ else if (enabledCount == 1) "当前有 1 项自动化规则正在运行。"
+ else "自动化规则已全部关闭,可在下方重新启用。",
+ color = Color.White.copy(alpha = 0.88f),
+ fontSize = 11.sp
+ )
+ }
+ }
+}
+
+@Composable
+private fun HeartRateCard(status: SmartStatus) {
+ AppCard {
+ Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ CardTitle(
+ icon = Icons.Filled.Favorite,
+ accent = Color(0xFFE0526A),
+ title = "心率监测从机",
+ subtitle = "Wi-Fi UDP 双板通信 · 心率为模拟数据",
+ trailing = {
+ Surface(
+ color = if (status.slaveConnected) Color(0xFFE5F6ED) else Color(0xFFFFF3DF),
+ shape = RoundedCornerShape(8.dp)
+ ) {
+ Text(
+ if (status.slaveConnected) "从机在线" else "从机离线",
+ modifier = Modifier.padding(horizontal = 9.dp, vertical = 5.dp),
+ color = if (status.slaveConnected) Green else Amber,
+ fontSize = 10.sp,
+ fontWeight = FontWeight.SemiBold
+ )
+ }
+ }
+ )
+ Divider(color = Color(0xFFF0F2F7))
+ Row(verticalAlignment = Alignment.Bottom) {
+ Text(
+ if (status.slaveConnected && status.heartRateBpm != null) status.heartRateBpm.toString() else "--",
+ color = Ink,
+ fontSize = 38.sp,
+ fontWeight = FontWeight.Bold
+ )
+ Spacer(Modifier.width(7.dp))
+ Text("BPM", color = Muted, fontSize = 12.sp, modifier = Modifier.padding(bottom = 8.dp))
+ Spacer(Modifier.weight(1f))
+ Text(if (status.heartRateSimulated) "模拟数据" else "传感器数据", color = Amber, fontSize = 11.sp, fontWeight = FontWeight.SemiBold)
+ }
+ Text(
+ if (status.slaveConnected) {
+ "从机 IP:${status.slaveIp.ifBlank { "已发现" }} · UDP/Wi-Fi 在线。心率是模拟值,不是实际人体测量。"
+ } else {
+ "等待第二块掌控板连接同一 Wi-Fi,并通过 UDP 上报模拟心率。路由器不可用时,从机会尝试加入 QHZXManagement。"
+ },
+ color = Muted,
+ fontSize = 11.sp,
+ lineHeight = 16.sp
+ )
+ if (status.slaveFallAlarm) {
+ Text("从机摔倒报警已触发", color = Red, fontSize = 12.sp, fontWeight = FontWeight.Bold)
+ }
+ }
+ }
+}
+
+@Composable
+private fun RfidCard(status: SmartStatus) {
+ AppCard {
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ MiniIcon(Icons.Filled.Sensors, if (status.rfidAuthorized) Green else Blue)
+ Spacer(Modifier.width(12.dp))
+ Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ Text("RFID 门禁", color = Ink, fontSize = 15.sp, fontWeight = FontWeight.Bold)
+ Text("最近识别卡号:${status.rfidLast}", color = Muted, fontSize = 12.sp)
+ Text(
+ if (status.rfidAuthorized) "授权卡匹配成功,门上灯已触发" else "授权 UID 在主机 main.py 中配置",
+ color = if (status.rfidAuthorized) Green else Muted,
+ fontSize = 11.sp
+ )
+ }
+ Box(
+ modifier = Modifier.size(10.dp).clip(CircleShape).background(if (status.rfidAuthorized) Green else Color(0xFFD2D8E3))
+ )
+ }
+ }
+}
+
+@Composable
+private fun SafetyCard(
+ fallen: Boolean,
+ count: Int,
+ connected: Boolean,
+ onReset: () -> Unit
+) {
+ AppCard(containerColor = if (fallen) Color(0xFFFFF0F1) else Color.White) {
+ Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(Icons.Filled.WarningAmber, contentDescription = null, tint = if (fallen) Red else Amber)
+ Spacer(Modifier.width(8.dp))
+ Text("安全监测", color = Ink, fontWeight = FontWeight.Bold, fontSize = 15.sp)
+ Spacer(Modifier.weight(1f))
+ Text("累计 $count 次", color = Muted, fontSize = 11.sp)
+ }
+ Text(
+ if (fallen) "检测到疑似摔倒,请确认现场安全。" else "当前没有活动警报。",
+ color = if (fallen) Red else Muted,
+ fontSize = 12.sp
+ )
+ if (fallen) {
+ OutlinedButton(onClick = onReset, enabled = connected, modifier = Modifier.fillMaxWidth()) {
+ Text("清除当前摔倒标记")
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun NoticeCard(title: String, message: String, warning: Boolean) {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(18.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = if (warning) Color(0xFFFFF6E7) else Color(0xFFEFF3FF)
+ )
+ ) {
+ Row(modifier = Modifier.padding(14.dp), verticalAlignment = Alignment.Top) {
+ Icon(
+ if (warning) Icons.Filled.WarningAmber else Icons.Filled.CheckCircle,
+ contentDescription = null,
+ tint = if (warning) Amber else Blue,
+ modifier = Modifier.size(19.dp)
+ )
+ Spacer(Modifier.width(9.dp))
+ Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
+ Text(title, color = Ink, fontSize = 12.sp, fontWeight = FontWeight.Bold)
+ Text(message, color = Muted, fontSize = 11.sp, lineHeight = 16.sp)
+ }
+ }
+ }
+}
+
+// ───────────────────────── 自动化页 ─────────────────────────
+@Composable
+private fun DeviceControlCard(
+ title: String,
+ description: String,
+ icon: ImageVector,
+ accent: Color,
+ checked: Boolean,
+ enabled: Boolean,
+ onCheckedChange: (Boolean) -> Unit,
+ trailingText: String? = null
+) {
+ AppCard {
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ MiniIcon(icon, accent)
+ Spacer(Modifier.width(12.dp))
+ Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(title, color = Ink, fontSize = 15.sp, fontWeight = FontWeight.Bold)
+ if (trailingText != null) {
+ Spacer(Modifier.width(7.dp))
+ Surface(
+ color = if (trailingText == "自动") Color(0xFFE8EDFF) else Color(0xFFF0F2F6),
+ shape = RoundedCornerShape(7.dp)
+ ) {
+ Text(
+ trailingText,
+ modifier = Modifier.padding(horizontal = 7.dp, vertical = 3.dp),
+ color = if (trailingText == "自动") Blue else Muted,
+ fontSize = 10.sp
+ )
+ }
+ }
+ }
+ Text(description, color = Muted, fontSize = 11.sp, lineHeight = 15.sp)
+ }
+ Spacer(Modifier.width(8.dp))
+ Switch(checked = checked, onCheckedChange = onCheckedChange, enabled = enabled)
+ }
+ }
+}
+
+@Composable
+private fun RuleRow(
+ icon: ImageVector,
+ accent: Color,
+ title: String,
+ desc: String,
+ checked: Boolean,
+ enabled: Boolean,
+ onChange: (Boolean) -> Unit
+) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(18.dp))
+ .background(Color(0xFFF7F9FD))
+ .padding(horizontal = 12.dp, vertical = 11.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ MiniIcon(icon, accent)
+ Spacer(Modifier.width(11.dp))
+ Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
+ Text(title, color = Ink, fontSize = 14.sp, fontWeight = FontWeight.SemiBold)
+ Text(desc, color = Muted, fontSize = 11.sp, lineHeight = 15.sp)
+ }
+ Switch(checked = checked, onCheckedChange = onChange, enabled = enabled)
+ }
+}
+
+@Composable
+private fun ThresholdSlider(
+ label: String,
+ valueText: String,
+ value: Float,
+ range: ClosedFloatingPointRange,
+ accent: Color,
+ enabled: Boolean,
+ minLabel: String,
+ maxLabel: String,
+ onChange: (Float) -> Unit
+) {
+ Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(label, color = Ink, fontSize = 13.sp, fontWeight = FontWeight.Medium)
+ Spacer(Modifier.weight(1f))
+ Surface(color = accent.copy(alpha = 0.12f), shape = RoundedCornerShape(9.dp)) {
+ Text(
+ valueText,
+ modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
+ color = accent,
+ fontSize = 13.sp,
+ fontWeight = FontWeight.Bold
+ )
+ }
+ }
+ Slider(
+ value = value,
+ onValueChange = onChange,
+ valueRange = range,
+ enabled = enabled,
+ colors = SliderDefaults.colors(
+ thumbColor = accent,
+ activeTrackColor = accent,
+ inactiveTrackColor = accent.copy(alpha = 0.18f)
+ )
+ )
+ Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
+ Text(minLabel, color = Muted, fontSize = 10.sp)
+ Text(maxLabel, color = Muted, fontSize = 10.sp)
+ }
+ }
+}
+
+@Composable
+private fun AutomationCard(
+ connected: Boolean,
+ pirAuto: Boolean,
+ onPirAutoChange: (Boolean) -> Unit,
+ fanAuto: Boolean,
+ onFanAutoChange: (Boolean) -> Unit,
+ temperatureThreshold: Float,
+ onTemperatureThresholdChange: (Float) -> Unit,
+ humidityThreshold: Float,
+ onHumidityThresholdChange: (Float) -> Unit,
+ lightTimeout: Int,
+ saving: Boolean,
+ onSave: () -> Unit
+) {
+ AppCard {
+ Column(
+ modifier = Modifier.padding(18.dp),
+ verticalArrangement = Arrangement.spacedBy(14.dp)
+ ) {
+ CardTitle(Icons.Filled.Tune, Blue, "自动化规则", "调整触发条件,保存后同步到主控")
+
+ Surface(color = Color(0xFFF7F8FD), shape = RoundedCornerShape(18.dp)) {
+ Column(modifier = Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ RuleRow(
+ icon = Icons.Filled.Sensors,
+ accent = Color(0xFFE38A34),
+ title = "人体感应自动照明",
+ desc = "P0 触发两路照明联动,超时后同时关闭",
+ checked = pirAuto,
+ enabled = connected,
+ onChange = onPirAutoChange
+ )
+ Divider(color = Hairline, modifier = Modifier.padding(horizontal = 4.dp))
+ RuleRow(
+ icon = Icons.Filled.Air,
+ accent = Color(0xFF138FBA),
+ title = "温湿度自动风扇",
+ desc = "温度或湿度达到任一阈值时启动",
+ checked = fanAuto,
+ enabled = connected,
+ onChange = onFanAutoChange
+ )
+ }
+ }
+
+ ThresholdSlider(
+ label = "温度阈值",
+ valueText = "${temperatureThreshold.toInt()} °C",
+ value = temperatureThreshold,
+ range = 18f..35f,
+ accent = Color(0xFFE38A34),
+ enabled = connected && fanAuto,
+ minLabel = "18 °C",
+ maxLabel = "35 °C",
+ onChange = onTemperatureThresholdChange
+ )
+ ThresholdSlider(
+ label = "湿度阈值",
+ valueText = "${humidityThreshold.toInt()} %",
+ value = humidityThreshold,
+ range = 40f..90f,
+ accent = Color(0xFF258EC0),
+ enabled = connected && fanAuto,
+ minLabel = "40 %",
+ maxLabel = "90 %",
+ onChange = onHumidityThresholdChange
+ )
+
+ Surface(color = Color(0xFFEFF3FF), shape = RoundedCornerShape(16.dp)) {
+ Column(
+ modifier = Modifier.fillMaxWidth().padding(13.dp),
+ verticalArrangement = Arrangement.spacedBy(5.dp)
+ ) {
+ Text("当前规则预览", color = Blue, fontSize = 12.sp, fontWeight = FontWeight.Bold)
+ Text(
+ if (pirAuto) "• 有人经过 → 两路灯点亮,无人 ${lightTimeout.takeIf { it > 0 } ?: 30} 秒后关闭"
+ else "• 人体感应照明:已关闭",
+ color = Ink, fontSize = 11.sp, lineHeight = 16.sp
+ )
+ Text(
+ if (fanAuto) "• 温度 ≥ ${temperatureThreshold.toInt()}°C 或湿度 ≥ ${humidityThreshold.toInt()}% → 风扇启动"
+ else "• 风扇自动模式:已关闭(手动控制)",
+ color = Ink, fontSize = 11.sp, lineHeight = 16.sp
+ )
+ Text(
+ "• 防频繁启停:温度低 2°C 且湿度低 5% 才自动关闭",
+ color = Muted, fontSize = 11.sp, lineHeight = 16.sp
+ )
+ }
+ }
+
+ Button(
+ onClick = onSave,
+ enabled = connected && !saving,
+ modifier = Modifier.fillMaxWidth().height(50.dp),
+ shape = RoundedCornerShape(15.dp)
+ ) {
+ Text(if (saving) "正在保存…" else "保存自动化规则", fontWeight = FontWeight.SemiBold)
+ }
+ }
+ }
+}
+
+@Composable
+private fun HistoryCard(history: List) {
+ AppCard {
+ Column(modifier = Modifier.padding(17.dp), verticalArrangement = Arrangement.spacedBy(11.dp)) {
+ CardTitle(
+ icon = Icons.Filled.History,
+ accent = Blue,
+ title = "运行记录",
+ subtitle = "摔倒状态与 AI 操作",
+ trailing = { Text("${history.size} 条", color = Muted, fontSize = 11.sp) }
+ )
+ Divider(color = Color(0xFFF0F2F7))
+ if (history.isEmpty()) {
+ Text("暂时没有事件。摔倒状态变化、AI 操作会记录在这里。", color = Muted, fontSize = 12.sp, lineHeight = 17.sp)
+ } else {
+ history.take(8).forEach { event ->
+ Row(verticalAlignment = Alignment.Top, modifier = Modifier.fillMaxWidth()) {
+ Icon(
+ if (event.alert) Icons.Filled.WarningAmber else Icons.Filled.CheckCircle,
+ contentDescription = null,
+ tint = if (event.alert) Red else Green,
+ modifier = Modifier.padding(top = 2.dp).size(19.dp)
+ )
+ Spacer(Modifier.width(9.dp))
+ Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
+ Text(event.title, color = Ink, fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
+ Text(event.detail, color = Muted, fontSize = 11.sp, lineHeight = 15.sp)
+ }
+ Spacer(Modifier.width(7.dp))
+ Text(event.time, color = Muted, fontSize = 10.sp)
+ }
+ }
+ }
+ }
+ }
+}
+
+// ───────────────────────── AI 页 ─────────────────────────
+@Composable
+private fun AiPage(
+ config: AiConfig,
+ chat: List,
+ busy: Boolean,
+ connected: Boolean,
+ onSend: (String) -> Unit,
+ onClear: () -> Unit,
+ onGoSettings: () -> Unit
+) {
+ var input by remember { mutableStateOf("") }
+ val listState = rememberLazyListState()
+ val total = chat.size + if (busy) 1 else 0
+
+ LaunchedEffect(total) {
+ if (total > 0) listState.animateScrollToItem(total - 1)
+ }
+
+ Column(modifier = Modifier.fillMaxSize().imePadding()) {
+ // 顶部栏
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(start = 18.dp, end = 10.dp, top = 14.dp, bottom = 8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Box(
+ modifier = Modifier
+ .size(44.dp)
+ .clip(RoundedCornerShape(15.dp))
+ .background(Brush.linearGradient(listOf(Purple, Blue))),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(Icons.Filled.AutoAwesome, contentDescription = null, tint = Color.White, modifier = Modifier.size(24.dp))
+ }
+ Spacer(Modifier.width(12.dp))
+ Column(modifier = Modifier.weight(1f)) {
+ Text("AI 管家", color = Ink, fontSize = 20.sp, fontWeight = FontWeight.Bold)
+ Text(
+ "${config.model} · " + when {
+ config.apiKey.isBlank() -> "未配置 Key"
+ config.allowControl && connected -> "设备在线 · 可调用控制工具"
+ config.allowControl -> "等待设备连接 · 工具已启用"
+ connected -> "设备在线 · 只读查询模式"
+ else -> "只读查询模式"
+ },
+ color = Muted,
+ fontSize = 11.sp,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ IconButton(onClick = onClear, enabled = chat.isNotEmpty()) {
+ Icon(Icons.Filled.DeleteOutline, contentDescription = "清空对话", tint = if (chat.isNotEmpty()) Muted else Color(0xFFCBD2DF))
+ }
+ }
+
+ if (config.apiKey.isNotBlank()) {
+ Surface(
+ modifier = Modifier.padding(horizontal = 18.dp).padding(bottom = 7.dp).fillMaxWidth(),
+ color = if (config.allowControl) Color(0xFFEAF7F0) else Color(0xFFEFF3FF),
+ shape = RoundedCornerShape(14.dp)
+ ) {
+ Row(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically) {
+ Icon(Icons.Filled.Bolt, contentDescription = null, tint = if (config.allowControl) Green else Blue, modifier = Modifier.size(17.dp))
+ Spacer(Modifier.width(8.dp))
+ Text(
+ if (config.allowControl) "工具已启用:AI 可查询状态、控制设备和修改自动化规则" else "只读模式:AI 可以查询状态,不会执行控制操作",
+ color = if (config.allowControl) Green else Blue,
+ fontSize = 11.sp,
+ lineHeight = 15.sp
+ )
+ }
+ }
+ }
+
+ if (config.apiKey.isBlank()) {
+ Surface(
+ modifier = Modifier.padding(horizontal = 18.dp).padding(bottom = 6.dp).fillMaxWidth().clickable { onGoSettings() },
+ color = Color(0xFFFFF6E7),
+ shape = RoundedCornerShape(16.dp)
+ ) {
+ Row(modifier = Modifier.padding(13.dp), verticalAlignment = Alignment.CenterVertically) {
+ Icon(Icons.Filled.Lock, contentDescription = null, tint = Amber, modifier = Modifier.size(18.dp))
+ Spacer(Modifier.width(9.dp))
+ Text("还没有填写 API Key,点这里前往「设置」配置", color = Amber, fontSize = 12.sp, fontWeight = FontWeight.Medium)
+ }
+ }
+ }
+
+ Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
+ if (chat.isEmpty() && !busy) {
+ AiEmptyState(onPick = onSend)
+ } else {
+ LazyColumn(
+ state = listState,
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(horizontal = 18.dp, vertical = 10.dp),
+ verticalArrangement = Arrangement.spacedBy(10.dp)
+ ) {
+ items(chat) { msg -> ChatBubble(msg) }
+ if (busy) {
+ item {
+ Row(modifier = Modifier.fillMaxWidth()) {
+ Surface(
+ color = Color.White,
+ shape = RoundedCornerShape(18.dp, 18.dp, 18.dp, 4.dp),
+ border = BorderStroke(1.dp, Hairline)
+ ) {
+ Text("思考中…", modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), color = Muted, fontSize = 13.sp)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // 输入栏
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 10.dp),
+ verticalAlignment = Alignment.Bottom
+ ) {
+ OutlinedTextField(
+ value = input,
+ onValueChange = { input = it },
+ modifier = Modifier.weight(1f),
+ placeholder = { Text("例如:把室内灯打开", fontSize = 14.sp) },
+ maxLines = 4,
+ shape = RoundedCornerShape(22.dp),
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
+ enabled = !busy
+ )
+ Spacer(Modifier.width(10.dp))
+ val canSend = input.isNotBlank() && !busy
+ Box(
+ modifier = Modifier
+ .padding(bottom = 4.dp)
+ .size(50.dp)
+ .alpha(if (canSend) 1f else 0.45f)
+ .clip(CircleShape)
+ .background(Brush.linearGradient(BrandGradient))
+ .clickable(enabled = canSend) {
+ val text = input
+ input = ""
+ onSend(text)
+ },
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(Icons.Filled.Send, contentDescription = "发送", tint = Color.White, modifier = Modifier.size(22.dp))
+ }
+ }
+ }
+}
+
+@Composable
+private fun AiEmptyState(onPick: (String) -> Unit) {
+ val suggestions = listOf(
+ "现在室内温度和湿度怎么样?",
+ "把室内灯打开",
+ "温度超过 30 度就自动开风扇",
+ "关闭风扇,并关掉门上灯"
+ )
+ Column(
+ modifier = Modifier.fillMaxSize().padding(horizontal = 24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Box(
+ modifier = Modifier
+ .size(72.dp)
+ .clip(CircleShape)
+ .background(Brush.linearGradient(listOf(Purple.copy(alpha = 0.22f), Blue.copy(alpha = 0.22f)))),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(Icons.Filled.SmartToy, contentDescription = null, tint = Blue, modifier = Modifier.size(36.dp))
+ }
+ Spacer(Modifier.height(14.dp))
+ Text("我是你的家居管家", color = Ink, fontSize = 18.sp, fontWeight = FontWeight.Bold)
+ Spacer(Modifier.height(4.dp))
+ Text("可以查询状态、控制灯光风扇、调整自动化规则", color = Muted, fontSize = 12.sp, textAlign = TextAlign.Center)
+ Spacer(Modifier.height(20.dp))
+ suggestions.forEach { text ->
+ Surface(
+ modifier = Modifier.padding(vertical = 4.dp).fillMaxWidth().clickable { onPick(text) },
+ color = Color.White,
+ shape = RoundedCornerShape(16.dp),
+ border = BorderStroke(1.dp, Hairline)
+ ) {
+ Row(modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically) {
+ Icon(Icons.Filled.AutoAwesome, contentDescription = null, tint = Purple, modifier = Modifier.size(16.dp))
+ Spacer(Modifier.width(9.dp))
+ Text(text, color = Ink, fontSize = 13.sp)
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun ChatBubble(msg: ChatMsg) {
+ when (msg.role) {
+ "user" -> Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
+ Box(
+ modifier = Modifier
+ .widthIn(max = 300.dp)
+ .clip(RoundedCornerShape(18.dp, 18.dp, 4.dp, 18.dp))
+ .background(Brush.linearGradient(BrandGradient))
+ .padding(horizontal = 14.dp, vertical = 10.dp)
+ ) {
+ Text(msg.text, color = Color.White, fontSize = 14.sp, lineHeight = 20.sp)
+ }
+ }
+
+ "tool" -> Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
+ Surface(color = Color(0xFFE8EDFF), shape = RoundedCornerShape(12.dp)) {
+ Row(modifier = Modifier.padding(horizontal = 11.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically) {
+ Icon(Icons.Filled.Bolt, contentDescription = null, tint = Blue, modifier = Modifier.size(14.dp))
+ Spacer(Modifier.width(5.dp))
+ Text(msg.text, color = Blue, fontSize = 11.sp, fontWeight = FontWeight.Medium)
+ }
+ }
+ }
+
+ "error" -> Row(modifier = Modifier.fillMaxWidth()) {
+ Surface(
+ modifier = Modifier.widthIn(max = 320.dp),
+ color = Color(0xFFFFF0F1),
+ shape = RoundedCornerShape(16.dp)
+ ) {
+ Text(msg.text, modifier = Modifier.padding(12.dp), color = Red, fontSize = 12.sp, lineHeight = 17.sp)
+ }
+ }
+
+ else -> Row(modifier = Modifier.fillMaxWidth()) {
+ Surface(
+ modifier = Modifier.widthIn(max = 320.dp),
+ color = Color.White,
+ shape = RoundedCornerShape(18.dp, 18.dp, 18.dp, 4.dp),
+ border = BorderStroke(1.dp, Hairline)
+ ) {
+ Text(msg.text, modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), color = Ink, fontSize = 14.sp, lineHeight = 21.sp)
+ }
+ }
+ }
+}
+
+// ───────────────────────── 设置页 ─────────────────────────
+@Composable
+private fun ConnectionCard(
+ host: String,
+ onHostChange: (String) -> Unit,
+ monitoring: Boolean,
+ connected: Boolean,
+ infoMessage: String,
+ errorMessage: String?,
+ onConnect: () -> Unit,
+ onPause: () -> Unit,
+ onRefresh: () -> Unit,
+ wifiMode: String,
+ apSsid: String,
+ apPassword: String,
+ apAddress: String,
+ onUseApAddress: () -> Unit
+) {
+ AppCard {
+ Column(modifier = Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ CardTitle(Icons.Filled.Router, Blue, "主控连接", "手机 HTTP API · 主从机 UDP/Wi-Fi")
+ OutlinedTextField(
+ value = host,
+ onValueChange = onHostChange,
+ modifier = Modifier.fillMaxWidth(),
+ label = { Text("设备 IP 地址") },
+ placeholder = { Text("例如:192.168.1.100") },
+ leadingIcon = { Icon(Icons.Filled.Wifi, contentDescription = null, tint = Muted) },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done),
+ shape = RoundedCornerShape(14.dp)
+ )
+ Text(
+ "手机连接主机所在 Wi-Fi;主机和从机也连接同一 Wi-Fi,通过 UDP 自动发现并同步心率与摔倒状态。IP 会自动保存,下次打开自动连接。",
+ color = Muted,
+ fontSize = 11.sp,
+ lineHeight = 15.sp
+ )
+ if (wifiMode == "AP") {
+ Text(
+ "主机已切换到管理热点:$apSsid · 密码:$apPassword · 地址:$apAddress",
+ color = Amber,
+ fontSize = 11.sp,
+ lineHeight = 16.sp
+ )
+ } else {
+ Text(
+ "如果路由器 Wi-Fi 连接失败,主机将广播 $apSsid(密码 $apPassword)。手机手动连接该热点后,可使用 192.168.4.1。",
+ color = Muted,
+ fontSize = 11.sp,
+ lineHeight = 16.sp
+ )
+ }
+ OutlinedButton(
+ onClick = onUseApAddress,
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(12.dp)
+ ) {
+ Icon(Icons.Filled.Wifi, contentDescription = null, modifier = Modifier.size(16.dp))
+ Spacer(Modifier.width(6.dp))
+ Text("填入备用热点地址 $apAddress")
+ }
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
+ if (monitoring) {
+ OutlinedButton(
+ onClick = onPause,
+ modifier = Modifier.weight(1f).height(48.dp),
+ shape = RoundedCornerShape(14.dp)
+ ) {
+ Icon(Icons.Filled.WifiOff, contentDescription = null, modifier = Modifier.size(17.dp))
+ Spacer(Modifier.width(6.dp))
+ Text("暂停")
+ }
+ } else {
+ Button(
+ onClick = onConnect,
+ enabled = host.trim().isNotEmpty(),
+ modifier = Modifier.weight(1f).height(48.dp),
+ shape = RoundedCornerShape(14.dp)
+ ) {
+ Icon(Icons.Filled.Wifi, contentDescription = null, modifier = Modifier.size(17.dp))
+ Spacer(Modifier.width(6.dp))
+ Text("连接并监测")
+ }
+ }
+ OutlinedButton(
+ onClick = onRefresh,
+ enabled = monitoring,
+ modifier = Modifier.height(48.dp),
+ shape = RoundedCornerShape(14.dp)
+ ) {
+ Icon(Icons.Filled.Refresh, contentDescription = null, modifier = Modifier.size(17.dp))
+ Spacer(Modifier.width(5.dp))
+ Text("刷新")
+ }
+ }
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Box(modifier = Modifier.size(7.dp).clip(CircleShape).background(if (connected) Green else if (monitoring) Amber else Color(0xFF9AA5B8)))
+ Spacer(Modifier.width(7.dp))
+ Text(errorMessage ?: infoMessage, color = if (errorMessage != null) Red else Muted, fontSize = 11.sp)
+ }
+ }
+ }
+}
+
+@Composable
+private fun ProviderChip(label: String, onClick: () -> Unit) {
+ Surface(
+ modifier = Modifier.clickable { onClick() },
+ color = Color(0xFFE8EDFF),
+ shape = RoundedCornerShape(10.dp)
+ ) {
+ Text(label, modifier = Modifier.padding(horizontal = 11.dp, vertical = 6.dp), color = Blue, fontSize = 12.sp, fontWeight = FontWeight.Medium)
+ }
+}
+
+@Composable
+private fun AiConfigCard(
+ config: AiConfig,
+ testing: Boolean,
+ testMessage: String,
+ onTest: (AiConfig) -> Unit,
+ onSave: (AiConfig) -> Unit
+) {
+ var draft by remember(config) { mutableStateOf(config) }
+ var showKey by remember { mutableStateOf(false) }
+ var savedHint by remember { mutableStateOf(false) }
+
+ LaunchedEffect(savedHint) {
+ if (savedHint) {
+ delay(2_000L)
+ savedHint = false
+ }
+ }
+
+ AppCard {
+ Column(modifier = Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ CardTitle(Icons.Filled.AutoAwesome, Purple, "AI 配置", "兼容 OpenAI 接口的任意服务")
+
+ Text("快速选择服务商", color = Muted, fontSize = 11.sp)
+ Row(horizontalArrangement = Arrangement.spacedBy(7.dp), modifier = Modifier.fillMaxWidth()) {
+ ProviderChip("OpenAI") {
+ draft = draft.copy(baseUrl = "https://api.openai.com/v1", model = "gpt-4o-mini")
+ }
+ ProviderChip("DeepSeek") {
+ draft = draft.copy(baseUrl = "https://api.deepseek.com/v1", model = "deepseek-chat")
+ }
+ ProviderChip("通义千问") {
+ draft = draft.copy(baseUrl = "https://dashscope.aliyuncs.com/compatible-mode/v1", model = "qwen-plus")
+ }
+ }
+
+ OutlinedTextField(
+ value = draft.baseUrl,
+ onValueChange = { draft = draft.copy(baseUrl = it) },
+ modifier = Modifier.fillMaxWidth(),
+ label = { Text("接口地址 Base URL") },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
+ shape = RoundedCornerShape(14.dp)
+ )
+ OutlinedTextField(
+ value = draft.apiKey,
+ onValueChange = { draft = draft.copy(apiKey = it) },
+ modifier = Modifier.fillMaxWidth(),
+ label = { Text("API Key") },
+ placeholder = { Text("sk-...") },
+ leadingIcon = { Icon(Icons.Filled.Lock, contentDescription = null, tint = Muted) },
+ trailingIcon = {
+ IconButton(onClick = { showKey = !showKey }) {
+ Icon(
+ if (showKey) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
+ contentDescription = if (showKey) "隐藏" else "显示",
+ tint = Muted
+ )
+ }
+ },
+ singleLine = true,
+ visualTransformation = if (showKey) VisualTransformation.None else PasswordVisualTransformation(),
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Next),
+ shape = RoundedCornerShape(14.dp)
+ )
+ OutlinedTextField(
+ value = draft.model,
+ onValueChange = { draft = draft.copy(model = it) },
+ modifier = Modifier.fillMaxWidth(),
+ label = { Text("模型名称") },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
+ shape = RoundedCornerShape(14.dp)
+ )
+
+ RuleRow(
+ icon = Icons.Filled.Bolt,
+ accent = Purple,
+ title = "允许 AI 控制设备",
+ desc = "开启后 AI 可调用工具开关灯/风扇、改规则、清除警报",
+ checked = draft.allowControl,
+ enabled = true,
+ onChange = { draft = draft.copy(allowControl = it) }
+ )
+
+ OutlinedTextField(
+ value = draft.systemPrompt,
+ onValueChange = { draft = draft.copy(systemPrompt = it) },
+ modifier = Modifier.fillMaxWidth().heightIn(min = 110.dp),
+ label = { Text("系统提示词") },
+ maxLines = 8,
+ shape = RoundedCornerShape(14.dp)
+ )
+ TextButton(onClick = { draft = draft.copy(systemPrompt = DEFAULT_SYSTEM_PROMPT) }) {
+ Text("恢复默认提示词", fontSize = 12.sp)
+ }
+
+ Text(
+ "Android 6.0 及以上会尝试使用 Android Keystore(AES-GCM)加密 API Key;若设备 Keystore 不可用则回退到应用本地存储。AI 对话内容和设备状态会发送到你填写的服务商接口。",
+ color = Muted,
+ fontSize = 11.sp,
+ lineHeight = 16.sp
+ )
+ if (testMessage.isNotBlank()) {
+ Surface(
+ color = when {
+ testMessage.startsWith("连接成功") -> Color(0xFFEAF7F0)
+ testMessage.startsWith("正在") -> Color(0xFFEFF3FF)
+ else -> Color(0xFFFFF2E9)
+ },
+ shape = RoundedCornerShape(12.dp)
+ ) {
+ Text(
+ testMessage,
+ modifier = Modifier.fillMaxWidth().padding(11.dp),
+ color = when {
+ testMessage.startsWith("连接成功") -> Green
+ testMessage.startsWith("正在") -> Blue
+ else -> Amber
+ },
+ fontSize = 11.sp,
+ lineHeight = 16.sp
+ )
+ }
+ }
+ Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(9.dp)) {
+ OutlinedButton(
+ onClick = { onTest(draft) },
+ enabled = !testing,
+ modifier = Modifier.weight(1f).height(50.dp),
+ shape = RoundedCornerShape(15.dp)
+ ) {
+ Text(if (testing) "测试中…" else "测试连接", fontWeight = FontWeight.SemiBold)
+ }
+ Button(
+ onClick = {
+ onSave(draft)
+ savedHint = true
+ },
+ modifier = Modifier.weight(1.25f).height(50.dp),
+ shape = RoundedCornerShape(15.dp)
+ ) {
+ Text(if (savedHint) "已保存 ✓" else "保存配置", fontWeight = FontWeight.SemiBold)
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun DataCard(onClear: () -> Unit) {
+ AppCard {
+ Column(modifier = Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ CardTitle(Icons.Filled.DeleteOutline, Red, "本地数据", "设备 IP、API Key 与 AI 设置")
+ OutlinedButton(
+ onClick = onClear,
+ modifier = Modifier.fillMaxWidth().height(46.dp),
+ shape = RoundedCornerShape(14.dp),
+ colors = ButtonDefaults.outlinedButtonColors(contentColor = Red)
+ ) {
+ Text("清除所有本地配置")
+ }
+ }
+ }
+}
+
+// ───────────────────────── 设备 API ─────────────────────────
+private object SmartLifeApi {
+ fun getJson(host: String, path: String): JSONObject {
+ val connection = (URL("http://$host$path").openConnection() as HttpURLConnection).apply {
+ requestMethod = "GET"
+ connectTimeout = 2_500
+ readTimeout = 2_500
+ useCaches = false
+ setRequestProperty("Accept", "application/json")
+ }
+ try {
+ val code = connection.responseCode
+ val stream = if (code in 200..299) connection.inputStream else connection.errorStream
+ val body = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty()
+ if (code !in 200..299) throw IOException("HTTP $code: $body")
+ return JSONObject(body)
+ } finally {
+ connection.disconnect()
+ }
+ }
+
+ fun parseStatus(json: JSONObject): SmartStatus {
+ fun doubleOrNull(key: String): Double? =
+ if (json.isNull(key) || !json.has(key)) null
+ else json.optDouble(key).takeIf { !it.isNaN() && !it.isInfinite() }
+ return SmartStatus(
+ temperature = doubleOrNull("temperature"),
+ humidity = doubleOrNull("humidity"),
+ pir = json.optBoolean("pir", false),
+ doorLight = json.optBoolean("door_light", false),
+ roomLight = json.optBoolean(
+ "room_light",
+ json.optBoolean("light13", false) || json.optBoolean("light14", false)
+ ),
+ fan = json.optBoolean("fan", false),
+ fanAuto = json.optBoolean("fan_auto", true),
+ pirAuto = json.optBoolean("pir_auto", true),
+ fanTempThreshold = json.optDouble("fan_temp_threshold", 28.0).toFloat(),
+ fanHumidityThreshold = json.optDouble("fan_humidity_threshold", 75.0).toFloat(),
+ lightTimeout = json.optInt("light_timeout", 30),
+ doorTimeout = json.optInt("door_timeout", 8),
+ rfidLast = json.optString("rfid_last", "—"),
+ rfidAuthorized = json.optBoolean("rfid_authorized", false),
+ sensorOk = json.optBoolean("sensor_ok", false),
+ fanAvailable = json.optBoolean("fan_available", false),
+ roomLightAvailable = json.optBoolean("room_light_available", false),
+ pinStatus = json.optString("pin_status", ""),
+ uptime = json.optLong("uptime", 0L),
+ fallen = json.optBoolean("fallen", false),
+ fallCount = json.optInt("fall_count", 0),
+ fallSource = json.optString("fall_source", ""),
+ heartRateBpm = if (json.isNull("heart_rate_bpm") || !json.has("heart_rate_bpm")) null else json.optInt("heart_rate_bpm"),
+ heartRateSimulated = json.optBoolean("heart_rate_simulated", true),
+ slaveConnected = json.optBoolean("slave_connected", false),
+ slaveIp = json.optString("slave_ip", ""),
+ slaveTransport = json.optString("slave_transport", "UDP/Wi-Fi"),
+ slaveFallAlarm = json.optBoolean("slave_fall_alarm", false),
+ slaveFallCount = json.optInt("slave_fall_count", 0),
+ wifiMode = json.optString("wifi_mode", "STA"),
+ ipAddress = json.optString("ip_address", ""),
+ apSsid = json.optString("ap_ssid", "QHZXManagement"),
+ apPassword = json.optString("ap_password", "QHZX2026")
+ )
+ }
+}
+
+// ───────────────────────── AI 接口(OpenAI 兼容 / Function Calling) ─────────────────────────
+private object AiClient {
+ /** 返回 choices[0].message;若包含 tool_calls 由调用方负责执行。 */
+ fun chat(cfg: AiConfig, messages: JSONArray, tools: JSONArray?): JSONObject {
+ val base = cfg.baseUrl.trim().trimEnd('/')
+ val url = if (base.endsWith("/chat/completions")) base else "$base/chat/completions"
+ val body = JSONObject()
+ .put("model", cfg.model.trim())
+ .put("messages", messages)
+ .put("temperature", 0.3)
+ if (tools != null && tools.length() > 0) {
+ body.put("tools", tools)
+ body.put("tool_choice", "auto")
+ }
+
+ val connection = (URL(url).openConnection() as HttpURLConnection).apply {
+ requestMethod = "POST"
+ connectTimeout = 15_000
+ readTimeout = 60_000
+ doOutput = true
+ useCaches = false
+ setRequestProperty("Content-Type", "application/json; charset=utf-8")
+ setRequestProperty("Accept", "application/json")
+ setRequestProperty("Authorization", "Bearer ${cfg.apiKey.trim()}")
+ }
+ try {
+ connection.outputStream.use { it.write(body.toString().toByteArray(Charsets.UTF_8)) }
+ val code = connection.responseCode
+ val stream = if (code in 200..299) connection.inputStream else connection.errorStream
+ val text = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty()
+ if (code !in 200..299) {
+ val detail = try {
+ JSONObject(text).optJSONObject("error")?.optString("message").orEmpty()
+ } catch (_: Exception) {
+ text.take(200)
+ }
+ throw IOException("HTTP $code ${detail.ifBlank { text.take(200) }}")
+ }
+ val choices = JSONObject(text).getJSONArray("choices")
+ return choices.getJSONObject(0).getJSONObject("message")
+ } finally {
+ connection.disconnect()
+ }
+ }
+}
+
+private fun aiTools(allowControl: Boolean): JSONArray {
+ fun fn(name: String, description: String, parameters: String): JSONObject =
+ JSONObject()
+ .put("type", "function")
+ .put(
+ "function",
+ JSONObject()
+ .put("name", name)
+ .put("description", description)
+ .put("parameters", JSONObject(parameters))
+ )
+
+ val tools = JSONArray().put(
+ fn(
+ "get_device_status",
+ "读取最新设备状态:温湿度、人体感应、灯和风扇状态、自动化参数、摔倒报警、从机连接和模拟心率。",
+ """{"type":"object","properties":{}}"""
+ )
+ )
+
+ if (allowControl) {
+ tools.put(
+ fn(
+ "control_device",
+ "开关指定设备。door_light=门上灯,room_light=室内照明总开关(P13/P14两路灯),fan=风扇/电机。手动控制风扇或室内灯会退出对应自动模式。",
+ """{"type":"object","properties":{"device":{"type":"string","enum":["door_light","room_light","fan"]},"state":{"type":"boolean","description":"true=开启,false=关闭"}},"required":["device","state"],"additionalProperties":false}"""
+ )
+ )
+ tools.put(
+ fn(
+ "update_automation",
+ "修改自动化规则并保存到设备。只传需要修改的字段,未提供字段保持原值。温度范围18-35摄氏度,湿度范围40-90%。",
+ """{"type":"object","properties":{"pir_auto":{"type":"boolean","description":"人体感应自动照明开关"},"fan_auto":{"type":"boolean","description":"温湿度自动风扇开关"},"fan_temp_threshold":{"type":"number","description":"风扇温度阈值,摄氏度,范围18-35"},"fan_humidity_threshold":{"type":"number","description":"风扇湿度阈值,百分比,范围40-90"}},"additionalProperties":false}"""
+ )
+ )
+ tools.put(
+ fn(
+ "clear_fall_alarm",
+ "清除设备上的摔倒警报标记。仅当用户明确要求清除并确认人员安全后调用。",
+ """{"type":"object","properties":{},"additionalProperties":false}"""
+ )
+ )
+ }
+ return tools
+}
+
+private fun deviceName(device: String): String = when (device) {
+ "door_light" -> "门上灯"
+ "room_light" -> "室内照明"
+ "fan" -> "风扇"
+ else -> device
+}
+
+private fun toolLabel(name: String, args: JSONObject): String = when (name) {
+ "get_device_status" -> "读取设备状态"
+ "control_device" -> "${deviceName(args.optString("device"))} → ${if (args.optBoolean("state")) "开启" else "关闭"}"
+ "update_automation" -> "更新自动化规则"
+ "clear_fall_alarm" -> "清除摔倒警报"
+ else -> "调用工具 $name"
+}
+
+private fun toolResult(ok: Boolean, message: String, status: SmartStatus?): String {
+ val obj = JSONObject().put("ok", ok).put("message", message)
+ if (status != null) obj.put("status", JSONObject(status.toAiJson()))
+ return obj.toString()
+}
+
+private fun SmartStatus.toAiJson(): String {
+ val o = JSONObject()
+ o.put("temperature_c", temperature ?: JSONObject.NULL)
+ o.put("humidity_percent", humidity ?: JSONObject.NULL)
+ o.put("someone_detected", pir)
+ o.put("door_light_on", doorLight)
+ o.put("room_light_on", roomLight)
+ o.put("fan_on", fan)
+ o.put("pir_auto_lighting", pirAuto)
+ o.put("fan_auto_mode", fanAuto)
+ o.put("fan_temp_threshold_c", fanTempThreshold.toDouble())
+ o.put("fan_humidity_threshold_percent", fanHumidityThreshold.toDouble())
+ o.put("fallen", fallen)
+ o.put("fall_count", fallCount)
+ o.put("heart_rate_bpm_simulated", heartRateBpm ?: JSONObject.NULL)
+ o.put("heart_rate_slave_online", slaveConnected)
+ o.put("heart_rate_is_simulated", heartRateSimulated)
+ o.put("slave_ip", slaveIp)
+ o.put("slave_fall_alarm", slaveFallAlarm)
+ o.put("slave_fall_count", slaveFallCount)
+ o.put("fan_available", fanAvailable)
+ o.put("room_light_available", roomLightAvailable)
+ o.put("rfid_authorized", rfidAuthorized)
+ o.put("last_rfid_uid", rfidLast)
+ o.put("wifi_mode", wifiMode)
+ o.put("ip_address", ipAddress)
+ return o.toString()
+}
+
+private fun addHistory(list: MutableList, event: LocalEvent) {
+ list.add(0, event)
+ while (list.size > 20) list.removeAt(list.lastIndex)
+}
+
+private fun nowClock(): String = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date())
diff --git a/app/app/src/main/res/drawable/ic_launcher.xml b/app/app/src/main/res/drawable/ic_launcher.xml
new file mode 100644
index 0000000..18d2891
--- /dev/null
+++ b/app/app/src/main/res/drawable/ic_launcher.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
diff --git a/app/app/src/main/res/values/styles.xml b/app/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000..d56052f
--- /dev/null
+++ b/app/app/src/main/res/values/styles.xml
@@ -0,0 +1,11 @@
+
+
+
+
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
new file mode 100644
index 0000000..1b4481a
--- /dev/null
+++ b/app/build.gradle.kts
@@ -0,0 +1,5 @@
+plugins {
+ id("com.android.application") version "8.7.3" apply false
+ id("org.jetbrains.kotlin.android") version "2.0.21" apply false
+ id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
+}
diff --git a/app/gradle.properties b/app/gradle.properties
new file mode 100644
index 0000000..96e3009
--- /dev/null
+++ b/app/gradle.properties
@@ -0,0 +1,4 @@
+org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
+android.useAndroidX=true
+kotlin.code.style=official
+org.gradle.parallel=true
diff --git a/app/gradle/wrapper/gradle-wrapper.properties b/app/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..40fc883
--- /dev/null
+++ b/app/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=manual-wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=manual-wrapper/dists
diff --git a/app/gradlew b/app/gradlew
new file mode 100644
index 0000000..911512f
--- /dev/null
+++ b/app/gradlew
@@ -0,0 +1,24 @@
+#!/usr/bin/env bash
+# Small wrapper bootstrap that downloads Gradle when it is not already cached.
+set -euo pipefail
+GRADLE_VERSION="8.9"
+GRADLE_USER_HOME="${GRADLE_USER_HOME:-$HOME/.gradle}"
+INSTALL_ROOT="$GRADLE_USER_HOME/manual-wrapper/gradle-$GRADLE_VERSION"
+GRADLE_BIN="$INSTALL_ROOT/gradle-$GRADLE_VERSION/bin/gradle"
+if [[ ! -x "$GRADLE_BIN" ]]; then
+ mkdir -p "$INSTALL_ROOT"
+ ARCHIVE="$INSTALL_ROOT/gradle-$GRADLE_VERSION-bin.zip"
+ URL="https://services.gradle.org/distributions/gradle-$GRADLE_VERSION-bin.zip"
+ echo "Gradle $GRADLE_VERSION 尚未安装,正在下载……"
+ if command -v curl >/dev/null 2>&1; then
+ curl --fail --location --retry 3 "$URL" --output "$ARCHIVE"
+ elif command -v wget >/dev/null 2>&1; then
+ wget -O "$ARCHIVE" "$URL"
+ else
+ echo "需要 curl 或 wget 才能下载 Gradle。" >&2
+ exit 1
+ fi
+ unzip -q -o "$ARCHIVE" -d "$INSTALL_ROOT"
+ rm -f "$ARCHIVE"
+fi
+exec "$GRADLE_BIN" "$@"
diff --git a/app/gradlew.bat b/app/gradlew.bat
new file mode 100644
index 0000000..1fcdf9d
--- /dev/null
+++ b/app/gradlew.bat
@@ -0,0 +1,12 @@
+@echo off
+setlocal
+set "GRADLE_VERSION=8.9"
+set "INSTALL_ROOT=%USERPROFILE%\.gradle\manual-wrapper\gradle-%GRADLE_VERSION%"
+set "GRADLE_BIN=%INSTALL_ROOT%\gradle-%GRADLE_VERSION%\bin\gradle.bat"
+if not exist "%GRADLE_BIN%" (
+ echo Gradle %GRADLE_VERSION% 尚未安装,正在下载……
+ powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference='Stop'; $root='%INSTALL_ROOT%'; New-Item -ItemType Directory -Force -Path $root | Out-Null; $zip=Join-Path $root 'gradle-%GRADLE_VERSION%-bin.zip'; Invoke-WebRequest -Uri 'https://services.gradle.org/distributions/gradle-%GRADLE_VERSION%-bin.zip' -OutFile $zip; Expand-Archive -Path $zip -DestinationPath $root -Force; Remove-Item $zip -Force"
+ if errorlevel 1 exit /b 1
+)
+call "%GRADLE_BIN%" %*
+exit /b %ERRORLEVEL%
diff --git a/app/settings.gradle.kts b/app/settings.gradle.kts
new file mode 100644
index 0000000..4dac0bc
--- /dev/null
+++ b/app/settings.gradle.kts
@@ -0,0 +1,18 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "SmartLife"
+include(":app")