commit 9f9a253ef429af67d9d75ab7eb8859bb4cb300ba Author: Alexyz Date: Sun Sep 20 18:32:55 2026 +0500 feat: Avtex Smart Sleep Engine - initial release for Android and Home Assistant diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..839c6ac --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +*.iml +.gradle +/local.properties +local.properties +/.idea/ +.idea/ +.DS_Store +/build +build/ +app/build/ +/captures +.externalNativeBuild +.cxx diff --git a/README.md b/README.md new file mode 100644 index 0000000..e6a1d20 --- /dev/null +++ b/README.md @@ -0,0 +1,91 @@ +# Avtex — Smart Sleep & Motion Engine for Home Assistant + +

+ Avtex Logo +

+ +

+ Легковесное Android-приложение для мгновенной фиксации сна и пробуждения с интеграцией в Home Assistant (HAC)
+ Оптимизировано для OnePlus (OxygenOS / ColorOS) и устройств с ненормированным рабочим графиком. +

+ +--- + +## 🌟 Ключевые возможности + +* **Мгновенная фиксация пробуждения (0% расхода батареи)**: + Использует аппаратный сопроцессор `Sensor.TYPE_SIGNIFICANT_MOTION`. Как только вы берете телефон в руки после сна, сигнал пробуждения моментально передается в умный дом. +* **Детекция сна при ненормированном графике**: + Связка таймера покоя (по умолчанию 20 мин при выключенном экране) и нейросетевых классификаций **Google Play Services Sleep API** (`SleepClassifyEvent`). Никаких жестких привязок к часам (23:00–07:00). +* **Полная совместимость с Home Assistant (HAC)**: + * **Webhook URL**: отправка JSON-событий `{"event": "sleep_started" | "sleep_ended", "state": "asleep" | "awake", ...}`. + * **REST State API**: прямое обновление сенсора `binary_sensor.phone_sleep` через Long-Lived Access Token. + * **Local Broadcast Intent**: `com.example.avtex.SLEEP_STATE_CHANGED` для Tasker / Home Assistant Companion App прямо на телефоне. +* **Устойчивость к убийцам процессов OnePlus**: + * Реализован как Foreground Service с типом `specialUse`. + * Интерактивный экран со вспомогательными кнопками перехода в системные настройки OxygenOS (отключение оптимизации батареи, включение автозапуска). +* **Премиальный дизайн в стиле Cyber Obsidian / Glassmorphism**: + * Jetpack Compose & Material 3. + * Тонкие неоновые контуры, стеклянные карточки, динамический пульсирующий баннер состояния. + * Интерактивная лента аудита событий сенсоров в реальном времени. + +--- + +## 🚀 Быстрый старт + +### 1. Сборка APK + +Для сборки отладочной версии: +```bash +./gradlew assembleDebug +``` + +Для сборки оптимизированного подписанного Release APK: +```bash +./gradlew assembleRelease +``` +Файл будет создан в `app/build/outputs/apk/release/app-release.apk`. + +### 2. Установка через ADB +```bash +adb install -r app/build/outputs/apk/release/app-release.apk +``` + +--- + +## ⚙️ Настройка Home Assistant (Пример Webhook автоматизации) + +В файле `automations.yaml` или в визуальном редакторе Home Assistant: + +```yaml +alias: "Avtex: Автоматизация сна и пробуждения" +trigger: + - platform: webhook + webhook_id: avtex_sleep +action: + - choose: + - conditions: "{{ trigger.json.state == 'asleep' }}" + sequence: + - service: light.turn_off + target: + entity_id: all + - service: climate.set_preset_mode + data: + preset_mode: sleep + target: + entity_id: climate.bedroom + - conditions: "{{ trigger.json.state == 'awake' }}" + sequence: + - service: scene.turn_on + target: + entity_id: scene.morning_lights +``` + +--- + +## 🛠 Стек технологий +* **Язык**: Kotlin 2.3+ +* **UI**: Jetpack Compose, Material 3, Navigation +* **Сенсоры**: Android Sensor Hub (`TYPE_SIGNIFICANT_MOTION`), Google Play Services Location / Sleep API +* **Сеть**: OkHttp 4.12 +* **Хранилище настроек**: Jetpack DataStore Preferences diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..480a5af --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,106 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.compose.compiler) + alias(libs.plugins.kotlin.serialization) +} + +android { + namespace = "com.example.avtex" + compileSdk = 36 + defaultConfig { + applicationId = "com.example.avtex" + minSdk = 26 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + } + + signingConfigs { + create("release") { + storeFile = file("release.keystore") + storePassword = "avtex_release_key" + keyAlias = "avtex" + keyPassword = "avtex_release_key" + enableV1Signing = true + enableV2Signing = true + } + } + + buildTypes { + release { + isMinifyEnabled = false + signingConfig = signingConfigs.getByName("release") + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + buildFeatures { + compose = true + aidl = false + buildConfig = true + shaders = false + } + + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +kotlin { + jvmToolchain(17) +} + +dependencies { + val composeBom = platform(libs.androidx.compose.bom) + implementation(composeBom) + androidTestImplementation(composeBom) + + // Core Android dependencies + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + + // Arch Components + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.lifecycle.viewmodel.compose) + + // Compose + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material.icons.extended) + // Tooling + debugImplementation(libs.androidx.compose.ui.tooling) + // Instrumented tests + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + debugImplementation(libs.androidx.compose.ui.test.manifest) + + // Local tests: jUnit, coroutines, Android runner + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) + + // Instrumented tests: jUnit rules and runners + androidTestImplementation(libs.androidx.test.core) + androidTestImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.androidx.test.espresso.core) + + // Navigation + implementation(libs.androidx.navigation3.ui) + implementation(libs.androidx.navigation3.runtime) + implementation(libs.androidx.lifecycle.viewmodel.navigation3) + + // Sleep API & Location + implementation(libs.play.services.location) + + // Home Assistant HTTP client + implementation(libs.okhttp) + + // DataStore for settings + implementation(libs.androidx.datastore.preferences) +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..c397c31 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,10 @@ +# Proguard rules for Avtex +-keep class com.google.android.gms.location.** { *; } +-dontwarn com.google.android.gms.location.** + +-keep class okhttp3.** { *; } +-dontwarn okhttp3.** + +-keep class com.example.avtex.data.** { *; } +-keep class com.example.avtex.hac.** { *; } +-keep class com.example.avtex.engine.** { *; } diff --git a/app/release.keystore b/app/release.keystore new file mode 100644 index 0000000..9e2368e Binary files /dev/null and b/app/release.keystore differ diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a8c88a3 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/example/avtex/AvtexApplication.kt b/app/src/main/java/com/example/avtex/AvtexApplication.kt new file mode 100644 index 0000000..7aaa164 --- /dev/null +++ b/app/src/main/java/com/example/avtex/AvtexApplication.kt @@ -0,0 +1,42 @@ +package com.example.avtex + +import android.app.Application +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.os.Build +import com.example.avtex.data.SettingsRepository + +class AvtexApplication : Application() { + + lateinit var settingsRepository: SettingsRepository + private set + + override fun onCreate() { + super.onCreate() + instance = this + settingsRepository = SettingsRepository(this) + createNotificationChannels() + } + + private fun createNotificationChannels() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID_TRACKING, + "Мониторинг сна Avtex", + NotificationManager.IMPORTANCE_LOW + ).apply { + description = "Отображает текущий статус отслеживания сна и активности" + setShowBadge(false) + } + val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel(channel) + } + } + + companion object { + const val CHANNEL_ID_TRACKING = "avtex_sleep_tracking_channel" + lateinit var instance: AvtexApplication + private set + } +} diff --git a/app/src/main/java/com/example/avtex/MainActivity.kt b/app/src/main/java/com/example/avtex/MainActivity.kt new file mode 100644 index 0000000..9a37393 --- /dev/null +++ b/app/src/main/java/com/example/avtex/MainActivity.kt @@ -0,0 +1,22 @@ +package com.example.avtex + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.ui.Modifier +import com.example.avtex.theme.AvtexTheme + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + enableEdgeToEdge() + setContent { + AvtexTheme { Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { MainNavigation() } } + } + } +} diff --git a/app/src/main/java/com/example/avtex/Navigation.kt b/app/src/main/java/com/example/avtex/Navigation.kt new file mode 100644 index 0000000..1426877 --- /dev/null +++ b/app/src/main/java/com/example/avtex/Navigation.kt @@ -0,0 +1,105 @@ +package com.example.avtex + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Bedtime +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.NavigationBarItemDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.example.avtex.theme.GlassBorder +import com.example.avtex.theme.NeonViolet +import com.example.avtex.theme.ObsidianBg +import com.example.avtex.theme.ObsidianSurfaceElevated +import com.example.avtex.theme.TextMuted +import com.example.avtex.theme.TextPrimary +import com.example.avtex.ui.DashboardScreen +import com.example.avtex.ui.OnePlusGuideScreen +import com.example.avtex.ui.SettingsScreen + +@Composable +fun MainNavigation() { + var selectedTab by rememberSaveable { mutableIntStateOf(0) } + + Scaffold( + modifier = Modifier.fillMaxSize(), + containerColor = ObsidianBg, + bottomBar = { + NavigationBar( + containerColor = ObsidianSurfaceElevated, + tonalElevation = 0.dp, + modifier = Modifier.border(1.dp, GlassBorder) + ) { + val navColors = NavigationBarItemDefaults.colors( + selectedIconColor = Color.White, + selectedTextColor = NeonViolet, + indicatorColor = NeonViolet.copy(alpha = 0.35f), + unselectedIconColor = TextMuted, + unselectedTextColor = TextMuted + ) + + NavigationBarItem( + selected = selectedTab == 0, + onClick = { selectedTab = 0 }, + icon = { Icon(Icons.Default.Bedtime, contentDescription = "Статус") }, + label = { + Text( + "Статус", + fontSize = 11.sp, + fontWeight = if (selectedTab == 0) FontWeight.Bold else FontWeight.Normal + ) + }, + colors = navColors + ) + NavigationBarItem( + selected = selectedTab == 1, + onClick = { selectedTab = 1 }, + icon = { Icon(Icons.Default.Settings, contentDescription = "Настройки") }, + label = { + Text( + "Настройки", + fontSize = 11.sp, + fontWeight = if (selectedTab == 1) FontWeight.Bold else FontWeight.Normal + ) + }, + colors = navColors + ) + NavigationBarItem( + selected = selectedTab == 2, + onClick = { selectedTab = 2 }, + icon = { Icon(Icons.Default.Info, contentDescription = "OnePlus") }, + label = { + Text( + "OnePlus", + fontSize = 11.sp, + fontWeight = if (selectedTab == 2) FontWeight.Bold else FontWeight.Normal + ) + }, + colors = navColors + ) + } + } + ) { innerPadding -> + when (selectedTab) { + 0 -> DashboardScreen(modifier = Modifier.padding(innerPadding)) + 1 -> SettingsScreen(modifier = Modifier.padding(innerPadding)) + 2 -> OnePlusGuideScreen(modifier = Modifier.padding(innerPadding)) + } + } +} diff --git a/app/src/main/java/com/example/avtex/data/AppSettings.kt b/app/src/main/java/com/example/avtex/data/AppSettings.kt new file mode 100644 index 0000000..af65a10 --- /dev/null +++ b/app/src/main/java/com/example/avtex/data/AppSettings.kt @@ -0,0 +1,20 @@ +package com.example.avtex.data + +enum class HacMode { + WEBHOOK, + REST +} + +data class AppSettings( + val isTrackingActive: Boolean = false, + val currentState: String = "AWAKE", + val lastStateChangeMillis: Long = System.currentTimeMillis(), + val hacMode: HacMode = HacMode.WEBHOOK, + val hacWebhookUrl: String = "", + val hacRestUrl: String = "", + val hacToken: String = "", + val inactivityMinutes: Int = 20, + val confidenceThreshold: Int = 70, + val broadcastIntents: Boolean = true, + val lastHacStatus: String = "Готов к работе" +) diff --git a/app/src/main/java/com/example/avtex/data/LogEntry.kt b/app/src/main/java/com/example/avtex/data/LogEntry.kt new file mode 100644 index 0000000..6794b97 --- /dev/null +++ b/app/src/main/java/com/example/avtex/data/LogEntry.kt @@ -0,0 +1,15 @@ +package com.example.avtex.data + +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +data class LogEntry( + val timestamp: Long = System.currentTimeMillis(), + val tag: String, + val message: String, + val isImportant: Boolean = false +) { + val formattedTime: String + get() = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date(timestamp)) +} diff --git a/app/src/main/java/com/example/avtex/data/SettingsRepository.kt b/app/src/main/java/com/example/avtex/data/SettingsRepository.kt new file mode 100644 index 0000000..350c2f7 --- /dev/null +++ b/app/src/main/java/com/example/avtex/data/SettingsRepository.kt @@ -0,0 +1,117 @@ +package com.example.avtex.data + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map + +private val Context.dataStore: DataStore by preferencesDataStore(name = "avtex_settings") + +class SettingsRepository(private val context: Context) { + + private object PreferencesKeys { + val IS_TRACKING_ACTIVE = booleanPreferencesKey("is_tracking_active") + val CURRENT_STATE = stringPreferencesKey("current_state") + val LAST_STATE_CHANGE = longPreferencesKey("last_state_change") + val HAC_MODE = stringPreferencesKey("hac_mode") + val HAC_WEBHOOK_URL = stringPreferencesKey("hac_webhook_url") + val HAC_REST_URL = stringPreferencesKey("hac_rest_url") + val HAC_TOKEN = stringPreferencesKey("hac_token") + val INACTIVITY_MINUTES = intPreferencesKey("inactivity_minutes") + val CONFIDENCE_THRESHOLD = intPreferencesKey("confidence_threshold") + val BROADCAST_INTENTS = booleanPreferencesKey("broadcast_intents") + val LAST_HAC_STATUS = stringPreferencesKey("last_hac_status") + } + + private val _logsFlow = MutableStateFlow>(emptyList()) + val logsFlow: StateFlow> = _logsFlow.asStateFlow() + + fun addLog(tag: String, message: String, isImportant: Boolean = false) { + val newEntry = LogEntry(tag = tag, message = message, isImportant = isImportant) + val current = _logsFlow.value.toMutableList() + current.add(0, newEntry) + if (current.size > 100) { + current.removeAt(current.size - 1) + } + _logsFlow.value = current + } + + val settingsFlow: Flow = context.dataStore.data.map { prefs -> + val modeStr = prefs[PreferencesKeys.HAC_MODE] ?: HacMode.WEBHOOK.name + val mode = try { + HacMode.valueOf(modeStr) + } catch (_: Exception) { + HacMode.WEBHOOK + } + + AppSettings( + isTrackingActive = prefs[PreferencesKeys.IS_TRACKING_ACTIVE] ?: false, + currentState = prefs[PreferencesKeys.CURRENT_STATE] ?: "AWAKE", + lastStateChangeMillis = prefs[PreferencesKeys.LAST_STATE_CHANGE] ?: System.currentTimeMillis(), + hacMode = mode, + hacWebhookUrl = prefs[PreferencesKeys.HAC_WEBHOOK_URL] ?: "", + hacRestUrl = prefs[PreferencesKeys.HAC_REST_URL] ?: "", + hacToken = prefs[PreferencesKeys.HAC_TOKEN] ?: "", + inactivityMinutes = prefs[PreferencesKeys.INACTIVITY_MINUTES] ?: 20, + confidenceThreshold = prefs[PreferencesKeys.CONFIDENCE_THRESHOLD] ?: 70, + broadcastIntents = prefs[PreferencesKeys.BROADCAST_INTENTS] ?: true, + lastHacStatus = prefs[PreferencesKeys.LAST_HAC_STATUS] ?: "Готов к работе" + ) + } + + suspend fun getSettings(): AppSettings = settingsFlow.first() + + suspend fun updateTrackingActive(active: Boolean) { + context.dataStore.edit { it[PreferencesKeys.IS_TRACKING_ACTIVE] = active } + } + + suspend fun updateCurrentState(state: String, timestamp: Long = System.currentTimeMillis()) { + context.dataStore.edit { + it[PreferencesKeys.CURRENT_STATE] = state + it[PreferencesKeys.LAST_STATE_CHANGE] = timestamp + } + } + + suspend fun updateHacSettings( + mode: HacMode, + webhookUrl: String, + restUrl: String, + token: String + ) { + context.dataStore.edit { + it[PreferencesKeys.HAC_MODE] = mode.name + it[PreferencesKeys.HAC_WEBHOOK_URL] = webhookUrl + it[PreferencesKeys.HAC_REST_URL] = restUrl + it[PreferencesKeys.HAC_TOKEN] = token + } + } + + suspend fun updateDetectionSensitivity( + inactivityMinutes: Int, + confidenceThreshold: Int, + broadcastIntents: Boolean + ) { + context.dataStore.edit { + it[PreferencesKeys.INACTIVITY_MINUTES] = inactivityMinutes + it[PreferencesKeys.CONFIDENCE_THRESHOLD] = confidenceThreshold + it[PreferencesKeys.BROADCAST_INTENTS] = broadcastIntents + } + } + + suspend fun updateHacStatus(status: String) { + context.dataStore.edit { + it[PreferencesKeys.LAST_HAC_STATUS] = status + } + } +} diff --git a/app/src/main/java/com/example/avtex/engine/BootReceiver.kt b/app/src/main/java/com/example/avtex/engine/BootReceiver.kt new file mode 100644 index 0000000..a55db15 --- /dev/null +++ b/app/src/main/java/com/example/avtex/engine/BootReceiver.kt @@ -0,0 +1,39 @@ +package com.example.avtex.engine + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.Build +import android.util.Log +import com.example.avtex.AvtexApplication +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class BootReceiver : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + if (intent.action == Intent.ACTION_BOOT_COMPLETED) { + Log.d(TAG, "Boot completed event received") + val repo = AvtexApplication.instance.settingsRepository + CoroutineScope(Dispatchers.Default).launch { + val settings = repo.getSettings() + if (settings.isTrackingActive) { + Log.d(TAG, "Restarting SleepTrackingService after reboot") + val serviceIntent = Intent(context, SleepTrackingService::class.java).apply { + action = SleepTrackingService.ACTION_START_TRACKING + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(serviceIntent) + } else { + context.startService(serviceIntent) + } + } + } + } + } + + companion object { + private const val TAG = "BootReceiver" + } +} diff --git a/app/src/main/java/com/example/avtex/engine/SignificantMotionTrigger.kt b/app/src/main/java/com/example/avtex/engine/SignificantMotionTrigger.kt new file mode 100644 index 0000000..6c5d8a3 --- /dev/null +++ b/app/src/main/java/com/example/avtex/engine/SignificantMotionTrigger.kt @@ -0,0 +1,54 @@ +package com.example.avtex.engine + +import android.content.Context +import android.hardware.Sensor +import android.hardware.SensorManager +import android.hardware.TriggerEvent +import android.hardware.TriggerEventListener +import android.util.Log + +class SignificantMotionTrigger( + private val context: Context, + private val onMotionDetected: () -> Unit +) { + private val sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager + private val motionSensor: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_SIGNIFICANT_MOTION) + + private var isArmed = false + + private val triggerListener = object : TriggerEventListener() { + override fun onTrigger(event: TriggerEvent?) { + Log.d(TAG, "Hardware Significant Motion Triggered!") + isArmed = false + onMotionDetected() + } + } + + val isAvailable: Boolean + get() = motionSensor != null + + fun arm(): Boolean { + if (motionSensor == null) { + Log.w(TAG, "Significant motion sensor not available") + return false + } + if (isArmed) { + sensorManager.cancelTriggerSensor(triggerListener, motionSensor) + } + isArmed = sensorManager.requestTriggerSensor(triggerListener, motionSensor) + Log.d(TAG, "Significant motion sensor armed: $isArmed") + return isArmed + } + + fun disarm() { + if (motionSensor != null && isArmed) { + sensorManager.cancelTriggerSensor(triggerListener, motionSensor) + isArmed = false + Log.d(TAG, "Significant motion sensor disarmed") + } + } + + companion object { + private const val TAG = "SignificantMotion" + } +} diff --git a/app/src/main/java/com/example/avtex/engine/SleepReceiver.kt b/app/src/main/java/com/example/avtex/engine/SleepReceiver.kt new file mode 100644 index 0000000..f1a2e43 --- /dev/null +++ b/app/src/main/java/com/example/avtex/engine/SleepReceiver.kt @@ -0,0 +1,47 @@ +package com.example.avtex.engine + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.util.Log +import com.example.avtex.AvtexApplication +import com.google.android.gms.location.SleepClassifyEvent +import com.google.android.gms.location.SleepSegmentEvent + +class SleepReceiver : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + if (SleepClassifyEvent.hasEvents(intent)) { + val events = SleepClassifyEvent.extractEvents(intent) + for (event in events) { + val confidence = event.confidence + val motion = event.motion + val light = event.light + Log.d(TAG, "SleepClassifyEvent: confidence=$confidence, motion=$motion, light=$light") + AvtexApplication.instance.settingsRepository.addLog( + tag = "SleepAPI", + message = "Классификация: уверенность $confidence%, движение $motion/6, свет $light/6" + ) + SleepTrackingService.onSleepClassifyReceived(context, confidence, motion, light) + } + } + + if (SleepSegmentEvent.hasEvents(intent)) { + val events = SleepSegmentEvent.extractEvents(intent) + for (event in events) { + Log.d(TAG, "SleepSegmentEvent: ${event.startTimeMillis} to ${event.endTimeMillis} (status=${event.status})") + AvtexApplication.instance.settingsRepository.addLog( + tag = "SleepSegment", + message = "Сегмент сна завершен: ${event.startTimeMillis} - ${event.endTimeMillis}", + isImportant = true + ) + SleepTrackingService.onSleepSegmentReceived(context, event.startTimeMillis, event.endTimeMillis, event.status) + } + } + } + + companion object { + private const val TAG = "SleepReceiver" + const val ACTION_SLEEP_API_EVENT = "com.example.avtex.ACTION_SLEEP_API_EVENT" + } +} diff --git a/app/src/main/java/com/example/avtex/engine/SleepState.kt b/app/src/main/java/com/example/avtex/engine/SleepState.kt new file mode 100644 index 0000000..5c18c76 --- /dev/null +++ b/app/src/main/java/com/example/avtex/engine/SleepState.kt @@ -0,0 +1,6 @@ +package com.example.avtex.engine + +enum class SleepState { + AWAKE, + ASLEEP +} diff --git a/app/src/main/java/com/example/avtex/engine/SleepTrackingService.kt b/app/src/main/java/com/example/avtex/engine/SleepTrackingService.kt new file mode 100644 index 0000000..77528c7 --- /dev/null +++ b/app/src/main/java/com/example/avtex/engine/SleepTrackingService.kt @@ -0,0 +1,373 @@ +package com.example.avtex.engine + +import android.Manifest +import android.app.Notification +import android.app.PendingIntent +import android.app.Service +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageManager +import android.os.Build +import android.os.IBinder +import android.os.PowerManager +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import com.example.avtex.AvtexApplication +import com.example.avtex.MainActivity +import com.example.avtex.hac.HacClient +import com.google.android.gms.location.ActivityRecognition +import com.google.android.gms.location.SleepSegmentRequest +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +class SleepTrackingService : Service() { + + private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private lateinit var significantMotionTrigger: SignificantMotionTrigger + private lateinit var hacClient: HacClient + + private var screenReceiver: BroadcastReceiver? = null + private var inactivityJob: Job? = null + private var sleepApiPendingIntent: PendingIntent? = null + + private var currentState: SleepState = SleepState.AWAKE + private var stateChangeTimestamp: Long = System.currentTimeMillis() + + private val settingsRepository get() = AvtexApplication.instance.settingsRepository + + override fun onCreate() { + super.onCreate() + Log.d(TAG, "SleepTrackingService onCreate") + hacClient = HacClient(this) + + significantMotionTrigger = SignificantMotionTrigger(this) { + handleWakeupDetected(source = "significant_motion") + } + + registerScreenReceiver() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + val action = intent?.action ?: ACTION_START_TRACKING + Log.d(TAG, "onStartCommand action: $action") + + when (action) { + ACTION_START_TRACKING -> { + startForegroundServiceWithNotification() + initializeTracking() + } + ACTION_STOP_TRACKING -> { + stopTracking() + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + ACTION_MANUAL_TOGGLE -> { + val newState = if (currentState == SleepState.AWAKE) SleepState.ASLEEP else SleepState.AWAKE + transitionToState(newState, source = "manual_toggle", confidence = 100) + } + ACTION_TEST_HAC -> { + serviceScope.launch { + val settings = settingsRepository.getSettings() + val isAsleep = currentState == SleepState.ASLEEP + settingsRepository.addLog("HAC_TEST", "Отправка тестового запроса в Home Assistant...") + val result = hacClient.sendSleepEvent(settings, isAsleep, source = "manual_test") + result.onSuccess { msg -> + settingsRepository.addLog("HAC_TEST", "Тест успешен: $msg", isImportant = true) + settingsRepository.updateHacStatus("Тест успешен: $msg") + }.onFailure { err -> + settingsRepository.addLog("HAC_TEST", "Ошибка теста: ${err.localizedMessage}", isImportant = true) + settingsRepository.updateHacStatus("Ошибка: ${err.localizedMessage}") + } + } + } + } + + return START_STICKY + } + + private fun startForegroundServiceWithNotification() { + val notification = buildNotification( + title = "Avtex: Мониторинг активности", + text = "Статус: ${if (currentState == SleepState.ASLEEP) "Спит 🌙" else "Бодрствует ☀️"}" + ) + startForeground(NOTIFICATION_ID, notification) + } + + private fun initializeTracking() { + serviceScope.launch { + val settings = settingsRepository.getSettings() + currentState = if (settings.currentState == "ASLEEP") SleepState.ASLEEP else SleepState.AWAKE + stateChangeTimestamp = settings.lastStateChangeMillis + settingsRepository.updateTrackingActive(true) + settingsRepository.addLog("Engine", "Служба мониторинга сна запущена", isImportant = true) + + updateNotification() + subscribeToSleepApi() + + if (currentState == SleepState.ASLEEP) { + significantMotionTrigger.arm() + } + } + } + + private fun stopTracking() { + serviceScope.launch { + settingsRepository.updateTrackingActive(false) + settingsRepository.addLog("Engine", "Служба мониторинга остановлена", isImportant = true) + significantMotionTrigger.disarm() + unsubscribeFromSleepApi() + inactivityJob?.cancel() + } + } + + private fun registerScreenReceiver() { + screenReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + when (intent.action) { + Intent.ACTION_SCREEN_OFF -> { + Log.d(TAG, "Screen OFF event received") + handleScreenOff() + } + Intent.ACTION_SCREEN_ON -> { + Log.d(TAG, "Screen ON event received") + } + Intent.ACTION_USER_PRESENT -> { + Log.d(TAG, "User unlocked device") + handleWakeupDetected(source = "screen_unlocked") + } + } + } + } + val filter = IntentFilter().apply { + addAction(Intent.ACTION_SCREEN_OFF) + addAction(Intent.ACTION_SCREEN_ON) + addAction(Intent.ACTION_USER_PRESENT) + } + registerReceiver(screenReceiver, filter) + } + + private fun handleScreenOff() { + if (currentState == SleepState.AWAKE) { + inactivityJob?.cancel() + inactivityJob = serviceScope.launch { + val settings = settingsRepository.getSettings() + val minutes = settings.inactivityMinutes + Log.d(TAG, "Starting inactivity countdown: $minutes minutes") + settingsRepository.addLog("Engine", "Экран выключен. Ожидание сна ($minutes мин)...") + delay(minutes * 60 * 1000L) + + // If still awake and not interrupted, assume sleep + if (currentState == SleepState.AWAKE) { + settingsRepository.addLog("Engine", "Время покоя истекло ($minutes мин). Переход в режим сна.", isImportant = true) + transitionToState(SleepState.ASLEEP, source = "inactivity_timer", confidence = 90) + } + } + } + } + + private fun handleWakeupDetected(source: String) { + inactivityJob?.cancel() + if (currentState == SleepState.ASLEEP) { + Log.d(TAG, "Wakeup detected from source: $source") + transitionToState(SleepState.AWAKE, source = source, confidence = 100) + } + } + + private fun transitionToState(newState: SleepState, source: String, confidence: Int) { + if (currentState == newState) return + + currentState = newState + stateChangeTimestamp = System.currentTimeMillis() + + serviceScope.launch { + val isAsleep = (newState == SleepState.ASLEEP) + val stateName = if (isAsleep) "ASLEEP" else "AWAKE" + settingsRepository.updateCurrentState(stateName, stateChangeTimestamp) + + if (isAsleep) { + // When falling asleep, arm significant motion trigger for instant wake detection + val armed = significantMotionTrigger.arm() + settingsRepository.addLog( + "Engine", + "Зафиксировано засыпание ($source). Датчик движения взведен: $armed", + isImportant = true + ) + } else { + // When waking up, disarm trigger + significantMotionTrigger.disarm() + settingsRepository.addLog( + "Engine", + "Зафиксировано пробуждение ($source).", + isImportant = true + ) + } + + updateNotification() + + // Acquire temporary WakeLock to ensure HTTP request reaches HAC on OnePlus + val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager + val wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "Avtex:HacTransmissionWakeLock") + wakeLock.acquire(15000L) // max 15 seconds + + try { + val settings = settingsRepository.getSettings() + val result = hacClient.sendSleepEvent(settings, isAsleep, source, confidence) + result.onSuccess { msg -> + Log.d(TAG, "HAC send success: $msg") + settingsRepository.addLog("HAC", "Отправлено в Home Assistant: $msg") + settingsRepository.updateHacStatus("Отправлено в ${formatTime(stateChangeTimestamp)}: $msg") + }.onFailure { err -> + Log.e(TAG, "HAC send error", err) + settingsRepository.addLog("HAC", "Ошибка отправки в Home Assistant: ${err.localizedMessage}", isImportant = true) + settingsRepository.updateHacStatus("Ошибка: ${err.localizedMessage}") + } + } finally { + if (wakeLock.isHeld) { + wakeLock.release() + } + } + } + } + + private fun subscribeToSleepApi() { + if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACTIVITY_RECOGNITION) != PackageManager.PERMISSION_GRANTED) { + Log.w(TAG, "ACTIVITY_RECOGNITION permission not granted for Sleep API") + settingsRepository.addLog("SleepAPI", "Нет разрешения на активность. Используются аппаратные датчики движения.") + return + } + + try { + val intent = Intent(this, SleepReceiver::class.java).apply { + action = SleepReceiver.ACTION_SLEEP_API_EVENT + } + val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE + } else { + PendingIntent.FLAG_UPDATE_CURRENT + } + sleepApiPendingIntent = PendingIntent.getBroadcast(this, REQUEST_CODE_SLEEP_API, intent, flags) + + val request = SleepSegmentRequest.getDefaultSleepSegmentRequest() + val task = ActivityRecognition.getClient(this).requestSleepSegmentUpdates(sleepApiPendingIntent!!, request) + task.addOnSuccessListener { + Log.d(TAG, "Successfully subscribed to Google Sleep API") + settingsRepository.addLog("SleepAPI", "Google Sleep API успешно подключен") + } + task.addOnFailureListener { e -> + Log.e(TAG, "Failed to subscribe to Google Sleep API", e) + settingsRepository.addLog("SleepAPI", "Не удалось подключить Google Sleep API: ${e.localizedMessage}") + } + } catch (e: Exception) { + Log.e(TAG, "Exception subscribing to Sleep API", e) + } + } + + private fun unsubscribeFromSleepApi() { + sleepApiPendingIntent?.let { pi -> + try { + ActivityRecognition.getClient(this).removeSleepSegmentUpdates(pi) + Log.d(TAG, "Unsubscribed from Sleep API") + } catch (e: Exception) { + Log.e(TAG, "Error unsubscribing from Sleep API", e) + } + } + } + + private fun buildNotification(title: String, text: String): Notification { + val openAppIntent = Intent(this, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP + } + val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + } else { + PendingIntent.FLAG_UPDATE_CURRENT + } + val pendingIntent = PendingIntent.getActivity(this, 0, openAppIntent, flags) + + return NotificationCompat.Builder(this, AvtexApplication.CHANNEL_ID_TRACKING) + .setContentTitle(title) + .setContentText(text) + .setSmallIcon(android.R.drawable.ic_lock_idle_charging) + .setContentIntent(pendingIntent) + .setOngoing(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .build() + } + + private fun updateNotification() { + val stateText = if (currentState == SleepState.ASLEEP) "Спит 🌙" else "Бодрствует ☀️" + val timeFormatted = formatTime(stateChangeTimestamp) + val notification = buildNotification( + title = "Avtex: $stateText", + text = "Состояние с $timeFormatted" + ) + val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager + notificationManager.notify(NOTIFICATION_ID, notification) + } + + private fun formatTime(millis: Long): String { + return SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(millis)) + } + + override fun onDestroy() { + Log.d(TAG, "SleepTrackingService onDestroy") + screenReceiver?.let { + unregisterReceiver(it) + screenReceiver = null + } + significantMotionTrigger.disarm() + unsubscribeFromSleepApi() + serviceScope.cancel() + super.onDestroy() + } + + override fun onBind(intent: Intent?): IBinder? = null + + companion object { + private const val TAG = "SleepTrackingService" + private const val NOTIFICATION_ID = 1001 + private const val REQUEST_CODE_SLEEP_API = 2001 + + const val ACTION_START_TRACKING = "com.example.avtex.ACTION_START_TRACKING" + const val ACTION_STOP_TRACKING = "com.example.avtex.ACTION_STOP_TRACKING" + const val ACTION_MANUAL_TOGGLE = "com.example.avtex.ACTION_MANUAL_TOGGLE" + const val ACTION_TEST_HAC = "com.example.avtex.ACTION_TEST_HAC" + + @Volatile + private var instance: SleepTrackingService? = null + + fun onSleepClassifyReceived(context: Context, confidence: Int, motion: Int, light: Int) { + // If confidence is high and device is in stationary motion + val repo = AvtexApplication.instance.settingsRepository + CoroutineScope(Dispatchers.Default).launch { + val settings = repo.getSettings() + if (settings.isTrackingActive && settings.currentState == "AWAKE") { + if (confidence >= settings.confidenceThreshold && motion <= 2) { + Log.d(TAG, "SleepClassify indicates sleep: confidence=$confidence, motion=$motion") + // Send intent to service to transition state + val intent = Intent(context, SleepTrackingService::class.java).apply { + action = ACTION_MANUAL_TOGGLE // or specific intent + } + // Only trigger if actually confident + } + } + } + } + + fun onSleepSegmentReceived(context: Context, start: Long, end: Long, status: Int) { + Log.d(TAG, "SleepSegment completed: start=$start, end=$end, status=$status") + } + } +} diff --git a/app/src/main/java/com/example/avtex/hac/HacClient.kt b/app/src/main/java/com/example/avtex/hac/HacClient.kt new file mode 100644 index 0000000..8abb264 --- /dev/null +++ b/app/src/main/java/com/example/avtex/hac/HacClient.kt @@ -0,0 +1,168 @@ +package com.example.avtex.hac + +import android.content.Context +import android.content.Intent +import com.example.avtex.data.AppSettings +import com.example.avtex.data.HacMode +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONObject +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.concurrent.TimeUnit + +class HacClient(private val context: Context) { + + private val httpClient = OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(10, TimeUnit.SECONDS) + .writeTimeout(10, TimeUnit.SECONDS) + .build() + + private val jsonMediaType = "application/json; charset=utf-8".toMediaType() + + suspend fun sendSleepEvent( + settings: AppSettings, + isAsleep: Boolean, + source: String, + confidence: Int = 100 + ): Result = withContext(Dispatchers.IO) { + val stateStr = if (isAsleep) "asleep" else "awake" + val eventStr = if (isAsleep) "sleep_started" else "sleep_ended" + val timestamp = System.currentTimeMillis() + + // 1. Send Local Broadcast if enabled + if (settings.broadcastIntents) { + val broadcastIntent = Intent(ACTION_SLEEP_STATE_CHANGED).apply { + putExtra("state", stateStr) + putExtra("event", eventStr) + putExtra("is_asleep", isAsleep) + putExtra("source", source) + putExtra("confidence", confidence) + putExtra("timestamp", timestamp) + setPackage(context.packageName) + } + context.sendBroadcast(broadcastIntent) + } + + // 2. Send HTTP Request based on configured mode + when (settings.hacMode) { + HacMode.WEBHOOK -> { + if (settings.hacWebhookUrl.isBlank()) { + return@withContext Result.failure(IllegalArgumentException("Webhook URL не задан в настройках")) + } + sendWebhook( + url = settings.hacWebhookUrl.trim(), + event = eventStr, + state = stateStr, + confidence = confidence, + source = source, + timestamp = timestamp + ) + } + HacMode.REST -> { + if (settings.hacRestUrl.isBlank()) { + return@withContext Result.failure(IllegalArgumentException("REST API URL не задан в настройках")) + } + sendRest( + url = settings.hacRestUrl.trim(), + token = settings.hacToken.trim(), + isAsleep = isAsleep, + confidence = confidence, + source = source, + timestamp = timestamp + ) + } + } + } + + private fun sendWebhook( + url: String, + event: String, + state: String, + confidence: Int, + source: String, + timestamp: Long + ): Result { + return try { + val json = JSONObject().apply { + put("event", event) + put("state", state) + put("confidence", confidence) + put("source", source) + put("timestamp", timestamp) + put("time_iso", SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX", Locale.US).format(Date(timestamp))) + } + + val body = json.toString().toRequestBody(jsonMediaType) + val request = Request.Builder() + .url(url) + .post(body) + .build() + + val response = httpClient.newCall(request).execute() + response.use { + if (it.isSuccessful) { + Result.success("Webhook OK (${it.code})") + } else { + Result.failure(Exception("Ошибка сервера: HTTP ${it.code} ${it.message}")) + } + } + } catch (e: Exception) { + Result.failure(e) + } + } + + private fun sendRest( + url: String, + token: String, + isAsleep: Boolean, + confidence: Int, + source: String, + timestamp: Long + ): Result { + return try { + val binaryState = if (isAsleep) "on" else "off" + val attributes = JSONObject().apply { + put("device_class", "occupancy") + put("friendly_name", "Сон пользователя (Avtex)") + put("confidence", confidence) + put("source", source) + put("last_updated", SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX", Locale.US).format(Date(timestamp))) + } + val payload = JSONObject().apply { + put("state", binaryState) + put("attributes", attributes) + } + + val body = payload.toString().toRequestBody(jsonMediaType) + val requestBuilder = Request.Builder() + .url(url) + .post(body) + + if (token.isNotBlank()) { + requestBuilder.header("Authorization", "Bearer $token") + } + + val response = httpClient.newCall(requestBuilder.build()).execute() + response.use { + if (it.isSuccessful) { + Result.success("REST OK (${it.code})") + } else { + Result.failure(Exception("Ошибка REST API: HTTP ${it.code} ${it.message}")) + } + } + } catch (e: Exception) { + Result.failure(e) + } + } + + companion object { + const val ACTION_SLEEP_STATE_CHANGED = "com.example.avtex.SLEEP_STATE_CHANGED" + } +} diff --git a/app/src/main/java/com/example/avtex/theme/Color.kt b/app/src/main/java/com/example/avtex/theme/Color.kt new file mode 100644 index 0000000..e3caf33 --- /dev/null +++ b/app/src/main/java/com/example/avtex/theme/Color.kt @@ -0,0 +1,34 @@ +package com.example.avtex.theme + +import androidx.compose.ui.graphics.Color + +// Obsidian & Midnight Backgrounds +val ObsidianBg = Color(0xFF090A0F) +val ObsidianSurface = Color(0xFF11131E) +val ObsidianSurfaceElevated = Color(0xFF181B2A) +val ObsidianSurfaceHigh = Color(0xFF21253A) +val GlassBorder = Color(0x1FFFFFFF) + +// Sleep Neon Accents (Night / Sleep) +val NeonViolet = Color(0xFFA855F7) +val NeonPurple = Color(0xFF8B5CF6) +val ElectricIndigo = Color(0xFF6366F1) +val DeepIndigo = Color(0xFF1E1B4B) +val LavenderGlow = Color(0xFFE9D5FF) + +// Wake Solar Accents (Day / Awake) +val SolarAmber = Color(0xFFF59E0B) +val SunsetOrange = Color(0xFFF97316) +val MorningGold = Color(0xFFFCD34D) +val DeepSolar = Color(0xFF451A03) + +// Status & Indicators +val EmeraldMint = Color(0xFF10B981) +val EmeraldGlow = Color(0xFF34D399) +val CoralRed = Color(0xFFEF4444) +val CyanAccent = Color(0xFF06B6D4) + +// Typography +val TextPrimary = Color(0xFFF8FAFC) +val TextSecondary = Color(0xFF94A3B8) +val TextMuted = Color(0xFF64748B) diff --git a/app/src/main/java/com/example/avtex/theme/Theme.kt b/app/src/main/java/com/example/avtex/theme/Theme.kt new file mode 100644 index 0000000..64d8c3f --- /dev/null +++ b/app/src/main/java/com/example/avtex/theme/Theme.kt @@ -0,0 +1,38 @@ +package com.example.avtex.theme + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +private val AvtexDarkColorScheme = darkColorScheme( + primary = NeonViolet, + onPrimary = Color.White, + primaryContainer = DeepIndigo, + onPrimaryContainer = LavenderGlow, + secondary = SolarAmber, + onSecondary = Color.Black, + secondaryContainer = DeepSolar, + onSecondaryContainer = MorningGold, + tertiary = CyanAccent, + background = ObsidianBg, + onBackground = TextPrimary, + surface = ObsidianSurface, + onSurface = TextPrimary, + surfaceVariant = ObsidianSurfaceElevated, + onSurfaceVariant = TextSecondary, + surfaceContainerHighest = ObsidianSurfaceHigh, + outline = TextMuted, + outlineVariant = GlassBorder +) + +@Composable +fun AvtexTheme( + content: @Composable () -> Unit +) { + MaterialTheme( + colorScheme = AvtexDarkColorScheme, + typography = Typography, + content = content + ) +} diff --git a/app/src/main/java/com/example/avtex/theme/Type.kt b/app/src/main/java/com/example/avtex/theme/Type.kt new file mode 100644 index 0000000..86ae5bb --- /dev/null +++ b/app/src/main/java/com/example/avtex/theme/Type.kt @@ -0,0 +1,36 @@ +package com.example.avtex.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = + Typography( + bodyLarge = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp, + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ + ) diff --git a/app/src/main/java/com/example/avtex/ui/DashboardScreen.kt b/app/src/main/java/com/example/avtex/ui/DashboardScreen.kt new file mode 100644 index 0000000..099ec5c --- /dev/null +++ b/app/src/main/java/com/example/avtex/ui/DashboardScreen.kt @@ -0,0 +1,728 @@ +package com.example.avtex.ui + +import android.Manifest +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.Bedtime +import androidx.compose.material.icons.filled.Bolt +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Hub +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Psychology +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Sensors +import androidx.compose.material.icons.filled.Stop +import androidx.compose.material.icons.filled.WbSunny +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.content.ContextCompat +import com.example.avtex.AvtexApplication +import com.example.avtex.R +import com.example.avtex.data.AppSettings +import com.example.avtex.data.LogEntry +import com.example.avtex.engine.SleepTrackingService +import com.example.avtex.theme.CyanAccent +import com.example.avtex.theme.DeepIndigo +import com.example.avtex.theme.DeepSolar +import com.example.avtex.theme.ElectricIndigo +import com.example.avtex.theme.EmeraldGlow +import com.example.avtex.theme.EmeraldMint +import com.example.avtex.theme.GlassBorder +import com.example.avtex.theme.LavenderGlow +import com.example.avtex.theme.MorningGold +import com.example.avtex.theme.NeonPurple +import com.example.avtex.theme.NeonViolet +import com.example.avtex.theme.ObsidianBg +import com.example.avtex.theme.ObsidianSurface +import com.example.avtex.theme.ObsidianSurfaceElevated +import com.example.avtex.theme.ObsidianSurfaceHigh +import com.example.avtex.theme.SolarAmber +import com.example.avtex.theme.SunsetOrange +import com.example.avtex.theme.TextMuted +import com.example.avtex.theme.TextPrimary +import com.example.avtex.theme.TextSecondary +import java.util.concurrent.TimeUnit + +@Composable +fun DashboardScreen(modifier: Modifier = Modifier) { + val context = LocalContext.current + val repository = remember { AvtexApplication.instance.settingsRepository } + val settings by repository.settingsFlow.collectAsState(initial = AppSettings()) + val logs by repository.logsFlow.collectAsState(initial = emptyList()) + + val isAsleep = settings.currentState == "ASLEEP" + + // Permission launcher for Notifications and Activity Recognition + val permissionsLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestMultiplePermissions() + ) { permissions -> + val notifGranted = permissions[Manifest.permission.POST_NOTIFICATIONS] ?: true + val actGranted = permissions[Manifest.permission.ACTIVITY_RECOGNITION] ?: true + if (notifGranted && actGranted) { + startTrackingService(context) + } + } + + fun onToggleTracking(enable: Boolean) { + if (enable) { + val neededPermissions = mutableListOf() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { + neededPermissions.add(Manifest.permission.POST_NOTIFICATIONS) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && + ContextCompat.checkSelfPermission(context, Manifest.permission.ACTIVITY_RECOGNITION) != PackageManager.PERMISSION_GRANTED) { + neededPermissions.add(Manifest.permission.ACTIVITY_RECOGNITION) + } + + if (neededPermissions.isNotEmpty()) { + permissionsLauncher.launch(neededPermissions.toTypedArray()) + } else { + startTrackingService(context) + } + } else { + stopTrackingService(context) + } + } + + LazyColumn( + modifier = modifier + .fillMaxSize() + .background(ObsidianBg), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // 1. Futuristic Brand Header + item { + BrandHeader(isTrackingActive = settings.isTrackingActive) + } + + // 2. Dynamic Ambient Hero State Card + item { + DynamicHeroCard( + isAsleep = isAsleep, + isTrackingActive = settings.isTrackingActive, + lastStateChangeMillis = settings.lastStateChangeMillis + ) + } + + // 3. Engine Metrics Grid + item { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + MetricGlassCard( + modifier = Modifier.weight(1f), + icon = Icons.Default.Sensors, + title = "Датчик движения", + value = if (isAsleep) "Взведен ⚡" else "Активен", + subtitle = "Significant Motion", + accentColor = if (isAsleep) NeonViolet else SolarAmber + ) + MetricGlassCard( + modifier = Modifier.weight(1f), + icon = Icons.Default.Psychology, + title = "Google Sleep API", + value = "${settings.confidenceThreshold}%", + subtitle = "Порог нейросети", + accentColor = CyanAccent + ) + MetricGlassCard( + modifier = Modifier.weight(1f), + icon = Icons.Default.Hub, + title = "Home Assistant", + value = settings.hacMode.name, + subtitle = "HAC протокол", + accentColor = EmeraldMint + ) + } + } + + // 4. Master Monitoring Glass Card + item { + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(20.dp)) + .background(ObsidianSurfaceElevated) + .border(1.dp, GlassBorder, RoundedCornerShape(20.dp)) + .padding(18.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + .background(if (settings.isTrackingActive) EmeraldGlow else TextMuted) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Фоновый мониторинг сна", + fontWeight = FontWeight.Bold, + fontSize = 16.sp, + color = TextPrimary + ) + } + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = if (settings.isTrackingActive) "Служба активна в фоне • нулевой расход батареи" + else "Служба остановлена • нажмите для запуска", + fontSize = 12.sp, + color = if (settings.isTrackingActive) EmeraldMint else TextMuted + ) + } + Switch( + checked = settings.isTrackingActive, + onCheckedChange = { onToggleTracking(it) }, + colors = SwitchDefaults.colors( + checkedThumbColor = Color.White, + checkedTrackColor = NeonViolet, + uncheckedThumbColor = TextMuted, + uncheckedTrackColor = ObsidianSurfaceHigh + ) + ) + } + } + } + + // 5. Quick Action Control Buttons + item { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + FilledTonalButton( + onClick = { + val intent = Intent(context, SleepTrackingService::class.java).apply { + action = SleepTrackingService.ACTION_MANUAL_TOGGLE + } + context.startService(intent) + }, + modifier = Modifier + .weight(1f) + .height(48.dp), + shape = RoundedCornerShape(14.dp), + colors = ButtonDefaults.filledTonalButtonColors( + containerColor = ObsidianSurfaceElevated, + contentColor = TextPrimary + ) + ) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = if (isAsleep) SolarAmber else NeonViolet + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = "Переключить", fontSize = 13.sp, fontWeight = FontWeight.Medium) + } + + Button( + onClick = { + val intent = Intent(context, SleepTrackingService::class.java).apply { + action = SleepTrackingService.ACTION_TEST_HAC + } + context.startService(intent) + }, + modifier = Modifier + .weight(1f) + .height(48.dp), + shape = RoundedCornerShape(14.dp), + colors = ButtonDefaults.buttonColors( + containerColor = NeonViolet, + contentColor = Color.White + ) + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Send, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = "Тест в HAC", fontSize = 13.sp, fontWeight = FontWeight.SemiBold) + } + } + } + + // 6. HAC Live Connection Banner + item { + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(ObsidianSurface) + .border(1.dp, GlassBorder, RoundedCornerShape(16.dp)) + .padding(14.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .clip(CircleShape) + .background(EmeraldMint.copy(alpha = 0.15f)) + .padding(8.dp) + ) { + Icon( + imageVector = Icons.Default.Bolt, + contentDescription = null, + tint = EmeraldMint, + modifier = Modifier.size(18.dp) + ) + } + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Home Assistant Status", + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + color = TextSecondary + ) + Text( + text = settings.lastHacStatus, + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + color = TextPrimary + ) + } + } + } + } + + // 7. Audit Log Header + item { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "ТЕЛЕМЕТРИЯ И СОБЫТИЯ", + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 1.2.sp, + color = TextMuted + ) + Text( + text = "${logs.size} событий", + fontSize = 11.sp, + color = TextMuted + ) + } + } + + if (logs.isEmpty()) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .background(ObsidianSurface) + .padding(24.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "Событий пока нет. Включите мониторинг для сбора данных.", + fontSize = 13.sp, + color = TextMuted + ) + } + } + } else { + items(logs) { log -> + FuturisticLogRow(log) + } + } + } +} + +@Composable +private fun BrandHeader(isTrackingActive: Boolean) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Image( + painter = painterResource(id = R.drawable.app_logo), + contentDescription = "Avtex Logo", + modifier = Modifier + .size(42.dp) + .clip(RoundedCornerShape(12.dp)) + .border(1.dp, GlassBorder, RoundedCornerShape(12.dp)) + ) + Spacer(modifier = Modifier.width(12.dp)) + Column { + Text( + text = "AVTEX", + fontSize = 20.sp, + fontWeight = FontWeight.Black, + letterSpacing = 2.sp, + color = TextPrimary + ) + Text( + text = "SMART SLEEP ENGINE", + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 1.5.sp, + color = NeonViolet + ) + } + } + + Box( + modifier = Modifier + .clip(RoundedCornerShape(20.dp)) + .background(if (isTrackingActive) EmeraldMint.copy(alpha = 0.15f) else ObsidianSurfaceElevated) + .border( + 1.dp, + if (isTrackingActive) EmeraldMint.copy(alpha = 0.3f) else GlassBorder, + RoundedCornerShape(20.dp) + ) + .padding(horizontal = 12.dp, vertical = 6.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(6.dp) + .clip(CircleShape) + .background(if (isTrackingActive) EmeraldGlow else TextMuted) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = if (isTrackingActive) "ONLINE" else "STANDBY", + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 1.sp, + color = if (isTrackingActive) EmeraldMint else TextMuted + ) + } + } + } +} + +@Composable +private fun DynamicHeroCard( + isAsleep: Boolean, + isTrackingActive: Boolean, + lastStateChangeMillis: Long +) { + val durationText = remember(lastStateChangeMillis) { + val diff = System.currentTimeMillis() - lastStateChangeMillis + val hours = TimeUnit.MILLISECONDS.toHours(diff) + val minutes = TimeUnit.MILLISECONDS.toMinutes(diff) % 60 + if (hours > 0) "$hours ч $minutes мин" else "$minutes мин" + } + + // Breathing glow animation + val infiniteTransition = rememberInfiniteTransition(label = "pulse") + val glowScale by infiniteTransition.animateFloat( + initialValue = 0.99f, + targetValue = 1.01f, + animationSpec = infiniteRepeatable( + animation = tween(2500), + repeatMode = RepeatMode.Reverse + ), + label = "glowScale" + ) + + val gradientColors = if (isAsleep) { + listOf( + Color(0xFF0F0B24), + Color(0xFF1E1446), + Color(0xFF2A1560), + Color(0xFF4C1D95) + ) + } else { + listOf( + Color(0xFF2E1005), + Color(0xFF4A1A05), + Color(0xFF7C2D12), + Color(0xFFB45309) + ) + } + + val accentBorderColor = if (isAsleep) NeonViolet.copy(alpha = 0.4f) else SolarAmber.copy(alpha = 0.4f) + + Card( + modifier = Modifier + .fillMaxWidth() + .height(210.dp) + .scale(glowScale) + .border(1.dp, accentBorderColor, RoundedCornerShape(26.dp)), + shape = RoundedCornerShape(26.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Brush.linearGradient(gradientColors)) + .padding(22.dp) + ) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.SpaceBetween + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .clip(CircleShape) + .background(Color.White.copy(alpha = 0.12f)) + .border(1.dp, Color.White.copy(alpha = 0.2f), CircleShape) + .padding(14.dp) + ) { + Icon( + imageVector = if (isAsleep) Icons.Default.Bedtime else Icons.Default.WbSunny, + contentDescription = null, + tint = if (isAsleep) LavenderGlow else MorningGold, + modifier = Modifier.size(34.dp) + ) + } + + Box( + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .background( + if (isTrackingActive) EmeraldMint.copy(alpha = 0.25f) + else Color.White.copy(alpha = 0.1f) + ) + .border( + 1.dp, + if (isTrackingActive) EmeraldMint.copy(alpha = 0.5f) + else Color.White.copy(alpha = 0.15f), + RoundedCornerShape(12.dp) + ) + .padding(horizontal = 12.dp, vertical = 6.dp) + ) { + Text( + text = if (isTrackingActive) "МОНИТОРИНГ АКТИВЕН" else "ПАУЗА", + color = Color.White, + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.8.sp + ) + } + } + + Column { + Text( + text = if (isAsleep) "Спит" else "Бодрствует", + color = Color.White, + fontSize = 36.sp, + fontWeight = FontWeight.Black, + letterSpacing = (-0.5).sp + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = if (isAsleep) "Датчик движения взведен • $durationText в покое" + else "Активность зафиксирована • $durationText с пробуждения", + color = Color.White.copy(alpha = 0.85f), + fontSize = 13.sp, + fontWeight = FontWeight.Medium + ) + } + } + } + } +} + +@Composable +private fun MetricGlassCard( + modifier: Modifier = Modifier, + icon: ImageVector, + title: String, + value: String, + subtitle: String, + accentColor: Color +) { + Box( + modifier = modifier + .clip(RoundedCornerShape(16.dp)) + .background(ObsidianSurfaceElevated) + .border(1.dp, GlassBorder, RoundedCornerShape(16.dp)) + .padding(12.dp) + ) { + Column(verticalArrangement = Arrangement.SpaceBetween) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(accentColor.copy(alpha = 0.15f)) + .padding(6.dp) + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = accentColor, + modifier = Modifier.size(16.dp) + ) + } + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = value, + fontSize = 13.sp, + fontWeight = FontWeight.Bold, + color = TextPrimary + ) + Text( + text = title, + fontSize = 10.sp, + fontWeight = FontWeight.Medium, + color = TextMuted, + lineHeight = 12.sp + ) + } + } +} + +@Composable +private fun FuturisticLogRow(log: LogEntry) { + val tagColor = when (log.tag) { + "Engine" -> NeonViolet + "SleepAPI" -> CyanAccent + "SleepSegment" -> ElectricIndigo + "SignificantMotion" -> SolarAmber + "HAC", "HAC_TEST" -> EmeraldMint + else -> TextSecondary + } + + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(if (log.isImportant) ObsidianSurfaceHigh else ObsidianSurface) + .border( + 1.dp, + if (log.isImportant) tagColor.copy(alpha = 0.4f) else GlassBorder, + RoundedCornerShape(12.dp) + ) + .padding(horizontal = 14.dp, vertical = 10.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = log.formattedTime, + fontSize = 11.sp, + color = TextMuted, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.SemiBold + ) + Spacer(modifier = Modifier.width(10.dp)) + Box( + modifier = Modifier + .clip(RoundedCornerShape(6.dp)) + .background(tagColor.copy(alpha = 0.15f)) + .border(1.dp, tagColor.copy(alpha = 0.3f), RoundedCornerShape(6.dp)) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) { + Text( + text = log.tag, + fontSize = 10.sp, + color = tagColor, + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace + ) + } + Spacer(modifier = Modifier.width(10.dp)) + Text( + text = log.message, + fontSize = 12.sp, + color = if (log.isImportant) TextPrimary else TextSecondary, + fontWeight = if (log.isImportant) FontWeight.Medium else FontWeight.Normal, + modifier = Modifier.weight(1f) + ) + } + } +} + +private fun startTrackingService(context: Context) { + val intent = Intent(context, SleepTrackingService::class.java).apply { + action = SleepTrackingService.ACTION_START_TRACKING + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent) + } else { + context.startService(intent) + } +} + +private fun stopTrackingService(context: Context) { + val intent = Intent(context, SleepTrackingService::class.java).apply { + action = SleepTrackingService.ACTION_STOP_TRACKING + } + context.startService(intent) +} diff --git a/app/src/main/java/com/example/avtex/ui/OnePlusGuideScreen.kt b/app/src/main/java/com/example/avtex/ui/OnePlusGuideScreen.kt new file mode 100644 index 0000000..d0f9db5 --- /dev/null +++ b/app/src/main/java/com/example/avtex/ui/OnePlusGuideScreen.kt @@ -0,0 +1,329 @@ +package com.example.avtex.ui + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.PowerManager +import android.provider.Settings +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.BatteryAlert +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.PowerSettingsNew +import androidx.compose.material.icons.filled.Shield +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.example.avtex.theme.CyanAccent +import com.example.avtex.theme.EmeraldGlow +import com.example.avtex.theme.EmeraldMint +import com.example.avtex.theme.GlassBorder +import com.example.avtex.theme.NeonViolet +import com.example.avtex.theme.ObsidianBg +import com.example.avtex.theme.ObsidianSurface +import com.example.avtex.theme.ObsidianSurfaceElevated +import com.example.avtex.theme.SolarAmber +import com.example.avtex.theme.TextMuted +import com.example.avtex.theme.TextPrimary +import com.example.avtex.theme.TextSecondary + +@Composable +fun OnePlusGuideScreen(modifier: Modifier = Modifier) { + val context = LocalContext.current + val powerManager = remember { context.getSystemService(Context.POWER_SERVICE) as PowerManager } + val isIgnoringBatteryOptimizations = remember { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + powerManager.isIgnoringBatteryOptimizations(context.packageName) + } else true + } + + LazyColumn( + modifier = modifier + .fillMaxSize() + .background(ObsidianBg), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + item { + Column(modifier = Modifier.padding(vertical = 4.dp)) { + Text( + text = "Оптимизация для OnePlus", + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + color = TextPrimary + ) + Text( + text = "Настройка OxygenOS / ColorOS для работы в фоновом режиме", + fontSize = 13.sp, + color = TextMuted + ) + } + } + + // 1. Battery Optimization Step + item { + OnePlusStepCard( + step = "01", + icon = Icons.Default.BatteryAlert, + title = "Отключить оптимизацию батареи", + status = if (isIgnoringBatteryOptimizations) "АКТИВНО" else "ТРЕБУЕТСЯ", + statusColor = if (isIgnoringBatteryOptimizations) EmeraldMint else SolarAmber, + description = "OxygenOS замораживает фоновые службы при выключении экрана. Для бесперебойной работы установите режим «Не оптимизировать» (Unrestricted).", + actionLabel = "Открыть настройки батареи", + onAction = { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + try { + val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply { + data = Uri.parse("package:${context.packageName}") + } + context.startActivity(intent) + } catch (_: Exception) { + val intent = Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS) + context.startActivity(intent) + } + } + } + ) + } + + // 2. Auto-Launch & Background Activity Step + item { + OnePlusStepCard( + step = "02", + icon = Icons.Default.PowerSettingsNew, + title = "Автозапуск и фоновая активность", + status = "РЕКОМЕНДУЕТСЯ", + statusColor = CyanAccent, + description = "В свойствах приложения перейдите в «Использование батареи» и включите пункты:\n• «Разрешить работу в фоновом режиме»\n• «Разрешить автозапуск» (Auto-launch).", + actionLabel = "Свойства приложения в системе", + onAction = { + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.parse("package:${context.packageName}") + } + context.startActivity(intent) + } + ) + } + + // 3. Lock in Recents Step + item { + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(22.dp)) + .background(ObsidianSurfaceElevated) + .border(1.dp, GlassBorder, RoundedCornerShape(22.dp)) + .padding(18.dp) + ) { + Column { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(10.dp)) + .background(NeonViolet.copy(alpha = 0.15f)) + .padding(8.dp) + ) { + Icon( + imageVector = Icons.Default.Lock, + contentDescription = null, + tint = NeonViolet, + modifier = Modifier.size(20.dp) + ) + } + Spacer(modifier = Modifier.width(10.dp)) + Text( + text = "Закрепить в недавних", + fontWeight = FontWeight.Bold, + fontSize = 16.sp, + color = TextPrimary + ) + } + Text( + text = "ШАГ 03", + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace, + color = NeonViolet + ) + } + Spacer(modifier = Modifier.height(10.dp)) + Text( + text = "Откройте экран недавних приложений (свайп снизу вверх с удержанием), нажмите три точки над карточкой Avtex и выберите «Закрепить» (иконка замочка 🔒). Это предотвратит выгрузку приложения при очистке памяти.", + fontSize = 13.sp, + color = TextSecondary, + lineHeight = 18.sp + ) + } + } + } + + // 4. Hardware Sensor Hub Assurance + item { + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(20.dp)) + .background(ObsidianSurface) + .border(1.dp, GlassBorder, RoundedCornerShape(20.dp)) + .padding(16.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .clip(CircleShape) + .background(EmeraldMint.copy(alpha = 0.15f)) + .padding(10.dp) + ) { + Icon( + imageVector = Icons.Default.Shield, + contentDescription = null, + tint = EmeraldMint, + modifier = Modifier.size(22.dp) + ) + } + Spacer(modifier = Modifier.width(14.dp)) + Column { + Text( + text = "Аппаратная энергоэффективность", + fontWeight = FontWeight.Bold, + fontSize = 14.sp, + color = TextPrimary + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = "Датчик Significant Motion работает на отдельном микроконтроллере (Sensor Hub). Батарея OnePlus не разряжается даже при круглосуточном отслеживании.", + fontSize = 12.sp, + color = TextMuted, + lineHeight = 16.sp + ) + } + } + } + } + } +} + +@Composable +private fun OnePlusStepCard( + step: String, + icon: ImageVector, + title: String, + status: String, + statusColor: Color, + description: String, + actionLabel: String, + onAction: () -> Unit +) { + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(22.dp)) + .background(ObsidianSurfaceElevated) + .border(1.dp, GlassBorder, RoundedCornerShape(22.dp)) + .padding(18.dp) + ) { + Column { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row(modifier = Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(10.dp)) + .background(statusColor.copy(alpha = 0.15f)) + .padding(8.dp) + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = statusColor, + modifier = Modifier.size(20.dp) + ) + } + Spacer(modifier = Modifier.width(10.dp)) + Text( + text = title, + fontWeight = FontWeight.Bold, + fontSize = 15.sp, + color = TextPrimary + ) + } + Box( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(statusColor.copy(alpha = 0.15f)) + .border(1.dp, statusColor.copy(alpha = 0.3f), RoundedCornerShape(8.dp)) + .padding(horizontal = 8.dp, vertical = 3.dp) + ) { + Text( + text = status, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace, + color = statusColor + ) + } + } + + Spacer(modifier = Modifier.height(10.dp)) + Text( + text = description, + fontSize = 13.sp, + color = TextSecondary, + lineHeight = 18.sp + ) + Spacer(modifier = Modifier.height(14.dp)) + OutlinedButton( + onClick = onAction, + shape = RoundedCornerShape(12.dp), + modifier = Modifier.fillMaxWidth(), + border = androidx.compose.foundation.BorderStroke(1.dp, GlassBorder), + colors = ButtonDefaults.outlinedButtonColors( + containerColor = ObsidianSurface, + contentColor = TextPrimary + ) + ) { + Text(text = actionLabel, fontSize = 13.sp, fontWeight = FontWeight.Medium) + } + } + } +} diff --git a/app/src/main/java/com/example/avtex/ui/SettingsScreen.kt b/app/src/main/java/com/example/avtex/ui/SettingsScreen.kt new file mode 100644 index 0000000..e80a709 --- /dev/null +++ b/app/src/main/java/com/example/avtex/ui/SettingsScreen.kt @@ -0,0 +1,469 @@ +package com.example.avtex.ui + +import androidx.compose.animation.AnimatedVisibility +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.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Dns +import androidx.compose.material.icons.filled.Link +import androidx.compose.material.icons.filled.NotificationsActive +import androidx.compose.material.icons.filled.Save +import androidx.compose.material.icons.filled.Tune +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.example.avtex.AvtexApplication +import com.example.avtex.data.AppSettings +import com.example.avtex.data.HacMode +import com.example.avtex.theme.CyanAccent +import com.example.avtex.theme.EmeraldMint +import com.example.avtex.theme.GlassBorder +import com.example.avtex.theme.NeonViolet +import com.example.avtex.theme.ObsidianBg +import com.example.avtex.theme.ObsidianSurface +import com.example.avtex.theme.ObsidianSurfaceElevated +import com.example.avtex.theme.ObsidianSurfaceHigh +import com.example.avtex.theme.TextMuted +import com.example.avtex.theme.TextPrimary +import com.example.avtex.theme.TextSecondary +import kotlinx.coroutines.launch +import kotlin.math.roundToInt + +@Composable +fun SettingsScreen(modifier: Modifier = Modifier) { + val repository = remember { AvtexApplication.instance.settingsRepository } + val settings by repository.settingsFlow.collectAsState(initial = AppSettings()) + val coroutineScope = rememberCoroutineScope() + + var selectedMode by remember(settings.hacMode) { mutableStateOf(settings.hacMode) } + var webhookUrl by remember(settings.hacWebhookUrl) { mutableStateOf(settings.hacWebhookUrl) } + var restUrl by remember(settings.hacRestUrl) { mutableStateOf(settings.hacRestUrl) } + var token by remember(settings.hacToken) { mutableStateOf(settings.hacToken) } + var inactivityMinutes by remember(settings.inactivityMinutes) { mutableFloatStateOf(settings.inactivityMinutes.toFloat()) } + var confidenceThreshold by remember(settings.confidenceThreshold) { mutableFloatStateOf(settings.confidenceThreshold.toFloat()) } + var broadcastIntents by remember(settings.broadcastIntents) { mutableStateOf(settings.broadcastIntents) } + + var isSaved by remember { mutableStateOf(false) } + + LazyColumn( + modifier = modifier + .fillMaxSize() + .background(ObsidianBg), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(18.dp) + ) { + // 1. Header + item { + Column(modifier = Modifier.padding(vertical = 4.dp)) { + Text( + text = "Конфигурация", + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + color = TextPrimary + ) + Text( + text = "Интеграция с Home Assistant и калибровка сенсоров", + fontSize = 13.sp, + color = TextMuted + ) + } + } + + // 2. HAC Connection Glass Card + item { + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(22.dp)) + .background(ObsidianSurfaceElevated) + .border(1.dp, GlassBorder, RoundedCornerShape(22.dp)) + .padding(18.dp) + ) { + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(10.dp)) + .background(NeonViolet.copy(alpha = 0.15f)) + .padding(8.dp) + ) { + Icon( + imageVector = Icons.Default.Dns, + contentDescription = null, + tint = NeonViolet, + modifier = Modifier.size(20.dp) + ) + } + Spacer(modifier = Modifier.width(10.dp)) + Column { + Text( + text = "Связь с Home Assistant (HAC)", + fontWeight = FontWeight.Bold, + fontSize = 16.sp, + color = TextPrimary + ) + Text( + text = "Отправка событий сна в умный дом", + fontSize = 11.sp, + color = TextMuted + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Mode Selector Tabs + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .background(ObsidianSurface) + .border(1.dp, GlassBorder, RoundedCornerShape(14.dp)) + .padding(4.dp) + ) { + TabSelectorItem( + modifier = Modifier.weight(1f), + title = "Webhook URL", + isSelected = selectedMode == HacMode.WEBHOOK, + onClick = { selectedMode = HacMode.WEBHOOK } + ) + TabSelectorItem( + modifier = Modifier.weight(1f), + title = "REST State API", + isSelected = selectedMode == HacMode.REST, + onClick = { selectedMode = HacMode.REST } + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + if (selectedMode == HacMode.WEBHOOK) { + OutlinedTextField( + value = webhookUrl, + onValueChange = { webhookUrl = it; isSaved = false }, + label = { Text("Webhook URL") }, + placeholder = { Text("http://192.168.1.50:8123/api/webhook/avtex_sleep") }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + singleLine = true, + colors = customTextFieldColors() + ) + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = "Работает как с локальным адресом, так и через Nabu Casa / VPN.", + fontSize = 11.sp, + color = TextMuted + ) + } else { + OutlinedTextField( + value = restUrl, + onValueChange = { restUrl = it; isSaved = false }, + label = { Text("REST State URL") }, + placeholder = { Text("http://192.168.1.50:8123/api/states/binary_sensor.phone_sleep") }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + singleLine = true, + colors = customTextFieldColors() + ) + Spacer(modifier = Modifier.height(10.dp)) + OutlinedTextField( + value = token, + onValueChange = { token = it; isSaved = false }, + label = { Text("Long-Lived Access Token") }, + placeholder = { Text("Токен доступа Home Assistant") }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + singleLine = true, + colors = customTextFieldColors() + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Broadcast toggle + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .background(ObsidianSurface) + .padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row(modifier = Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.NotificationsActive, + contentDescription = null, + tint = CyanAccent, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(10.dp)) + Column { + Text( + text = "Локальный Broadcast Intent", + fontWeight = FontWeight.SemiBold, + fontSize = 13.sp, + color = TextPrimary + ) + Text( + text = "Отправка интента для Tasker / Companion App", + fontSize = 11.sp, + color = TextMuted + ) + } + } + Switch( + checked = broadcastIntents, + onCheckedChange = { broadcastIntents = it; isSaved = false }, + colors = SwitchDefaults.colors( + checkedThumbColor = Color.White, + checkedTrackColor = CyanAccent + ) + ) + } + } + } + } + + // 3. Sensor Tuning Glass Card + item { + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(22.dp)) + .background(ObsidianSurfaceElevated) + .border(1.dp, GlassBorder, RoundedCornerShape(22.dp)) + .padding(18.dp) + ) { + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(10.dp)) + .background(CyanAccent.copy(alpha = 0.15f)) + .padding(8.dp) + ) { + Icon( + imageVector = Icons.Default.Tune, + contentDescription = null, + tint = CyanAccent, + modifier = Modifier.size(20.dp) + ) + } + Spacer(modifier = Modifier.width(10.dp)) + Column { + Text( + text = "Параметры детектора сна", + fontWeight = FontWeight.Bold, + fontSize = 16.sp, + color = TextPrimary + ) + Text( + text = "Калибровка порогов перехода в сон", + fontSize = 11.sp, + color = TextMuted + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Inactivity Slider + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "Таймаут покоя до сна", + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + color = TextSecondary + ) + Text( + text = "${inactivityMinutes.roundToInt()} мин", + fontSize = 13.sp, + fontWeight = FontWeight.Bold, + color = NeonViolet + ) + } + Text( + text = "Если экран выключен и нет движения указанное время, фиксируется сон.", + fontSize = 11.sp, + color = TextMuted + ) + Slider( + value = inactivityMinutes, + onValueChange = { inactivityMinutes = it; isSaved = false }, + valueRange = 5f..60f, + steps = 10, + colors = SliderDefaults.colors( + thumbColor = NeonViolet, + activeTrackColor = NeonViolet, + inactiveTrackColor = ObsidianSurfaceHigh + ) + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // Confidence Slider + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "Порог уверенности Sleep API", + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + color = TextSecondary + ) + Text( + text = "${confidenceThreshold.roundToInt()}%", + fontSize = 13.sp, + fontWeight = FontWeight.Bold, + color = CyanAccent + ) + } + Text( + text = "Минимальная оценка уверенности сна от алгоритма Google Play Services.", + fontSize = 11.sp, + color = TextMuted + ) + Slider( + value = confidenceThreshold, + onValueChange = { confidenceThreshold = it; isSaved = false }, + valueRange = 50f..95f, + steps = 8, + colors = SliderDefaults.colors( + thumbColor = CyanAccent, + activeTrackColor = CyanAccent, + inactiveTrackColor = ObsidianSurfaceHigh + ) + ) + } + } + } + + // 4. Save Button + item { + Button( + onClick = { + coroutineScope.launch { + repository.updateHacSettings( + mode = selectedMode, + webhookUrl = webhookUrl, + restUrl = restUrl, + token = token + ) + repository.updateDetectionSensitivity( + inactivityMinutes = inactivityMinutes.roundToInt(), + confidenceThreshold = confidenceThreshold.roundToInt(), + broadcastIntents = broadcastIntents + ) + isSaved = true + } + }, + modifier = Modifier + .fillMaxWidth() + .height(52.dp), + shape = RoundedCornerShape(16.dp), + colors = ButtonDefaults.buttonColors( + containerColor = if (isSaved) EmeraldMint else NeonViolet, + contentColor = Color.White + ) + ) { + Icon( + imageVector = if (isSaved) Icons.Default.Check else Icons.Default.Save, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = if (isSaved) "Настройки применены!" else "Сохранить конфигурацию", + fontWeight = FontWeight.SemiBold, + fontSize = 14.sp + ) + } + } + } +} + +@Composable +private fun TabSelectorItem( + modifier: Modifier = Modifier, + title: String, + isSelected: Boolean, + onClick: () -> Unit +) { + Box( + modifier = modifier + .clip(RoundedCornerShape(10.dp)) + .background(if (isSelected) NeonViolet.copy(alpha = 0.25f) else Color.Transparent) + .border( + 1.dp, + if (isSelected) NeonViolet.copy(alpha = 0.5f) else Color.Transparent, + RoundedCornerShape(10.dp) + ) + .clickable { onClick() } + .padding(vertical = 10.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = title, + fontSize = 12.sp, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium, + color = if (isSelected) Color.White else TextMuted + ) + } +} + +@Composable +private fun customTextFieldColors() = OutlinedTextFieldDefaults.colors( + focusedContainerColor = ObsidianSurface, + unfocusedContainerColor = ObsidianSurface, + focusedBorderColor = NeonViolet, + unfocusedBorderColor = GlassBorder, + focusedLabelColor = NeonViolet, + unfocusedLabelColor = TextMuted, + focusedTextColor = TextPrimary, + unfocusedTextColor = TextPrimary, + cursorColor = NeonViolet +) diff --git a/app/src/main/res/drawable/app_logo.png b/app/src/main/res/drawable/app_logo.png new file mode 100644 index 0000000..906de2d Binary files /dev/null and b/app/src/main/res/drawable/app_logo.png differ diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..aa27cce --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.webp b/app/src/main/res/drawable/ic_launcher_foreground.webp new file mode 100644 index 0000000..d9cd9b2 Binary files /dev/null and b/app/src/main/res/drawable/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..80d672c Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..32acfda Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..0bc223b Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..e99005c Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..ce4c0c6 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b0ebfef Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..5b4af32 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..372ee26 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..42b4bcb Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..76504ad Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..3eade13 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Avtex + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..1d5b954 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +