commit ad20ba3325a7810d1417fde60de1af46c4e008e0 Author: Eugene Date: Sun Nov 30 00:58:24 2025 +0300 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa724b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/.name b/.idea/.name new file mode 100644 index 0000000..afd0ccc --- /dev/null +++ b/.idea/.name @@ -0,0 +1 @@ +Тренировка \ No newline at end of file diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml new file mode 100644 index 0000000..4a53bee --- /dev/null +++ b/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 0000000..7643783 --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,123 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..79ee123 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..b86273d --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml new file mode 100644 index 0000000..b268ef3 --- /dev/null +++ b/.idea/deploymentTargetSelector.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..97f0a8e --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,18 @@ + + + + + + \ No newline at end of file diff --git a/.idea/markdown.xml b/.idea/markdown.xml new file mode 100644 index 0000000..c61ea33 --- /dev/null +++ b/.idea/markdown.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/.idea/migrations.xml b/.idea/migrations.xml new file mode 100644 index 0000000..f8051a6 --- /dev/null +++ b/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..74dd639 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml new file mode 100644 index 0000000..16660f1 --- /dev/null +++ b/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..79ae43d --- /dev/null +++ b/README.md @@ -0,0 +1,114 @@ +# Грузоблочный тренажер - Android приложение + +Android приложение для управления грузоблочным тренажером с ESP32 микроконтроллером. + +## Функциональность + +1. **Регистрация участника** - добавление и выбор участника тренировки +2. **Выбор режима упражнений** - выбор готового сценария или создание индивидуального +3. **Пуск и останов упражнения** - управление тренировкой +4. **Подсчет подъемов** - в режиме на время считаются выполненные подъемы +5. **Отсчет времени** - в режиме на количество отсчитывается время выполнения +6. **Кардиодатчик** - отображение показаний пульса в реальном времени через Bluetooth +7. **Печать отчетов** - вывод результатов тренировки на принтер + +## Технологии + +- Kotlin +- Jetpack Compose +- Room Database +- WebSocket (OkHttp) для связи с ESP32 +- Bluetooth LE для кардиодатчика +- Navigation Component + +## Настройка + +### WebSocket подключение к ESP32 + +В файле `MainActivity.kt` измените URL WebSocket: + +```kotlin +val websocketUrl = "ws://192.168.1.100:81" // Замените на IP вашего ESP32 +``` + +ESP32 должен отправлять сообщения в формате JSON: +```json +{"position": "top"} // или "bottom" +``` + +### Bluetooth кардиодатчик + +Приложение автоматически сканирует и подключается к Bluetooth устройствам с Heart Rate Service (UUID: 0000180d-0000-1000-8000-00805f9b34fb). + +При первом запуске приложение запросит разрешения: +- Bluetooth +- Location (требуется для Bluetooth LE сканирования на Android 12+) + +## Структура проекта + +``` +app/src/main/java/ru/kgeu/training/ +├── data/ +│ ├── dao/ # Data Access Objects для Room +│ ├── database/ # Room Database +│ ├── model/ # Модели данных +│ └── repository/ # Репозиторий для работы с данными +├── network/ +│ └── WebSocketClient.kt # WebSocket клиент для ESP32 +├── bluetooth/ +│ └── HeartRateMonitor.kt # Bluetooth сервис для кардиодатчика +├── ui/ +│ ├── navigation/ # Навигация +│ ├── screen/ # UI экраны +│ ├── theme/ # Тема приложения +│ └── viewmodel/ # ViewModels +└── util/ + └── PrintUtil.kt # Утилита для печати +``` + +## Режимы упражнений + +### Режим на время +- Устанавливается целевое время (в секундах) +- Считается количество выполненных подъемов +- Тренировка завершается по истечении времени + +### Режим на количество +- Устанавливается целевое количество подъемов +- Отсчитывается время выполнения +- Тренировка завершается при достижении цели + +## База данных + +Приложение использует Room Database для хранения: +- Участников тренировок +- Сценариев упражнений (готовых и пользовательских) +- Сессий тренировок с результатами + +## Печать + +Функция печати использует стандартную систему печати Android и поддерживает: +- Печать на принтеры через Wi-Fi +- Сохранение в PDF +- Отправку по email + +## Требования + +- Android 8.0 (API 26) или выше +- Поддержка Bluetooth LE +- Подключение к сети для WebSocket + +## Разработка + +Для сборки проекта используйте: + +```bash +./gradlew assembleDebug +``` + +Для установки на устройство: + +```bash +./gradlew installDebug +``` + 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..9075d86 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,77 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.ksp) +} + +android { + namespace = "ru.kgeu.training" + compileSdk { + version = release(36) + } + + defaultConfig { + applicationId = "ru.kgeu.training" + minSdk = 34 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } + buildFeatures { + compose = true + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.compose.material3) + + // Navigation + implementation(libs.navigation.compose) + + // Room Database + implementation(libs.androidx.room.runtime) + implementation(libs.androidx.room.ktx) + ksp(libs.androidx.room.compiler) + + // WebSocket + implementation(libs.okhttp) + + // ViewModel + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.lifecycle.runtime.compose) + + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + debugImplementation(libs.androidx.compose.ui.tooling) + debugImplementation(libs.androidx.compose.ui.test.manifest) +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/androidTest/java/ru/kgeu/training/ExampleInstrumentedTest.kt b/app/src/androidTest/java/ru/kgeu/training/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..0dcb91b --- /dev/null +++ b/app/src/androidTest/java/ru/kgeu/training/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package ru.kgeu.training + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("ru.kgeu.training", appContext.packageName) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..acfbb01 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/ru/kgeu/training/MainActivity.kt b/app/src/main/java/ru/kgeu/training/MainActivity.kt new file mode 100644 index 0000000..1d98d94 --- /dev/null +++ b/app/src/main/java/ru/kgeu/training/MainActivity.kt @@ -0,0 +1,79 @@ +package ru.kgeu.training + +import android.os.Bundle +import android.util.Log +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 androidx.navigation.compose.rememberNavController +import ru.kgeu.training.ui.navigation.MainContainer +import ru.kgeu.training.ui.theme.ТренировкаTheme +import ru.kgeu.training.util.PrintUtil + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + val application = applicationContext as TrainingApplication + + // TCP подключение к ESP32 - можно настроить в настройках приложения + val tcpHost = "192.168.4.1" // Замените на реальный IP ESP32 + val tcpPort = 8080 // Замените на реальный порт ESP32 + + setContent { + ТренировкаTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + val navController = rememberNavController() + + MainContainer( + navController = navController, + tcpHost = tcpHost, + tcpPort = tcpPort, + application = application, + onPrint = { session, participantName, scenarioName -> + PrintUtil.printWorkoutSession( + context = this@MainActivity, + session = session, + participantName = participantName, + scenarioName = scenarioName + ) + } + ) + } + } + } + } + + override fun onDestroy() { + super.onDestroy() + Log.d("MainActivity", "Activity destroyed, disconnecting TCP and Bluetooth") + try { + val application = applicationContext as? TrainingApplication + application?.tcpClient?.disconnect() + application?.heartRateMonitor?.disconnect() + } catch (e: Exception) { + Log.e("MainActivity", "Error disconnecting on destroy", e) + } + } + + override fun onStop() { + super.onStop() + // Отключаем соединения при остановке активности (например, при переходе в фоновый режим) + Log.d("MainActivity", "Activity stopped, disconnecting TCP and Bluetooth") + try { + val application = applicationContext as? TrainingApplication + application?.tcpClient?.disconnect() + application?.heartRateMonitor?.disconnect() + } catch (e: Exception) { + Log.e("MainActivity", "Error disconnecting on stop", e) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/kgeu/training/TrainingApplication.kt b/app/src/main/java/ru/kgeu/training/TrainingApplication.kt new file mode 100644 index 0000000..7018791 --- /dev/null +++ b/app/src/main/java/ru/kgeu/training/TrainingApplication.kt @@ -0,0 +1,32 @@ +package ru.kgeu.training + +import android.app.Application +import androidx.room.Room +import ru.kgeu.training.data.database.TrainingDatabase +import ru.kgeu.training.data.repository.TrainingRepository +import ru.kgeu.training.network.TcpClient +import ru.kgeu.training.bluetooth.HeartRateMonitor + +class TrainingApplication : Application() { + val database by lazy { + Room.databaseBuilder( + applicationContext, + TrainingDatabase::class.java, + TrainingDatabase.DATABASE_NAME + ) + .fallbackToDestructiveMigration() // Для отладки - удаляет старую БД при изменении схемы + .build() + } + + val repository by lazy { + TrainingRepository( + participantDao = database.participantDao(), + scenarioDao = database.exerciseScenarioDao(), + sessionDao = database.workoutSessionDao() + ) + } + + val tcpClient by lazy { TcpClient() } + val heartRateMonitor by lazy { HeartRateMonitor(applicationContext) } +} + diff --git a/app/src/main/java/ru/kgeu/training/bluetooth/HeartRateMonitor.kt b/app/src/main/java/ru/kgeu/training/bluetooth/HeartRateMonitor.kt new file mode 100644 index 0000000..6d11f22 --- /dev/null +++ b/app/src/main/java/ru/kgeu/training/bluetooth/HeartRateMonitor.kt @@ -0,0 +1,169 @@ +package ru.kgeu.training.bluetooth + +import android.bluetooth.* +import android.bluetooth.le.ScanCallback +import android.bluetooth.le.ScanFilter +import android.bluetooth.le.ScanResult +import android.bluetooth.le.ScanSettings +import android.content.Context +import android.util.Log +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.util.* + +class HeartRateMonitor(private val context: Context) { + private val bluetoothManager: BluetoothManager? = + context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager + private val bluetoothAdapter: BluetoothAdapter? = bluetoothManager?.adapter + private val bluetoothLeScanner = bluetoothAdapter?.bluetoothLeScanner + private var bluetoothGatt: BluetoothGatt? = null + + private val _heartRate = MutableStateFlow(null) + val heartRate: StateFlow = _heartRate.asStateFlow() + + private val _connectionState = MutableStateFlow(BluetoothConnectionState.Disconnected) + val connectionState: StateFlow = _connectionState.asStateFlow() + + // Heart Rate Service UUIDs + private val HEART_RATE_SERVICE_UUID = UUID.fromString("0000180d-0000-1000-8000-00805f9b34fb") + private val HEART_RATE_MEASUREMENT_CHARACTERISTIC_UUID = UUID.fromString("00002a37-0000-1000-8000-00805f9b34fb") + + private val scanCallback = object : ScanCallback() { + override fun onScanResult(callbackType: Int, result: ScanResult) { + val device = result.device + val scanRecord = result.scanRecord + + // Check if device has Heart Rate Service + val serviceUuids = scanRecord?.serviceUuids + val heartRateServiceUuid = android.os.ParcelUuid(HEART_RATE_SERVICE_UUID) + if (serviceUuids != null && serviceUuids.contains(heartRateServiceUuid)) { + Log.d(TAG, "Found heart rate device: ${device.address}") + stopScan() + connectToDevice(device) + } + } + + override fun onScanFailed(errorCode: Int) { + Log.e(TAG, "Scan failed with error code: $errorCode") + _connectionState.value = BluetoothConnectionState.Error + } + } + + private val gattCallback = object : BluetoothGattCallback() { + override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { + when (newState) { + BluetoothProfile.STATE_CONNECTED -> { + Log.d(TAG, "Connected to GATT server") + _connectionState.value = BluetoothConnectionState.Connected + gatt.discoverServices() + } + BluetoothProfile.STATE_DISCONNECTED -> { + Log.d(TAG, "Disconnected from GATT server") + _connectionState.value = BluetoothConnectionState.Disconnected + _heartRate.value = null + } + } + } + + override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { + if (status == BluetoothGatt.GATT_SUCCESS) { + val service = gatt.getService(HEART_RATE_SERVICE_UUID) + val characteristic = service?.getCharacteristic(HEART_RATE_MEASUREMENT_CHARACTERISTIC_UUID) + + if (characteristic != null) { + val enabled = gatt.setCharacteristicNotification(characteristic, true) + if (enabled) { + val descriptor = characteristic.getDescriptor( + UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") + ) + if (descriptor != null) { + // Используем deprecated методы для совместимости со всеми версиями Android + @Suppress("DEPRECATION") + descriptor.setValue(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE) + @Suppress("DEPRECATION") + gatt.writeDescriptor(descriptor) + } + } + } + } + } + + override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) { + if (characteristic.uuid == HEART_RATE_MEASUREMENT_CHARACTERISTIC_UUID) { + // Используем deprecated свойство для совместимости + @Suppress("DEPRECATION") + val value = characteristic.value + val heartRateValue = parseHeartRate(value ?: byteArrayOf()) + _heartRate.value = heartRateValue + Log.d(TAG, "Heart rate: $heartRateValue bpm") + } + } + } + + private fun parseHeartRate(value: ByteArray): Int { + // Heart Rate Measurement Format according to Bluetooth spec + val flag = value[0].toInt() + val format = (flag and 0x01) != 0 // 0 = 8-bit, 1 = 16-bit + + return if (format && value.size >= 3) { + // 16-bit value + ((value[2].toInt() and 0xFF) shl 8) or (value[1].toInt() and 0xFF) + } else if (value.size >= 2) { + // 8-bit value + value[1].toInt() and 0xFF + } else { + 0 + } + } + + fun startScan() { + if (bluetoothLeScanner == null) { + Log.e(TAG, "Bluetooth LE Scanner not available") + _connectionState.value = BluetoothConnectionState.Error + return + } + + _connectionState.value = BluetoothConnectionState.Scanning + + val filter = ScanFilter.Builder() + .setServiceUuid(android.os.ParcelUuid(HEART_RATE_SERVICE_UUID)) + .build() + + val settings = ScanSettings.Builder() + .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) + .build() + + bluetoothLeScanner.startScan(listOf(filter), settings, scanCallback) + } + + fun stopScan() { + bluetoothLeScanner?.stopScan(scanCallback) + } + + private fun connectToDevice(device: BluetoothDevice) { + _connectionState.value = BluetoothConnectionState.Connecting + bluetoothGatt = device.connectGatt(context, false, gattCallback) + } + + fun disconnect() { + bluetoothGatt?.disconnect() + bluetoothGatt?.close() + bluetoothGatt = null + _connectionState.value = BluetoothConnectionState.Disconnected + _heartRate.value = null + } + + companion object { + private const val TAG = "HeartRateMonitor" + } +} + +enum class BluetoothConnectionState { + Disconnected, + Scanning, + Connecting, + Connected, + Error +} + diff --git a/app/src/main/java/ru/kgeu/training/data/dao/ExerciseScenarioDao.kt b/app/src/main/java/ru/kgeu/training/data/dao/ExerciseScenarioDao.kt new file mode 100644 index 0000000..b58f9e4 --- /dev/null +++ b/app/src/main/java/ru/kgeu/training/data/dao/ExerciseScenarioDao.kt @@ -0,0 +1,36 @@ +package ru.kgeu.training.data.dao + +import androidx.room.* +import kotlinx.coroutines.flow.Flow +import ru.kgeu.training.data.model.ExerciseScenario + +@Dao +interface ExerciseScenarioDao { + @Query("SELECT * FROM exercise_scenarios ORDER BY createdAt DESC") + fun getAllScenarios(): Flow> + + @Query("SELECT * FROM exercise_scenarios WHERE isCustom = 0 ORDER BY createdAt DESC") + fun getReadyScenarios(): Flow> + + @Query("SELECT * FROM exercise_scenarios WHERE isCustom = 1 ORDER BY createdAt DESC") + fun getCustomScenarios(): Flow> + + @Query("SELECT COUNT(*) FROM exercise_scenarios WHERE isCustom = 0") + suspend fun getReadyScenariosCount(): Int + + @Query("SELECT * FROM exercise_scenarios WHERE id = :id") + suspend fun getScenarioById(id: Long): ExerciseScenario? + + @Insert + suspend fun insertScenario(scenario: ExerciseScenario): Long + + @Update + suspend fun updateScenario(scenario: ExerciseScenario) + + @Delete + suspend fun deleteScenario(scenario: ExerciseScenario) + + @Query("DELETE FROM exercise_scenarios WHERE isCustom = 0") + suspend fun deleteAllReadyScenarios() +} + diff --git a/app/src/main/java/ru/kgeu/training/data/dao/ParticipantDao.kt b/app/src/main/java/ru/kgeu/training/data/dao/ParticipantDao.kt new file mode 100644 index 0000000..89ce79c --- /dev/null +++ b/app/src/main/java/ru/kgeu/training/data/dao/ParticipantDao.kt @@ -0,0 +1,24 @@ +package ru.kgeu.training.data.dao + +import androidx.room.* +import kotlinx.coroutines.flow.Flow +import ru.kgeu.training.data.model.Participant + +@Dao +interface ParticipantDao { + @Query("SELECT * FROM participants ORDER BY registrationDate DESC") + fun getAllParticipants(): Flow> + + @Query("SELECT * FROM participants WHERE id = :id") + suspend fun getParticipantById(id: Long): Participant? + + @Insert + suspend fun insertParticipant(participant: Participant): Long + + @Update + suspend fun updateParticipant(participant: Participant) + + @Delete + suspend fun deleteParticipant(participant: Participant) +} + diff --git a/app/src/main/java/ru/kgeu/training/data/dao/WorkoutSessionDao.kt b/app/src/main/java/ru/kgeu/training/data/dao/WorkoutSessionDao.kt new file mode 100644 index 0000000..83a3a67 --- /dev/null +++ b/app/src/main/java/ru/kgeu/training/data/dao/WorkoutSessionDao.kt @@ -0,0 +1,30 @@ +package ru.kgeu.training.data.dao + +import androidx.room.* +import kotlinx.coroutines.flow.Flow +import ru.kgeu.training.data.model.WorkoutSession + +@Dao +interface WorkoutSessionDao { + @Query("SELECT * FROM workout_sessions ORDER BY startTime DESC") + fun getAllSessions(): Flow> + + @Query("SELECT * FROM workout_sessions WHERE participantId = :participantId ORDER BY startTime DESC") + fun getSessionsByParticipant(participantId: Long): Flow> + + @Query("SELECT * FROM workout_sessions WHERE id = :id") + suspend fun getSessionById(id: Long): WorkoutSession? + + @Insert + suspend fun insertSession(session: WorkoutSession): Long + + @Update + suspend fun updateSession(session: WorkoutSession) + + @Delete + suspend fun deleteSession(session: WorkoutSession) + + @Query("DELETE FROM workout_sessions WHERE participantId = :participantId") + suspend fun deleteSessionsByParticipant(participantId: Long) +} + diff --git a/app/src/main/java/ru/kgeu/training/data/database/TrainingDatabase.kt b/app/src/main/java/ru/kgeu/training/data/database/TrainingDatabase.kt new file mode 100644 index 0000000..ae14f32 --- /dev/null +++ b/app/src/main/java/ru/kgeu/training/data/database/TrainingDatabase.kt @@ -0,0 +1,26 @@ +package ru.kgeu.training.data.database + +import androidx.room.Database +import androidx.room.RoomDatabase +import ru.kgeu.training.data.dao.ExerciseScenarioDao +import ru.kgeu.training.data.dao.ParticipantDao +import ru.kgeu.training.data.dao.WorkoutSessionDao +import ru.kgeu.training.data.model.ExerciseScenario +import ru.kgeu.training.data.model.Participant +import ru.kgeu.training.data.model.WorkoutSession + +@Database( + entities = [Participant::class, ExerciseScenario::class, WorkoutSession::class], + version = 1, + exportSchema = false +) +abstract class TrainingDatabase : RoomDatabase() { + abstract fun participantDao(): ParticipantDao + abstract fun exerciseScenarioDao(): ExerciseScenarioDao + abstract fun workoutSessionDao(): WorkoutSessionDao + + companion object { + const val DATABASE_NAME = "training_database" + } +} + diff --git a/app/src/main/java/ru/kgeu/training/data/model/ExerciseScenario.kt b/app/src/main/java/ru/kgeu/training/data/model/ExerciseScenario.kt new file mode 100644 index 0000000..58400d6 --- /dev/null +++ b/app/src/main/java/ru/kgeu/training/data/model/ExerciseScenario.kt @@ -0,0 +1,22 @@ +package ru.kgeu.training.data.model + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "exercise_scenarios") +data class ExerciseScenario( + @PrimaryKey(autoGenerate = true) + val id: Long = 0, + val name: String, + val description: String? = null, + val exerciseMode: ExerciseMode, + val targetValue: Int, // количество подъемов или время в секундах + val isCustom: Boolean = false, // true для пользовательских, false для готовых + val createdAt: Long = System.currentTimeMillis() +) + +enum class ExerciseMode { + TIME_BASED, // Режим на время - считаем подъемы + COUNT_BASED // Режим на количество - отсчитываем время +} + diff --git a/app/src/main/java/ru/kgeu/training/data/model/Participant.kt b/app/src/main/java/ru/kgeu/training/data/model/Participant.kt new file mode 100644 index 0000000..5723577 --- /dev/null +++ b/app/src/main/java/ru/kgeu/training/data/model/Participant.kt @@ -0,0 +1,16 @@ +package ru.kgeu.training.data.model + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "participants") +data class Participant( + @PrimaryKey(autoGenerate = true) + val id: Long = 0, + val name: String, + val age: Int? = null, + val weight: Float? = null, + val height: Float? = null, + val registrationDate: Long = System.currentTimeMillis() +) + diff --git a/app/src/main/java/ru/kgeu/training/data/model/WeightPosition.kt b/app/src/main/java/ru/kgeu/training/data/model/WeightPosition.kt new file mode 100644 index 0000000..81e09d0 --- /dev/null +++ b/app/src/main/java/ru/kgeu/training/data/model/WeightPosition.kt @@ -0,0 +1,8 @@ +package ru.kgeu.training.data.model + +enum class WeightPosition { + TOP, // Груз поднят + MIDDLE, // Груз в движении (не верх и не низ) + BOTTOM // Груз опущен +} + diff --git a/app/src/main/java/ru/kgeu/training/data/model/WorkoutSession.kt b/app/src/main/java/ru/kgeu/training/data/model/WorkoutSession.kt new file mode 100644 index 0000000..5933a44 --- /dev/null +++ b/app/src/main/java/ru/kgeu/training/data/model/WorkoutSession.kt @@ -0,0 +1,20 @@ +package ru.kgeu.training.data.model + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "workout_sessions") +data class WorkoutSession( + @PrimaryKey(autoGenerate = true) + val id: Long = 0, + val participantId: Long, + val scenarioId: Long, + val startTime: Long, + val endTime: Long? = null, + val completedLifts: Int = 0, + val durationSeconds: Long = 0, + val averageHeartRate: Int? = null, + val maxHeartRate: Int? = null, + val minHeartRate: Int? = null +) + diff --git a/app/src/main/java/ru/kgeu/training/data/repository/TrainingRepository.kt b/app/src/main/java/ru/kgeu/training/data/repository/TrainingRepository.kt new file mode 100644 index 0000000..1007456 --- /dev/null +++ b/app/src/main/java/ru/kgeu/training/data/repository/TrainingRepository.kt @@ -0,0 +1,53 @@ +package ru.kgeu.training.data.repository + +import android.util.Log +import kotlinx.coroutines.flow.Flow +import ru.kgeu.training.data.dao.ExerciseScenarioDao +import ru.kgeu.training.data.dao.ParticipantDao +import ru.kgeu.training.data.dao.WorkoutSessionDao +import ru.kgeu.training.data.model.ExerciseScenario +import ru.kgeu.training.data.model.Participant +import ru.kgeu.training.data.model.WorkoutSession + +class TrainingRepository( + private val participantDao: ParticipantDao, + private val scenarioDao: ExerciseScenarioDao, + private val sessionDao: WorkoutSessionDao +) { + // Participants + fun getAllParticipants(): Flow> = participantDao.getAllParticipants() + suspend fun getParticipantById(id: Long): Participant? = participantDao.getParticipantById(id) + suspend fun insertParticipant(participant: Participant): Long { + Log.d("TrainingRepository", "Inserting participant: name='${participant.name}', age=${participant.age}") + val id = participantDao.insertParticipant(participant) + Log.d("TrainingRepository", "Participant inserted with id: $id") + return id + } + suspend fun updateParticipant(participant: Participant) = participantDao.updateParticipant(participant) + suspend fun deleteParticipant(participant: Participant) { + // Сначала удаляем все сессии тренировок этого участника + sessionDao.deleteSessionsByParticipant(participant.id) + // Затем удаляем самого участника + participantDao.deleteParticipant(participant) + } + + // Scenarios + fun getAllScenarios(): Flow> = scenarioDao.getAllScenarios() + fun getReadyScenarios(): Flow> = scenarioDao.getReadyScenarios() + fun getCustomScenarios(): Flow> = scenarioDao.getCustomScenarios() + suspend fun getReadyScenariosCount(): Int = scenarioDao.getReadyScenariosCount() + suspend fun getScenarioById(id: Long): ExerciseScenario? = scenarioDao.getScenarioById(id) + suspend fun insertScenario(scenario: ExerciseScenario): Long = scenarioDao.insertScenario(scenario) + suspend fun updateScenario(scenario: ExerciseScenario) = scenarioDao.updateScenario(scenario) + suspend fun deleteScenario(scenario: ExerciseScenario) = scenarioDao.deleteScenario(scenario) + suspend fun deleteAllReadyScenarios() = scenarioDao.deleteAllReadyScenarios() + + // Sessions + fun getAllSessions(): Flow> = sessionDao.getAllSessions() + fun getSessionsByParticipant(participantId: Long): Flow> = sessionDao.getSessionsByParticipant(participantId) + suspend fun getSessionById(id: Long): WorkoutSession? = sessionDao.getSessionById(id) + suspend fun insertSession(session: WorkoutSession): Long = sessionDao.insertSession(session) + suspend fun updateSession(session: WorkoutSession) = sessionDao.updateSession(session) + suspend fun deleteSession(session: WorkoutSession) = sessionDao.deleteSession(session) +} + diff --git a/app/src/main/java/ru/kgeu/training/network/TcpClient.kt b/app/src/main/java/ru/kgeu/training/network/TcpClient.kt new file mode 100644 index 0000000..16c12b8 --- /dev/null +++ b/app/src/main/java/ru/kgeu/training/network/TcpClient.kt @@ -0,0 +1,228 @@ +package ru.kgeu.training.network + +import android.util.Log +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.json.JSONObject +import ru.kgeu.training.data.model.WeightPosition +import java.io.BufferedReader +import java.io.InputStreamReader +import java.net.Socket +import java.net.SocketTimeoutException + +class TcpClient { + private var socket: Socket? = null + private var reader: BufferedReader? = null + private var readJob: Job? = null + private val clientScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + private val _connectionState = MutableStateFlow(ConnectionState.Disconnected) + val connectionState: StateFlow = _connectionState.asStateFlow() + + private val _weightPosition = MutableStateFlow(null) + val weightPosition: StateFlow = _weightPosition.asStateFlow() + + fun connect(host: String, port: Int) { + Log.d(TAG, "Attempting to connect to TCP: $host:$port") + + // Отключаемся если уже подключены + if (_connectionState.value == ConnectionState.Connected) { + Log.d(TAG, "Already connected, disconnecting first") + disconnect() + } + + // Отменяем предыдущую задачу чтения если есть + readJob?.cancel() + readJob = null + + _connectionState.value = ConnectionState.Connecting + + clientScope.launch { + try { + // Небольшая задержка для завершения отключения + delay(100) + + Log.d(TAG, "Creating socket connection...") + val newSocket = Socket() + // Оптимизация TCP: отключаем алгоритм Nagle для уменьшения задержек + newSocket.tcpNoDelay = true + // Уменьшаем таймаут чтения для более быстрой реакции + newSocket.soTimeout = 1000 // 1 секунда вместо 10 + newSocket.connect(java.net.InetSocketAddress(host, port), 5000) // 5 секунд таймаут на подключение + + socket = newSocket + reader = BufferedReader(InputStreamReader(socket!!.getInputStream(), "UTF-8"), 8192) // Увеличиваем размер буфера + + Log.d(TAG, "TCP connection established to $host:$port") + withContext(Dispatchers.Main) { + _connectionState.value = ConnectionState.Connected + } + + // Запускаем чтение данных в отдельной корутине + readJob = clientScope.launch { + readMessages() + } + + } catch (e: SocketTimeoutException) { + Log.e(TAG, "Connection timeout: $host:$port", e) + withContext(Dispatchers.Main) { + _connectionState.value = ConnectionState.Disconnected + } + socket?.close() + socket = null + reader = null + } catch (e: java.net.UnknownHostException) { + Log.e(TAG, "Unknown host: $host", e) + withContext(Dispatchers.Main) { + _connectionState.value = ConnectionState.Disconnected + } + socket?.close() + socket = null + reader = null + } catch (e: java.net.ConnectException) { + Log.e(TAG, "Connection refused: $host:$port", e) + withContext(Dispatchers.Main) { + _connectionState.value = ConnectionState.Disconnected + } + socket?.close() + socket = null + reader = null + } catch (e: Exception) { + Log.e(TAG, "Error connecting to TCP: $host:$port", e) + e.printStackTrace() + withContext(Dispatchers.Main) { + _connectionState.value = ConnectionState.Disconnected + } + socket?.close() + socket = null + reader = null + } + } + } + + private suspend fun readMessages() { + val currentReader = reader + val currentSocket = socket + + if (currentReader == null || currentSocket == null) { + Log.w(TAG, "Cannot read messages: reader or socket is null") + return + } + + try { + Log.d(TAG, "Starting to read messages from TCP stream...") + while (currentSocket.isConnected && !currentSocket.isClosed) { + try { + val line = currentReader.readLine() + if (line == null) { + Log.d(TAG, "End of stream reached") + break + } + + // Обрабатываем сообщение асинхронно, не блокируя чтение + processMessage(line) + + } catch (e: SocketTimeoutException) { + // Таймаут на чтение - это нормально, продолжаем + // Не логируем каждый таймаут, чтобы не засорять лог + continue + } catch (e: Exception) { + Log.e(TAG, "Error reading message", e) + break + } + } + } catch (e: Exception) { + Log.e(TAG, "Error in readMessages loop", e) + } finally { + Log.d(TAG, "Stopped reading messages") + withContext(Dispatchers.Main) { + _connectionState.value = ConnectionState.Disconnected + _weightPosition.value = null + } + } + } + + private fun processMessage(message: String) { + try { + val json = JSONObject(message) + val position = json.optString("position", "") + val weightPos = when (position.lowercase()) { + "top", "up" -> WeightPosition.TOP + "middle", "moving", "in_motion", "motion" -> WeightPosition.MIDDLE + "bottom", "down" -> WeightPosition.BOTTOM + else -> null + } + + if (weightPos != null) { + // Обновляем только если значение изменилось (защита от дублирующихся сообщений) + // Используем tryEmit для неблокирующего обновления + val currentValue = _weightPosition.value + if (currentValue != weightPos) { + // Обновляем синхронно на IO диспетчере, затем переключаемся на Main для UI + clientScope.launch(Dispatchers.Main) { + _weightPosition.value = weightPos + Log.d(TAG, "Position changed: $currentValue -> $weightPos") + } + } + } else { + Log.w(TAG, "Unknown position value: '$position' in message: $message") + } + } catch (e: Exception) { + Log.e(TAG, "Error parsing message: $message", e) + } + } + + fun disconnect() { + Log.d(TAG, "Disconnecting TCP connection...") + + readJob?.cancel() + readJob = null + + try { + reader?.close() + } catch (e: Exception) { + Log.e(TAG, "Error closing reader", e) + } + reader = null + + try { + socket?.close() + } catch (e: Exception) { + Log.e(TAG, "Error closing socket", e) + } + socket = null + + clientScope.launch(Dispatchers.Main) { + _connectionState.value = ConnectionState.Disconnected + _weightPosition.value = null + } + + Log.d(TAG, "TCP connection disconnected") + } + + fun sendMessage(message: String) { + clientScope.launch { + try { + val currentSocket = socket + if (currentSocket != null && currentSocket.isConnected && !currentSocket.isClosed) { + val writer = java.io.OutputStreamWriter(currentSocket.getOutputStream(), "UTF-8") + writer.write(message) + writer.write("\n") + writer.flush() + Log.d(TAG, "Sent message: $message") + } else { + Log.w(TAG, "Cannot send message: socket is not connected") + } + } catch (e: Exception) { + Log.e(TAG, "Error sending message: $message", e) + } + } + } + + companion object { + private const val TAG = "TcpClient" + } +} + diff --git a/app/src/main/java/ru/kgeu/training/network/WebSocketClient.kt b/app/src/main/java/ru/kgeu/training/network/WebSocketClient.kt new file mode 100644 index 0000000..5fdc99b --- /dev/null +++ b/app/src/main/java/ru/kgeu/training/network/WebSocketClient.kt @@ -0,0 +1,281 @@ +package ru.kgeu.training.network + +import android.util.Log +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import okhttp3.* +import org.json.JSONObject +import ru.kgeu.training.data.model.WeightPosition +import java.util.concurrent.TimeUnit + +class WebSocketClient { + private val client = OkHttpClient.Builder() + .pingInterval(30, TimeUnit.SECONDS) + .readTimeout(10, TimeUnit.SECONDS) + .connectTimeout(10, TimeUnit.SECONDS) + .writeTimeout(10, TimeUnit.SECONDS) + .build() + + private var webSocket: WebSocket? = null + private val _connectionState = MutableStateFlow(ConnectionState.Disconnected) + val connectionState: StateFlow = _connectionState.asStateFlow() + + private val _weightPosition = MutableStateFlow(null) + val weightPosition: StateFlow = _weightPosition.asStateFlow() + + private var connectionTimeoutJob: Job? = null + + private val listener = object : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: Response) { + Log.d(TAG, "WebSocket connected, response: ${response.code}") + connectionTimeoutJob?.cancel() + connectionTimeoutJob = null + // Обновляем состояние на главном потоке через Dispatchers.Main + CoroutineScope(Dispatchers.Main).launch { + _connectionState.value = ConnectionState.Connected + } + } + + override fun onMessage(webSocket: WebSocket, text: String) { + Log.d(TAG, "Received message: $text") + + // Проверяем, что это сообщение от текущего активного соединения + if (this@WebSocketClient.webSocket != webSocket) { + Log.w(TAG, "Received message from closed/stale WebSocket, ignoring") + return + } + + // Если получили сообщение, значит соединение установлено + // Обновляем статус, если он еще не Connected + if (_connectionState.value != ConnectionState.Connected) { + Log.d(TAG, "Connection confirmed by message, updating state to Connected") + connectionTimeoutJob?.cancel() + connectionTimeoutJob = null + CoroutineScope(Dispatchers.Main).launch { + _connectionState.value = ConnectionState.Connected + } + } + + try { + val json = JSONObject(text) + val position = json.optString("position", "") + val weightPos = when (position.lowercase()) { + "top", "up" -> WeightPosition.TOP + "middle", "moving", "in_motion", "motion" -> WeightPosition.MIDDLE + "bottom", "down" -> WeightPosition.BOTTOM + else -> null + } + + if (weightPos != null) { + // Обновляем только если значение изменилось (защита от дублирующихся сообщений) + CoroutineScope(Dispatchers.Main).launch { + if (_weightPosition.value != weightPos) { + Log.d(TAG, "Position changed: ${_weightPosition.value} -> $weightPos") + _weightPosition.value = weightPos + } else { + Log.d(TAG, "Position unchanged: $weightPos (duplicate message ignored)") + } + } + } else { + Log.w(TAG, "Unknown position value: '$position' in message: $text") + } + } catch (e: Exception) { + Log.e(TAG, "Error parsing message: $text", e) + e.printStackTrace() + } + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + val errorType = t.javaClass.simpleName + Log.e(TAG, "WebSocket failure: $errorType - ${t.message}") + + when { + t is java.net.SocketTimeoutException -> { + Log.e(TAG, "Connection timeout - ESP32 is not responding") + Log.e(TAG, "Check if ESP32 is running and accessible at the configured URL") + } + t is java.net.UnknownHostException -> { + Log.e(TAG, "Unknown host - cannot resolve ESP32 IP address") + Log.e(TAG, "Check if IP address is correct and device is on the same network") + } + t is java.net.ConnectException -> { + Log.e(TAG, "Connection refused - ESP32 is not accepting connections") + Log.e(TAG, "Check if ESP32 WebSocket server is running on the correct port") + } + t is java.net.ProtocolException -> { + Log.e(TAG, "Protocol error - ESP32 sent invalid WebSocket frame") + Log.e(TAG, "This is usually an ESP32 issue. Connection will be closed.") + Log.e(TAG, "The connection may still work, but ESP32 should fix the protocol implementation") + } + else -> { + Log.e(TAG, "Unexpected error: ${t.message}") + } + } + + Log.e(TAG, "Response: ${response?.code} ${response?.message}") + if (response != null) { + try { + val responseBody = response.peekBody(1024).string() + if (responseBody.isNotEmpty()) { + Log.e(TAG, "Response body preview: $responseBody") + } + } catch (e: Exception) { + // Игнорируем ошибки чтения response body + } + } + t.printStackTrace() + connectionTimeoutJob?.cancel() + connectionTimeoutJob = null + + // Для ProtocolException не обнуляем webSocket сразу, так как соединение может еще работать + // Обнуляем только если это не ProtocolException + if (t !is java.net.ProtocolException) { + CoroutineScope(Dispatchers.Main).launch { + _connectionState.value = ConnectionState.Disconnected + _weightPosition.value = null + } + // Обнуляем ссылку только если это текущее соединение + if (this@WebSocketClient.webSocket == webSocket) { + this@WebSocketClient.webSocket = null + } + } else { + // Для ProtocolException только обновляем состояние, но не обнуляем webSocket + // Это позволит продолжать получать сообщения, если соединение все еще работает + Log.w(TAG, "ProtocolException occurred, but keeping connection alive") + CoroutineScope(Dispatchers.Main).launch { + // Не меняем состояние на Disconnected для ProtocolException + // Соединение может продолжать работать + } + } + } + + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { + Log.d(TAG, "WebSocket closing: code=$code, reason=$reason") + CoroutineScope(Dispatchers.Main).launch { + _connectionState.value = ConnectionState.Disconnected + } + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + Log.d(TAG, "WebSocket closed: code=$code, reason=$reason") + CoroutineScope(Dispatchers.Main).launch { + _connectionState.value = ConnectionState.Disconnected + _weightPosition.value = null + } + // Обнуляем ссылку на webSocket после полного закрытия + if (this@WebSocketClient.webSocket == webSocket) { + this@WebSocketClient.webSocket = null + } + } + } + + fun connect(url: String) { + Log.d(TAG, "Attempting to connect to WebSocket: $url") + try { + // Валидация URL + if (!url.startsWith("ws://") && !url.startsWith("wss://")) { + Log.e(TAG, "Invalid WebSocket URL format: $url (must start with ws:// or wss://)") + _connectionState.value = ConnectionState.Disconnected + return + } + + if (_connectionState.value == ConnectionState.Connected) { + Log.d(TAG, "Already connected, disconnecting first") + disconnect() + // Даем время на отключение + Thread.sleep(100) + } + + // Отменяем предыдущий таймаут если есть + connectionTimeoutJob?.cancel() + + _connectionState.value = ConnectionState.Connecting + Log.d(TAG, "Creating WebSocket request to: $url") + + val request = Request.Builder() + .url(url) + .build() + + Log.d(TAG, "Calling client.newWebSocket()...") + webSocket = client.newWebSocket(request, listener) + Log.d(TAG, "WebSocket created, waiting for connection...") + + // Таймаут для подключения - если через 10 секунд не подключились, считаем ошибкой + // Увеличил до 10 секунд, так как connectTimeout тоже 10 секунд + connectionTimeoutJob = CoroutineScope(Dispatchers.Default).launch { + delay(10000) + if (_connectionState.value == ConnectionState.Connecting) { + Log.w(TAG, "WebSocket connection timeout after 10 seconds. URL: $url") + Log.w(TAG, "Current state: ${_connectionState.value}, webSocket: ${webSocket != null}") + Log.w(TAG, "Possible issues:") + Log.w(TAG, " 1. ESP32 is not running or not accessible") + Log.w(TAG, " 2. Wrong IP address or port") + Log.w(TAG, " 3. Device and ESP32 are not on the same network") + Log.w(TAG, " 4. Firewall blocking the connection") + CoroutineScope(Dispatchers.Main).launch { + _connectionState.value = ConnectionState.Disconnected + } + } + } + } catch (e: Exception) { + Log.e(TAG, "Error connecting to WebSocket: $url", e) + Log.e(TAG, "Exception type: ${e.javaClass.simpleName}, message: ${e.message}") + e.printStackTrace() + _connectionState.value = ConnectionState.Disconnected + } + } + + fun disconnect() { + Log.d(TAG, "Disconnecting WebSocket...") + connectionTimeoutJob?.cancel() + connectionTimeoutJob = null + + val socket = webSocket + if (socket != null) { + try { + // Закрываем соединение с кодом 1000 (нормальное закрытие) + // НЕ обнуляем webSocket здесь - это сделает onClosed callback + val closed = socket.close(1000, "Disconnecting") + Log.d(TAG, "WebSocket close() called, result: $closed") + + // Обновляем состояние сразу, но не обнуляем webSocket + // onClosed callback обнулит его после полного закрытия + CoroutineScope(Dispatchers.Main).launch { + _connectionState.value = ConnectionState.Disconnected + } + } catch (e: Exception) { + Log.e(TAG, "Error closing WebSocket", e) + e.printStackTrace() + // В случае ошибки все равно обнуляем + webSocket = null + CoroutineScope(Dispatchers.Main).launch { + _connectionState.value = ConnectionState.Disconnected + _weightPosition.value = null + } + } + } else { + Log.d(TAG, "WebSocket is already null, nothing to disconnect") + CoroutineScope(Dispatchers.Main).launch { + _connectionState.value = ConnectionState.Disconnected + _weightPosition.value = null + } + } + } + + fun sendMessage(message: String) { + webSocket?.send(message) + } + + companion object { + private const val TAG = "WebSocketClient" + } +} + +enum class ConnectionState { + Disconnected, + Connecting, + Connected +} + diff --git a/app/src/main/java/ru/kgeu/training/ui/navigation/MainContainer.kt b/app/src/main/java/ru/kgeu/training/ui/navigation/MainContainer.kt new file mode 100644 index 0000000..9d81f5c --- /dev/null +++ b/app/src/main/java/ru/kgeu/training/ui/navigation/MainContainer.kt @@ -0,0 +1,81 @@ +package ru.kgeu.training.ui.navigation + +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.List +import androidx.compose.material.icons.filled.Home +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.navigation.NavDestination.Companion.hierarchy +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.NavHostController +import androidx.navigation.compose.currentBackStackEntryAsState +import ru.kgeu.training.TrainingApplication +import ru.kgeu.training.ui.screen.* + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MainContainer( + navController: NavHostController, + tcpHost: String, + tcpPort: Int, + application: TrainingApplication, + onPrint: (ru.kgeu.training.data.model.WorkoutSession, String, String) -> Unit +) { + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentDestination = navBackStackEntry?.destination + + // Определяем, показывать ли bottom navigation + val showBottomNav = currentDestination?.route in listOf( + Screen.Main.route, + Screen.Statistics.route + ) + + Scaffold( + bottomBar = { + if (showBottomNav) { + NavigationBar { + NavigationBarItem( + icon = { Icon(Icons.Default.Home, contentDescription = null) }, + label = { Text("Тренировка") }, + selected = currentDestination?.hierarchy?.any { it.route == Screen.Main.route } == true, + onClick = { + navController.navigate(Screen.Main.route) { + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + } + ) + NavigationBarItem( + icon = { Icon(Icons.AutoMirrored.Filled.List, contentDescription = null) }, + label = { Text("Статистика") }, + selected = currentDestination?.hierarchy?.any { it.route == Screen.Statistics.route } == true, + onClick = { + navController.navigate(Screen.Statistics.route) { + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + } + ) + } + } + } + ) { innerPadding -> + NavGraph( + navController = navController, + tcpHost = tcpHost, + tcpPort = tcpPort, + application = application, + onPrint = onPrint, + modifier = Modifier.padding(innerPadding) + ) + } +} + diff --git a/app/src/main/java/ru/kgeu/training/ui/navigation/NavGraph.kt b/app/src/main/java/ru/kgeu/training/ui/navigation/NavGraph.kt new file mode 100644 index 0000000..d994efe --- /dev/null +++ b/app/src/main/java/ru/kgeu/training/ui/navigation/NavGraph.kt @@ -0,0 +1,164 @@ +package ru.kgeu.training.ui.navigation + +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.lifecycle.viewmodel.compose.viewModel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import ru.kgeu.training.TrainingApplication +import ru.kgeu.training.data.model.ExerciseScenario +import ru.kgeu.training.data.model.Participant +import ru.kgeu.training.ui.screen.* +import ru.kgeu.training.ui.viewmodel.* + +sealed class Screen(val route: String) { + object Main : Screen("main") + object Statistics : Screen("statistics") + object ParticipantRegistration : Screen("participant_registration") + object ScenarioSelection : Screen("scenario_selection") + object CustomScenario : Screen("custom_scenario") + object Workout : Screen("workout") +} + +@Composable +fun NavGraph( + navController: NavHostController, + tcpHost: String, + tcpPort: Int, + application: TrainingApplication, + onPrint: (ru.kgeu.training.data.model.WorkoutSession, String, String) -> Unit, + modifier: Modifier = Modifier +) { + NavHost( + navController = navController, + startDestination = Screen.Main.route, + modifier = modifier + ) { + composable(Screen.Statistics.route) { + val viewModelFactory = application.createViewModelFactory() + val viewModel: StatisticsViewModel = viewModel(factory = viewModelFactory) + StatisticsScreen( + viewModel = viewModel, + onPrint = onPrint + ) + } + + composable(Screen.Main.route) { + MainScreen( + onStartWorkout = { + navController.navigate(Screen.ParticipantRegistration.route) + } + ) + } + + composable(Screen.ParticipantRegistration.route) { + val viewModelFactory = application.createViewModelFactory() + val viewModel: ParticipantViewModel = viewModel(factory = viewModelFactory) + ParticipantRegistrationScreen( + viewModel = viewModel, + onParticipantSelected = { participant -> + NavigationState.setParticipant(participant) + navController.navigate(Screen.ScenarioSelection.route) { + popUpTo(Screen.ParticipantRegistration.route) { inclusive = true } + } + }, + onNavigateBack = { + navController.popBackStack() + } + ) + } + + composable(Screen.ScenarioSelection.route) { + val viewModelFactory = application.createViewModelFactory() + val viewModel: ScenarioViewModel = viewModel(factory = viewModelFactory) + ScenarioSelectionScreen( + viewModel = viewModel, + onScenarioSelected = { scenario -> + NavigationState.setScenario(scenario) + navController.navigate(Screen.Workout.route) { + popUpTo(Screen.Main.route) { inclusive = false } + } + }, + onNavigateBack = { + navController.popBackStack() + }, + onCreateCustom = { + navController.navigate(Screen.CustomScenario.route) + } + ) + } + + composable(Screen.CustomScenario.route) { + val viewModelFactory = application.createViewModelFactory() + val viewModel: ScenarioViewModel = viewModel(factory = viewModelFactory) + CustomScenarioScreen( + viewModel = viewModel, + onScenarioCreated = { + navController.popBackStack() + }, + onNavigateBack = { + navController.popBackStack() + } + ) + } + + composable(Screen.Workout.route) { + val participant = NavigationState.selectedParticipant + val scenario = NavigationState.selectedScenario + + if (participant != null && scenario != null) { + val viewModelFactory = application.createViewModelFactory() + val viewModel: WorkoutViewModel = viewModel(factory = viewModelFactory) + val scope = rememberCoroutineScope() + + // Используем ключ для remember, чтобы состояние сохранялось между рекомпозициями + var navigationHandled by remember(participant.id, scenario.id) { mutableStateOf(false) } + + WorkoutScreen( + participant = participant, + scenario = scenario, + tcpHost = tcpHost, + tcpPort = tcpPort, + viewModel = viewModel, + onWorkoutFinished = { + if (!navigationHandled) { + navigationHandled = true + NavigationState.clear() + + // Возвращаемся на главный экран, очищая весь стек + scope.launch { + delay(100) + val currentRoute = navController.currentBackStackEntry?.destination?.route + if (currentRoute == Screen.Workout.route) { + navController.navigate(Screen.Main.route) { + popUpTo(Screen.Main.route) { + inclusive = true + } + launchSingleTop = true + } + } + } + } + } + ) + } else { + // Fallback - вернуться на главный экран только если мы еще не там + LaunchedEffect(Unit) { + val currentRoute = navController.currentBackStackEntry?.destination?.route + if (currentRoute != Screen.Main.route && currentRoute != null) { + navController.navigate(Screen.Main.route) { + popUpTo(Screen.Main.route) { + inclusive = true + } + launchSingleTop = true + } + } + } + } + } + } +} + diff --git a/app/src/main/java/ru/kgeu/training/ui/navigation/NavigationState.kt b/app/src/main/java/ru/kgeu/training/ui/navigation/NavigationState.kt new file mode 100644 index 0000000..c3539fe --- /dev/null +++ b/app/src/main/java/ru/kgeu/training/ui/navigation/NavigationState.kt @@ -0,0 +1,23 @@ +package ru.kgeu.training.ui.navigation + +import ru.kgeu.training.data.model.ExerciseScenario +import ru.kgeu.training.data.model.Participant + +object NavigationState { + var selectedParticipant: Participant? = null + var selectedScenario: ExerciseScenario? = null + + fun setParticipant(participant: Participant) { + selectedParticipant = participant + } + + fun setScenario(scenario: ExerciseScenario) { + selectedScenario = scenario + } + + fun clear() { + selectedParticipant = null + selectedScenario = null + } +} + diff --git a/app/src/main/java/ru/kgeu/training/ui/screen/CustomScenarioScreen.kt b/app/src/main/java/ru/kgeu/training/ui/screen/CustomScenarioScreen.kt new file mode 100644 index 0000000..94b72f6 --- /dev/null +++ b/app/src/main/java/ru/kgeu/training/ui/screen/CustomScenarioScreen.kt @@ -0,0 +1,120 @@ +package ru.kgeu.training.ui.screen + +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import ru.kgeu.training.data.model.ExerciseMode +import ru.kgeu.training.ui.viewmodel.ScenarioViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CustomScenarioScreen( + viewModel: ScenarioViewModel, + onScenarioCreated: () -> Unit, + onNavigateBack: () -> Unit +) { + var name by remember { mutableStateOf("") } + var description by remember { mutableStateOf("") } + var selectedMode by remember { mutableStateOf(ExerciseMode.TIME_BASED) } + var targetValue by remember { mutableStateOf("") } + val scope = rememberCoroutineScope() + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Создать сценарий") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Назад" + ) + } + } + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Название *") }, + modifier = Modifier.fillMaxWidth() + ) + + OutlinedTextField( + value = description, + onValueChange = { description = it }, + label = { Text("Описание") }, + modifier = Modifier.fillMaxWidth(), + maxLines = 3 + ) + + Text("Режим упражнения:") + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + FilterChip( + selected = selectedMode == ExerciseMode.TIME_BASED, + onClick = { selectedMode = ExerciseMode.TIME_BASED }, + label = { Text("На время") } + ) + FilterChip( + selected = selectedMode == ExerciseMode.COUNT_BASED, + onClick = { selectedMode = ExerciseMode.COUNT_BASED }, + label = { Text("На количество") } + ) + } + + OutlinedTextField( + value = targetValue, + onValueChange = { targetValue = it }, + label = { + Text( + when (selectedMode) { + ExerciseMode.TIME_BASED -> "Время (секунды) *" + ExerciseMode.COUNT_BASED -> "Количество подъемов *" + } + ) + }, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.weight(1f)) + + Button( + onClick = { + val target = targetValue.toIntOrNull() + if (name.isNotBlank() && target != null && target > 0) { + scope.launch { + viewModel.createCustomScenario( + name = name, + description = description.ifBlank { null }, + mode = selectedMode, + targetValue = target + ) + onScenarioCreated() + } + } + }, + modifier = Modifier.fillMaxWidth(), + enabled = name.isNotBlank() && targetValue.toIntOrNull() != null + ) { + Text("Создать") + } + } + } +} + diff --git a/app/src/main/java/ru/kgeu/training/ui/screen/MainScreen.kt b/app/src/main/java/ru/kgeu/training/ui/screen/MainScreen.kt new file mode 100644 index 0000000..f2f7d38 --- /dev/null +++ b/app/src/main/java/ru/kgeu/training/ui/screen/MainScreen.kt @@ -0,0 +1,52 @@ +package ru.kgeu.training.ui.screen + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MainScreen( + onStartWorkout: () -> Unit +) { + Scaffold( + topBar = { + CenterAlignedTopAppBar( + title = { Text("Ударный молот") } + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "Добро пожаловать!", + style = MaterialTheme.typography.headlineMedium + ) + + Spacer(modifier = Modifier.height(32.dp)) + + Button( + onClick = onStartWorkout, + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + ) { + Text( + text = "Начать тренировку", + style = MaterialTheme.typography.titleMedium + ) + } + } + } +} + diff --git a/app/src/main/java/ru/kgeu/training/ui/screen/ParticipantRegistrationScreen.kt b/app/src/main/java/ru/kgeu/training/ui/screen/ParticipantRegistrationScreen.kt new file mode 100644 index 0000000..c9fdba3 --- /dev/null +++ b/app/src/main/java/ru/kgeu/training/ui/screen/ParticipantRegistrationScreen.kt @@ -0,0 +1,403 @@ +package ru.kgeu.training.ui.screen + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInParent +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import ru.kgeu.training.data.model.Participant +import ru.kgeu.training.ui.viewmodel.ParticipantViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ParticipantRegistrationScreen( + viewModel: ParticipantViewModel, + onParticipantSelected: (Participant) -> Unit, + onNavigateBack: () -> Unit +) { + var showAddDialog by remember { mutableStateOf(false) } + var showEditDialog by remember { mutableStateOf(false) } + var showDeleteConfirmDialog by remember { mutableStateOf(null) } + var editingParticipant by remember { mutableStateOf(null) } + var name by remember { mutableStateOf("") } + var age by remember { mutableStateOf("") } + var weight by remember { mutableStateOf("") } + var height by remember { mutableStateOf("") } + + val participants by viewModel.participants.collectAsState() + val scope = rememberCoroutineScope() + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Регистрация участника") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Назад" + ) + } + } + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(16.dp) + ) { + Button( + onClick = { showAddDialog = true }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Добавить нового участника") + } + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "Выберите участника:", + style = MaterialTheme.typography.titleMedium + ) + + Spacer(modifier = Modifier.height(8.dp)) + + if (participants.isEmpty()) { + Spacer(modifier = Modifier.weight(1f)) + Text( + text = "Нет зарегистрированных участников", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.weight(1f)) + } else { + LazyColumn { + items(participants) { participant -> + var buttonsBounds by remember { mutableStateOf?>(null) } + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .pointerInput(Unit) { + detectTapGestures { tapOffset -> + // Проверяем, не попал ли клик в область кнопок + val isInButtonsArea = buttonsBounds?.let { (topLeft, bottomRight) -> + tapOffset.x >= topLeft.x && tapOffset.x <= bottomRight.x && + tapOffset.y >= topLeft.y && tapOffset.y <= bottomRight.y + } ?: false + + if (!isInButtonsArea) { + onParticipantSelected(participant) + } + } + } + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier + .weight(1f) + .padding(end = 8.dp) + ) { + Text( + text = participant.name.ifBlank { "Без имени" }, + style = MaterialTheme.typography.titleMedium + ) + participant.age?.let { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Возраст: $it лет", + style = MaterialTheme.typography.bodyMedium + ) + } + participant.weight?.let { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Вес: $it кг", + style = MaterialTheme.typography.bodyMedium + ) + } + participant.height?.let { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Рост: $it см", + style = MaterialTheme.typography.bodyMedium + ) + } + } + // Кнопки в отдельном контейнере + Box( + modifier = Modifier.onGloballyPositioned { coordinates -> + val position = coordinates.positionInParent() + val size = coordinates.size + buttonsBounds = Pair( + position, + Offset(position.x + size.width, position.y + size.height) + ) + } + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + IconButton( + onClick = { + editingParticipant = participant + name = participant.name + age = participant.age?.toString() ?: "" + weight = participant.weight?.toString() ?: "" + height = participant.height?.toString() ?: "" + showEditDialog = true + } + ) { + Icon( + imageVector = Icons.Default.Edit, + contentDescription = "Редактировать", + tint = MaterialTheme.colorScheme.primary + ) + } + IconButton( + onClick = { + showDeleteConfirmDialog = participant + } + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "Удалить", + tint = MaterialTheme.colorScheme.error + ) + } + } + } + } + } + } + } + } + } + } + + if (showAddDialog) { + AlertDialog( + onDismissRequest = { showAddDialog = false }, + title = { Text("Новый участник") }, + text = { + Column { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Имя *") }, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = age, + onValueChange = { age = it }, + label = { Text("Возраст") }, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = weight, + onValueChange = { weight = it }, + label = { Text("Вес (кг)") }, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = height, + onValueChange = { height = it }, + label = { Text("Рост (см)") }, + modifier = Modifier.fillMaxWidth() + ) + } + }, + confirmButton = { + Button( + onClick = { + val trimmedName = name.trim() + if (trimmedName.isNotBlank()) { + val ageInt = age.toIntOrNull() + val weightFloat = weight.toFloatOrNull() + val heightFloat = height.toFloatOrNull() + + scope.launch { + try { + viewModel.addParticipant(trimmedName, ageInt, weightFloat, heightFloat) + // Очищаем поля и закрываем диалог только после успешного сохранения + name = "" + age = "" + weight = "" + height = "" + showAddDialog = false + } catch (e: Exception) { + android.util.Log.e("ParticipantRegistration", "Error adding participant", e) + // Можно показать сообщение об ошибке пользователю + } + } + } + } + ) { + Text("Добавить") + } + }, + dismissButton = { + TextButton(onClick = { showAddDialog = false }) { + Text("Отмена") + } + } + ) + } + + // Диалог редактирования + if (showEditDialog && editingParticipant != null) { + AlertDialog( + onDismissRequest = { + showEditDialog = false + editingParticipant = null + name = "" + age = "" + weight = "" + height = "" + }, + title = { Text("Редактировать участника") }, + text = { + Column { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Имя *") }, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = age, + onValueChange = { age = it }, + label = { Text("Возраст") }, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = weight, + onValueChange = { weight = it }, + label = { Text("Вес (кг)") }, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = height, + onValueChange = { height = it }, + label = { Text("Рост (см)") }, + modifier = Modifier.fillMaxWidth() + ) + } + }, + confirmButton = { + Button( + onClick = { + val trimmedName = name.trim() + if (trimmedName.isNotBlank() && editingParticipant != null) { + val ageInt = age.toIntOrNull() + val weightFloat = weight.toFloatOrNull() + val heightFloat = height.toFloatOrNull() + + scope.launch { + try { + val updatedParticipant = editingParticipant!!.copy( + name = trimmedName, + age = ageInt, + weight = weightFloat, + height = heightFloat + ) + viewModel.updateParticipant(updatedParticipant) + showEditDialog = false + editingParticipant = null + name = "" + age = "" + weight = "" + height = "" + } catch (e: Exception) { + android.util.Log.e("ParticipantRegistration", "Error updating participant", e) + } + } + } + } + ) { + Text("Сохранить") + } + }, + dismissButton = { + TextButton(onClick = { + showEditDialog = false + editingParticipant = null + name = "" + age = "" + weight = "" + height = "" + }) { + Text("Отмена") + } + } + ) + } + + // Диалог подтверждения удаления + showDeleteConfirmDialog?.let { participant -> + AlertDialog( + onDismissRequest = { showDeleteConfirmDialog = null }, + title = { Text("Удалить участника?") }, + text = { + Text("Вы уверены, что хотите удалить участника \"${participant.name}\"? Это действие нельзя отменить.") + }, + confirmButton = { + Button( + onClick = { + scope.launch { + try { + viewModel.deleteParticipant(participant) + showDeleteConfirmDialog = null + } catch (e: Exception) { + android.util.Log.e("ParticipantRegistration", "Error deleting participant", e) + showDeleteConfirmDialog = null + } + } + }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error + ) + ) { + Text("Удалить") + } + }, + dismissButton = { + TextButton(onClick = { showDeleteConfirmDialog = null }) { + Text("Отмена") + } + } + ) + } +} + diff --git a/app/src/main/java/ru/kgeu/training/ui/screen/ScenarioSelectionScreen.kt b/app/src/main/java/ru/kgeu/training/ui/screen/ScenarioSelectionScreen.kt new file mode 100644 index 0000000..66aac7c --- /dev/null +++ b/app/src/main/java/ru/kgeu/training/ui/screen/ScenarioSelectionScreen.kt @@ -0,0 +1,244 @@ +package ru.kgeu.training.ui.screen + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import kotlinx.coroutines.launch +import ru.kgeu.training.data.model.ExerciseMode +import ru.kgeu.training.data.model.ExerciseScenario +import ru.kgeu.training.ui.viewmodel.ScenarioViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ScenarioSelectionScreen( + viewModel: ScenarioViewModel, + onScenarioSelected: (ExerciseScenario) -> Unit, + onNavigateBack: () -> Unit, + onCreateCustom: () -> Unit +) { + var selectedTab by remember { mutableStateOf(0) } + var showDeleteAllDialog by remember { mutableStateOf(false) } + var scenarioToDelete by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() + + val readyScenarios by viewModel.readyScenarios.collectAsState() + val customScenarios by viewModel.customScenarios.collectAsState() + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Выбор сценария") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Назад" + ) + } + } + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { + TabRow(selectedTabIndex = selectedTab) { + Tab( + selected = selectedTab == 0, + onClick = { selectedTab = 0 }, + text = { Text("Готовые") } + ) + Tab( + selected = selectedTab == 1, + onClick = { selectedTab = 1 }, + text = { Text("Пользовательские") } + ) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Button( + onClick = onCreateCustom, + modifier = Modifier.weight(1f) + ) { + Text("Создать индивидуальный сценарий") + } + + // Кнопка очистки готовых сценариев (только для вкладки "Готовые") + if (selectedTab == 0 && readyScenarios.isNotEmpty()) { + OutlinedButton( + onClick = { showDeleteAllDialog = true }, + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error + ) + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text("Очистить") + } + } + } + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + val scenarios = if (selectedTab == 0) readyScenarios else customScenarios + + items(scenarios) { scenario -> + ScenarioCard( + scenario = scenario, + onClick = { onScenarioSelected(scenario) }, + onDelete = { + if (scenario.isCustom) { + // Для пользовательских сценариев - удаляем сразу + scope.launch { + viewModel.deleteScenario(scenario) + } + } else { + // Для готовых сценариев - показываем диалог подтверждения + scenarioToDelete = scenario + } + }, + showDeleteButton = true + ) + } + } + } + } + + // Диалог подтверждения удаления одного готового сценария + scenarioToDelete?.let { scenario -> + AlertDialog( + onDismissRequest = { scenarioToDelete = null }, + title = { Text("Удалить сценарий?") }, + text = { + Text("Вы уверены, что хотите удалить сценарий \"${scenario.name}\"? После удаления всех готовых сценариев они будут автоматически пересозданы.") + }, + confirmButton = { + Button( + onClick = { + scope.launch { + viewModel.deleteScenario(scenario) + scenarioToDelete = null + } + }, + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error) + ) { + Text("Удалить") + } + }, + dismissButton = { + TextButton(onClick = { scenarioToDelete = null }) { + Text("Отмена") + } + } + ) + } + + // Диалог подтверждения удаления всех готовых сценариев + if (showDeleteAllDialog) { + AlertDialog( + onDismissRequest = { showDeleteAllDialog = false }, + title = { Text("Очистить все готовые сценарии?") }, + text = { + Text("Вы уверены, что хотите удалить все готовые сценарии (${readyScenarios.size} шт.)? После удаления будут автоматически созданы 4 базовых сценария.") + }, + confirmButton = { + Button( + onClick = { + scope.launch { + viewModel.deleteAllReadyScenarios() + showDeleteAllDialog = false + } + }, + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error) + ) { + Text("Очистить все") + } + }, + dismissButton = { + TextButton(onClick = { showDeleteAllDialog = false }) { + Text("Отмена") + } + } + ) + } +} + +@Composable +fun ScenarioCard( + scenario: ExerciseScenario, + onClick: () -> Unit, + onDelete: () -> Unit, + showDeleteButton: Boolean = false +) { + Card( + onClick = onClick, + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = scenario.name, + style = MaterialTheme.typography.titleMedium + ) + scenario.description?.let { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = it, + style = MaterialTheme.typography.bodyMedium + ) + } + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = when (scenario.exerciseMode) { + ExerciseMode.TIME_BASED -> "Режим: на время (${scenario.targetValue} сек)" + ExerciseMode.COUNT_BASED -> "Режим: на количество (${scenario.targetValue} подъемов)" + }, + style = MaterialTheme.typography.bodySmall + ) + } + + if (showDeleteButton) { + IconButton( + onClick = onDelete, + modifier = Modifier.size(48.dp) + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "Удалить", + tint = MaterialTheme.colorScheme.error + ) + } + } + } + } +} + diff --git a/app/src/main/java/ru/kgeu/training/ui/screen/StatisticsScreen.kt b/app/src/main/java/ru/kgeu/training/ui/screen/StatisticsScreen.kt new file mode 100644 index 0000000..a6a2203 --- /dev/null +++ b/app/src/main/java/ru/kgeu/training/ui/screen/StatisticsScreen.kt @@ -0,0 +1,599 @@ +package ru.kgeu.training.ui.screen + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import ru.kgeu.training.data.model.WorkoutSession +import ru.kgeu.training.ui.viewmodel.StatisticsViewModel +import java.text.SimpleDateFormat +import java.util.* + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun StatisticsScreen( + viewModel: StatisticsViewModel, + onPrint: (WorkoutSession, String, String) -> Unit +) { + val participantStatistics by viewModel.participantStatistics.collectAsState() + val selectedParticipantId by viewModel.selectedParticipantId.collectAsState() + val selectedSession by viewModel.selectedSession.collectAsState() + val scope = rememberCoroutineScope() + + var participantName by remember { mutableStateOf(null) } + var scenarioName by remember { mutableStateOf(null) } + var sessionToDelete by remember { mutableStateOf(null) } + + val displayedSessions = if (selectedParticipantId != null) { + viewModel.getSessionsForParticipant(selectedParticipantId!!) + } else { + emptyList() + } + + LaunchedEffect(selectedSession) { + selectedSession?.let { session -> + scope.launch { + participantName = viewModel.getParticipantName(session.participantId) + scenarioName = viewModel.getScenarioName(session.scenarioId) + } + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Статистика тренировок") } + ) + } + ) { padding -> + if (participantStatistics.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentAlignment = Alignment.Center + ) { + Text( + text = "Нет данных о тренировках", + style = MaterialTheme.typography.bodyLarge + ) + } + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + items(participantStatistics) { stats -> + ParticipantStatisticsCard( + statistics = stats, + isExpanded = selectedParticipantId == stats.participant.id, + onExpandClick = { + viewModel.selectParticipant( + if (selectedParticipantId == stats.participant.id) null + else stats.participant.id + ) + }, + sessions = if (selectedParticipantId == stats.participant.id) displayedSessions else emptyList(), + onSessionClick = { session -> + viewModel.selectSession(session) + }, + onPrint = { session -> + scope.launch { + val pName = viewModel.getParticipantName(session.participantId) ?: "Неизвестно" + val sName = viewModel.getScenarioName(session.scenarioId) ?: "Неизвестно" + onPrint(session, pName, sName) + } + }, + onDelete = { session -> + sessionToDelete = session + } + ) + } + } + } + } + + // Диалог с деталями сессии + selectedSession?.let { session -> + AlertDialog( + onDismissRequest = { viewModel.selectSession(null) }, + title = { Text("Детали тренировки") }, + text = { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text("Участник: ${participantName ?: "Загрузка..."}") + Text("Сценарий: ${scenarioName ?: "Загрузка..."}") + Text("Дата: ${formatDate(session.startTime)}") + Text("Длительность: ${formatDuration(session.durationSeconds)}") + Text("Подъемов: ${session.completedLifts}") + session.averageHeartRate?.let { + Text("Средний пульс: $it уд/мин") + } + session.maxHeartRate?.let { + Text("Максимальный пульс: $it уд/мин") + } + session.minHeartRate?.let { + Text("Минимальный пульс: $it уд/мин") + } + } + }, + confirmButton = { + Button( + onClick = { + scope.launch { + val pName = viewModel.getParticipantName(session.participantId) ?: "Неизвестно" + val sName = viewModel.getScenarioName(session.scenarioId) ?: "Неизвестно" + onPrint(session, pName, sName) + } + viewModel.selectSession(null) + } + ) { + Text("Печать") + } + }, + dismissButton = { + TextButton(onClick = { viewModel.selectSession(null) }) { + Text("Закрыть") + } + } + ) + } + + // Диалог подтверждения удаления + sessionToDelete?.let { session -> + AlertDialog( + onDismissRequest = { sessionToDelete = null }, + title = { Text("Удалить запись?") }, + text = { + Text("Вы уверены, что хотите удалить эту запись тренировки от ${formatDate(session.startTime)}?") + }, + confirmButton = { + Button( + onClick = { + scope.launch { + viewModel.deleteSession(session) + sessionToDelete = null + } + }, + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error) + ) { + Text("Удалить") + } + }, + dismissButton = { + TextButton(onClick = { sessionToDelete = null }) { + Text("Отмена") + } + } + ) + } +} + +@Composable +fun ParticipantStatisticsCard( + statistics: ru.kgeu.training.ui.viewmodel.ParticipantStatistics, + isExpanded: Boolean, + onExpandClick: () -> Unit, + sessions: List, + onSessionClick: (WorkoutSession) -> Unit, + onPrint: (WorkoutSession) -> Unit, + onDelete: (WorkoutSession) -> Unit +) { + Card( + modifier = Modifier.fillMaxWidth(), + onClick = onExpandClick + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = statistics.participant.name, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Тренировок: ${statistics.totalSessions}", + style = MaterialTheme.typography.bodyMedium + ) + Text( + text = "Всего подъемов: ${statistics.totalLifts}", + style = MaterialTheme.typography.bodyMedium + ) + Text( + text = "Среднее подъемов: ${String.format("%.1f", statistics.averageLifts)}", + style = MaterialTheme.typography.bodyMedium + ) + Text( + text = "Средняя длительность: ${formatDuration(statistics.averageDuration.toLong())}", + style = MaterialTheme.typography.bodyMedium + ) + statistics.averageHeartRate?.let { + Text( + text = "Средний пульс: ${String.format("%.0f", it)} уд/мин", + style = MaterialTheme.typography.bodyMedium + ) + } + } + } + + if (isExpanded && sessions.isNotEmpty()) { + Spacer(modifier = Modifier.height(16.dp)) + HorizontalDivider() + Spacer(modifier = Modifier.height(16.dp)) + + // График подъемов по времени + Text( + text = "Подъемы по времени", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(8.dp)) + LiftsChart( + sessions = sessions, + modifier = Modifier + .fillMaxWidth() + .height(200.dp) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // График пульса по времени (если есть данные) + val sessionsWithHeartRate = sessions.filter { it.averageHeartRate != null } + if (sessionsWithHeartRate.isNotEmpty()) { + Text( + text = "Пульс по времени", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(8.dp)) + HeartRateChart( + sessions = sessionsWithHeartRate, + modifier = Modifier + .fillMaxWidth() + .height(200.dp) + ) + Spacer(modifier = Modifier.height(16.dp)) + } + + // Список сессий + Text( + text = "История тренировок", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(8.dp)) + sessions.forEach { session -> + SessionCard( + session = session, + onClick = { onSessionClick(session) }, + onPrint = { onPrint(session) }, + onDelete = { onDelete(session) }, + modifier = Modifier.padding(vertical = 4.dp) + ) + } + } + } + } +} + +@Composable +fun LiftsChart( + sessions: List, + modifier: Modifier = Modifier +) { + if (sessions.isEmpty()) return + + val maxLifts = sessions.maxOfOrNull { it.completedLifts }?.toFloat() ?: 1f + val minLifts = sessions.minOfOrNull { it.completedLifts }?.toFloat() ?: 0f + val range = maxLifts - minLifts + + val primaryColor = MaterialTheme.colorScheme.primary + val onSurfaceVariantColor = MaterialTheme.colorScheme.onSurfaceVariant + val typography = MaterialTheme.typography + + Card( + modifier = modifier, + shape = RoundedCornerShape(8.dp) + ) { + Box(modifier = Modifier.fillMaxSize().padding(16.dp)) { + Canvas(modifier = Modifier.fillMaxSize()) { + val width = size.width + val height = size.height + val padding = 40.dp.toPx() + val chartWidth = width - padding * 2 + val chartHeight = height - padding * 2 + + // Оси + drawLine( + color = Color.Gray, + start = Offset(padding, padding), + end = Offset(padding, padding + chartHeight), + strokeWidth = 2f + ) + drawLine( + color = Color.Gray, + start = Offset(padding, padding + chartHeight), + end = Offset(padding + chartWidth, padding + chartHeight), + strokeWidth = 2f + ) + + // Линия графика + if (sessions.size > 1) { + val path = Path() + val stepX = chartWidth / (sessions.size - 1) + + sessions.forEachIndexed { index, session -> + val x = padding + stepX * index + val normalizedValue = if (range > 0) { + (session.completedLifts - minLifts) / range + } else 0.5f + val y = padding + chartHeight - (normalizedValue * chartHeight) + + if (index == 0) { + path.moveTo(x, y) + } else { + path.lineTo(x, y) + } + } + + drawPath( + path = path, + color = primaryColor, + style = Stroke(width = 3f) + ) + + // Точки + sessions.forEachIndexed { index, session -> + val x = padding + stepX * index + val normalizedValue = if (range > 0) { + (session.completedLifts - minLifts) / range + } else 0.5f + val y = padding + chartHeight - (normalizedValue * chartHeight) + + drawCircle( + color = primaryColor, + radius = 6.dp.toPx(), + center = Offset(x, y) + ) + } + } + } + + // Подписи осей + Column( + modifier = Modifier.align(Alignment.BottomStart).padding(start = 8.dp, bottom = 8.dp) + ) { + Text( + text = "0", + style = typography.bodySmall, + color = onSurfaceVariantColor + ) + } + Column( + modifier = Modifier.align(Alignment.TopEnd).padding(end = 8.dp, top = 8.dp) + ) { + Text( + text = "${maxLifts.toInt()}", + style = typography.bodySmall, + color = onSurfaceVariantColor + ) + } + } + } +} + +@Composable +fun HeartRateChart( + sessions: List, + modifier: Modifier = Modifier +) { + if (sessions.isEmpty()) return + + val heartRates = sessions.mapNotNull { it.averageHeartRate?.toFloat() } + val maxHR = heartRates.maxOrNull() ?: 1f + val minHR = heartRates.minOrNull() ?: 0f + val range = maxHR - minHR + + val errorColor = MaterialTheme.colorScheme.error + val onSurfaceVariantColor = MaterialTheme.colorScheme.onSurfaceVariant + val typography = MaterialTheme.typography + + Card( + modifier = modifier, + shape = RoundedCornerShape(8.dp) + ) { + Box(modifier = Modifier.fillMaxSize().padding(16.dp)) { + Canvas(modifier = Modifier.fillMaxSize()) { + val width = size.width + val height = size.height + val padding = 40.dp.toPx() + val chartWidth = width - padding * 2 + val chartHeight = height - padding * 2 + + // Оси + drawLine( + color = Color.Gray, + start = Offset(padding, padding), + end = Offset(padding, padding + chartHeight), + strokeWidth = 2f + ) + drawLine( + color = Color.Gray, + start = Offset(padding, padding + chartHeight), + end = Offset(padding + chartWidth, padding + chartHeight), + strokeWidth = 2f + ) + + // Линия графика + if (sessions.size > 1) { + val path = Path() + val stepX = chartWidth / (sessions.size - 1) + + sessions.forEachIndexed { index, session -> + session.averageHeartRate?.let { hr -> + val x = padding + stepX * index + val normalizedValue = if (range > 0) { + (hr - minHR) / range + } else 0.5f + val y = padding + chartHeight - (normalizedValue * chartHeight) + + if (index == 0) { + path.moveTo(x, y) + } else { + path.lineTo(x, y) + } + } + } + + drawPath( + path = path, + color = errorColor, + style = Stroke(width = 3f) + ) + + // Точки + sessions.forEachIndexed { index, session -> + session.averageHeartRate?.let { hr -> + val x = padding + stepX * index + val normalizedValue = if (range > 0) { + (hr - minHR) / range + } else 0.5f + val y = padding + chartHeight - (normalizedValue * chartHeight) + + drawCircle( + color = errorColor, + radius = 6.dp.toPx(), + center = Offset(x, y) + ) + } + } + } + } + + // Подписи осей + Column( + modifier = Modifier.align(Alignment.BottomStart).padding(start = 8.dp, bottom = 8.dp) + ) { + Text( + text = "${minHR.toInt()}", + style = typography.bodySmall, + color = onSurfaceVariantColor + ) + } + Column( + modifier = Modifier.align(Alignment.TopEnd).padding(end = 8.dp, top = 8.dp) + ) { + Text( + text = "${maxHR.toInt()}", + style = typography.bodySmall, + color = onSurfaceVariantColor + ) + } + } + } +} + +@Composable +fun SessionCard( + session: WorkoutSession, + onClick: () -> Unit, + onPrint: () -> Unit, + onDelete: () -> Unit, + modifier: Modifier = Modifier +) { + Card( + onClick = onClick, + modifier = modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = formatDate(session.startTime), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Длительность: ${formatDuration(session.durationSeconds)}", + style = MaterialTheme.typography.bodyMedium + ) + Text( + text = "Подъемов: ${session.completedLifts}", + style = MaterialTheme.typography.bodyMedium + ) + session.averageHeartRate?.let { + Text( + text = "Пульс: $it уд/мин", + style = MaterialTheme.typography.bodySmall + ) + } + } + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + TextButton(onClick = onPrint) { + Text("Печать") + } + IconButton( + onClick = onDelete, + modifier = Modifier.size(48.dp) + ) { + Icon( + imageVector = Icons.Filled.Delete, + contentDescription = "Удалить", + tint = MaterialTheme.colorScheme.error + ) + } + } + } + } + } +} + +fun formatDate(timestamp: Long): String { + val dateFormat = SimpleDateFormat("dd.MM.yyyy HH:mm", Locale.getDefault()) + return dateFormat.format(Date(timestamp)) +} + +fun formatDuration(seconds: Long): String { + val hours = seconds / 3600 + val minutes = (seconds % 3600) / 60 + val secs = seconds % 60 + + return if (hours > 0) { + String.format("%d:%02d:%02d", hours, minutes, secs) + } else { + String.format("%d:%02d", minutes, secs) + } +} diff --git a/app/src/main/java/ru/kgeu/training/ui/screen/WorkoutScreen.kt b/app/src/main/java/ru/kgeu/training/ui/screen/WorkoutScreen.kt new file mode 100644 index 0000000..fbafd33 --- /dev/null +++ b/app/src/main/java/ru/kgeu/training/ui/screen/WorkoutScreen.kt @@ -0,0 +1,339 @@ +package ru.kgeu.training.ui.screen + +import android.util.Log +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import kotlinx.coroutines.launch +import ru.kgeu.training.data.model.ExerciseMode +import ru.kgeu.training.data.model.ExerciseScenario +import ru.kgeu.training.data.model.Participant +import ru.kgeu.training.data.model.WorkoutSession +import ru.kgeu.training.ui.viewmodel.WorkoutViewModel +import java.util.concurrent.TimeUnit + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WorkoutScreen( + participant: Participant, + scenario: ExerciseScenario, + tcpHost: String, + tcpPort: Int, + viewModel: WorkoutViewModel, + onWorkoutFinished: () -> Unit +) { + val isActive by viewModel.isWorkoutActive.collectAsState() + val isTimerStarted by viewModel.isTimerStarted.collectAsState() + val completedLifts by viewModel.completedLifts.collectAsState() + val elapsedTime by viewModel.elapsedTime.collectAsState() + val heartRate by viewModel.heartRate.collectAsState() + val currentSession by viewModel.currentSession.collectAsState() + + val connectionState by viewModel.tcpConnectionState.collectAsState() + val bluetoothState by viewModel.bluetoothConnectionState.collectAsState() + val scope = rememberCoroutineScope() + + // Получаем текущее положение веса для отображения + val weightPosition by viewModel.weightPosition.collectAsState() + + var workoutStarted by remember { mutableStateOf(false) } + + LaunchedEffect(participant.id, scenario.id) { + if (!workoutStarted && !isActive && participant.id > 0 && scenario.id > 0) { + workoutStarted = true + try { + viewModel.startWorkout( + participantId = participant.id, + scenarioId = scenario.id, + mode = scenario.exerciseMode, + target = scenario.targetValue, + tcpHost = tcpHost, + tcpPort = tcpPort + ) + } catch (e: Exception) { + Log.e("WorkoutScreen", "Error starting workout", e) + workoutStarted = false + } + } + } + + DisposableEffect(Unit) { + onDispose { + Log.d("WorkoutScreen", "WorkoutScreen disposed, stopping workout and disconnecting") + if (isActive) { + viewModel.stopWorkout() + } else { + // Если тренировка не активна, но мы выходим с экрана, все равно отключаем соединения + // чтобы они не оставались активными + try { + viewModel.disconnectAll() + } catch (e: Exception) { + Log.e("WorkoutScreen", "Error disconnecting on dispose", e) + } + } + } + } + + Scaffold( + topBar = { + CenterAlignedTopAppBar( + title = { Text(scenario.name) } + ) + }, + bottomBar = { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Button( + onClick = { + viewModel.stopWorkout() + // Небольшая задержка перед навигацией для завершения сохранения + scope.launch { + kotlinx.coroutines.delay(300) + onWorkoutFinished() + } + }, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error + ) + ) { + Text("Остановить") + } + } + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + // Участник + Card(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Участник: ${participant.name}", + style = MaterialTheme.typography.titleMedium + ) + } + } + + // Статус подключений и таймера + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + StatusChip( + label = "ESP32", + isConnected = connectionState == ru.kgeu.training.network.ConnectionState.Connected + ) + StatusChip( + label = "Кардиодатчик", + isConnected = bluetoothState == ru.kgeu.training.bluetooth.BluetoothConnectionState.Connected + ) + } + + // Статус таймера + if (!isTimerStarted) { + Surface( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.small + ) { + Text( + text = "Ожидание", + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + // Текущее положение веса + weightPosition?.let { position -> + Surface( + color = when (position) { + ru.kgeu.training.data.model.WeightPosition.TOP -> MaterialTheme.colorScheme.primaryContainer + ru.kgeu.training.data.model.WeightPosition.MIDDLE -> MaterialTheme.colorScheme.secondaryContainer + ru.kgeu.training.data.model.WeightPosition.BOTTOM -> MaterialTheme.colorScheme.tertiaryContainer + }, + shape = MaterialTheme.shapes.small + ) { + Text( + text = "Положение: ${ + when (position) { + ru.kgeu.training.data.model.WeightPosition.TOP -> "Верх" + ru.kgeu.training.data.model.WeightPosition.MIDDLE -> "В движении" + ru.kgeu.training.data.model.WeightPosition.BOTTOM -> "Низ" + } + }", + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + style = MaterialTheme.typography.bodySmall + ) + } + } + } + + // Показания кардиодатчика + if (heartRate != null) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ) + ) { + Column( + modifier = Modifier.padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Пульс", + style = MaterialTheme.typography.bodyMedium + ) + Text( + text = "$heartRate", + fontSize = 48.sp, + fontWeight = FontWeight.Bold + ) + Text( + text = "уд/мин", + style = MaterialTheme.typography.bodySmall + ) + } + } + } + + // Основные показатели + when (scenario.exerciseMode) { + ExerciseMode.TIME_BASED -> { + // Режим на время - показываем количество подъемов + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer + ) + ) { + Column( + modifier = Modifier.padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Выполнено подъемов", + style = MaterialTheme.typography.bodyMedium + ) + Text( + text = "$completedLifts", + fontSize = 64.sp, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + Text( + text = "Цель: ${scenario.targetValue} сек", + style = MaterialTheme.typography.bodySmall + ) + } + } + + // Таймер + Text( + text = formatTime(elapsedTime), + fontSize = 32.sp, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + } + + ExerciseMode.COUNT_BASED -> { + // Режим на количество - показываем таймер + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer + ) + ) { + Column( + modifier = Modifier.padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Время", + style = MaterialTheme.typography.bodyMedium + ) + Text( + text = formatTime(elapsedTime), + fontSize = 64.sp, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + Text( + text = "Цель: ${scenario.targetValue} подъемов", + style = MaterialTheme.typography.bodySmall + ) + } + } + + // Количество подъемов + Text( + text = "$completedLifts / ${scenario.targetValue}", + fontSize = 32.sp, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + } + } + } + } +} + +@Composable +fun StatusChip(label: String, isConnected: Boolean) { + Surface( + color = if (isConnected) MaterialTheme.colorScheme.primaryContainer + else MaterialTheme.colorScheme.errorContainer, + shape = MaterialTheme.shapes.small + ) { + Text( + text = "$label: ${if (isConnected) "✓" else "✗"}", + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + style = MaterialTheme.typography.bodySmall + ) + } +} + +fun formatTime(seconds: Long): String { + val hours = TimeUnit.SECONDS.toHours(seconds) + val minutes = TimeUnit.SECONDS.toMinutes(seconds) % 60 + val secs = seconds % 60 + + return if (hours > 0) { + String.format("%02d:%02d:%02d", hours, minutes, secs) + } else { + String.format("%02d:%02d", minutes, secs) + } +} + diff --git a/app/src/main/java/ru/kgeu/training/ui/theme/Color.kt b/app/src/main/java/ru/kgeu/training/ui/theme/Color.kt new file mode 100644 index 0000000..c087432 --- /dev/null +++ b/app/src/main/java/ru/kgeu/training/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package ru.kgeu.training.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) \ No newline at end of file diff --git a/app/src/main/java/ru/kgeu/training/ui/theme/Theme.kt b/app/src/main/java/ru/kgeu/training/ui/theme/Theme.kt new file mode 100644 index 0000000..1467be8 --- /dev/null +++ b/app/src/main/java/ru/kgeu/training/ui/theme/Theme.kt @@ -0,0 +1,58 @@ +package ru.kgeu.training.ui.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +private val DarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80 +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40 + + /* Other default colors to override + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), + */ +) + +@Composable +fun ТренировкаTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/app/src/main/java/ru/kgeu/training/ui/theme/Type.kt b/app/src/main/java/ru/kgeu/training/ui/theme/Type.kt new file mode 100644 index 0000000..fdfab96 --- /dev/null +++ b/app/src/main/java/ru/kgeu/training/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package ru.kgeu.training.ui.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 + ) + */ +) \ No newline at end of file diff --git a/app/src/main/java/ru/kgeu/training/ui/viewmodel/ParticipantViewModel.kt b/app/src/main/java/ru/kgeu/training/ui/viewmodel/ParticipantViewModel.kt new file mode 100644 index 0000000..3fddb64 --- /dev/null +++ b/app/src/main/java/ru/kgeu/training/ui/viewmodel/ParticipantViewModel.kt @@ -0,0 +1,74 @@ +package ru.kgeu.training.ui.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import ru.kgeu.training.data.model.Participant +import ru.kgeu.training.data.repository.TrainingRepository + +class ParticipantViewModel(private val repository: TrainingRepository) : ViewModel() { + private val _participants = MutableStateFlow>(emptyList()) + val participants: StateFlow> = _participants.asStateFlow() + + private val _selectedParticipant = MutableStateFlow(null) + val selectedParticipant: StateFlow = _selectedParticipant.asStateFlow() + + init { + loadParticipants() + } + + private fun loadParticipants() { + viewModelScope.launch { + repository.getAllParticipants().collect { list -> + _participants.value = list + } + } + } + + fun selectParticipant(participant: Participant) { + _selectedParticipant.value = participant + } + + suspend fun addParticipant(name: String, age: Int?, weight: Float?, height: Float?): Long { + // Имя уже обрезано в UI, но на всякий случай обрезаем еще раз + val trimmedName = name.trim() + android.util.Log.d("ParticipantViewModel", "Adding participant with name: '$trimmedName' (length: ${trimmedName.length})") + + if (trimmedName.isEmpty()) { + android.util.Log.e("ParticipantViewModel", "Cannot add participant with empty name") + throw IllegalArgumentException("Name cannot be empty") + } + + val participant = Participant( + name = trimmedName, + age = age, + weight = weight, + height = height + ) + val id = repository.insertParticipant(participant) + android.util.Log.d("ParticipantViewModel", "Participant added with id: $id, name: '${participant.name}'") + + // Flow автоматически обновится после вставки, так как Room отслеживает изменения + return id + } + + suspend fun updateParticipant(participant: Participant) { + val trimmedName = participant.name.trim() + if (trimmedName.isEmpty()) { + throw IllegalArgumentException("Name cannot be empty") + } + + val updatedParticipant = participant.copy(name = trimmedName) + repository.updateParticipant(updatedParticipant) + android.util.Log.d("ParticipantViewModel", "Participant updated: id=${updatedParticipant.id}, name='${updatedParticipant.name}'") + } + + suspend fun deleteParticipant(participant: Participant) { + repository.deleteParticipant(participant) + android.util.Log.d("ParticipantViewModel", "Participant deleted: id=${participant.id}, name='${participant.name}'") + } +} + diff --git a/app/src/main/java/ru/kgeu/training/ui/viewmodel/ScenarioViewModel.kt b/app/src/main/java/ru/kgeu/training/ui/viewmodel/ScenarioViewModel.kt new file mode 100644 index 0000000..bb90fed --- /dev/null +++ b/app/src/main/java/ru/kgeu/training/ui/viewmodel/ScenarioViewModel.kt @@ -0,0 +1,115 @@ +package ru.kgeu.training.ui.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import ru.kgeu.training.data.model.ExerciseMode +import ru.kgeu.training.data.model.ExerciseScenario +import ru.kgeu.training.data.repository.TrainingRepository + +class ScenarioViewModel(private val repository: TrainingRepository) : ViewModel() { + private val _readyScenarios = MutableStateFlow>(emptyList()) + val readyScenarios: StateFlow> = _readyScenarios.asStateFlow() + + private val _customScenarios = MutableStateFlow>(emptyList()) + val customScenarios: StateFlow> = _customScenarios.asStateFlow() + + private val _selectedScenario = MutableStateFlow(null) + val selectedScenario: StateFlow = _selectedScenario.asStateFlow() + + init { + viewModelScope.launch { + // Сначала инициализируем дефолтные сценарии, если их еще нет + initializeDefaultScenarios() + } + // Затем загружаем сценарии из базы + loadScenarios() + } + + private fun loadScenarios() { + viewModelScope.launch { + repository.getReadyScenarios().collect { list -> + _readyScenarios.value = list + } + } + viewModelScope.launch { + repository.getCustomScenarios().collect { list -> + _customScenarios.value = list + } + } + } + + private suspend fun initializeDefaultScenarios() { + // Проверяем количество готовых сценариев в базе данных + val count = repository.getReadyScenariosCount() + + // Если сценариев нет, создаем дефолтные (только 4 базовых) + if (count == 0) { + val defaultScenarios = listOf( + ExerciseScenario( + name = "Тренировка на время - 60 секунд", + description = "Выполните максимальное количество подъемов за 60 секунд", + exerciseMode = ExerciseMode.TIME_BASED, + targetValue = 60, + isCustom = false + ), + ExerciseScenario( + name = "Тренировка на время - 120 секунд", + description = "Выполните максимальное количество подъемов за 120 секунд", + exerciseMode = ExerciseMode.TIME_BASED, + targetValue = 120, + isCustom = false + ), + ExerciseScenario( + name = "Тренировка на количество - 10 подъемов", + description = "Выполните 10 подъемов на время", + exerciseMode = ExerciseMode.COUNT_BASED, + targetValue = 10, + isCustom = false + ), + ExerciseScenario( + name = "Тренировка на количество - 20 подъемов", + description = "Выполните 20 подъемов на время", + exerciseMode = ExerciseMode.COUNT_BASED, + targetValue = 20, + isCustom = false + ) + ) + defaultScenarios.forEach { repository.insertScenario(it) } + } + } + + fun selectScenario(scenario: ExerciseScenario) { + _selectedScenario.value = scenario + } + + suspend fun createCustomScenario( + name: String, + description: String?, + mode: ExerciseMode, + targetValue: Int + ): Long { + val scenario = ExerciseScenario( + name = name, + description = description, + exerciseMode = mode, + targetValue = targetValue, + isCustom = true + ) + return repository.insertScenario(scenario) + } + + suspend fun deleteScenario(scenario: ExerciseScenario) { + repository.deleteScenario(scenario) + } + + suspend fun deleteAllReadyScenarios() { + repository.deleteAllReadyScenarios() + // После удаления всех готовых сценариев, пересоздаем базовые + initializeDefaultScenarios() + } +} + diff --git a/app/src/main/java/ru/kgeu/training/ui/viewmodel/StatisticsViewModel.kt b/app/src/main/java/ru/kgeu/training/ui/viewmodel/StatisticsViewModel.kt new file mode 100644 index 0000000..b25fc62 --- /dev/null +++ b/app/src/main/java/ru/kgeu/training/ui/viewmodel/StatisticsViewModel.kt @@ -0,0 +1,121 @@ +package ru.kgeu.training.ui.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch +import ru.kgeu.training.data.model.Participant +import ru.kgeu.training.data.model.WorkoutSession +import ru.kgeu.training.data.repository.TrainingRepository + +data class ParticipantStatistics( + val participant: Participant, + val sessions: List, + val totalSessions: Int, + val totalLifts: Int, + val averageLifts: Double, + val averageDuration: Double, + val averageHeartRate: Double? +) + +class StatisticsViewModel(private val repository: TrainingRepository) : ViewModel() { + private val _sessions = MutableStateFlow>(emptyList()) + val sessions: StateFlow> = _sessions.asStateFlow() + + private val _participants = MutableStateFlow>(emptyList()) + val participants: StateFlow> = _participants.asStateFlow() + + private val _participantStatistics = MutableStateFlow>(emptyList()) + val participantStatistics: StateFlow> = _participantStatistics.asStateFlow() + + private val _selectedSession = MutableStateFlow(null) + val selectedSession: StateFlow = _selectedSession.asStateFlow() + + private val _selectedParticipantId = MutableStateFlow(null) + val selectedParticipantId: StateFlow = _selectedParticipantId.asStateFlow() + + init { + loadData() + } + + private fun loadData() { + viewModelScope.launch { + repository.getAllSessions().collect { sessionsList -> + _sessions.value = sessionsList + updateParticipantStatistics(sessionsList) + } + } + + viewModelScope.launch { + repository.getAllParticipants().collect { participantsList -> + _participants.value = participantsList + // Обновляем статистику при изменении участников, чтобы отобразить актуальные имена + updateParticipantStatistics(_sessions.value) + } + } + } + + private suspend fun updateParticipantStatistics(sessions: List) { + val participantsMap = _participants.value.associateBy { it.id } + val groupedSessions = sessions.groupBy { it.participantId } + + val statistics = groupedSessions.map { (participantId, participantSessions) -> + val participant = participantsMap[participantId] ?: return@map null + + val totalLifts = participantSessions.sumOf { it.completedLifts } + val averageLifts = if (participantSessions.isNotEmpty()) { + totalLifts.toDouble() / participantSessions.size + } else 0.0 + + val averageDuration = if (participantSessions.isNotEmpty()) { + participantSessions.map { it.durationSeconds }.average() + } else 0.0 + + val heartRates = participantSessions.mapNotNull { it.averageHeartRate } + val averageHeartRate = if (heartRates.isNotEmpty()) { + heartRates.average() + } else null + + ParticipantStatistics( + participant = participant, + sessions = participantSessions.sortedByDescending { it.startTime }, + totalSessions = participantSessions.size, + totalLifts = totalLifts, + averageLifts = averageLifts, + averageDuration = averageDuration, + averageHeartRate = averageHeartRate + ) + }.filterNotNull().sortedByDescending { it.sessions.firstOrNull()?.startTime ?: 0L } + + _participantStatistics.value = statistics + } + + fun selectSession(session: WorkoutSession?) { + _selectedSession.value = session + } + + fun selectParticipant(participantId: Long?) { + _selectedParticipantId.value = participantId + } + + fun getSessionsForParticipant(participantId: Long): List { + return _sessions.value.filter { it.participantId == participantId } + .sortedByDescending { it.startTime } + } + + suspend fun getParticipantName(participantId: Long): String? { + return repository.getParticipantById(participantId)?.name + } + + suspend fun getScenarioName(scenarioId: Long): String? { + return repository.getScenarioById(scenarioId)?.name + } + + suspend fun deleteSession(session: WorkoutSession) { + repository.deleteSession(session) + // Статистика обновится автоматически через Flow + } +} diff --git a/app/src/main/java/ru/kgeu/training/ui/viewmodel/ViewModelFactory.kt b/app/src/main/java/ru/kgeu/training/ui/viewmodel/ViewModelFactory.kt new file mode 100644 index 0000000..c6ff7a4 --- /dev/null +++ b/app/src/main/java/ru/kgeu/training/ui/viewmodel/ViewModelFactory.kt @@ -0,0 +1,39 @@ +package ru.kgeu.training.ui.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import ru.kgeu.training.TrainingApplication +import ru.kgeu.training.bluetooth.HeartRateMonitor +import ru.kgeu.training.data.repository.TrainingRepository +import ru.kgeu.training.network.TcpClient + +class ViewModelFactory( + private val repository: TrainingRepository, + private val tcpClient: TcpClient, + private val heartRateMonitor: HeartRateMonitor +) : ViewModelProvider.Factory { + + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + return when { + modelClass.isAssignableFrom(ParticipantViewModel::class.java) -> { + ParticipantViewModel(repository) as T + } + modelClass.isAssignableFrom(ScenarioViewModel::class.java) -> { + ScenarioViewModel(repository) as T + } + modelClass.isAssignableFrom(WorkoutViewModel::class.java) -> { + WorkoutViewModel(repository, tcpClient, heartRateMonitor) as T + } + modelClass.isAssignableFrom(StatisticsViewModel::class.java) -> { + StatisticsViewModel(repository) as T + } + else -> throw IllegalArgumentException("Unknown ViewModel class") + } + } +} + +fun TrainingApplication.createViewModelFactory(): ViewModelFactory { + return ViewModelFactory(repository, tcpClient, heartRateMonitor) +} + diff --git a/app/src/main/java/ru/kgeu/training/ui/viewmodel/WorkoutViewModel.kt b/app/src/main/java/ru/kgeu/training/ui/viewmodel/WorkoutViewModel.kt new file mode 100644 index 0000000..39531be --- /dev/null +++ b/app/src/main/java/ru/kgeu/training/ui/viewmodel/WorkoutViewModel.kt @@ -0,0 +1,320 @@ +package ru.kgeu.training.ui.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import ru.kgeu.training.data.model.ExerciseMode +import ru.kgeu.training.data.model.WeightPosition +import ru.kgeu.training.data.model.WorkoutSession +import ru.kgeu.training.data.repository.TrainingRepository +import ru.kgeu.training.network.TcpClient +import ru.kgeu.training.bluetooth.HeartRateMonitor + +class WorkoutViewModel( + private val repository: TrainingRepository, + private val tcpClient: TcpClient, + private val heartRateMonitor: HeartRateMonitor +) : ViewModel() { + private val _isWorkoutActive = MutableStateFlow(false) + val isWorkoutActive: StateFlow = _isWorkoutActive.asStateFlow() + + private val _isTimerStarted = MutableStateFlow(false) + val isTimerStarted: StateFlow = _isTimerStarted.asStateFlow() + + private val _completedLifts = MutableStateFlow(0) + val completedLifts: StateFlow = _completedLifts.asStateFlow() + + private val _elapsedTime = MutableStateFlow(0L) // в секундах + val elapsedTime: StateFlow = _elapsedTime.asStateFlow() + + private val _remainingTime = MutableStateFlow(0L) // для режима на количество + val remainingTime: StateFlow = _remainingTime.asStateFlow() + + private val _heartRate = MutableStateFlow(null) + val heartRate: StateFlow = _heartRate.asStateFlow() + + private val _currentSession = MutableStateFlow(null) + val currentSession: StateFlow = _currentSession.asStateFlow() + + val tcpConnectionState: StateFlow = tcpClient.connectionState + val bluetoothConnectionState: StateFlow = heartRateMonitor.connectionState + val weightPosition: StateFlow = tcpClient.weightPosition + + private var workoutJob: Job? = null + private var weightPositionJob: Job? = null + private var heartRateJob: Job? = null + private var lastPosition: WeightPosition? = null + private var isLiftingUp: Boolean = false // Флаг: находимся ли мы в процессе подъема (BOTTOM -> MIDDLE -> TOP) + private var targetValue: Int = 0 + private var exerciseMode: ExerciseMode = ExerciseMode.TIME_BASED + private var participantId: Long = 0 + private var scenarioId: Long = 0 + private var isStopping = false + private var timerStartTime: Long = 0 // Время когда таймер реально запустился + + private val heartRateReadings = mutableListOf() + + init { + observeWeightPosition() + observeHeartRate() + } + + private fun observeWeightPosition() { + weightPositionJob?.cancel() + weightPositionJob = viewModelScope.launch { + tcpClient.weightPosition.collect { position -> + if (_isWorkoutActive.value && !isStopping && position != null) { + handleWeightPositionChange(position) + } + } + } + } + + private fun observeHeartRate() { + heartRateJob?.cancel() + heartRateJob = viewModelScope.launch { + heartRateMonitor.heartRate.collect { hr -> + if (!isStopping) { + _heartRate.value = hr + if (hr != null && _isWorkoutActive.value) { + heartRateReadings.add(hr) + } + } + } + } + } + + private fun handleWeightPositionChange(position: WeightPosition) { + when { + // Начало подъема: BOTTOM -> MIDDLE + lastPosition == WeightPosition.BOTTOM && position == WeightPosition.MIDDLE -> { + isLiftingUp = true // Начинаем подъем + android.util.Log.d("WorkoutViewModel", "Lift started: BOTTOM -> MIDDLE") + + // Запускаем таймер только при первом подъеме + if (!_isTimerStarted.value) { + _isTimerStarted.value = true + timerStartTime = System.currentTimeMillis() + + // Обновляем startTime в сессии на момент реального запуска таймера + val session = _currentSession.value + if (session != null) { + _currentSession.value = session.copy(startTime = timerStartTime) + viewModelScope.launch { + try { + repository.updateSession(_currentSession.value!!) + } catch (e: Exception) { + android.util.Log.e("WorkoutViewModel", "Error updating session start time", e) + } + } + } + + android.util.Log.d("WorkoutViewModel", "Timer started - first lift began") + } + } + + // Завершение подъема: MIDDLE -> TOP (считаем подъем только здесь!) + lastPosition == WeightPosition.MIDDLE && position == WeightPosition.TOP && isLiftingUp -> { + _completedLifts.value++ + isLiftingUp = false // Подъем завершен + android.util.Log.d("WorkoutViewModel", "Lift completed: MIDDLE -> TOP. Total lifts: ${_completedLifts.value}") + + // Если режим на количество и достигнута цель + if (exerciseMode == ExerciseMode.COUNT_BASED && + _completedLifts.value >= targetValue) { + stopWorkout() + } + } + + // Начало опускания: TOP -> MIDDLE + lastPosition == WeightPosition.TOP && position == WeightPosition.MIDDLE -> { + isLiftingUp = false // Опускаемся вниз + android.util.Log.d("WorkoutViewModel", "Lowering started: TOP -> MIDDLE") + } + + // Завершение опускания: MIDDLE -> BOTTOM (готовы к следующему подъему) + lastPosition == WeightPosition.MIDDLE && position == WeightPosition.BOTTOM -> { + isLiftingUp = false // Вернулись в исходное положение + android.util.Log.d("WorkoutViewModel", "Lowering completed: MIDDLE -> BOTTOM. Ready for next lift") + } + } + + lastPosition = position + } + + fun startWorkout( + participantId: Long, + scenarioId: Long, + mode: ExerciseMode, + target: Int, + tcpHost: String, + tcpPort: Int + ) { + if (_isWorkoutActive.value) return + + this.participantId = participantId + this.scenarioId = scenarioId + this.exerciseMode = mode + this.targetValue = target + + _completedLifts.value = 0 + _elapsedTime.value = 0 + _isTimerStarted.value = false // Таймер запустится только при переходе BOTTOM -> MIDDLE + _remainingTime.value = if (mode == ExerciseMode.COUNT_BASED) target.toLong() else 0 + lastPosition = null + isLiftingUp = false // Сбрасываем флаг подъема + timerStartTime = 0 + heartRateReadings.clear() + + // Пытаемся подключиться к TCP, но не блокируем запуск тренировки если не удалось + try { + tcpClient.connect(tcpHost, tcpPort) + } catch (e: Exception) { + android.util.Log.e("WorkoutViewModel", "Failed to connect TCP", e) + } + + // Запускаем сканирование Bluetooth, но не блокируем если не удалось + try { + heartRateMonitor.startScan() + } catch (e: Exception) { + android.util.Log.e("WorkoutViewModel", "Failed to start Bluetooth scan", e) + } + + val session = WorkoutSession( + participantId = participantId, + scenarioId = scenarioId, + startTime = System.currentTimeMillis() + ) + + viewModelScope.launch { + try { + val sessionId = repository.insertSession(session) + _currentSession.value = session.copy(id = sessionId) + } catch (e: Exception) { + android.util.Log.e("WorkoutViewModel", "Failed to insert session", e) + // Создаем временную сессию без ID для отображения + _currentSession.value = session + } + } + + _isWorkoutActive.value = true + + // Таймер запускается только когда _isTimerStarted становится true (при переходе BOTTOM -> MIDDLE) + workoutJob = viewModelScope.launch { + while (_isWorkoutActive.value) { + delay(1000) + + // Обновляем таймер только если он был запущен + if (_isTimerStarted.value) { + if (exerciseMode == ExerciseMode.TIME_BASED) { + _elapsedTime.value++ + if (_elapsedTime.value >= target) { + stopWorkout() + } + } else if (exerciseMode == ExerciseMode.COUNT_BASED) { + _elapsedTime.value++ + } + } + } + } + } + + fun stopWorkout() { + if (isStopping || !_isWorkoutActive.value) return + + isStopping = true + _isWorkoutActive.value = false + _isTimerStarted.value = false + + // Отменяем все корутины + workoutJob?.cancel() + workoutJob = null + + // Отключаем соединения + try { + tcpClient.disconnect() + } catch (e: Exception) { + android.util.Log.e("WorkoutViewModel", "Error disconnecting TCP", e) + } + + try { + heartRateMonitor.disconnect() + } catch (e: Exception) { + android.util.Log.e("WorkoutViewModel", "Error disconnecting Bluetooth", e) + } + + // Сохраняем сессию + val session = _currentSession.value + if (session != null) { + val endTime = System.currentTimeMillis() + // Используем timerStartTime если таймер был запущен, иначе 0 (тренировка не началась) + val actualStartTime = if (timerStartTime > 0) timerStartTime else session.startTime + val duration = if (timerStartTime > 0) { + (endTime - actualStartTime) / 1000 + } else { + 0L // Тренировка не началась (таймер не запустился) + } + + val avgHeartRate = if (heartRateReadings.isNotEmpty()) { + heartRateReadings.average().toInt() + } else null + + val updatedSession = session.copy( + startTime = actualStartTime, // Обновляем startTime на реальное время начала + endTime = endTime, + completedLifts = _completedLifts.value, + durationSeconds = duration, + averageHeartRate = avgHeartRate, + maxHeartRate = heartRateReadings.maxOrNull(), + minHeartRate = heartRateReadings.minOrNull() + ) + + viewModelScope.launch { + try { + repository.updateSession(updatedSession) + _currentSession.value = updatedSession + } catch (e: Exception) { + android.util.Log.e("WorkoutViewModel", "Error updating session", e) + } finally { + isStopping = false + } + } + } else { + isStopping = false + } + } + + fun disconnectAll() { + android.util.Log.d(TAG, "Disconnecting all connections") + try { + tcpClient.disconnect() + } catch (e: Exception) { + android.util.Log.e(TAG, "Error disconnecting TCP", e) + } + try { + heartRateMonitor.disconnect() + } catch (e: Exception) { + android.util.Log.e(TAG, "Error disconnecting Bluetooth", e) + } + } + + override fun onCleared() { + super.onCleared() + android.util.Log.d(TAG, "WorkoutViewModel cleared, disconnecting all") + isStopping = true + workoutJob?.cancel() + weightPositionJob?.cancel() + heartRateJob?.cancel() + disconnectAll() + } + + companion object { + private const val TAG = "WorkoutViewModel" + } +} + diff --git a/app/src/main/java/ru/kgeu/training/util/PrintUtil.kt b/app/src/main/java/ru/kgeu/training/util/PrintUtil.kt new file mode 100644 index 0000000..8b49de0 --- /dev/null +++ b/app/src/main/java/ru/kgeu/training/util/PrintUtil.kt @@ -0,0 +1,92 @@ +package ru.kgeu.training.util + +import android.content.Context +import android.print.PrintAttributes +import android.print.PrintDocumentAdapter +import android.print.PrintManager +import android.webkit.WebView +import android.webkit.WebViewClient +import ru.kgeu.training.data.model.WorkoutSession +import java.text.SimpleDateFormat +import java.util.* + +object PrintUtil { + fun printWorkoutSession(context: Context, session: WorkoutSession, participantName: String, scenarioName: String) { + val webView = WebView(context) + webView.webViewClient = object : WebViewClient() { + override fun onPageFinished(view: WebView?, url: String?) { + createPrintJob(context, webView) + } + } + + val html = generateHtmlReport(session, participantName, scenarioName) + webView.loadDataWithBaseURL(null, html, "text/html", "UTF-8", null) + } + + private fun createPrintJob(context: Context, webView: WebView) { + val printManager = context.getSystemService(Context.PRINT_SERVICE) as PrintManager + val printAdapter = webView.createPrintDocumentAdapter("WorkoutReport") + val jobName = "Тренировка - ${Date()}" + + printManager.print( + jobName, + printAdapter, + PrintAttributes.Builder() + .setMediaSize(PrintAttributes.MediaSize.ISO_A4) + .setResolution(PrintAttributes.Resolution("pdf", "pdf", 600, 600)) + .setMinMargins(PrintAttributes.Margins.NO_MARGINS) + .build() + ) + } + + private fun generateHtmlReport(session: WorkoutSession, participantName: String, scenarioName: String): String { + val dateFormat = SimpleDateFormat("dd.MM.yyyy HH:mm", Locale.getDefault()) + val startTime = dateFormat.format(Date(session.startTime)) + val endTime = session.endTime?.let { dateFormat.format(Date(it)) } ?: "В процессе" + val duration = formatDuration(session.durationSeconds) + + return """ + + + + + + + +

Отчет о тренировке

+ + + + + + + + + ${session.averageHeartRate?.let { "" } ?: ""} + ${session.maxHeartRate?.let { "" } ?: ""} + ${session.minHeartRate?.let { "" } ?: ""} +
ПараметрЗначение
Участник$participantName
Сценарий$scenarioName
Начало$startTime
Окончание$endTime
Длительность$duration
Выполнено подъемов${session.completedLifts}
Средний пульс$it уд/мин
Максимальный пульс$it уд/мин
Минимальный пульс$it уд/мин
+ + + """.trimIndent() + } + + private fun formatDuration(seconds: Long): String { + val hours = seconds / 3600 + val minutes = (seconds % 3600) / 60 + val secs = seconds % 60 + + return if (hours > 0) { + String.format("%d:%02d:%02d", hours, minutes, secs) + } else { + String.format("%d:%02d", minutes, secs) + } + } +} + 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..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/app/src/main/res/mipmap-anydpi/ic_launcher.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/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..c209e78 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..b2dfe3d 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..4f0f1d6 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..62b611d 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..948a307 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..1b9a695 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..28d4b77 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..9287f50 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..aa7d642 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..9126ae3 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/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..6fa70bd --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Тренировка + \ 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..bea5431 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +