last commit

This commit is contained in:
2026-01-22 13:00:58 +03:00
parent ad20ba3325
commit 72e2e88e7b
13 changed files with 851 additions and 243 deletions

1
.idea/gradle.xml generated
View File

@@ -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>

View 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
View File

@@ -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
View 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
View File

@@ -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
```
## Лицензия
Разработано для КГЭУ (Казанский государственный энергетический университет).

View File

@@ -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 {

View File

@@ -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() {

View File

@@ -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()
)

View File

@@ -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()
}
}

View File

@@ -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()
}

View File

@@ -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)
)
}
}
}
}
}

View File

@@ -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)

View File

@@ -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 {