last commit
This commit is contained in:
1
.idea/gradle.xml
generated
1
.idea/gradle.xml
generated
@@ -1,5 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
|
<component name="GradleMigrationSettings" migrationVersion="1" />
|
||||||
<component name="GradleSettings">
|
<component name="GradleSettings">
|
||||||
<option name="linkedExternalProjectsSettings">
|
<option name="linkedExternalProjectsSettings">
|
||||||
<GradleProjectSettings>
|
<GradleProjectSettings>
|
||||||
|
|||||||
50
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
50
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<profile version="1.0">
|
||||||
|
<option name="myName" value="Project Default" />
|
||||||
|
<inspection_tool class="ComposePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="ComposePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="ComposePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="ComposePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="GlancePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="GlancePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="GlancePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="GlancePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="PreviewDeviceShouldUseNewSpec" enabled="true" level="WEAK WARNING" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
</profile>
|
||||||
|
</component>
|
||||||
1
.idea/misc.xml
generated
1
.idea/misc.xml
generated
@@ -1,4 +1,3 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
||||||
|
|||||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
231
README.md
231
README.md
@@ -1,114 +1,183 @@
|
|||||||
# Грузоблочный тренажер - Android приложение
|
# Тренировочное приложение КГЭУ
|
||||||
|
|
||||||
Android приложение для управления грузоблочным тренажером с ESP32 микроконтроллером.
|
Android-приложение для проведения и учёта тренировок с использованием тренажёра, подключённого к ESP32.
|
||||||
|
|
||||||
## Функциональность
|
## Описание
|
||||||
|
|
||||||
1. **Регистрация участника** - добавление и выбор участника тренировки
|
Приложение позволяет:
|
||||||
2. **Выбор режима упражнений** - выбор готового сценария или создание индивидуального
|
- Регистрировать участников тренировок
|
||||||
3. **Пуск и останов упражнения** - управление тренировкой
|
- Создавать и выбирать сценарии упражнений
|
||||||
4. **Подсчет подъемов** - в режиме на время считаются выполненные подъемы
|
- Проводить тренировки с автоматическим подсчётом подъёмов
|
||||||
5. **Отсчет времени** - в режиме на количество отсчитывается время выполнения
|
- Отслеживать пульс через Bluetooth-кардиодатчик
|
||||||
6. **Кардиодатчик** - отображение показаний пульса в реальном времени через Bluetooth
|
- Просматривать статистику тренировок
|
||||||
7. **Печать отчетов** - вывод результатов тренировки на принтер
|
|
||||||
|
|
||||||
## Технологии
|
## Режимы тренировок
|
||||||
|
|
||||||
- Kotlin
|
### Режим на время (TIME_BASED)
|
||||||
- Jetpack Compose
|
- Задаётся время выполнения упражнения в секундах
|
||||||
- Room Database
|
- Приложение считает количество выполненных подъёмов
|
||||||
- WebSocket (OkHttp) для связи с ESP32
|
- Норматив: минимальное количество подъёмов за заданное время
|
||||||
- Bluetooth LE для кардиодатчика
|
|
||||||
- Navigation Component
|
|
||||||
|
|
||||||
## Настройка
|
### Режим на количество (COUNT_BASED)
|
||||||
|
- Задаётся целевое количество подъёмов
|
||||||
|
- Приложение засекает время выполнения
|
||||||
|
- Норматив: максимальное время для выполнения заданного количества подъёмов
|
||||||
|
|
||||||
### WebSocket подключение к ESP32
|
## Технические требования
|
||||||
|
|
||||||
В файле `MainActivity.kt` измените URL WebSocket:
|
- Android 14 (API 34) или выше
|
||||||
|
- Bluetooth для подключения кардиодатчика
|
||||||
|
- WiFi для подключения к ESP32
|
||||||
|
|
||||||
```kotlin
|
## Подключение к оборудованию
|
||||||
val websocketUrl = "ws://192.168.1.100:81" // Замените на IP вашего ESP32
|
|
||||||
```
|
|
||||||
|
|
||||||
ESP32 должен отправлять сообщения в формате JSON:
|
### ESP32
|
||||||
|
Приложение подключается к ESP32 по TCP/IP. ESP32 должен отправлять JSON-сообщения с положением груза:
|
||||||
```json
|
```json
|
||||||
{"position": "top"} // или "bottom"
|
{"position": "top"} // Груз вверху
|
||||||
|
{"position": "middle"} // Груз в движении
|
||||||
|
{"position": "bottom"} // Груз внизу
|
||||||
```
|
```
|
||||||
|
|
||||||
### Bluetooth кардиодатчик
|
Поддерживаемые значения `position`:
|
||||||
|
- `top`, `up` — верхнее положение
|
||||||
|
- `middle`, `moving`, `in_motion`, `motion` — груз в движении
|
||||||
|
- `bottom`, `down` — нижнее положение
|
||||||
|
|
||||||
Приложение автоматически сканирует и подключается к Bluetooth устройствам с Heart Rate Service (UUID: 0000180d-0000-1000-8000-00805f9b34fb).
|
### Кардиодатчик
|
||||||
|
Приложение поддерживает Bluetooth-кардиодатчики с протоколом Heart Rate Service (стандартный BLE профиль).
|
||||||
|
|
||||||
|
## Сборка проекта
|
||||||
|
|
||||||
|
### Требования
|
||||||
|
- Android Studio Ladybug (2024.2) или новее
|
||||||
|
- JDK 11
|
||||||
|
- Android SDK 36
|
||||||
|
|
||||||
|
### Шаги сборки
|
||||||
|
|
||||||
|
1. Клонируйте репозиторий:
|
||||||
|
```bash
|
||||||
|
git clone <url-репозитория>
|
||||||
|
cd AndroidStudioProjects
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Откройте проект в Android Studio:
|
||||||
|
- File → Open → выберите папку проекта
|
||||||
|
|
||||||
|
3. Дождитесь синхронизации Gradle (происходит автоматически)
|
||||||
|
|
||||||
|
4. Соберите проект:
|
||||||
|
- Build → Make Project (Ctrl+F9)
|
||||||
|
|
||||||
|
## Установка на телефон
|
||||||
|
|
||||||
|
### Способ 1: Через Android Studio (рекомендуется для разработки)
|
||||||
|
|
||||||
|
1. Включите на телефоне **Режим разработчика**:
|
||||||
|
- Настройки → О телефоне → 7 раз нажмите на "Номер сборки"
|
||||||
|
|
||||||
|
2. Включите **Отладку по USB**:
|
||||||
|
- Настройки → Для разработчиков → Отладка по USB → Включить
|
||||||
|
|
||||||
|
3. Подключите телефон к компьютеру USB-кабелем
|
||||||
|
|
||||||
|
4. Разрешите отладку на телефоне (появится диалог при первом подключении)
|
||||||
|
|
||||||
|
5. В Android Studio выберите ваше устройство в выпадающем списке устройств (рядом с кнопкой Run)
|
||||||
|
|
||||||
|
6. Нажмите **Run** (Shift+F10) или зелёную кнопку ▶
|
||||||
|
|
||||||
|
### Способ 2: Установка APK-файла
|
||||||
|
|
||||||
|
1. Соберите релизный APK:
|
||||||
|
- Build → Build Bundle(s) / APK(s) → Build APK(s)
|
||||||
|
- APK будет в папке `app/build/outputs/apk/debug/`
|
||||||
|
|
||||||
|
2. Скопируйте APK на телефон любым способом:
|
||||||
|
- Через USB-кабель
|
||||||
|
- Через облачное хранилище
|
||||||
|
- Через мессенджер
|
||||||
|
|
||||||
|
3. На телефоне разрешите установку из неизвестных источников:
|
||||||
|
- Настройки → Безопасность → Неизвестные источники (или аналогичный пункт)
|
||||||
|
|
||||||
|
4. Откройте APK-файл на телефоне и установите
|
||||||
|
|
||||||
|
### Способ 3: Через ADB (командная строка)
|
||||||
|
|
||||||
|
1. Установите ADB (входит в Android SDK Platform Tools)
|
||||||
|
|
||||||
|
2. Подключите телефон с включённой отладкой по USB
|
||||||
|
|
||||||
|
3. Проверьте подключение:
|
||||||
|
```bash
|
||||||
|
adb devices
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Установите APK:
|
||||||
|
```bash
|
||||||
|
adb install app/build/outputs/apk/debug/app-debug.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
Или соберите и установите одной командой:
|
||||||
|
```bash
|
||||||
|
./gradlew installDebug
|
||||||
|
```
|
||||||
|
|
||||||
|
## Разрешения приложения
|
||||||
|
|
||||||
При первом запуске приложение запросит разрешения:
|
При первом запуске приложение запросит разрешения:
|
||||||
- Bluetooth
|
- **Bluetooth** — для подключения к кардиодатчику
|
||||||
- Location (требуется для Bluetooth LE сканирования на Android 12+)
|
- **Nearby Devices** — для сканирования Bluetooth-устройств
|
||||||
|
- **Internet/Network** — для подключения к ESP32
|
||||||
|
|
||||||
## Структура проекта
|
## Структура проекта
|
||||||
|
|
||||||
```
|
```
|
||||||
app/src/main/java/ru/kgeu/training/
|
app/src/main/java/ru/kgeu/training/
|
||||||
|
├── bluetooth/ # Работа с Bluetooth (кардиодатчик)
|
||||||
├── data/
|
├── data/
|
||||||
│ ├── dao/ # Data Access Objects для Room
|
│ ├── dao/ # Data Access Objects для Room
|
||||||
│ ├── database/ # Room Database
|
│ ├── database/ # Конфигурация базы данных
|
||||||
│ ├── model/ # Модели данных
|
│ ├── model/ # Модели данных (Participant, Scenario, Session)
|
||||||
│ └── repository/ # Репозиторий для работы с данными
|
│ └── repository/ # Репозиторий для работы с данными
|
||||||
├── network/
|
├── network/ # TCP-клиент для ESP32
|
||||||
│ └── WebSocketClient.kt # WebSocket клиент для ESP32
|
|
||||||
├── bluetooth/
|
|
||||||
│ └── HeartRateMonitor.kt # Bluetooth сервис для кардиодатчика
|
|
||||||
├── ui/
|
├── ui/
|
||||||
│ ├── navigation/ # Навигация
|
│ ├── navigation/ # Навигация приложения
|
||||||
│ ├── screen/ # UI экраны
|
│ ├── screen/ # Экраны (Compose)
|
||||||
│ ├── theme/ # Тема приложения
|
│ ├── theme/ # Тема приложения
|
||||||
│ └── viewmodel/ # ViewModels
|
│ └── viewmodel/ # ViewModels
|
||||||
└── util/
|
├── util/ # Вспомогательные утилиты
|
||||||
└── PrintUtil.kt # Утилита для печати
|
├── MainActivity.kt # Главная Activity
|
||||||
|
└── TrainingApplication.kt # Application класс
|
||||||
```
|
```
|
||||||
|
|
||||||
## Режимы упражнений
|
## Настройка подключения к ESP32
|
||||||
|
|
||||||
### Режим на время
|
IP-адрес и порт ESP32 можно настроить в приложении перед началом тренировки.
|
||||||
- Устанавливается целевое время (в секундах)
|
|
||||||
- Считается количество выполненных подъемов
|
|
||||||
- Тренировка завершается по истечении времени
|
|
||||||
|
|
||||||
### Режим на количество
|
По умолчанию:
|
||||||
- Устанавливается целевое количество подъемов
|
- Порт: 8080 (или настраиваемый)
|
||||||
- Отсчитывается время выполнения
|
- Протокол: TCP
|
||||||
- Тренировка завершается при достижении цели
|
|
||||||
|
|
||||||
## База данных
|
## Решение проблем
|
||||||
|
|
||||||
Приложение использует Room Database для хранения:
|
### Не подключается к ESP32
|
||||||
- Участников тренировок
|
1. Проверьте, что телефон и ESP32 в одной WiFi-сети
|
||||||
- Сценариев упражнений (готовых и пользовательских)
|
2. Проверьте правильность IP-адреса и порта
|
||||||
- Сессий тренировок с результатами
|
3. Убедитесь, что ESP32 запущен и слушает указанный порт
|
||||||
|
|
||||||
## Печать
|
### Не подключается кардиодатчик
|
||||||
|
1. Убедитесь, что Bluetooth включён на телефоне
|
||||||
|
2. Проверьте, что кардиодатчик активен и не подключён к другому устройству
|
||||||
|
3. Перезапустите кардиодатчик
|
||||||
|
|
||||||
Функция печати использует стандартную систему печати Android и поддерживает:
|
### Приложение не устанавливается
|
||||||
- Печать на принтеры через Wi-Fi
|
1. Проверьте версию Android (требуется 14+)
|
||||||
- Сохранение в PDF
|
2. Убедитесь, что разрешена установка из неизвестных источников
|
||||||
- Отправку по email
|
3. Удалите предыдущую версию приложения, если она установлена
|
||||||
|
|
||||||
## Требования
|
## Лицензия
|
||||||
|
|
||||||
- Android 8.0 (API 26) или выше
|
|
||||||
- Поддержка Bluetooth LE
|
|
||||||
- Подключение к сети для WebSocket
|
|
||||||
|
|
||||||
## Разработка
|
|
||||||
|
|
||||||
Для сборки проекта используйте:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./gradlew assembleDebug
|
|
||||||
```
|
|
||||||
|
|
||||||
Для установки на устройство:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./gradlew installDebug
|
|
||||||
```
|
|
||||||
|
|
||||||
|
Разработано для КГЭУ (Казанский государственный энергетический университет).
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ class HeartRateMonitor(private val context: Context) {
|
|||||||
private val bluetoothLeScanner = bluetoothAdapter?.bluetoothLeScanner
|
private val bluetoothLeScanner = bluetoothAdapter?.bluetoothLeScanner
|
||||||
private var bluetoothGatt: BluetoothGatt? = null
|
private var bluetoothGatt: BluetoothGatt? = null
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var isDisconnecting = false
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var isScanning = false
|
||||||
|
|
||||||
private val _heartRate = MutableStateFlow<Int?>(null)
|
private val _heartRate = MutableStateFlow<Int?>(null)
|
||||||
val heartRate: StateFlow<Int?> = _heartRate.asStateFlow()
|
val heartRate: StateFlow<Int?> = _heartRate.asStateFlow()
|
||||||
|
|
||||||
@@ -124,7 +130,11 @@ class HeartRateMonitor(private val context: Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Сбрасываем флаг отключения
|
||||||
|
isDisconnecting = false
|
||||||
|
|
||||||
_connectionState.value = BluetoothConnectionState.Scanning
|
_connectionState.value = BluetoothConnectionState.Scanning
|
||||||
|
isScanning = true
|
||||||
|
|
||||||
val filter = ScanFilter.Builder()
|
val filter = ScanFilter.Builder()
|
||||||
.setServiceUuid(android.os.ParcelUuid(HEART_RATE_SERVICE_UUID))
|
.setServiceUuid(android.os.ParcelUuid(HEART_RATE_SERVICE_UUID))
|
||||||
@@ -134,11 +144,26 @@ class HeartRateMonitor(private val context: Context) {
|
|||||||
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
|
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
bluetoothLeScanner.startScan(listOf(filter), settings, scanCallback)
|
try {
|
||||||
|
bluetoothLeScanner.startScan(listOf(filter), settings, scanCallback)
|
||||||
|
Log.d(TAG, "[BT] Scan started")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "[BT] Error starting scan", e)
|
||||||
|
isScanning = false
|
||||||
|
_connectionState.value = BluetoothConnectionState.Error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun stopScan() {
|
fun stopScan() {
|
||||||
bluetoothLeScanner?.stopScan(scanCallback)
|
if (!isScanning) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
bluetoothLeScanner?.stopScan(scanCallback)
|
||||||
|
Log.d(TAG, "[BT] Scan stopped")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "[BT] Error stopping scan", e)
|
||||||
|
}
|
||||||
|
isScanning = false
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun connectToDevice(device: BluetoothDevice) {
|
private fun connectToDevice(device: BluetoothDevice) {
|
||||||
@@ -147,11 +172,44 @@ class HeartRateMonitor(private val context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun disconnect() {
|
fun disconnect() {
|
||||||
bluetoothGatt?.disconnect()
|
// Предотвращаем параллельные вызовы disconnect
|
||||||
bluetoothGatt?.close()
|
if (isDisconnecting) {
|
||||||
|
Log.d(TAG, "[BT] disconnect() already in progress, skipping")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isDisconnecting = true
|
||||||
|
|
||||||
|
val startTime = System.currentTimeMillis()
|
||||||
|
Log.d(TAG, "[BT] disconnect() called at $startTime")
|
||||||
|
|
||||||
|
// Останавливаем сканирование если оно активно
|
||||||
|
stopScan()
|
||||||
|
|
||||||
|
val disconnectStart = System.currentTimeMillis()
|
||||||
|
try {
|
||||||
|
bluetoothGatt?.disconnect()
|
||||||
|
val disconnectEnd = System.currentTimeMillis()
|
||||||
|
Log.d(TAG, "[BT] GATT disconnect() took ${disconnectEnd - disconnectStart}ms")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "[BT] Error in GATT disconnect()", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
val closeStart = System.currentTimeMillis()
|
||||||
|
try {
|
||||||
|
bluetoothGatt?.close()
|
||||||
|
val closeEnd = System.currentTimeMillis()
|
||||||
|
Log.d(TAG, "[BT] GATT close() took ${closeEnd - closeStart}ms")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "[BT] Error in GATT close()", e)
|
||||||
|
}
|
||||||
|
|
||||||
bluetoothGatt = null
|
bluetoothGatt = null
|
||||||
_connectionState.value = BluetoothConnectionState.Disconnected
|
_connectionState.value = BluetoothConnectionState.Disconnected
|
||||||
_heartRate.value = null
|
_heartRate.value = null
|
||||||
|
|
||||||
|
val endTime = System.currentTimeMillis()
|
||||||
|
Log.d(TAG, "[BT] disconnect() completed. Total time: ${endTime - startTime}ms")
|
||||||
|
isDisconnecting = false
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import ru.kgeu.training.data.model.WorkoutSession
|
|||||||
|
|
||||||
@Database(
|
@Database(
|
||||||
entities = [Participant::class, ExerciseScenario::class, WorkoutSession::class],
|
entities = [Participant::class, ExerciseScenario::class, WorkoutSession::class],
|
||||||
version = 1,
|
version = 2,
|
||||||
exportSchema = false
|
exportSchema = false
|
||||||
)
|
)
|
||||||
abstract class TrainingDatabase : RoomDatabase() {
|
abstract class TrainingDatabase : RoomDatabase() {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ data class ExerciseScenario(
|
|||||||
val description: String? = null,
|
val description: String? = null,
|
||||||
val exerciseMode: ExerciseMode,
|
val exerciseMode: ExerciseMode,
|
||||||
val targetValue: Int, // количество подъемов или время в секундах
|
val targetValue: Int, // количество подъемов или время в секундах
|
||||||
|
val standardValue: Int? = null, // норматив: для TIME_BASED - мин. кол-во подъемов, для COUNT_BASED - макс. время в секундах
|
||||||
val isCustom: Boolean = false, // true для пользовательских, false для готовых
|
val isCustom: Boolean = false, // true для пользовательских, false для готовых
|
||||||
val createdAt: Long = System.currentTimeMillis()
|
val createdAt: Long = System.currentTimeMillis()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,104 +16,187 @@ class TcpClient {
|
|||||||
private var socket: Socket? = null
|
private var socket: Socket? = null
|
||||||
private var reader: BufferedReader? = null
|
private var reader: BufferedReader? = null
|
||||||
private var readJob: Job? = null
|
private var readJob: Job? = null
|
||||||
|
private var reconnectJob: Job? = null
|
||||||
private val clientScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
private val clientScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var isDisconnecting = false
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var shouldReconnect = false
|
||||||
|
|
||||||
|
// Счетчик поколений соединений - увеличивается при каждом новом connect()
|
||||||
|
// Используется для предотвращения race conditions, когда старый finally блок
|
||||||
|
// пытается переподключиться после того, как было создано новое соединение
|
||||||
|
@Volatile
|
||||||
|
private var connectionGeneration = 0
|
||||||
|
|
||||||
|
// Сохраняем параметры подключения для переподключения
|
||||||
|
private var lastHost: String = ""
|
||||||
|
private var lastPort: Int = 0
|
||||||
|
|
||||||
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
|
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
|
||||||
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
|
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
|
||||||
|
|
||||||
private val _weightPosition = MutableStateFlow<WeightPosition?>(null)
|
private val _weightPosition = MutableStateFlow<WeightPosition?>(null)
|
||||||
val weightPosition: StateFlow<WeightPosition?> = _weightPosition.asStateFlow()
|
val weightPosition: StateFlow<WeightPosition?> = _weightPosition.asStateFlow()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "TcpClient"
|
||||||
|
private const val RECONNECT_DELAY_MS = 500L // Минимальная задержка перед переподключением
|
||||||
|
private const val MAX_RECONNECT_ATTEMPTS = 10
|
||||||
|
}
|
||||||
|
|
||||||
fun connect(host: String, port: Int) {
|
fun connect(host: String, port: Int) {
|
||||||
Log.d(TAG, "Attempting to connect to TCP: $host:$port")
|
Log.d(TAG, "Attempting to connect to TCP: $host:$port")
|
||||||
|
|
||||||
|
// Увеличиваем поколение соединения - это invalidates все старые finally блоки
|
||||||
|
connectionGeneration++
|
||||||
|
val currentGeneration = connectionGeneration
|
||||||
|
Log.d(TAG, "Connection generation: $currentGeneration")
|
||||||
|
|
||||||
|
// Сохраняем параметры для переподключения
|
||||||
|
lastHost = host
|
||||||
|
lastPort = port
|
||||||
|
shouldReconnect = true
|
||||||
|
|
||||||
// Отключаемся если уже подключены
|
// Отключаемся если уже подключены
|
||||||
if (_connectionState.value == ConnectionState.Connected) {
|
if (_connectionState.value == ConnectionState.Connected) {
|
||||||
Log.d(TAG, "Already connected, disconnecting first")
|
Log.d(TAG, "Already connected, disconnecting first")
|
||||||
disconnect()
|
disconnectInternal(triggerReconnect = false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Отменяем предыдущую задачу чтения если есть
|
// Сбрасываем флаг отключения для нового подключения
|
||||||
|
isDisconnecting = false
|
||||||
|
|
||||||
|
// Отменяем предыдущие задачи
|
||||||
readJob?.cancel()
|
readJob?.cancel()
|
||||||
readJob = null
|
readJob = null
|
||||||
|
reconnectJob?.cancel()
|
||||||
|
reconnectJob = null
|
||||||
|
|
||||||
|
connectInternal(host, port, generation = currentGeneration)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun connectInternal(host: String, port: Int, attemptNumber: Int = 1, generation: Int = connectionGeneration) {
|
||||||
|
if (isDisconnecting || !shouldReconnect) {
|
||||||
|
Log.d(TAG, "Skipping connection attempt: isDisconnecting=$isDisconnecting, shouldReconnect=$shouldReconnect")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, что это всё ещё актуальное поколение соединения
|
||||||
|
if (generation != connectionGeneration) {
|
||||||
|
Log.d(TAG, "Skipping connection attempt: generation mismatch (expected $connectionGeneration, got $generation)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
_connectionState.value = ConnectionState.Connecting
|
_connectionState.value = ConnectionState.Connecting
|
||||||
|
|
||||||
clientScope.launch {
|
clientScope.launch {
|
||||||
try {
|
try {
|
||||||
// Небольшая задержка для завершения отключения
|
// Небольшая задержка для завершения отключения
|
||||||
delay(100)
|
if (attemptNumber > 1) {
|
||||||
|
delay(RECONNECT_DELAY_MS)
|
||||||
|
} else {
|
||||||
|
delay(50)
|
||||||
|
}
|
||||||
|
|
||||||
Log.d(TAG, "Creating socket connection...")
|
// Повторная проверка после задержки
|
||||||
|
if (!shouldReconnect || generation != connectionGeneration) {
|
||||||
|
Log.d(TAG, "Aborting connection after delay: shouldReconnect=$shouldReconnect, generation match=${generation == connectionGeneration}")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(TAG, "Creating socket connection (attempt $attemptNumber, generation $generation)...")
|
||||||
val newSocket = Socket()
|
val newSocket = Socket()
|
||||||
// Оптимизация TCP: отключаем алгоритм Nagle для уменьшения задержек
|
// Оптимизация TCP: отключаем алгоритм Nagle для уменьшения задержек
|
||||||
newSocket.tcpNoDelay = true
|
newSocket.tcpNoDelay = true
|
||||||
// Уменьшаем таймаут чтения для более быстрой реакции
|
// Уменьшаем таймаут чтения для более быстрой реакции
|
||||||
newSocket.soTimeout = 1000 // 1 секунда вместо 10
|
newSocket.soTimeout = 2000 // 2 секунды
|
||||||
newSocket.connect(java.net.InetSocketAddress(host, port), 5000) // 5 секунд таймаут на подключение
|
newSocket.connect(java.net.InetSocketAddress(host, port), 3000) // 3 секунды таймаут на подключение
|
||||||
|
|
||||||
|
// Проверяем ещё раз перед назначением
|
||||||
|
if (generation != connectionGeneration) {
|
||||||
|
Log.d(TAG, "Connection established but generation changed, closing socket")
|
||||||
|
newSocket.close()
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
socket = newSocket
|
socket = newSocket
|
||||||
reader = BufferedReader(InputStreamReader(socket!!.getInputStream(), "UTF-8"), 8192) // Увеличиваем размер буфера
|
reader = BufferedReader(InputStreamReader(socket!!.getInputStream(), "UTF-8"), 8192)
|
||||||
|
|
||||||
Log.d(TAG, "TCP connection established to $host:$port")
|
Log.d(TAG, "TCP connection established to $host:$port (generation $generation)")
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
_connectionState.value = ConnectionState.Connected
|
_connectionState.value = ConnectionState.Connected
|
||||||
}
|
}
|
||||||
|
|
||||||
// Запускаем чтение данных в отдельной корутине
|
// Запускаем чтение данных в отдельной корутине, передаём generation
|
||||||
readJob = clientScope.launch {
|
readJob = clientScope.launch {
|
||||||
readMessages()
|
readMessages(generation)
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (e: SocketTimeoutException) {
|
} catch (e: SocketTimeoutException) {
|
||||||
Log.e(TAG, "Connection timeout: $host:$port", e)
|
Log.e(TAG, "Connection timeout: $host:$port (attempt $attemptNumber)", e)
|
||||||
withContext(Dispatchers.Main) {
|
handleConnectionFailure(attemptNumber, generation)
|
||||||
_connectionState.value = ConnectionState.Disconnected
|
|
||||||
}
|
|
||||||
socket?.close()
|
|
||||||
socket = null
|
|
||||||
reader = null
|
|
||||||
} catch (e: java.net.UnknownHostException) {
|
} catch (e: java.net.UnknownHostException) {
|
||||||
Log.e(TAG, "Unknown host: $host", e)
|
Log.e(TAG, "Unknown host: $host (attempt $attemptNumber)", e)
|
||||||
withContext(Dispatchers.Main) {
|
handleConnectionFailure(attemptNumber, generation)
|
||||||
_connectionState.value = ConnectionState.Disconnected
|
|
||||||
}
|
|
||||||
socket?.close()
|
|
||||||
socket = null
|
|
||||||
reader = null
|
|
||||||
} catch (e: java.net.ConnectException) {
|
} catch (e: java.net.ConnectException) {
|
||||||
Log.e(TAG, "Connection refused: $host:$port", e)
|
Log.e(TAG, "Connection refused: $host:$port (attempt $attemptNumber)", e)
|
||||||
withContext(Dispatchers.Main) {
|
handleConnectionFailure(attemptNumber, generation)
|
||||||
_connectionState.value = ConnectionState.Disconnected
|
|
||||||
}
|
|
||||||
socket?.close()
|
|
||||||
socket = null
|
|
||||||
reader = null
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Error connecting to TCP: $host:$port", e)
|
Log.e(TAG, "Error connecting to TCP: $host:$port (attempt $attemptNumber)", e)
|
||||||
e.printStackTrace()
|
handleConnectionFailure(attemptNumber, generation)
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
_connectionState.value = ConnectionState.Disconnected
|
|
||||||
}
|
|
||||||
socket?.close()
|
|
||||||
socket = null
|
|
||||||
reader = null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun readMessages() {
|
private suspend fun handleConnectionFailure(attemptNumber: Int, generation: Int) {
|
||||||
|
// Проверяем, что это всё ещё актуальное поколение
|
||||||
|
if (generation != connectionGeneration) {
|
||||||
|
Log.d(TAG, "Ignoring connection failure: generation mismatch")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
socket?.close()
|
||||||
|
socket = null
|
||||||
|
reader = null
|
||||||
|
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
// Обновляем состояние только если generation всё ещё актуален
|
||||||
|
if (generation == connectionGeneration) {
|
||||||
|
_connectionState.value = ConnectionState.Disconnected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пытаемся переподключиться если разрешено и не превышен лимит попыток
|
||||||
|
if (shouldReconnect && attemptNumber < MAX_RECONNECT_ATTEMPTS && lastHost.isNotEmpty() && generation == connectionGeneration) {
|
||||||
|
Log.d(TAG, "Scheduling reconnect attempt ${attemptNumber + 1} in ${RECONNECT_DELAY_MS}ms (generation $generation)")
|
||||||
|
delay(RECONNECT_DELAY_MS)
|
||||||
|
if (shouldReconnect && generation == connectionGeneration) {
|
||||||
|
connectInternal(lastHost, lastPort, attemptNumber + 1, generation)
|
||||||
|
}
|
||||||
|
} else if (attemptNumber >= MAX_RECONNECT_ATTEMPTS) {
|
||||||
|
Log.e(TAG, "Max reconnect attempts reached ($MAX_RECONNECT_ATTEMPTS)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun readMessages(generation: Int) {
|
||||||
val currentReader = reader
|
val currentReader = reader
|
||||||
val currentSocket = socket
|
val currentSocket = socket
|
||||||
|
|
||||||
if (currentReader == null || currentSocket == null) {
|
if (currentReader == null || currentSocket == null) {
|
||||||
Log.w(TAG, "Cannot read messages: reader or socket is null")
|
Log.w(TAG, "Cannot read messages: reader or socket is null")
|
||||||
|
triggerReconnect(generation)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var consecutiveTimeouts = 0
|
||||||
|
val maxConsecutiveTimeouts = 10 // После 10 таймаутов подряд (20 сек) считаем соединение потерянным
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Log.d(TAG, "Starting to read messages from TCP stream...")
|
Log.d(TAG, "Starting to read messages from TCP stream (generation $generation)...")
|
||||||
while (currentSocket.isConnected && !currentSocket.isClosed) {
|
while (currentSocket.isConnected && !currentSocket.isClosed && !isDisconnecting && generation == connectionGeneration) {
|
||||||
try {
|
try {
|
||||||
val line = currentReader.readLine()
|
val line = currentReader.readLine()
|
||||||
if (line == null) {
|
if (line == null) {
|
||||||
@@ -121,13 +204,21 @@ class TcpClient {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
consecutiveTimeouts = 0 // Сбрасываем счетчик при получении данных
|
||||||
// Обрабатываем сообщение асинхронно, не блокируя чтение
|
// Обрабатываем сообщение асинхронно, не блокируя чтение
|
||||||
processMessage(line)
|
processMessage(line)
|
||||||
|
|
||||||
} catch (e: SocketTimeoutException) {
|
} catch (e: SocketTimeoutException) {
|
||||||
// Таймаут на чтение - это нормально, продолжаем
|
// Таймаут на чтение - это нормально, но следим за их количеством
|
||||||
// Не логируем каждый таймаут, чтобы не засорять лог
|
consecutiveTimeouts++
|
||||||
|
if (consecutiveTimeouts >= maxConsecutiveTimeouts) {
|
||||||
|
Log.w(TAG, "Too many consecutive timeouts ($consecutiveTimeouts), connection might be dead")
|
||||||
|
break
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
|
} catch (e: java.io.IOException) {
|
||||||
|
Log.e(TAG, "IO Error reading message (connection lost?)", e)
|
||||||
|
break
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Error reading message", e)
|
Log.e(TAG, "Error reading message", e)
|
||||||
break
|
break
|
||||||
@@ -136,10 +227,53 @@ class TcpClient {
|
|||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Error in readMessages loop", e)
|
Log.e(TAG, "Error in readMessages loop", e)
|
||||||
} finally {
|
} finally {
|
||||||
Log.d(TAG, "Stopped reading messages")
|
Log.d(TAG, "Stopped reading messages (generation $generation), current generation=$connectionGeneration, isDisconnecting=$isDisconnecting, shouldReconnect=$shouldReconnect")
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
_connectionState.value = ConnectionState.Disconnected
|
// Закрываем текущие ресурсы
|
||||||
_weightPosition.value = null
|
try { currentSocket.close() } catch (e: Exception) { }
|
||||||
|
|
||||||
|
// ВАЖНО: Всё остальное делаем только если generation совпадает
|
||||||
|
// Это предотвращает race condition, когда старый finally блок влияет на новое соединение
|
||||||
|
if (generation != connectionGeneration) {
|
||||||
|
Log.d(TAG, "Generation mismatch in finally block, skipping cleanup (old=$generation, current=$connectionGeneration)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обнуляем socket/reader только если они не были заменены новым подключением
|
||||||
|
if (socket === currentSocket) {
|
||||||
|
socket = null
|
||||||
|
}
|
||||||
|
if (reader === currentReader) {
|
||||||
|
reader = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем состояние только если это всё ещё наше соединение (не было создано новое)
|
||||||
|
if (socket == null && generation == connectionGeneration) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
_connectionState.value = ConnectionState.Disconnected
|
||||||
|
_weightPosition.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пытаемся переподключиться если это не было намеренным отключением И generation совпадает
|
||||||
|
if (!isDisconnecting && shouldReconnect && generation == connectionGeneration) {
|
||||||
|
triggerReconnect(generation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun triggerReconnect(generation: Int) {
|
||||||
|
if (!shouldReconnect || lastHost.isEmpty() || isDisconnecting || generation != connectionGeneration) {
|
||||||
|
Log.d(TAG, "Skipping triggerReconnect: shouldReconnect=$shouldReconnect, hasHost=${lastHost.isNotEmpty()}, isDisconnecting=$isDisconnecting, generationMatch=${generation == connectionGeneration}")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(TAG, "Triggering reconnect to $lastHost:$lastPort (generation $generation)")
|
||||||
|
reconnectJob?.cancel()
|
||||||
|
reconnectJob = clientScope.launch {
|
||||||
|
delay(RECONNECT_DELAY_MS)
|
||||||
|
if (shouldReconnect && !isDisconnecting && generation == connectionGeneration) {
|
||||||
|
connectInternal(lastHost, lastPort, generation = generation)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -175,31 +309,74 @@ class TcpClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun disconnect() {
|
fun disconnect() {
|
||||||
Log.d(TAG, "Disconnecting TCP connection...")
|
disconnectInternal(triggerReconnect = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun disconnectInternal(triggerReconnect: Boolean) {
|
||||||
|
// Предотвращаем параллельные вызовы disconnect
|
||||||
|
if (isDisconnecting) {
|
||||||
|
Log.d(TAG, "[TCP] disconnect() already in progress, skipping")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isDisconnecting = true
|
||||||
|
|
||||||
|
// Если это полное отключение - запрещаем переподключение
|
||||||
|
if (!triggerReconnect) {
|
||||||
|
shouldReconnect = false
|
||||||
|
}
|
||||||
|
|
||||||
|
val startTime = System.currentTimeMillis()
|
||||||
|
Log.d(TAG, "[TCP] disconnect() called at $startTime, triggerReconnect=$triggerReconnect")
|
||||||
|
|
||||||
|
// Отменяем задачу переподключения
|
||||||
|
reconnectJob?.cancel()
|
||||||
|
reconnectJob = null
|
||||||
|
|
||||||
|
val cancelStart = System.currentTimeMillis()
|
||||||
readJob?.cancel()
|
readJob?.cancel()
|
||||||
readJob = null
|
readJob = null
|
||||||
|
val cancelEnd = System.currentTimeMillis()
|
||||||
|
Log.d(TAG, "[TCP] Job cancelled in ${cancelEnd - cancelStart}ms")
|
||||||
|
|
||||||
try {
|
// ВАЖНО: Сначала закрываем SOCKET, чтобы прервать блокирующий readLine()
|
||||||
reader?.close()
|
// Это заставит readLine() выбросить исключение и освободить блокировку на reader
|
||||||
} catch (e: Exception) {
|
val socketCloseStart = System.currentTimeMillis()
|
||||||
Log.e(TAG, "Error closing reader", e)
|
|
||||||
}
|
|
||||||
reader = null
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
socket?.close()
|
socket?.close()
|
||||||
|
val socketCloseEnd = System.currentTimeMillis()
|
||||||
|
Log.d(TAG, "[TCP] Socket closed in ${socketCloseEnd - socketCloseStart}ms")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Error closing socket", e)
|
Log.e(TAG, "[TCP] Error closing socket", e)
|
||||||
}
|
}
|
||||||
socket = null
|
socket = null
|
||||||
|
|
||||||
clientScope.launch(Dispatchers.Main) {
|
// Теперь безопасно закрываем reader (блокировка уже освобождена)
|
||||||
_connectionState.value = ConnectionState.Disconnected
|
val readerCloseStart = System.currentTimeMillis()
|
||||||
_weightPosition.value = null
|
try {
|
||||||
|
reader?.close()
|
||||||
|
val readerCloseEnd = System.currentTimeMillis()
|
||||||
|
Log.d(TAG, "[TCP] Reader closed in ${readerCloseEnd - readerCloseStart}ms")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "[TCP] Error closing reader", e)
|
||||||
|
}
|
||||||
|
reader = null
|
||||||
|
|
||||||
|
// Обновляем состояние синхронно, чтобы избежать race condition с новым подключением
|
||||||
|
// Если connect() будет вызван сразу после disconnect(), он корректно установит shouldReconnect = true
|
||||||
|
// и новое соединение не будет затёрто асинхронным обновлением состояния
|
||||||
|
if (!shouldReconnect) {
|
||||||
|
clientScope.launch(Dispatchers.Main) {
|
||||||
|
// Повторная проверка, т.к. состояние могло измениться пока мы ждали Main thread
|
||||||
|
if (!shouldReconnect && socket == null) {
|
||||||
|
_connectionState.value = ConnectionState.Disconnected
|
||||||
|
_weightPosition.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.d(TAG, "TCP connection disconnected")
|
val endTime = System.currentTimeMillis()
|
||||||
|
Log.d(TAG, "[TCP] disconnect() completed. Total time: ${endTime - startTime}ms")
|
||||||
|
isDisconnecting = false
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sendMessage(message: String) {
|
fun sendMessage(message: String) {
|
||||||
@@ -221,8 +398,16 @@ class TcpClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
/**
|
||||||
private const val TAG = "TcpClient"
|
* Полностью освобождает ресурсы. Вызывайте при уничтожении объекта.
|
||||||
|
*/
|
||||||
|
fun destroy() {
|
||||||
|
Log.d(TAG, "[TCP] destroy() called")
|
||||||
|
shouldReconnect = false
|
||||||
|
reconnectJob?.cancel()
|
||||||
|
reconnectJob = null
|
||||||
|
disconnect()
|
||||||
|
clientScope.cancel()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ fun CustomScenarioScreen(
|
|||||||
var description by remember { mutableStateOf("") }
|
var description by remember { mutableStateOf("") }
|
||||||
var selectedMode by remember { mutableStateOf(ExerciseMode.TIME_BASED) }
|
var selectedMode by remember { mutableStateOf(ExerciseMode.TIME_BASED) }
|
||||||
var targetValue by remember { mutableStateOf("") }
|
var targetValue by remember { mutableStateOf("") }
|
||||||
|
var standardValue by remember { mutableStateOf("") }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
@@ -92,18 +93,42 @@ fun CustomScenarioScreen(
|
|||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = standardValue,
|
||||||
|
onValueChange = { standardValue = it },
|
||||||
|
label = {
|
||||||
|
Text(
|
||||||
|
when (selectedMode) {
|
||||||
|
ExerciseMode.TIME_BASED -> "Норматив: мин. подъемов (опционально)"
|
||||||
|
ExerciseMode.COUNT_BASED -> "Норматив: макс. время в сек. (опционально)"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
supportingText = {
|
||||||
|
Text(
|
||||||
|
when (selectedMode) {
|
||||||
|
ExerciseMode.TIME_BASED -> "Если задан - зелёный экран при выполнении, красный - при невыполнении"
|
||||||
|
ExerciseMode.COUNT_BASED -> "Если задан - зелёный экран если уложились в время, красный - если нет"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
val target = targetValue.toIntOrNull()
|
val target = targetValue.toIntOrNull()
|
||||||
|
val standard = standardValue.toIntOrNull()?.takeIf { it > 0 }
|
||||||
if (name.isNotBlank() && target != null && target > 0) {
|
if (name.isNotBlank() && target != null && target > 0) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
viewModel.createCustomScenario(
|
viewModel.createCustomScenario(
|
||||||
name = name,
|
name = name,
|
||||||
description = description.ifBlank { null },
|
description = description.ifBlank { null },
|
||||||
mode = selectedMode,
|
mode = selectedMode,
|
||||||
targetValue = target
|
targetValue = target,
|
||||||
|
standardValue = standard
|
||||||
)
|
)
|
||||||
onScenarioCreated()
|
onScenarioCreated()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,33 @@
|
|||||||
package ru.kgeu.training.ui.screen
|
package ru.kgeu.training.ui.screen
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.animateColorAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.ui.zIndex
|
||||||
|
import ru.kgeu.training.data.model.WeightPosition
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import ru.kgeu.training.data.model.ExerciseMode
|
import ru.kgeu.training.data.model.ExerciseMode
|
||||||
import ru.kgeu.training.data.model.ExerciseScenario
|
import ru.kgeu.training.data.model.ExerciseScenario
|
||||||
import ru.kgeu.training.data.model.Participant
|
import ru.kgeu.training.data.model.Participant
|
||||||
import ru.kgeu.training.data.model.WorkoutSession
|
import ru.kgeu.training.data.model.WorkoutSession
|
||||||
|
import ru.kgeu.training.ui.viewmodel.WorkoutResult
|
||||||
import ru.kgeu.training.ui.viewmodel.WorkoutViewModel
|
import ru.kgeu.training.ui.viewmodel.WorkoutViewModel
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
@@ -35,6 +47,7 @@ fun WorkoutScreen(
|
|||||||
val elapsedTime by viewModel.elapsedTime.collectAsState()
|
val elapsedTime by viewModel.elapsedTime.collectAsState()
|
||||||
val heartRate by viewModel.heartRate.collectAsState()
|
val heartRate by viewModel.heartRate.collectAsState()
|
||||||
val currentSession by viewModel.currentSession.collectAsState()
|
val currentSession by viewModel.currentSession.collectAsState()
|
||||||
|
val workoutResult by viewModel.workoutResult.collectAsState()
|
||||||
|
|
||||||
val connectionState by viewModel.tcpConnectionState.collectAsState()
|
val connectionState by viewModel.tcpConnectionState.collectAsState()
|
||||||
val bluetoothState by viewModel.bluetoothConnectionState.collectAsState()
|
val bluetoothState by viewModel.bluetoothConnectionState.collectAsState()
|
||||||
@@ -43,6 +56,20 @@ fun WorkoutScreen(
|
|||||||
// Получаем текущее положение веса для отображения
|
// Получаем текущее положение веса для отображения
|
||||||
val weightPosition by viewModel.weightPosition.collectAsState()
|
val weightPosition by viewModel.weightPosition.collectAsState()
|
||||||
|
|
||||||
|
// Яркий зеленый цвет для подсветки при достижении крайних позиций (TOP/BOTTOM)
|
||||||
|
val flashGreen = Color(0xFF00E676) // Material Green A400 - яркий зеленый
|
||||||
|
val isAtEndPosition = weightPosition == WeightPosition.TOP || weightPosition == WeightPosition.BOTTOM
|
||||||
|
val cardBackgroundColor by animateColorAsState(
|
||||||
|
targetValue = if (isAtEndPosition) flashGreen else MaterialTheme.colorScheme.secondaryContainer,
|
||||||
|
animationSpec = tween(durationMillis = 150),
|
||||||
|
label = "cardFlash"
|
||||||
|
)
|
||||||
|
val cardContentColor by animateColorAsState(
|
||||||
|
targetValue = if (isAtEndPosition) Color.White else MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
animationSpec = tween(durationMillis = 150),
|
||||||
|
label = "cardContentFlash"
|
||||||
|
)
|
||||||
|
|
||||||
var workoutStarted by remember { mutableStateOf(false) }
|
var workoutStarted by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
LaunchedEffect(participant.id, scenario.id) {
|
LaunchedEffect(participant.id, scenario.id) {
|
||||||
@@ -54,6 +81,7 @@ fun WorkoutScreen(
|
|||||||
scenarioId = scenario.id,
|
scenarioId = scenario.id,
|
||||||
mode = scenario.exerciseMode,
|
mode = scenario.exerciseMode,
|
||||||
target = scenario.targetValue,
|
target = scenario.targetValue,
|
||||||
|
standard = scenario.standardValue,
|
||||||
tcpHost = tcpHost,
|
tcpHost = tcpHost,
|
||||||
tcpPort = tcpPort
|
tcpPort = tcpPort
|
||||||
)
|
)
|
||||||
@@ -81,38 +109,45 @@ fun WorkoutScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
// Полноэкранный результат тренировки (если задан норматив)
|
||||||
topBar = {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
CenterAlignedTopAppBar(
|
Scaffold(
|
||||||
title = { Text(scenario.name) }
|
topBar = {
|
||||||
)
|
CenterAlignedTopAppBar(
|
||||||
},
|
title = { Text(scenario.name) }
|
||||||
bottomBar = {
|
)
|
||||||
Row(
|
},
|
||||||
modifier = Modifier
|
bottomBar = {
|
||||||
.fillMaxWidth()
|
Row(
|
||||||
.padding(16.dp),
|
modifier = Modifier
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
.fillMaxWidth()
|
||||||
) {
|
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 116.dp),
|
||||||
Button(
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
onClick = {
|
|
||||||
viewModel.stopWorkout()
|
|
||||||
// Небольшая задержка перед навигацией для завершения сохранения
|
|
||||||
scope.launch {
|
|
||||||
kotlinx.coroutines.delay(300)
|
|
||||||
onWorkoutFinished()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
colors = ButtonDefaults.buttonColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.error
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
Text("Остановить")
|
Button(
|
||||||
|
onClick = {
|
||||||
|
viewModel.stopWorkout()
|
||||||
|
// Небольшая задержка перед навигацией для завершения сохранения
|
||||||
|
scope.launch {
|
||||||
|
kotlinx.coroutines.delay(200)
|
||||||
|
// Если нет норматива - сразу выходим, иначе ждем пока пользователь закроет результат
|
||||||
|
if (scenario.standardValue == null) {
|
||||||
|
onWorkoutFinished()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(0.6f)
|
||||||
|
.height(76.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text("Остановить")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
) { padding ->
|
||||||
) { padding ->
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -231,7 +266,8 @@ fun WorkoutScreen(
|
|||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
colors = CardDefaults.cardColors(
|
colors = CardDefaults.cardColors(
|
||||||
containerColor = MaterialTheme.colorScheme.secondaryContainer
|
containerColor = cardBackgroundColor,
|
||||||
|
contentColor = cardContentColor
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
@@ -240,18 +276,21 @@ fun WorkoutScreen(
|
|||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Выполнено подъемов",
|
text = "Выполнено подъемов",
|
||||||
style = MaterialTheme.typography.bodyMedium
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = cardContentColor
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "$completedLifts",
|
text = "$completedLifts",
|
||||||
fontSize = 64.sp,
|
fontSize = 64.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
color = cardContentColor
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "Цель: ${scenario.targetValue} сек",
|
text = "Цель: ${scenario.targetValue} сек",
|
||||||
style = MaterialTheme.typography.bodySmall
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = cardContentColor
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -271,7 +310,8 @@ fun WorkoutScreen(
|
|||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
colors = CardDefaults.cardColors(
|
colors = CardDefaults.cardColors(
|
||||||
containerColor = MaterialTheme.colorScheme.secondaryContainer
|
containerColor = cardBackgroundColor,
|
||||||
|
contentColor = cardContentColor
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
@@ -280,18 +320,21 @@ fun WorkoutScreen(
|
|||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Время",
|
text = "Время",
|
||||||
style = MaterialTheme.typography.bodyMedium
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = cardContentColor
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = formatTime(elapsedTime),
|
text = formatTime(elapsedTime),
|
||||||
fontSize = 64.sp,
|
fontSize = 64.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
color = cardContentColor
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "Цель: ${scenario.targetValue} подъемов",
|
text = "Цель: ${scenario.targetValue} подъемов",
|
||||||
style = MaterialTheme.typography.bodySmall
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = cardContentColor
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -307,6 +350,71 @@ fun WorkoutScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Полноэкранный оверлей с результатом (зеленый/красный)
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = workoutResult != null,
|
||||||
|
enter = fadeIn(animationSpec = tween(300)),
|
||||||
|
exit = fadeOut(animationSpec = tween(300)),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.zIndex(10f)
|
||||||
|
) {
|
||||||
|
val resultColor = when (workoutResult) {
|
||||||
|
WorkoutResult.PASSED -> Color(0xFF00C853) // Яркий зеленый
|
||||||
|
WorkoutResult.FAILED -> Color(0xFFD50000) // Яркий красный
|
||||||
|
null -> Color.Transparent
|
||||||
|
}
|
||||||
|
val resultText = when (workoutResult) {
|
||||||
|
WorkoutResult.PASSED -> "НОРМАТИВ ВЫПОЛНЕН!"
|
||||||
|
WorkoutResult.FAILED -> "НОРМАТИВ НЕ ВЫПОЛНЕН"
|
||||||
|
null -> ""
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(resultColor)
|
||||||
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null
|
||||||
|
) {
|
||||||
|
viewModel.clearWorkoutResult()
|
||||||
|
onWorkoutFinished()
|
||||||
|
},
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = resultText,
|
||||||
|
fontSize = 36.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.White,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Подъемов: $completedLifts",
|
||||||
|
fontSize = 24.sp,
|
||||||
|
color = Color.White.copy(alpha = 0.9f)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Время: ${formatTime(elapsedTime)}",
|
||||||
|
fontSize = 24.sp,
|
||||||
|
color = Color.White.copy(alpha = 0.9f)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
Text(
|
||||||
|
text = "Нажмите для продолжения",
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = Color.White.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -90,13 +90,15 @@ class ScenarioViewModel(private val repository: TrainingRepository) : ViewModel(
|
|||||||
name: String,
|
name: String,
|
||||||
description: String?,
|
description: String?,
|
||||||
mode: ExerciseMode,
|
mode: ExerciseMode,
|
||||||
targetValue: Int
|
targetValue: Int,
|
||||||
|
standardValue: Int? = null
|
||||||
): Long {
|
): Long {
|
||||||
val scenario = ExerciseScenario(
|
val scenario = ExerciseScenario(
|
||||||
name = name,
|
name = name,
|
||||||
description = description,
|
description = description,
|
||||||
exerciseMode = mode,
|
exerciseMode = mode,
|
||||||
targetValue = targetValue,
|
targetValue = targetValue,
|
||||||
|
standardValue = standardValue,
|
||||||
isCustom = true
|
isCustom = true
|
||||||
)
|
)
|
||||||
return repository.insertScenario(scenario)
|
return repository.insertScenario(scenario)
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ package ru.kgeu.training.ui.viewmodel
|
|||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import ru.kgeu.training.data.model.ExerciseMode
|
import ru.kgeu.training.data.model.ExerciseMode
|
||||||
import ru.kgeu.training.data.model.WeightPosition
|
import ru.kgeu.training.data.model.WeightPosition
|
||||||
import ru.kgeu.training.data.model.WorkoutSession
|
import ru.kgeu.training.data.model.WorkoutSession
|
||||||
@@ -15,6 +17,11 @@ import ru.kgeu.training.data.repository.TrainingRepository
|
|||||||
import ru.kgeu.training.network.TcpClient
|
import ru.kgeu.training.network.TcpClient
|
||||||
import ru.kgeu.training.bluetooth.HeartRateMonitor
|
import ru.kgeu.training.bluetooth.HeartRateMonitor
|
||||||
|
|
||||||
|
enum class WorkoutResult {
|
||||||
|
PASSED, // Норматив выполнен
|
||||||
|
FAILED // Норматив не выполнен
|
||||||
|
}
|
||||||
|
|
||||||
class WorkoutViewModel(
|
class WorkoutViewModel(
|
||||||
private val repository: TrainingRepository,
|
private val repository: TrainingRepository,
|
||||||
private val tcpClient: TcpClient,
|
private val tcpClient: TcpClient,
|
||||||
@@ -41,6 +48,9 @@ class WorkoutViewModel(
|
|||||||
private val _currentSession = MutableStateFlow<WorkoutSession?>(null)
|
private val _currentSession = MutableStateFlow<WorkoutSession?>(null)
|
||||||
val currentSession: StateFlow<WorkoutSession?> = _currentSession.asStateFlow()
|
val currentSession: StateFlow<WorkoutSession?> = _currentSession.asStateFlow()
|
||||||
|
|
||||||
|
private val _workoutResult = MutableStateFlow<WorkoutResult?>(null)
|
||||||
|
val workoutResult: StateFlow<WorkoutResult?> = _workoutResult.asStateFlow()
|
||||||
|
|
||||||
val tcpConnectionState: StateFlow<ru.kgeu.training.network.ConnectionState> = tcpClient.connectionState
|
val tcpConnectionState: StateFlow<ru.kgeu.training.network.ConnectionState> = tcpClient.connectionState
|
||||||
val bluetoothConnectionState: StateFlow<ru.kgeu.training.bluetooth.BluetoothConnectionState> = heartRateMonitor.connectionState
|
val bluetoothConnectionState: StateFlow<ru.kgeu.training.bluetooth.BluetoothConnectionState> = heartRateMonitor.connectionState
|
||||||
val weightPosition: StateFlow<ru.kgeu.training.data.model.WeightPosition?> = tcpClient.weightPosition
|
val weightPosition: StateFlow<ru.kgeu.training.data.model.WeightPosition?> = tcpClient.weightPosition
|
||||||
@@ -51,13 +61,14 @@ class WorkoutViewModel(
|
|||||||
private var lastPosition: WeightPosition? = null
|
private var lastPosition: WeightPosition? = null
|
||||||
private var isLiftingUp: Boolean = false // Флаг: находимся ли мы в процессе подъема (BOTTOM -> MIDDLE -> TOP)
|
private var isLiftingUp: Boolean = false // Флаг: находимся ли мы в процессе подъема (BOTTOM -> MIDDLE -> TOP)
|
||||||
private var targetValue: Int = 0
|
private var targetValue: Int = 0
|
||||||
|
private var standardValue: Int? = null
|
||||||
private var exerciseMode: ExerciseMode = ExerciseMode.TIME_BASED
|
private var exerciseMode: ExerciseMode = ExerciseMode.TIME_BASED
|
||||||
private var participantId: Long = 0
|
private var participantId: Long = 0
|
||||||
private var scenarioId: Long = 0
|
private var scenarioId: Long = 0
|
||||||
private var isStopping = false
|
private var isStopping = false
|
||||||
private var timerStartTime: Long = 0 // Время когда таймер реально запустился
|
private var timerStartTime: Long = 0 // Время когда таймер реально запустился
|
||||||
|
|
||||||
private val heartRateReadings = mutableListOf<Int>()
|
private val heartRateReadings = java.util.Collections.synchronizedList(mutableListOf<Int>())
|
||||||
|
|
||||||
init {
|
init {
|
||||||
observeWeightPosition()
|
observeWeightPosition()
|
||||||
@@ -127,7 +138,7 @@ class WorkoutViewModel(
|
|||||||
// Если режим на количество и достигнута цель
|
// Если режим на количество и достигнута цель
|
||||||
if (exerciseMode == ExerciseMode.COUNT_BASED &&
|
if (exerciseMode == ExerciseMode.COUNT_BASED &&
|
||||||
_completedLifts.value >= targetValue) {
|
_completedLifts.value >= targetValue) {
|
||||||
stopWorkout()
|
stopWorkout(isManualStop = false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,6 +163,7 @@ class WorkoutViewModel(
|
|||||||
scenarioId: Long,
|
scenarioId: Long,
|
||||||
mode: ExerciseMode,
|
mode: ExerciseMode,
|
||||||
target: Int,
|
target: Int,
|
||||||
|
standard: Int?,
|
||||||
tcpHost: String,
|
tcpHost: String,
|
||||||
tcpPort: Int
|
tcpPort: Int
|
||||||
) {
|
) {
|
||||||
@@ -161,11 +173,13 @@ class WorkoutViewModel(
|
|||||||
this.scenarioId = scenarioId
|
this.scenarioId = scenarioId
|
||||||
this.exerciseMode = mode
|
this.exerciseMode = mode
|
||||||
this.targetValue = target
|
this.targetValue = target
|
||||||
|
this.standardValue = standard
|
||||||
|
|
||||||
_completedLifts.value = 0
|
_completedLifts.value = 0
|
||||||
_elapsedTime.value = 0
|
_elapsedTime.value = 0
|
||||||
_isTimerStarted.value = false // Таймер запустится только при переходе BOTTOM -> MIDDLE
|
_isTimerStarted.value = false // Таймер запустится только при переходе BOTTOM -> MIDDLE
|
||||||
_remainingTime.value = if (mode == ExerciseMode.COUNT_BASED) target.toLong() else 0
|
_remainingTime.value = if (mode == ExerciseMode.COUNT_BASED) target.toLong() else 0
|
||||||
|
_workoutResult.value = null // Сбрасываем результат предыдущей тренировки
|
||||||
lastPosition = null
|
lastPosition = null
|
||||||
isLiftingUp = false // Сбрасываем флаг подъема
|
isLiftingUp = false // Сбрасываем флаг подъема
|
||||||
timerStartTime = 0
|
timerStartTime = 0
|
||||||
@@ -214,7 +228,7 @@ class WorkoutViewModel(
|
|||||||
if (exerciseMode == ExerciseMode.TIME_BASED) {
|
if (exerciseMode == ExerciseMode.TIME_BASED) {
|
||||||
_elapsedTime.value++
|
_elapsedTime.value++
|
||||||
if (_elapsedTime.value >= target) {
|
if (_elapsedTime.value >= target) {
|
||||||
stopWorkout()
|
stopWorkout(isManualStop = false)
|
||||||
}
|
}
|
||||||
} else if (exerciseMode == ExerciseMode.COUNT_BASED) {
|
} else if (exerciseMode == ExerciseMode.COUNT_BASED) {
|
||||||
_elapsedTime.value++
|
_elapsedTime.value++
|
||||||
@@ -224,69 +238,145 @@ class WorkoutViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun stopWorkout() {
|
fun stopWorkout(isManualStop: Boolean = true) {
|
||||||
if (isStopping || !_isWorkoutActive.value) return
|
val startTime = System.currentTimeMillis()
|
||||||
|
android.util.Log.d(TAG, "[STOP] stopWorkout() called at ${startTime}, isManualStop=$isManualStop")
|
||||||
|
|
||||||
|
if (isStopping || !_isWorkoutActive.value) {
|
||||||
|
android.util.Log.d(TAG, "[STOP] Already stopping or not active, returning")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val timeAfterCheck = System.currentTimeMillis()
|
||||||
|
android.util.Log.d(TAG, "[STOP] Check took ${timeAfterCheck - startTime}ms")
|
||||||
|
|
||||||
isStopping = true
|
isStopping = true
|
||||||
_isWorkoutActive.value = false
|
_isWorkoutActive.value = false
|
||||||
_isTimerStarted.value = false
|
_isTimerStarted.value = false
|
||||||
|
|
||||||
// Отменяем все корутины
|
val timeAfterFlags = System.currentTimeMillis()
|
||||||
|
android.util.Log.d(TAG, "[STOP] Flags set in ${timeAfterFlags - timeAfterCheck}ms")
|
||||||
|
|
||||||
|
// Отменяем ВСЕ корутины сначала, чтобы они не пытались обрабатывать данные во время отключения
|
||||||
workoutJob?.cancel()
|
workoutJob?.cancel()
|
||||||
workoutJob = null
|
workoutJob = null
|
||||||
|
weightPositionJob?.cancel()
|
||||||
|
weightPositionJob = null
|
||||||
|
heartRateJob?.cancel()
|
||||||
|
heartRateJob = null
|
||||||
|
|
||||||
// Отключаем соединения
|
val timeAfterCancel = System.currentTimeMillis()
|
||||||
try {
|
android.util.Log.d(TAG, "[STOP] Jobs cancelled in ${timeAfterCancel - timeAfterFlags}ms")
|
||||||
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
|
val session = _currentSession.value
|
||||||
if (session != null) {
|
val endTime = System.currentTimeMillis()
|
||||||
val endTime = System.currentTimeMillis()
|
val actualStartTime = if (timerStartTime > 0) timerStartTime else session?.startTime ?: endTime
|
||||||
// Используем timerStartTime если таймер был запущен, иначе 0 (тренировка не началась)
|
val duration = if (timerStartTime > 0) {
|
||||||
val actualStartTime = if (timerStartTime > 0) timerStartTime else session.startTime
|
(endTime - actualStartTime) / 1000
|
||||||
val duration = if (timerStartTime > 0) {
|
} else {
|
||||||
(endTime - actualStartTime) / 1000
|
0L // Тренировка не началась (таймер не запустился)
|
||||||
} else {
|
}
|
||||||
0L // Тренировка не началась (таймер не запустился)
|
|
||||||
}
|
// Копируем данные пульса для безопасного вычисления (synchronized list)
|
||||||
|
val heartRateCopy = synchronized(heartRateReadings) { heartRateReadings.toList() }
|
||||||
val avgHeartRate = if (heartRateReadings.isNotEmpty()) {
|
val avgHeartRate = if (heartRateCopy.isNotEmpty()) {
|
||||||
heartRateReadings.average().toInt()
|
heartRateCopy.average().toInt()
|
||||||
} else null
|
} else null
|
||||||
|
|
||||||
val updatedSession = session.copy(
|
// Вычисляем результат, если задан норматив и тренировка завершилась естественно (не вручную)
|
||||||
startTime = actualStartTime, // Обновляем startTime на реальное время начала
|
val currentLifts = _completedLifts.value
|
||||||
endTime = endTime,
|
val result = if (!isManualStop) {
|
||||||
completedLifts = _completedLifts.value,
|
standardValue?.let { standard ->
|
||||||
durationSeconds = duration,
|
when (exerciseMode) {
|
||||||
averageHeartRate = avgHeartRate,
|
// Для TIME_BASED: норматив - минимальное количество подъемов
|
||||||
maxHeartRate = heartRateReadings.maxOrNull(),
|
ExerciseMode.TIME_BASED -> {
|
||||||
minHeartRate = heartRateReadings.minOrNull()
|
if (currentLifts >= standard) WorkoutResult.PASSED else WorkoutResult.FAILED
|
||||||
)
|
}
|
||||||
|
// Для COUNT_BASED: норматив - максимальное время в секундах
|
||||||
viewModelScope.launch {
|
ExerciseMode.COUNT_BASED -> {
|
||||||
try {
|
if (duration <= standard) WorkoutResult.PASSED else WorkoutResult.FAILED
|
||||||
repository.updateSession(updatedSession)
|
}
|
||||||
_currentSession.value = updatedSession
|
|
||||||
} catch (e: Exception) {
|
|
||||||
android.util.Log.e("WorkoutViewModel", "Error updating session", e)
|
|
||||||
} finally {
|
|
||||||
isStopping = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else null
|
||||||
isStopping = false
|
_workoutResult.value = result
|
||||||
|
|
||||||
|
val updatedSession = session?.copy(
|
||||||
|
startTime = actualStartTime,
|
||||||
|
endTime = endTime,
|
||||||
|
completedLifts = _completedLifts.value,
|
||||||
|
durationSeconds = duration,
|
||||||
|
averageHeartRate = avgHeartRate,
|
||||||
|
maxHeartRate = heartRateCopy.maxOrNull(),
|
||||||
|
minHeartRate = heartRateCopy.minOrNull()
|
||||||
|
)
|
||||||
|
|
||||||
|
val timeAfterSessionPrep = System.currentTimeMillis()
|
||||||
|
android.util.Log.d(TAG, "[STOP] Session prepared in ${timeAfterSessionPrep - timeAfterCancel}ms")
|
||||||
|
android.util.Log.d(TAG, "[STOP] Total time before coroutine launch: ${timeAfterSessionPrep - startTime}ms")
|
||||||
|
|
||||||
|
// Выполняем отключение соединений и сохранение в фоновом потоке
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val coroutineStartTime = System.currentTimeMillis()
|
||||||
|
android.util.Log.d(TAG, "[STOP] Coroutine started on IO dispatcher at ${coroutineStartTime}, delay from function start: ${coroutineStartTime - startTime}ms")
|
||||||
|
|
||||||
|
// Отключаем соединения в IO потоке (не блокируем Main Thread)
|
||||||
|
val tcpDisconnectStart = System.currentTimeMillis()
|
||||||
|
try {
|
||||||
|
android.util.Log.d(TAG, "[STOP] Calling tcpClient.disconnect()...")
|
||||||
|
tcpClient.disconnect()
|
||||||
|
val tcpDisconnectEnd = System.currentTimeMillis()
|
||||||
|
android.util.Log.d(TAG, "[STOP] tcpClient.disconnect() completed in ${tcpDisconnectEnd - tcpDisconnectStart}ms")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e(TAG, "[STOP] Error disconnecting TCP", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
val btDisconnectStart = System.currentTimeMillis()
|
||||||
|
try {
|
||||||
|
android.util.Log.d(TAG, "[STOP] Calling heartRateMonitor.disconnect()...")
|
||||||
|
heartRateMonitor.disconnect()
|
||||||
|
val btDisconnectEnd = System.currentTimeMillis()
|
||||||
|
android.util.Log.d(TAG, "[STOP] heartRateMonitor.disconnect() completed in ${btDisconnectEnd - btDisconnectStart}ms")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e(TAG, "[STOP] Error disconnecting Bluetooth", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохраняем сессию
|
||||||
|
if (updatedSession != null) {
|
||||||
|
val dbUpdateStart = System.currentTimeMillis()
|
||||||
|
try {
|
||||||
|
android.util.Log.d(TAG, "[STOP] Calling repository.updateSession()...")
|
||||||
|
repository.updateSession(updatedSession)
|
||||||
|
val dbUpdateEnd = System.currentTimeMillis()
|
||||||
|
android.util.Log.d(TAG, "[STOP] repository.updateSession() completed in ${dbUpdateEnd - dbUpdateStart}ms")
|
||||||
|
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
_currentSession.value = updatedSession
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e(TAG, "[STOP] Error updating session", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val finalStart = System.currentTimeMillis()
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
isStopping = false
|
||||||
|
// Перезапускаем наблюдатели для следующей тренировки
|
||||||
|
observeWeightPosition()
|
||||||
|
observeHeartRate()
|
||||||
|
|
||||||
|
val finalEnd = System.currentTimeMillis()
|
||||||
|
android.util.Log.d(TAG, "[STOP] Final cleanup completed. Total coroutine time: ${finalEnd - coroutineStartTime}ms, Total function time: ${finalEnd - startTime}ms")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val timeAfterLaunch = System.currentTimeMillis()
|
||||||
|
android.util.Log.d(TAG, "[STOP] Coroutine launched, function returning. Time to launch: ${timeAfterLaunch - startTime}ms")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearWorkoutResult() {
|
||||||
|
_workoutResult.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
fun disconnectAll() {
|
fun disconnectAll() {
|
||||||
@@ -310,7 +400,21 @@ class WorkoutViewModel(
|
|||||||
workoutJob?.cancel()
|
workoutJob?.cancel()
|
||||||
weightPositionJob?.cancel()
|
weightPositionJob?.cancel()
|
||||||
heartRateJob?.cancel()
|
heartRateJob?.cancel()
|
||||||
disconnectAll()
|
|
||||||
|
// НЕ вызываем destroy() - TcpClient это синглтон на уровне Application,
|
||||||
|
// и его CoroutineScope должен жить всё время работы приложения.
|
||||||
|
// Вызываем только disconnect() для освобождения текущего соединения.
|
||||||
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
Reference in New Issue
Block a user