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"?>
|
||||
<project version="4">
|
||||
<component name="GradleMigrationSettings" migrationVersion="1" />
|
||||
<component name="GradleSettings">
|
||||
<option name="linkedExternalProjectsSettings">
|
||||
<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">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<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. **Отсчет времени** - в режиме на количество отсчитывается время выполнения
|
||||
6. **Кардиодатчик** - отображение показаний пульса в реальном времени через Bluetooth
|
||||
7. **Печать отчетов** - вывод результатов тренировки на принтер
|
||||
Приложение позволяет:
|
||||
- Регистрировать участников тренировок
|
||||
- Создавать и выбирать сценарии упражнений
|
||||
- Проводить тренировки с автоматическим подсчётом подъёмов
|
||||
- Отслеживать пульс через Bluetooth-кардиодатчик
|
||||
- Просматривать статистику тренировок
|
||||
|
||||
## Технологии
|
||||
## Режимы тренировок
|
||||
|
||||
- Kotlin
|
||||
- Jetpack Compose
|
||||
- Room Database
|
||||
- WebSocket (OkHttp) для связи с ESP32
|
||||
- Bluetooth LE для кардиодатчика
|
||||
- Navigation Component
|
||||
### Режим на время (TIME_BASED)
|
||||
- Задаётся время выполнения упражнения в секундах
|
||||
- Приложение считает количество выполненных подъёмов
|
||||
- Норматив: минимальное количество подъёмов за заданное время
|
||||
|
||||
## Настройка
|
||||
### Режим на количество (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
|
||||
{"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
|
||||
- Location (требуется для Bluetooth LE сканирования на Android 12+)
|
||||
- **Bluetooth** — для подключения к кардиодатчику
|
||||
- **Nearby Devices** — для сканирования Bluetooth-устройств
|
||||
- **Internet/Network** — для подключения к ESP32
|
||||
|
||||
## Структура проекта
|
||||
|
||||
```
|
||||
app/src/main/java/ru/kgeu/training/
|
||||
├── bluetooth/ # Работа с Bluetooth (кардиодатчик)
|
||||
├── data/
|
||||
│ ├── dao/ # Data Access Objects для Room
|
||||
│ ├── database/ # Room Database
|
||||
│ ├── model/ # Модели данных
|
||||
│ └── repository/ # Репозиторий для работы с данными
|
||||
├── network/
|
||||
│ └── WebSocketClient.kt # WebSocket клиент для ESP32
|
||||
├── bluetooth/
|
||||
│ └── HeartRateMonitor.kt # Bluetooth сервис для кардиодатчика
|
||||
│ ├── dao/ # Data Access Objects для Room
|
||||
│ ├── database/ # Конфигурация базы данных
|
||||
│ ├── model/ # Модели данных (Participant, Scenario, Session)
|
||||
│ └── repository/ # Репозиторий для работы с данными
|
||||
├── network/ # TCP-клиент для ESP32
|
||||
├── ui/
|
||||
│ ├── navigation/ # Навигация
|
||||
│ ├── screen/ # UI экраны
|
||||
│ ├── theme/ # Тема приложения
|
||||
│ └── viewmodel/ # ViewModels
|
||||
└── util/
|
||||
└── PrintUtil.kt # Утилита для печати
|
||||
│ ├── navigation/ # Навигация приложения
|
||||
│ ├── screen/ # Экраны (Compose)
|
||||
│ ├── theme/ # Тема приложения
|
||||
│ └── viewmodel/ # ViewModels
|
||||
├── util/ # Вспомогательные утилиты
|
||||
├── 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
|
||||
- Сохранение в PDF
|
||||
- Отправку по email
|
||||
### Приложение не устанавливается
|
||||
1. Проверьте версию Android (требуется 14+)
|
||||
2. Убедитесь, что разрешена установка из неизвестных источников
|
||||
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 var bluetoothGatt: BluetoothGatt? = null
|
||||
|
||||
@Volatile
|
||||
private var isDisconnecting = false
|
||||
|
||||
@Volatile
|
||||
private var isScanning = false
|
||||
|
||||
private val _heartRate = MutableStateFlow<Int?>(null)
|
||||
val heartRate: StateFlow<Int?> = _heartRate.asStateFlow()
|
||||
|
||||
@@ -124,7 +130,11 @@ class HeartRateMonitor(private val context: Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Сбрасываем флаг отключения
|
||||
isDisconnecting = false
|
||||
|
||||
_connectionState.value = BluetoothConnectionState.Scanning
|
||||
isScanning = true
|
||||
|
||||
val filter = ScanFilter.Builder()
|
||||
.setServiceUuid(android.os.ParcelUuid(HEART_RATE_SERVICE_UUID))
|
||||
@@ -134,11 +144,26 @@ class HeartRateMonitor(private val context: Context) {
|
||||
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
|
||||
.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() {
|
||||
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) {
|
||||
@@ -147,11 +172,44 @@ class HeartRateMonitor(private val context: Context) {
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
bluetoothGatt?.disconnect()
|
||||
bluetoothGatt?.close()
|
||||
// Предотвращаем параллельные вызовы disconnect
|
||||
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
|
||||
_connectionState.value = BluetoothConnectionState.Disconnected
|
||||
_heartRate.value = null
|
||||
|
||||
val endTime = System.currentTimeMillis()
|
||||
Log.d(TAG, "[BT] disconnect() completed. Total time: ${endTime - startTime}ms")
|
||||
isDisconnecting = false
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -11,7 +11,7 @@ import ru.kgeu.training.data.model.WorkoutSession
|
||||
|
||||
@Database(
|
||||
entities = [Participant::class, ExerciseScenario::class, WorkoutSession::class],
|
||||
version = 1,
|
||||
version = 2,
|
||||
exportSchema = false
|
||||
)
|
||||
abstract class TrainingDatabase : RoomDatabase() {
|
||||
|
||||
@@ -11,6 +11,7 @@ data class ExerciseScenario(
|
||||
val description: String? = null,
|
||||
val exerciseMode: ExerciseMode,
|
||||
val targetValue: Int, // количество подъемов или время в секундах
|
||||
val standardValue: Int? = null, // норматив: для TIME_BASED - мин. кол-во подъемов, для COUNT_BASED - макс. время в секундах
|
||||
val isCustom: Boolean = false, // true для пользовательских, false для готовых
|
||||
val createdAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
@@ -16,104 +16,187 @@ class TcpClient {
|
||||
private var socket: Socket? = null
|
||||
private var reader: BufferedReader? = null
|
||||
private var readJob: Job? = null
|
||||
private var reconnectJob: Job? = null
|
||||
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)
|
||||
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
|
||||
|
||||
private val _weightPosition = MutableStateFlow<WeightPosition?>(null)
|
||||
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) {
|
||||
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) {
|
||||
Log.d(TAG, "Already connected, disconnecting first")
|
||||
disconnect()
|
||||
disconnectInternal(triggerReconnect = false)
|
||||
}
|
||||
|
||||
// Отменяем предыдущую задачу чтения если есть
|
||||
// Сбрасываем флаг отключения для нового подключения
|
||||
isDisconnecting = false
|
||||
|
||||
// Отменяем предыдущие задачи
|
||||
readJob?.cancel()
|
||||
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
|
||||
|
||||
clientScope.launch {
|
||||
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()
|
||||
// Оптимизация TCP: отключаем алгоритм Nagle для уменьшения задержек
|
||||
newSocket.tcpNoDelay = true
|
||||
// Уменьшаем таймаут чтения для более быстрой реакции
|
||||
newSocket.soTimeout = 1000 // 1 секунда вместо 10
|
||||
newSocket.connect(java.net.InetSocketAddress(host, port), 5000) // 5 секунд таймаут на подключение
|
||||
newSocket.soTimeout = 2000 // 2 секунды
|
||||
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
|
||||
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) {
|
||||
_connectionState.value = ConnectionState.Connected
|
||||
}
|
||||
|
||||
// Запускаем чтение данных в отдельной корутине
|
||||
// Запускаем чтение данных в отдельной корутине, передаём generation
|
||||
readJob = clientScope.launch {
|
||||
readMessages()
|
||||
readMessages(generation)
|
||||
}
|
||||
|
||||
} catch (e: SocketTimeoutException) {
|
||||
Log.e(TAG, "Connection timeout: $host:$port", e)
|
||||
withContext(Dispatchers.Main) {
|
||||
_connectionState.value = ConnectionState.Disconnected
|
||||
}
|
||||
socket?.close()
|
||||
socket = null
|
||||
reader = null
|
||||
Log.e(TAG, "Connection timeout: $host:$port (attempt $attemptNumber)", e)
|
||||
handleConnectionFailure(attemptNumber, generation)
|
||||
} 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
|
||||
Log.e(TAG, "Unknown host: $host (attempt $attemptNumber)", e)
|
||||
handleConnectionFailure(attemptNumber, generation)
|
||||
} 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
|
||||
Log.e(TAG, "Connection refused: $host:$port (attempt $attemptNumber)", e)
|
||||
handleConnectionFailure(attemptNumber, generation)
|
||||
} 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
|
||||
Log.e(TAG, "Error connecting to TCP: $host:$port (attempt $attemptNumber)", e)
|
||||
handleConnectionFailure(attemptNumber, generation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 currentSocket = socket
|
||||
|
||||
if (currentReader == null || currentSocket == null) {
|
||||
Log.w(TAG, "Cannot read messages: reader or socket is null")
|
||||
triggerReconnect(generation)
|
||||
return
|
||||
}
|
||||
|
||||
var consecutiveTimeouts = 0
|
||||
val maxConsecutiveTimeouts = 10 // После 10 таймаутов подряд (20 сек) считаем соединение потерянным
|
||||
|
||||
try {
|
||||
Log.d(TAG, "Starting to read messages from TCP stream...")
|
||||
while (currentSocket.isConnected && !currentSocket.isClosed) {
|
||||
Log.d(TAG, "Starting to read messages from TCP stream (generation $generation)...")
|
||||
while (currentSocket.isConnected && !currentSocket.isClosed && !isDisconnecting && generation == connectionGeneration) {
|
||||
try {
|
||||
val line = currentReader.readLine()
|
||||
if (line == null) {
|
||||
@@ -121,13 +204,21 @@ class TcpClient {
|
||||
break
|
||||
}
|
||||
|
||||
consecutiveTimeouts = 0 // Сбрасываем счетчик при получении данных
|
||||
// Обрабатываем сообщение асинхронно, не блокируя чтение
|
||||
processMessage(line)
|
||||
|
||||
} catch (e: SocketTimeoutException) {
|
||||
// Таймаут на чтение - это нормально, продолжаем
|
||||
// Не логируем каждый таймаут, чтобы не засорять лог
|
||||
// Таймаут на чтение - это нормально, но следим за их количеством
|
||||
consecutiveTimeouts++
|
||||
if (consecutiveTimeouts >= maxConsecutiveTimeouts) {
|
||||
Log.w(TAG, "Too many consecutive timeouts ($consecutiveTimeouts), connection might be dead")
|
||||
break
|
||||
}
|
||||
continue
|
||||
} catch (e: java.io.IOException) {
|
||||
Log.e(TAG, "IO Error reading message (connection lost?)", e)
|
||||
break
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error reading message", e)
|
||||
break
|
||||
@@ -136,10 +227,53 @@ class TcpClient {
|
||||
} 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
|
||||
Log.d(TAG, "Stopped reading messages (generation $generation), current generation=$connectionGeneration, isDisconnecting=$isDisconnecting, shouldReconnect=$shouldReconnect")
|
||||
|
||||
// Закрываем текущие ресурсы
|
||||
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() {
|
||||
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 = null
|
||||
val cancelEnd = System.currentTimeMillis()
|
||||
Log.d(TAG, "[TCP] Job cancelled in ${cancelEnd - cancelStart}ms")
|
||||
|
||||
try {
|
||||
reader?.close()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error closing reader", e)
|
||||
}
|
||||
reader = null
|
||||
|
||||
// ВАЖНО: Сначала закрываем SOCKET, чтобы прервать блокирующий readLine()
|
||||
// Это заставит readLine() выбросить исключение и освободить блокировку на reader
|
||||
val socketCloseStart = System.currentTimeMillis()
|
||||
try {
|
||||
socket?.close()
|
||||
val socketCloseEnd = System.currentTimeMillis()
|
||||
Log.d(TAG, "[TCP] Socket closed in ${socketCloseEnd - socketCloseStart}ms")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error closing socket", e)
|
||||
Log.e(TAG, "[TCP] Error closing socket", e)
|
||||
}
|
||||
socket = null
|
||||
|
||||
clientScope.launch(Dispatchers.Main) {
|
||||
_connectionState.value = ConnectionState.Disconnected
|
||||
_weightPosition.value = null
|
||||
// Теперь безопасно закрываем reader (блокировка уже освобождена)
|
||||
val readerCloseStart = System.currentTimeMillis()
|
||||
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) {
|
||||
@@ -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 selectedMode by remember { mutableStateOf(ExerciseMode.TIME_BASED) }
|
||||
var targetValue by remember { mutableStateOf("") }
|
||||
var standardValue by remember { mutableStateOf("") }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Scaffold(
|
||||
@@ -92,18 +93,42 @@ fun CustomScenarioScreen(
|
||||
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))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
val target = targetValue.toIntOrNull()
|
||||
val standard = standardValue.toIntOrNull()?.takeIf { it > 0 }
|
||||
if (name.isNotBlank() && target != null && target > 0) {
|
||||
scope.launch {
|
||||
viewModel.createCustomScenario(
|
||||
name = name,
|
||||
description = description.ifBlank { null },
|
||||
mode = selectedMode,
|
||||
targetValue = target
|
||||
targetValue = target,
|
||||
standardValue = standard
|
||||
)
|
||||
onScenarioCreated()
|
||||
}
|
||||
|
||||
@@ -1,21 +1,33 @@
|
||||
package ru.kgeu.training.ui.screen
|
||||
|
||||
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.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
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.compose.ui.zIndex
|
||||
import ru.kgeu.training.data.model.WeightPosition
|
||||
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.WorkoutResult
|
||||
import ru.kgeu.training.ui.viewmodel.WorkoutViewModel
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@@ -35,6 +47,7 @@ fun WorkoutScreen(
|
||||
val elapsedTime by viewModel.elapsedTime.collectAsState()
|
||||
val heartRate by viewModel.heartRate.collectAsState()
|
||||
val currentSession by viewModel.currentSession.collectAsState()
|
||||
val workoutResult by viewModel.workoutResult.collectAsState()
|
||||
|
||||
val connectionState by viewModel.tcpConnectionState.collectAsState()
|
||||
val bluetoothState by viewModel.bluetoothConnectionState.collectAsState()
|
||||
@@ -43,6 +56,20 @@ fun WorkoutScreen(
|
||||
// Получаем текущее положение веса для отображения
|
||||
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) }
|
||||
|
||||
LaunchedEffect(participant.id, scenario.id) {
|
||||
@@ -54,6 +81,7 @@ fun WorkoutScreen(
|
||||
scenarioId = scenario.id,
|
||||
mode = scenario.exerciseMode,
|
||||
target = scenario.targetValue,
|
||||
standard = scenario.standardValue,
|
||||
tcpHost = tcpHost,
|
||||
tcpPort = tcpPort
|
||||
)
|
||||
@@ -81,38 +109,45 @@ fun WorkoutScreen(
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
// Полноэкранный результат тренировки (если задан норматив)
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
CenterAlignedTopAppBar(
|
||||
title = { Text(scenario.name) }
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 116.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
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(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -231,7 +266,8 @@ fun WorkoutScreen(
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer
|
||||
containerColor = cardBackgroundColor,
|
||||
contentColor = cardContentColor
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
@@ -240,18 +276,21 @@ fun WorkoutScreen(
|
||||
) {
|
||||
Text(
|
||||
text = "Выполнено подъемов",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = cardContentColor
|
||||
)
|
||||
Text(
|
||||
text = "$completedLifts",
|
||||
fontSize = 64.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = cardContentColor
|
||||
)
|
||||
Text(
|
||||
text = "Цель: ${scenario.targetValue} сек",
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = cardContentColor
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -271,7 +310,8 @@ fun WorkoutScreen(
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer
|
||||
containerColor = cardBackgroundColor,
|
||||
contentColor = cardContentColor
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
@@ -280,18 +320,21 @@ fun WorkoutScreen(
|
||||
) {
|
||||
Text(
|
||||
text = "Время",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = cardContentColor
|
||||
)
|
||||
Text(
|
||||
text = formatTime(elapsedTime),
|
||||
fontSize = 64.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = cardContentColor
|
||||
)
|
||||
Text(
|
||||
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,
|
||||
description: String?,
|
||||
mode: ExerciseMode,
|
||||
targetValue: Int
|
||||
targetValue: Int,
|
||||
standardValue: Int? = null
|
||||
): Long {
|
||||
val scenario = ExerciseScenario(
|
||||
name = name,
|
||||
description = description,
|
||||
exerciseMode = mode,
|
||||
targetValue = targetValue,
|
||||
standardValue = standardValue,
|
||||
isCustom = true
|
||||
)
|
||||
return repository.insertScenario(scenario)
|
||||
|
||||
@@ -2,12 +2,14 @@ package ru.kgeu.training.ui.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
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 kotlinx.coroutines.withContext
|
||||
import ru.kgeu.training.data.model.ExerciseMode
|
||||
import ru.kgeu.training.data.model.WeightPosition
|
||||
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.bluetooth.HeartRateMonitor
|
||||
|
||||
enum class WorkoutResult {
|
||||
PASSED, // Норматив выполнен
|
||||
FAILED // Норматив не выполнен
|
||||
}
|
||||
|
||||
class WorkoutViewModel(
|
||||
private val repository: TrainingRepository,
|
||||
private val tcpClient: TcpClient,
|
||||
@@ -41,6 +48,9 @@ class WorkoutViewModel(
|
||||
private val _currentSession = MutableStateFlow<WorkoutSession?>(null)
|
||||
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 bluetoothConnectionState: StateFlow<ru.kgeu.training.bluetooth.BluetoothConnectionState> = heartRateMonitor.connectionState
|
||||
val weightPosition: StateFlow<ru.kgeu.training.data.model.WeightPosition?> = tcpClient.weightPosition
|
||||
@@ -51,13 +61,14 @@ class WorkoutViewModel(
|
||||
private var lastPosition: WeightPosition? = null
|
||||
private var isLiftingUp: Boolean = false // Флаг: находимся ли мы в процессе подъема (BOTTOM -> MIDDLE -> TOP)
|
||||
private var targetValue: Int = 0
|
||||
private var standardValue: Int? = null
|
||||
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<Int>()
|
||||
private val heartRateReadings = java.util.Collections.synchronizedList(mutableListOf<Int>())
|
||||
|
||||
init {
|
||||
observeWeightPosition()
|
||||
@@ -127,7 +138,7 @@ class WorkoutViewModel(
|
||||
// Если режим на количество и достигнута цель
|
||||
if (exerciseMode == ExerciseMode.COUNT_BASED &&
|
||||
_completedLifts.value >= targetValue) {
|
||||
stopWorkout()
|
||||
stopWorkout(isManualStop = false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,6 +163,7 @@ class WorkoutViewModel(
|
||||
scenarioId: Long,
|
||||
mode: ExerciseMode,
|
||||
target: Int,
|
||||
standard: Int?,
|
||||
tcpHost: String,
|
||||
tcpPort: Int
|
||||
) {
|
||||
@@ -161,11 +173,13 @@ class WorkoutViewModel(
|
||||
this.scenarioId = scenarioId
|
||||
this.exerciseMode = mode
|
||||
this.targetValue = target
|
||||
this.standardValue = standard
|
||||
|
||||
_completedLifts.value = 0
|
||||
_elapsedTime.value = 0
|
||||
_isTimerStarted.value = false // Таймер запустится только при переходе BOTTOM -> MIDDLE
|
||||
_remainingTime.value = if (mode == ExerciseMode.COUNT_BASED) target.toLong() else 0
|
||||
_workoutResult.value = null // Сбрасываем результат предыдущей тренировки
|
||||
lastPosition = null
|
||||
isLiftingUp = false // Сбрасываем флаг подъема
|
||||
timerStartTime = 0
|
||||
@@ -214,7 +228,7 @@ class WorkoutViewModel(
|
||||
if (exerciseMode == ExerciseMode.TIME_BASED) {
|
||||
_elapsedTime.value++
|
||||
if (_elapsedTime.value >= target) {
|
||||
stopWorkout()
|
||||
stopWorkout(isManualStop = false)
|
||||
}
|
||||
} else if (exerciseMode == ExerciseMode.COUNT_BASED) {
|
||||
_elapsedTime.value++
|
||||
@@ -224,69 +238,145 @@ class WorkoutViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun stopWorkout() {
|
||||
if (isStopping || !_isWorkoutActive.value) return
|
||||
fun stopWorkout(isManualStop: Boolean = true) {
|
||||
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
|
||||
_isWorkoutActive.value = false
|
||||
_isTimerStarted.value = false
|
||||
|
||||
// Отменяем все корутины
|
||||
val timeAfterFlags = System.currentTimeMillis()
|
||||
android.util.Log.d(TAG, "[STOP] Flags set in ${timeAfterFlags - timeAfterCheck}ms")
|
||||
|
||||
// Отменяем ВСЕ корутины сначала, чтобы они не пытались обрабатывать данные во время отключения
|
||||
workoutJob?.cancel()
|
||||
workoutJob = null
|
||||
weightPositionJob?.cancel()
|
||||
weightPositionJob = null
|
||||
heartRateJob?.cancel()
|
||||
heartRateJob = null
|
||||
|
||||
// Отключаем соединения
|
||||
try {
|
||||
tcpClient.disconnect()
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("WorkoutViewModel", "Error disconnecting TCP", e)
|
||||
}
|
||||
val timeAfterCancel = System.currentTimeMillis()
|
||||
android.util.Log.d(TAG, "[STOP] Jobs cancelled in ${timeAfterCancel - timeAfterFlags}ms")
|
||||
|
||||
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 endTime = System.currentTimeMillis()
|
||||
val actualStartTime = if (timerStartTime > 0) timerStartTime else session?.startTime ?: endTime
|
||||
val duration = if (timerStartTime > 0) {
|
||||
(endTime - actualStartTime) / 1000
|
||||
} else {
|
||||
0L // Тренировка не началась (таймер не запустился)
|
||||
}
|
||||
|
||||
val avgHeartRate = if (heartRateReadings.isNotEmpty()) {
|
||||
heartRateReadings.average().toInt()
|
||||
} else null
|
||||
// Копируем данные пульса для безопасного вычисления (synchronized list)
|
||||
val heartRateCopy = synchronized(heartRateReadings) { heartRateReadings.toList() }
|
||||
val avgHeartRate = if (heartRateCopy.isNotEmpty()) {
|
||||
heartRateCopy.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
|
||||
// Вычисляем результат, если задан норматив и тренировка завершилась естественно (не вручную)
|
||||
val currentLifts = _completedLifts.value
|
||||
val result = if (!isManualStop) {
|
||||
standardValue?.let { standard ->
|
||||
when (exerciseMode) {
|
||||
// Для TIME_BASED: норматив - минимальное количество подъемов
|
||||
ExerciseMode.TIME_BASED -> {
|
||||
if (currentLifts >= standard) WorkoutResult.PASSED else WorkoutResult.FAILED
|
||||
}
|
||||
// Для COUNT_BASED: норматив - максимальное время в секундах
|
||||
ExerciseMode.COUNT_BASED -> {
|
||||
if (duration <= standard) WorkoutResult.PASSED else WorkoutResult.FAILED
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
isStopping = false
|
||||
} else null
|
||||
_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() {
|
||||
@@ -310,7 +400,21 @@ class WorkoutViewModel(
|
||||
workoutJob?.cancel()
|
||||
weightPositionJob?.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 {
|
||||
|
||||
Reference in New Issue
Block a user