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"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings"> <component name="GradleSettings">
<option name="linkedExternalProjectsSettings"> <option name="linkedExternalProjectsSettings">
<GradleProjectSettings> <GradleProjectSettings>

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"> <project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" /> <component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK"> <component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">

6
.idea/vcs.xml generated Normal file
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. **Отсчет времени** - в режиме на количество отсчитывается время выполнения - Отслеживать пульс через Bluetooth-кардиодатчик
6. **Кардиодатчик** - отображение показаний пульса в реальном времени через Bluetooth - Просматривать статистику тренировок
7. **Печать отчетов** - вывод результатов тренировки на принтер
## Технологии ## Режимы тренировок
- Kotlin ### Режим на время (TIME_BASED)
- Jetpack Compose - Задаётся время выполнения упражнения в секундах
- Room Database - Приложение считает количество выполненных подъёмов
- WebSocket (OkHttp) для связи с ESP32 - Норматив: минимальное количество подъёмов за заданное время
- Bluetooth LE для кардиодатчика
- Navigation Component
## Настройка ### Режим на количество (COUNT_BASED)
- Задаётся целевое количество подъёмов
- Приложение засекает время выполнения
- Норматив: максимальное время для выполнения заданного количества подъёмов
### WebSocket подключение к ESP32 ## Технические требования
В файле `MainActivity.kt` измените URL WebSocket: - Android 14 (API 34) или выше
- Bluetooth для подключения кардиодатчика
- WiFi для подключения к ESP32
```kotlin ## Подключение к оборудованию
val websocketUrl = "ws://192.168.1.100:81" // Замените на IP вашего ESP32
```
ESP32 должен отправлять сообщения в формате JSON: ### ESP32
Приложение подключается к ESP32 по TCP/IP. ESP32 должен отправлять JSON-сообщения с положением груза:
```json ```json
{"position": "top"} // или "bottom" {"position": "top"} // Груз вверху
{"position": "middle"} // Груз в движении
{"position": "bottom"} // Груз внизу
``` ```
### Bluetooth кардиодатчик Поддерживаемые значения `position`:
- `top`, `up` — верхнее положение
- `middle`, `moving`, `in_motion`, `motion` — груз в движении
- `bottom`, `down` — нижнее положение
Приложение автоматически сканирует и подключается к Bluetooth устройствам с Heart Rate Service (UUID: 0000180d-0000-1000-8000-00805f9b34fb). ### Кардиодатчик
Приложение поддерживает Bluetooth-кардиодатчики с протоколом Heart Rate Service (стандартный BLE профиль).
## Сборка проекта
### Требования
- Android Studio Ladybug (2024.2) или новее
- JDK 11
- Android SDK 36
### Шаги сборки
1. Клонируйте репозиторий:
```bash
git clone <url-репозитория>
cd AndroidStudioProjects
```
2. Откройте проект в Android Studio:
- File → Open → выберите папку проекта
3. Дождитесь синхронизации Gradle (происходит автоматически)
4. Соберите проект:
- Build → Make Project (Ctrl+F9)
## Установка на телефон
### Способ 1: Через Android Studio (рекомендуется для разработки)
1. Включите на телефоне **Режим разработчика**:
- Настройки → О телефоне → 7 раз нажмите на "Номер сборки"
2. Включите **Отладку по USB**:
- Настройки → Для разработчиков → Отладка по USB → Включить
3. Подключите телефон к компьютеру USB-кабелем
4. Разрешите отладку на телефоне (появится диалог при первом подключении)
5. В Android Studio выберите ваше устройство в выпадающем списке устройств (рядом с кнопкой Run)
6. Нажмите **Run** (Shift+F10) или зелёную кнопку ▶
### Способ 2: Установка APK-файла
1. Соберите релизный APK:
- Build → Build Bundle(s) / APK(s) → Build APK(s)
- APK будет в папке `app/build/outputs/apk/debug/`
2. Скопируйте APK на телефон любым способом:
- Через USB-кабель
- Через облачное хранилище
- Через мессенджер
3. На телефоне разрешите установку из неизвестных источников:
- Настройки → Безопасность → Неизвестные источники (или аналогичный пункт)
4. Откройте APK-файл на телефоне и установите
### Способ 3: Через ADB (командная строка)
1. Установите ADB (входит в Android SDK Platform Tools)
2. Подключите телефон с включённой отладкой по USB
3. Проверьте подключение:
```bash
adb devices
```
4. Установите APK:
```bash
adb install app/build/outputs/apk/debug/app-debug.apk
```
Или соберите и установите одной командой:
```bash
./gradlew installDebug
```
## Разрешения приложения
При первом запуске приложение запросит разрешения: При первом запуске приложение запросит разрешения:
- Bluetooth - **Bluetooth** — для подключения к кардиодатчику
- Location (требуется для Bluetooth LE сканирования на Android 12+) - **Nearby Devices** — для сканирования Bluetooth-устройств
- **Internet/Network** — для подключения к ESP32
## Структура проекта ## Структура проекта
``` ```
app/src/main/java/ru/kgeu/training/ app/src/main/java/ru/kgeu/training/
├── bluetooth/ # Работа с Bluetooth (кардиодатчик)
├── data/ ├── data/
│ ├── dao/ # Data Access Objects для Room │ ├── dao/ # Data Access Objects для Room
│ ├── database/ # Room Database │ ├── database/ # Конфигурация базы данных
│ ├── model/ # Модели данных │ ├── model/ # Модели данных (Participant, Scenario, Session)
│ └── repository/ # Репозиторий для работы с данными │ └── repository/ # Репозиторий для работы с данными
├── network/ ├── network/ # TCP-клиент для ESP32
│ └── WebSocketClient.kt # WebSocket клиент для ESP32
├── bluetooth/
│ └── HeartRateMonitor.kt # Bluetooth сервис для кардиодатчика
├── ui/ ├── ui/
│ ├── navigation/ # Навигация │ ├── navigation/ # Навигация приложения
│ ├── screen/ # UI экраны │ ├── screen/ # Экраны (Compose)
│ ├── theme/ # Тема приложения │ ├── theme/ # Тема приложения
│ └── viewmodel/ # ViewModels │ └── viewmodel/ # ViewModels
── util/ ── util/ # Вспомогательные утилиты
└── PrintUtil.kt # Утилита для печати ├── MainActivity.kt # Главная Activity
└── TrainingApplication.kt # Application класс
``` ```
## Режимы упражнений ## Настройка подключения к ESP32
### Режим на время IP-адрес и порт ESP32 можно настроить в приложении перед началом тренировки.
- Устанавливается целевое время (в секундах)
- Считается количество выполненных подъемов
- Тренировка завершается по истечении времени
### Режим на количество По умолчанию:
- Устанавливается целевое количество подъемов - Порт: 8080 (или настраиваемый)
- Отсчитывается время выполнения - Протокол: TCP
- Тренировка завершается при достижении цели
## База данных ## Решение проблем
Приложение использует Room Database для хранения: ### Не подключается к ESP32
- Участников тренировок 1. Проверьте, что телефон и ESP32 в одной WiFi-сети
- Сценариев упражнений (готовых и пользовательских) 2. Проверьте правильность IP-адреса и порта
- Сессий тренировок с результатами 3. Убедитесь, что ESP32 запущен и слушает указанный порт
## Печать ### Не подключается кардиодатчик
1. Убедитесь, что Bluetooth включён на телефоне
2. Проверьте, что кардиодатчик активен и не подключён к другому устройству
3. Перезапустите кардиодатчик
Функция печати использует стандартную систему печати Android и поддерживает: ### Приложение не устанавливается
- Печать на принтеры через Wi-Fi 1. Проверьте версию Android (требуется 14+)
- Сохранение в PDF 2. Убедитесь, что разрешена установка из неизвестных источников
- Отправку по email 3. Удалите предыдущую версию приложения, если она установлена
## Требования ## Лицензия
- Android 8.0 (API 26) или выше
- Поддержка Bluetooth LE
- Подключение к сети для WebSocket
## Разработка
Для сборки проекта используйте:
```bash
./gradlew assembleDebug
```
Для установки на устройство:
```bash
./gradlew installDebug
```
Разработано для КГЭУ (Казанский государственный энергетический университет).

View File

@@ -19,6 +19,12 @@ class HeartRateMonitor(private val context: Context) {
private val bluetoothLeScanner = bluetoothAdapter?.bluetoothLeScanner private val bluetoothLeScanner = bluetoothAdapter?.bluetoothLeScanner
private var bluetoothGatt: BluetoothGatt? = null private var bluetoothGatt: BluetoothGatt? = null
@Volatile
private var isDisconnecting = false
@Volatile
private var isScanning = false
private val _heartRate = MutableStateFlow<Int?>(null) private val _heartRate = MutableStateFlow<Int?>(null)
val heartRate: StateFlow<Int?> = _heartRate.asStateFlow() val heartRate: StateFlow<Int?> = _heartRate.asStateFlow()
@@ -124,7 +130,11 @@ class HeartRateMonitor(private val context: Context) {
return return
} }
// Сбрасываем флаг отключения
isDisconnecting = false
_connectionState.value = BluetoothConnectionState.Scanning _connectionState.value = BluetoothConnectionState.Scanning
isScanning = true
val filter = ScanFilter.Builder() val filter = ScanFilter.Builder()
.setServiceUuid(android.os.ParcelUuid(HEART_RATE_SERVICE_UUID)) .setServiceUuid(android.os.ParcelUuid(HEART_RATE_SERVICE_UUID))
@@ -134,11 +144,26 @@ class HeartRateMonitor(private val context: Context) {
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
.build() .build()
bluetoothLeScanner.startScan(listOf(filter), settings, scanCallback) try {
bluetoothLeScanner.startScan(listOf(filter), settings, scanCallback)
Log.d(TAG, "[BT] Scan started")
} catch (e: Exception) {
Log.e(TAG, "[BT] Error starting scan", e)
isScanning = false
_connectionState.value = BluetoothConnectionState.Error
}
} }
fun stopScan() { fun stopScan() {
bluetoothLeScanner?.stopScan(scanCallback) if (!isScanning) return
try {
bluetoothLeScanner?.stopScan(scanCallback)
Log.d(TAG, "[BT] Scan stopped")
} catch (e: Exception) {
Log.e(TAG, "[BT] Error stopping scan", e)
}
isScanning = false
} }
private fun connectToDevice(device: BluetoothDevice) { private fun connectToDevice(device: BluetoothDevice) {
@@ -147,11 +172,44 @@ class HeartRateMonitor(private val context: Context) {
} }
fun disconnect() { fun disconnect() {
bluetoothGatt?.disconnect() // Предотвращаем параллельные вызовы disconnect
bluetoothGatt?.close() if (isDisconnecting) {
Log.d(TAG, "[BT] disconnect() already in progress, skipping")
return
}
isDisconnecting = true
val startTime = System.currentTimeMillis()
Log.d(TAG, "[BT] disconnect() called at $startTime")
// Останавливаем сканирование если оно активно
stopScan()
val disconnectStart = System.currentTimeMillis()
try {
bluetoothGatt?.disconnect()
val disconnectEnd = System.currentTimeMillis()
Log.d(TAG, "[BT] GATT disconnect() took ${disconnectEnd - disconnectStart}ms")
} catch (e: Exception) {
Log.e(TAG, "[BT] Error in GATT disconnect()", e)
}
val closeStart = System.currentTimeMillis()
try {
bluetoothGatt?.close()
val closeEnd = System.currentTimeMillis()
Log.d(TAG, "[BT] GATT close() took ${closeEnd - closeStart}ms")
} catch (e: Exception) {
Log.e(TAG, "[BT] Error in GATT close()", e)
}
bluetoothGatt = null bluetoothGatt = null
_connectionState.value = BluetoothConnectionState.Disconnected _connectionState.value = BluetoothConnectionState.Disconnected
_heartRate.value = null _heartRate.value = null
val endTime = System.currentTimeMillis()
Log.d(TAG, "[BT] disconnect() completed. Total time: ${endTime - startTime}ms")
isDisconnecting = false
} }
companion object { companion object {

View File

@@ -11,7 +11,7 @@ import ru.kgeu.training.data.model.WorkoutSession
@Database( @Database(
entities = [Participant::class, ExerciseScenario::class, WorkoutSession::class], entities = [Participant::class, ExerciseScenario::class, WorkoutSession::class],
version = 1, version = 2,
exportSchema = false exportSchema = false
) )
abstract class TrainingDatabase : RoomDatabase() { abstract class TrainingDatabase : RoomDatabase() {

View File

@@ -11,6 +11,7 @@ data class ExerciseScenario(
val description: String? = null, val description: String? = null,
val exerciseMode: ExerciseMode, val exerciseMode: ExerciseMode,
val targetValue: Int, // количество подъемов или время в секундах val targetValue: Int, // количество подъемов или время в секундах
val standardValue: Int? = null, // норматив: для TIME_BASED - мин. кол-во подъемов, для COUNT_BASED - макс. время в секундах
val isCustom: Boolean = false, // true для пользовательских, false для готовых val isCustom: Boolean = false, // true для пользовательских, false для готовых
val createdAt: Long = System.currentTimeMillis() val createdAt: Long = System.currentTimeMillis()
) )

View File

@@ -16,104 +16,187 @@ class TcpClient {
private var socket: Socket? = null private var socket: Socket? = null
private var reader: BufferedReader? = null private var reader: BufferedReader? = null
private var readJob: Job? = null private var readJob: Job? = null
private var reconnectJob: Job? = null
private val clientScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val clientScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@Volatile
private var isDisconnecting = false
@Volatile
private var shouldReconnect = false
// Счетчик поколений соединений - увеличивается при каждом новом connect()
// Используется для предотвращения race conditions, когда старый finally блок
// пытается переподключиться после того, как было создано новое соединение
@Volatile
private var connectionGeneration = 0
// Сохраняем параметры подключения для переподключения
private var lastHost: String = ""
private var lastPort: Int = 0
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected) private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow() val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
private val _weightPosition = MutableStateFlow<WeightPosition?>(null) private val _weightPosition = MutableStateFlow<WeightPosition?>(null)
val weightPosition: StateFlow<WeightPosition?> = _weightPosition.asStateFlow() val weightPosition: StateFlow<WeightPosition?> = _weightPosition.asStateFlow()
companion object {
private const val TAG = "TcpClient"
private const val RECONNECT_DELAY_MS = 500L // Минимальная задержка перед переподключением
private const val MAX_RECONNECT_ATTEMPTS = 10
}
fun connect(host: String, port: Int) { fun connect(host: String, port: Int) {
Log.d(TAG, "Attempting to connect to TCP: $host:$port") Log.d(TAG, "Attempting to connect to TCP: $host:$port")
// Увеличиваем поколение соединения - это invalidates все старые finally блоки
connectionGeneration++
val currentGeneration = connectionGeneration
Log.d(TAG, "Connection generation: $currentGeneration")
// Сохраняем параметры для переподключения
lastHost = host
lastPort = port
shouldReconnect = true
// Отключаемся если уже подключены // Отключаемся если уже подключены
if (_connectionState.value == ConnectionState.Connected) { if (_connectionState.value == ConnectionState.Connected) {
Log.d(TAG, "Already connected, disconnecting first") Log.d(TAG, "Already connected, disconnecting first")
disconnect() disconnectInternal(triggerReconnect = false)
} }
// Отменяем предыдущую задачу чтения если есть // Сбрасываем флаг отключения для нового подключения
isDisconnecting = false
// Отменяем предыдущие задачи
readJob?.cancel() readJob?.cancel()
readJob = null readJob = null
reconnectJob?.cancel()
reconnectJob = null
connectInternal(host, port, generation = currentGeneration)
}
private fun connectInternal(host: String, port: Int, attemptNumber: Int = 1, generation: Int = connectionGeneration) {
if (isDisconnecting || !shouldReconnect) {
Log.d(TAG, "Skipping connection attempt: isDisconnecting=$isDisconnecting, shouldReconnect=$shouldReconnect")
return
}
// Проверяем, что это всё ещё актуальное поколение соединения
if (generation != connectionGeneration) {
Log.d(TAG, "Skipping connection attempt: generation mismatch (expected $connectionGeneration, got $generation)")
return
}
_connectionState.value = ConnectionState.Connecting _connectionState.value = ConnectionState.Connecting
clientScope.launch { clientScope.launch {
try { try {
// Небольшая задержка для завершения отключения // Небольшая задержка для завершения отключения
delay(100) if (attemptNumber > 1) {
delay(RECONNECT_DELAY_MS)
} else {
delay(50)
}
Log.d(TAG, "Creating socket connection...") // Повторная проверка после задержки
if (!shouldReconnect || generation != connectionGeneration) {
Log.d(TAG, "Aborting connection after delay: shouldReconnect=$shouldReconnect, generation match=${generation == connectionGeneration}")
return@launch
}
Log.d(TAG, "Creating socket connection (attempt $attemptNumber, generation $generation)...")
val newSocket = Socket() val newSocket = Socket()
// Оптимизация TCP: отключаем алгоритм Nagle для уменьшения задержек // Оптимизация TCP: отключаем алгоритм Nagle для уменьшения задержек
newSocket.tcpNoDelay = true newSocket.tcpNoDelay = true
// Уменьшаем таймаут чтения для более быстрой реакции // Уменьшаем таймаут чтения для более быстрой реакции
newSocket.soTimeout = 1000 // 1 секунда вместо 10 newSocket.soTimeout = 2000 // 2 секунды
newSocket.connect(java.net.InetSocketAddress(host, port), 5000) // 5 секунд таймаут на подключение newSocket.connect(java.net.InetSocketAddress(host, port), 3000) // 3 секунды таймаут на подключение
// Проверяем ещё раз перед назначением
if (generation != connectionGeneration) {
Log.d(TAG, "Connection established but generation changed, closing socket")
newSocket.close()
return@launch
}
socket = newSocket socket = newSocket
reader = BufferedReader(InputStreamReader(socket!!.getInputStream(), "UTF-8"), 8192) // Увеличиваем размер буфера reader = BufferedReader(InputStreamReader(socket!!.getInputStream(), "UTF-8"), 8192)
Log.d(TAG, "TCP connection established to $host:$port") Log.d(TAG, "TCP connection established to $host:$port (generation $generation)")
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
_connectionState.value = ConnectionState.Connected _connectionState.value = ConnectionState.Connected
} }
// Запускаем чтение данных в отдельной корутине // Запускаем чтение данных в отдельной корутине, передаём generation
readJob = clientScope.launch { readJob = clientScope.launch {
readMessages() readMessages(generation)
} }
} catch (e: SocketTimeoutException) { } catch (e: SocketTimeoutException) {
Log.e(TAG, "Connection timeout: $host:$port", e) Log.e(TAG, "Connection timeout: $host:$port (attempt $attemptNumber)", e)
withContext(Dispatchers.Main) { handleConnectionFailure(attemptNumber, generation)
_connectionState.value = ConnectionState.Disconnected
}
socket?.close()
socket = null
reader = null
} catch (e: java.net.UnknownHostException) { } catch (e: java.net.UnknownHostException) {
Log.e(TAG, "Unknown host: $host", e) Log.e(TAG, "Unknown host: $host (attempt $attemptNumber)", e)
withContext(Dispatchers.Main) { handleConnectionFailure(attemptNumber, generation)
_connectionState.value = ConnectionState.Disconnected
}
socket?.close()
socket = null
reader = null
} catch (e: java.net.ConnectException) { } catch (e: java.net.ConnectException) {
Log.e(TAG, "Connection refused: $host:$port", e) Log.e(TAG, "Connection refused: $host:$port (attempt $attemptNumber)", e)
withContext(Dispatchers.Main) { handleConnectionFailure(attemptNumber, generation)
_connectionState.value = ConnectionState.Disconnected
}
socket?.close()
socket = null
reader = null
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Error connecting to TCP: $host:$port", e) Log.e(TAG, "Error connecting to TCP: $host:$port (attempt $attemptNumber)", e)
e.printStackTrace() handleConnectionFailure(attemptNumber, generation)
withContext(Dispatchers.Main) {
_connectionState.value = ConnectionState.Disconnected
}
socket?.close()
socket = null
reader = null
} }
} }
} }
private suspend fun readMessages() { private suspend fun handleConnectionFailure(attemptNumber: Int, generation: Int) {
// Проверяем, что это всё ещё актуальное поколение
if (generation != connectionGeneration) {
Log.d(TAG, "Ignoring connection failure: generation mismatch")
return
}
socket?.close()
socket = null
reader = null
withContext(Dispatchers.Main) {
// Обновляем состояние только если generation всё ещё актуален
if (generation == connectionGeneration) {
_connectionState.value = ConnectionState.Disconnected
}
}
// Пытаемся переподключиться если разрешено и не превышен лимит попыток
if (shouldReconnect && attemptNumber < MAX_RECONNECT_ATTEMPTS && lastHost.isNotEmpty() && generation == connectionGeneration) {
Log.d(TAG, "Scheduling reconnect attempt ${attemptNumber + 1} in ${RECONNECT_DELAY_MS}ms (generation $generation)")
delay(RECONNECT_DELAY_MS)
if (shouldReconnect && generation == connectionGeneration) {
connectInternal(lastHost, lastPort, attemptNumber + 1, generation)
}
} else if (attemptNumber >= MAX_RECONNECT_ATTEMPTS) {
Log.e(TAG, "Max reconnect attempts reached ($MAX_RECONNECT_ATTEMPTS)")
}
}
private suspend fun readMessages(generation: Int) {
val currentReader = reader val currentReader = reader
val currentSocket = socket val currentSocket = socket
if (currentReader == null || currentSocket == null) { if (currentReader == null || currentSocket == null) {
Log.w(TAG, "Cannot read messages: reader or socket is null") Log.w(TAG, "Cannot read messages: reader or socket is null")
triggerReconnect(generation)
return return
} }
var consecutiveTimeouts = 0
val maxConsecutiveTimeouts = 10 // После 10 таймаутов подряд (20 сек) считаем соединение потерянным
try { try {
Log.d(TAG, "Starting to read messages from TCP stream...") Log.d(TAG, "Starting to read messages from TCP stream (generation $generation)...")
while (currentSocket.isConnected && !currentSocket.isClosed) { while (currentSocket.isConnected && !currentSocket.isClosed && !isDisconnecting && generation == connectionGeneration) {
try { try {
val line = currentReader.readLine() val line = currentReader.readLine()
if (line == null) { if (line == null) {
@@ -121,13 +204,21 @@ class TcpClient {
break break
} }
consecutiveTimeouts = 0 // Сбрасываем счетчик при получении данных
// Обрабатываем сообщение асинхронно, не блокируя чтение // Обрабатываем сообщение асинхронно, не блокируя чтение
processMessage(line) processMessage(line)
} catch (e: SocketTimeoutException) { } catch (e: SocketTimeoutException) {
// Таймаут на чтение - это нормально, продолжаем // Таймаут на чтение - это нормально, но следим за их количеством
// Не логируем каждый таймаут, чтобы не засорять лог consecutiveTimeouts++
if (consecutiveTimeouts >= maxConsecutiveTimeouts) {
Log.w(TAG, "Too many consecutive timeouts ($consecutiveTimeouts), connection might be dead")
break
}
continue continue
} catch (e: java.io.IOException) {
Log.e(TAG, "IO Error reading message (connection lost?)", e)
break
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Error reading message", e) Log.e(TAG, "Error reading message", e)
break break
@@ -136,10 +227,53 @@ class TcpClient {
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Error in readMessages loop", e) Log.e(TAG, "Error in readMessages loop", e)
} finally { } finally {
Log.d(TAG, "Stopped reading messages") Log.d(TAG, "Stopped reading messages (generation $generation), current generation=$connectionGeneration, isDisconnecting=$isDisconnecting, shouldReconnect=$shouldReconnect")
withContext(Dispatchers.Main) {
_connectionState.value = ConnectionState.Disconnected // Закрываем текущие ресурсы
_weightPosition.value = null try { currentSocket.close() } catch (e: Exception) { }
// ВАЖНО: Всё остальное делаем только если generation совпадает
// Это предотвращает race condition, когда старый finally блок влияет на новое соединение
if (generation != connectionGeneration) {
Log.d(TAG, "Generation mismatch in finally block, skipping cleanup (old=$generation, current=$connectionGeneration)")
return
}
// Обнуляем socket/reader только если они не были заменены новым подключением
if (socket === currentSocket) {
socket = null
}
if (reader === currentReader) {
reader = null
}
// Обновляем состояние только если это всё ещё наше соединение (не было создано новое)
if (socket == null && generation == connectionGeneration) {
withContext(Dispatchers.Main) {
_connectionState.value = ConnectionState.Disconnected
_weightPosition.value = null
}
}
// Пытаемся переподключиться если это не было намеренным отключением И generation совпадает
if (!isDisconnecting && shouldReconnect && generation == connectionGeneration) {
triggerReconnect(generation)
}
}
}
private fun triggerReconnect(generation: Int) {
if (!shouldReconnect || lastHost.isEmpty() || isDisconnecting || generation != connectionGeneration) {
Log.d(TAG, "Skipping triggerReconnect: shouldReconnect=$shouldReconnect, hasHost=${lastHost.isNotEmpty()}, isDisconnecting=$isDisconnecting, generationMatch=${generation == connectionGeneration}")
return
}
Log.d(TAG, "Triggering reconnect to $lastHost:$lastPort (generation $generation)")
reconnectJob?.cancel()
reconnectJob = clientScope.launch {
delay(RECONNECT_DELAY_MS)
if (shouldReconnect && !isDisconnecting && generation == connectionGeneration) {
connectInternal(lastHost, lastPort, generation = generation)
} }
} }
} }
@@ -175,31 +309,74 @@ class TcpClient {
} }
fun disconnect() { fun disconnect() {
Log.d(TAG, "Disconnecting TCP connection...") disconnectInternal(triggerReconnect = false)
}
private fun disconnectInternal(triggerReconnect: Boolean) {
// Предотвращаем параллельные вызовы disconnect
if (isDisconnecting) {
Log.d(TAG, "[TCP] disconnect() already in progress, skipping")
return
}
isDisconnecting = true
// Если это полное отключение - запрещаем переподключение
if (!triggerReconnect) {
shouldReconnect = false
}
val startTime = System.currentTimeMillis()
Log.d(TAG, "[TCP] disconnect() called at $startTime, triggerReconnect=$triggerReconnect")
// Отменяем задачу переподключения
reconnectJob?.cancel()
reconnectJob = null
val cancelStart = System.currentTimeMillis()
readJob?.cancel() readJob?.cancel()
readJob = null readJob = null
val cancelEnd = System.currentTimeMillis()
Log.d(TAG, "[TCP] Job cancelled in ${cancelEnd - cancelStart}ms")
try { // ВАЖНО: Сначала закрываем SOCKET, чтобы прервать блокирующий readLine()
reader?.close() // Это заставит readLine() выбросить исключение и освободить блокировку на reader
} catch (e: Exception) { val socketCloseStart = System.currentTimeMillis()
Log.e(TAG, "Error closing reader", e)
}
reader = null
try { try {
socket?.close() socket?.close()
val socketCloseEnd = System.currentTimeMillis()
Log.d(TAG, "[TCP] Socket closed in ${socketCloseEnd - socketCloseStart}ms")
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Error closing socket", e) Log.e(TAG, "[TCP] Error closing socket", e)
} }
socket = null socket = null
clientScope.launch(Dispatchers.Main) { // Теперь безопасно закрываем reader (блокировка уже освобождена)
_connectionState.value = ConnectionState.Disconnected val readerCloseStart = System.currentTimeMillis()
_weightPosition.value = null try {
reader?.close()
val readerCloseEnd = System.currentTimeMillis()
Log.d(TAG, "[TCP] Reader closed in ${readerCloseEnd - readerCloseStart}ms")
} catch (e: Exception) {
Log.e(TAG, "[TCP] Error closing reader", e)
}
reader = null
// Обновляем состояние синхронно, чтобы избежать race condition с новым подключением
// Если connect() будет вызван сразу после disconnect(), он корректно установит shouldReconnect = true
// и новое соединение не будет затёрто асинхронным обновлением состояния
if (!shouldReconnect) {
clientScope.launch(Dispatchers.Main) {
// Повторная проверка, т.к. состояние могло измениться пока мы ждали Main thread
if (!shouldReconnect && socket == null) {
_connectionState.value = ConnectionState.Disconnected
_weightPosition.value = null
}
}
} }
Log.d(TAG, "TCP connection disconnected") val endTime = System.currentTimeMillis()
Log.d(TAG, "[TCP] disconnect() completed. Total time: ${endTime - startTime}ms")
isDisconnecting = false
} }
fun sendMessage(message: String) { fun sendMessage(message: String) {
@@ -221,8 +398,16 @@ class TcpClient {
} }
} }
companion object { /**
private const val TAG = "TcpClient" * Полностью освобождает ресурсы. Вызывайте при уничтожении объекта.
*/
fun destroy() {
Log.d(TAG, "[TCP] destroy() called")
shouldReconnect = false
reconnectJob?.cancel()
reconnectJob = null
disconnect()
clientScope.cancel()
} }
} }

View File

@@ -22,6 +22,7 @@ fun CustomScenarioScreen(
var description by remember { mutableStateOf("") } var description by remember { mutableStateOf("") }
var selectedMode by remember { mutableStateOf(ExerciseMode.TIME_BASED) } var selectedMode by remember { mutableStateOf(ExerciseMode.TIME_BASED) }
var targetValue by remember { mutableStateOf("") } var targetValue by remember { mutableStateOf("") }
var standardValue by remember { mutableStateOf("") }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
Scaffold( Scaffold(
@@ -92,18 +93,42 @@ fun CustomScenarioScreen(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
OutlinedTextField(
value = standardValue,
onValueChange = { standardValue = it },
label = {
Text(
when (selectedMode) {
ExerciseMode.TIME_BASED -> "Норматив: мин. подъемов (опционально)"
ExerciseMode.COUNT_BASED -> "Норматив: макс. время в сек. (опционально)"
}
)
},
supportingText = {
Text(
when (selectedMode) {
ExerciseMode.TIME_BASED -> "Если задан - зелёный экран при выполнении, красный - при невыполнении"
ExerciseMode.COUNT_BASED -> "Если задан - зелёный экран если уложились в время, красный - если нет"
}
)
},
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
Button( Button(
onClick = { onClick = {
val target = targetValue.toIntOrNull() val target = targetValue.toIntOrNull()
val standard = standardValue.toIntOrNull()?.takeIf { it > 0 }
if (name.isNotBlank() && target != null && target > 0) { if (name.isNotBlank() && target != null && target > 0) {
scope.launch { scope.launch {
viewModel.createCustomScenario( viewModel.createCustomScenario(
name = name, name = name,
description = description.ifBlank { null }, description = description.ifBlank { null },
mode = selectedMode, mode = selectedMode,
targetValue = target targetValue = target,
standardValue = standard
) )
onScenarioCreated() onScenarioCreated()
} }

View File

@@ -1,21 +1,33 @@
package ru.kgeu.training.ui.screen package ru.kgeu.training.ui.screen
import android.util.Log import android.util.Log
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import ru.kgeu.training.data.model.WeightPosition
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import ru.kgeu.training.data.model.ExerciseMode import ru.kgeu.training.data.model.ExerciseMode
import ru.kgeu.training.data.model.ExerciseScenario import ru.kgeu.training.data.model.ExerciseScenario
import ru.kgeu.training.data.model.Participant import ru.kgeu.training.data.model.Participant
import ru.kgeu.training.data.model.WorkoutSession import ru.kgeu.training.data.model.WorkoutSession
import ru.kgeu.training.ui.viewmodel.WorkoutResult
import ru.kgeu.training.ui.viewmodel.WorkoutViewModel import ru.kgeu.training.ui.viewmodel.WorkoutViewModel
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@@ -35,6 +47,7 @@ fun WorkoutScreen(
val elapsedTime by viewModel.elapsedTime.collectAsState() val elapsedTime by viewModel.elapsedTime.collectAsState()
val heartRate by viewModel.heartRate.collectAsState() val heartRate by viewModel.heartRate.collectAsState()
val currentSession by viewModel.currentSession.collectAsState() val currentSession by viewModel.currentSession.collectAsState()
val workoutResult by viewModel.workoutResult.collectAsState()
val connectionState by viewModel.tcpConnectionState.collectAsState() val connectionState by viewModel.tcpConnectionState.collectAsState()
val bluetoothState by viewModel.bluetoothConnectionState.collectAsState() val bluetoothState by viewModel.bluetoothConnectionState.collectAsState()
@@ -43,6 +56,20 @@ fun WorkoutScreen(
// Получаем текущее положение веса для отображения // Получаем текущее положение веса для отображения
val weightPosition by viewModel.weightPosition.collectAsState() val weightPosition by viewModel.weightPosition.collectAsState()
// Яркий зеленый цвет для подсветки при достижении крайних позиций (TOP/BOTTOM)
val flashGreen = Color(0xFF00E676) // Material Green A400 - яркий зеленый
val isAtEndPosition = weightPosition == WeightPosition.TOP || weightPosition == WeightPosition.BOTTOM
val cardBackgroundColor by animateColorAsState(
targetValue = if (isAtEndPosition) flashGreen else MaterialTheme.colorScheme.secondaryContainer,
animationSpec = tween(durationMillis = 150),
label = "cardFlash"
)
val cardContentColor by animateColorAsState(
targetValue = if (isAtEndPosition) Color.White else MaterialTheme.colorScheme.onSecondaryContainer,
animationSpec = tween(durationMillis = 150),
label = "cardContentFlash"
)
var workoutStarted by remember { mutableStateOf(false) } var workoutStarted by remember { mutableStateOf(false) }
LaunchedEffect(participant.id, scenario.id) { LaunchedEffect(participant.id, scenario.id) {
@@ -54,6 +81,7 @@ fun WorkoutScreen(
scenarioId = scenario.id, scenarioId = scenario.id,
mode = scenario.exerciseMode, mode = scenario.exerciseMode,
target = scenario.targetValue, target = scenario.targetValue,
standard = scenario.standardValue,
tcpHost = tcpHost, tcpHost = tcpHost,
tcpPort = tcpPort tcpPort = tcpPort
) )
@@ -81,38 +109,45 @@ fun WorkoutScreen(
} }
} }
Scaffold( // Полноэкранный результат тренировки (если задан норматив)
topBar = { Box(modifier = Modifier.fillMaxSize()) {
CenterAlignedTopAppBar( Scaffold(
title = { Text(scenario.name) } topBar = {
) CenterAlignedTopAppBar(
}, title = { Text(scenario.name) }
bottomBar = { )
Row( },
modifier = Modifier bottomBar = {
.fillMaxWidth() Row(
.padding(16.dp), modifier = Modifier
horizontalArrangement = Arrangement.spacedBy(8.dp) .fillMaxWidth()
) { .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 116.dp),
Button( horizontalArrangement = Arrangement.spacedBy(8.dp)
onClick = {
viewModel.stopWorkout()
// Небольшая задержка перед навигацией для завершения сохранения
scope.launch {
kotlinx.coroutines.delay(300)
onWorkoutFinished()
}
},
modifier = Modifier.weight(1f),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error
)
) { ) {
Text("Остановить") Button(
onClick = {
viewModel.stopWorkout()
// Небольшая задержка перед навигацией для завершения сохранения
scope.launch {
kotlinx.coroutines.delay(200)
// Если нет норматива - сразу выходим, иначе ждем пока пользователь закроет результат
if (scenario.standardValue == null) {
onWorkoutFinished()
}
}
},
modifier = Modifier
.weight(0.6f)
.height(76.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error
)
) {
Text("Остановить")
}
} }
} }
} ) { padding ->
) { padding ->
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -231,7 +266,8 @@ fun WorkoutScreen(
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer containerColor = cardBackgroundColor,
contentColor = cardContentColor
) )
) { ) {
Column( Column(
@@ -240,18 +276,21 @@ fun WorkoutScreen(
) { ) {
Text( Text(
text = "Выполнено подъемов", text = "Выполнено подъемов",
style = MaterialTheme.typography.bodyMedium style = MaterialTheme.typography.bodyMedium,
color = cardContentColor
) )
Text( Text(
text = "$completedLifts", text = "$completedLifts",
fontSize = 64.sp, fontSize = 64.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth(),
color = cardContentColor
) )
Text( Text(
text = "Цель: ${scenario.targetValue} сек", text = "Цель: ${scenario.targetValue} сек",
style = MaterialTheme.typography.bodySmall style = MaterialTheme.typography.bodySmall,
color = cardContentColor
) )
} }
} }
@@ -271,7 +310,8 @@ fun WorkoutScreen(
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer containerColor = cardBackgroundColor,
contentColor = cardContentColor
) )
) { ) {
Column( Column(
@@ -280,18 +320,21 @@ fun WorkoutScreen(
) { ) {
Text( Text(
text = "Время", text = "Время",
style = MaterialTheme.typography.bodyMedium style = MaterialTheme.typography.bodyMedium,
color = cardContentColor
) )
Text( Text(
text = formatTime(elapsedTime), text = formatTime(elapsedTime),
fontSize = 64.sp, fontSize = 64.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth(),
color = cardContentColor
) )
Text( Text(
text = "Цель: ${scenario.targetValue} подъемов", text = "Цель: ${scenario.targetValue} подъемов",
style = MaterialTheme.typography.bodySmall style = MaterialTheme.typography.bodySmall,
color = cardContentColor
) )
} }
} }
@@ -307,6 +350,71 @@ fun WorkoutScreen(
} }
} }
} }
}
// Полноэкранный оверлей с результатом (зеленый/красный)
AnimatedVisibility(
visible = workoutResult != null,
enter = fadeIn(animationSpec = tween(300)),
exit = fadeOut(animationSpec = tween(300)),
modifier = Modifier
.fillMaxSize()
.zIndex(10f)
) {
val resultColor = when (workoutResult) {
WorkoutResult.PASSED -> Color(0xFF00C853) // Яркий зеленый
WorkoutResult.FAILED -> Color(0xFFD50000) // Яркий красный
null -> Color.Transparent
}
val resultText = when (workoutResult) {
WorkoutResult.PASSED -> "НОРМАТИВ ВЫПОЛНЕН!"
WorkoutResult.FAILED -> "НОРМАТИВ НЕ ВЫПОЛНЕН"
null -> ""
}
Box(
modifier = Modifier
.fillMaxSize()
.background(resultColor)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) {
viewModel.clearWorkoutResult()
onWorkoutFinished()
},
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = resultText,
fontSize = 36.sp,
fontWeight = FontWeight.Bold,
color = Color.White,
textAlign = TextAlign.Center
)
Text(
text = "Подъемов: $completedLifts",
fontSize = 24.sp,
color = Color.White.copy(alpha = 0.9f)
)
Text(
text = "Время: ${formatTime(elapsedTime)}",
fontSize = 24.sp,
color = Color.White.copy(alpha = 0.9f)
)
Spacer(modifier = Modifier.height(32.dp))
Text(
text = "Нажмите для продолжения",
fontSize = 16.sp,
color = Color.White.copy(alpha = 0.7f)
)
}
}
}
} }
} }

View File

@@ -90,13 +90,15 @@ class ScenarioViewModel(private val repository: TrainingRepository) : ViewModel(
name: String, name: String,
description: String?, description: String?,
mode: ExerciseMode, mode: ExerciseMode,
targetValue: Int targetValue: Int,
standardValue: Int? = null
): Long { ): Long {
val scenario = ExerciseScenario( val scenario = ExerciseScenario(
name = name, name = name,
description = description, description = description,
exerciseMode = mode, exerciseMode = mode,
targetValue = targetValue, targetValue = targetValue,
standardValue = standardValue,
isCustom = true isCustom = true
) )
return repository.insertScenario(scenario) return repository.insertScenario(scenario)

View File

@@ -2,12 +2,14 @@ package ru.kgeu.training.ui.viewmodel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import ru.kgeu.training.data.model.ExerciseMode import ru.kgeu.training.data.model.ExerciseMode
import ru.kgeu.training.data.model.WeightPosition import ru.kgeu.training.data.model.WeightPosition
import ru.kgeu.training.data.model.WorkoutSession import ru.kgeu.training.data.model.WorkoutSession
@@ -15,6 +17,11 @@ import ru.kgeu.training.data.repository.TrainingRepository
import ru.kgeu.training.network.TcpClient import ru.kgeu.training.network.TcpClient
import ru.kgeu.training.bluetooth.HeartRateMonitor import ru.kgeu.training.bluetooth.HeartRateMonitor
enum class WorkoutResult {
PASSED, // Норматив выполнен
FAILED // Норматив не выполнен
}
class WorkoutViewModel( class WorkoutViewModel(
private val repository: TrainingRepository, private val repository: TrainingRepository,
private val tcpClient: TcpClient, private val tcpClient: TcpClient,
@@ -41,6 +48,9 @@ class WorkoutViewModel(
private val _currentSession = MutableStateFlow<WorkoutSession?>(null) private val _currentSession = MutableStateFlow<WorkoutSession?>(null)
val currentSession: StateFlow<WorkoutSession?> = _currentSession.asStateFlow() val currentSession: StateFlow<WorkoutSession?> = _currentSession.asStateFlow()
private val _workoutResult = MutableStateFlow<WorkoutResult?>(null)
val workoutResult: StateFlow<WorkoutResult?> = _workoutResult.asStateFlow()
val tcpConnectionState: StateFlow<ru.kgeu.training.network.ConnectionState> = tcpClient.connectionState val tcpConnectionState: StateFlow<ru.kgeu.training.network.ConnectionState> = tcpClient.connectionState
val bluetoothConnectionState: StateFlow<ru.kgeu.training.bluetooth.BluetoothConnectionState> = heartRateMonitor.connectionState val bluetoothConnectionState: StateFlow<ru.kgeu.training.bluetooth.BluetoothConnectionState> = heartRateMonitor.connectionState
val weightPosition: StateFlow<ru.kgeu.training.data.model.WeightPosition?> = tcpClient.weightPosition val weightPosition: StateFlow<ru.kgeu.training.data.model.WeightPosition?> = tcpClient.weightPosition
@@ -51,13 +61,14 @@ class WorkoutViewModel(
private var lastPosition: WeightPosition? = null private var lastPosition: WeightPosition? = null
private var isLiftingUp: Boolean = false // Флаг: находимся ли мы в процессе подъема (BOTTOM -> MIDDLE -> TOP) private var isLiftingUp: Boolean = false // Флаг: находимся ли мы в процессе подъема (BOTTOM -> MIDDLE -> TOP)
private var targetValue: Int = 0 private var targetValue: Int = 0
private var standardValue: Int? = null
private var exerciseMode: ExerciseMode = ExerciseMode.TIME_BASED private var exerciseMode: ExerciseMode = ExerciseMode.TIME_BASED
private var participantId: Long = 0 private var participantId: Long = 0
private var scenarioId: Long = 0 private var scenarioId: Long = 0
private var isStopping = false private var isStopping = false
private var timerStartTime: Long = 0 // Время когда таймер реально запустился private var timerStartTime: Long = 0 // Время когда таймер реально запустился
private val heartRateReadings = mutableListOf<Int>() private val heartRateReadings = java.util.Collections.synchronizedList(mutableListOf<Int>())
init { init {
observeWeightPosition() observeWeightPosition()
@@ -127,7 +138,7 @@ class WorkoutViewModel(
// Если режим на количество и достигнута цель // Если режим на количество и достигнута цель
if (exerciseMode == ExerciseMode.COUNT_BASED && if (exerciseMode == ExerciseMode.COUNT_BASED &&
_completedLifts.value >= targetValue) { _completedLifts.value >= targetValue) {
stopWorkout() stopWorkout(isManualStop = false)
} }
} }
@@ -152,6 +163,7 @@ class WorkoutViewModel(
scenarioId: Long, scenarioId: Long,
mode: ExerciseMode, mode: ExerciseMode,
target: Int, target: Int,
standard: Int?,
tcpHost: String, tcpHost: String,
tcpPort: Int tcpPort: Int
) { ) {
@@ -161,11 +173,13 @@ class WorkoutViewModel(
this.scenarioId = scenarioId this.scenarioId = scenarioId
this.exerciseMode = mode this.exerciseMode = mode
this.targetValue = target this.targetValue = target
this.standardValue = standard
_completedLifts.value = 0 _completedLifts.value = 0
_elapsedTime.value = 0 _elapsedTime.value = 0
_isTimerStarted.value = false // Таймер запустится только при переходе BOTTOM -> MIDDLE _isTimerStarted.value = false // Таймер запустится только при переходе BOTTOM -> MIDDLE
_remainingTime.value = if (mode == ExerciseMode.COUNT_BASED) target.toLong() else 0 _remainingTime.value = if (mode == ExerciseMode.COUNT_BASED) target.toLong() else 0
_workoutResult.value = null // Сбрасываем результат предыдущей тренировки
lastPosition = null lastPosition = null
isLiftingUp = false // Сбрасываем флаг подъема isLiftingUp = false // Сбрасываем флаг подъема
timerStartTime = 0 timerStartTime = 0
@@ -214,7 +228,7 @@ class WorkoutViewModel(
if (exerciseMode == ExerciseMode.TIME_BASED) { if (exerciseMode == ExerciseMode.TIME_BASED) {
_elapsedTime.value++ _elapsedTime.value++
if (_elapsedTime.value >= target) { if (_elapsedTime.value >= target) {
stopWorkout() stopWorkout(isManualStop = false)
} }
} else if (exerciseMode == ExerciseMode.COUNT_BASED) { } else if (exerciseMode == ExerciseMode.COUNT_BASED) {
_elapsedTime.value++ _elapsedTime.value++
@@ -224,69 +238,145 @@ class WorkoutViewModel(
} }
} }
fun stopWorkout() { fun stopWorkout(isManualStop: Boolean = true) {
if (isStopping || !_isWorkoutActive.value) return val startTime = System.currentTimeMillis()
android.util.Log.d(TAG, "[STOP] stopWorkout() called at ${startTime}, isManualStop=$isManualStop")
if (isStopping || !_isWorkoutActive.value) {
android.util.Log.d(TAG, "[STOP] Already stopping or not active, returning")
return
}
val timeAfterCheck = System.currentTimeMillis()
android.util.Log.d(TAG, "[STOP] Check took ${timeAfterCheck - startTime}ms")
isStopping = true isStopping = true
_isWorkoutActive.value = false _isWorkoutActive.value = false
_isTimerStarted.value = false _isTimerStarted.value = false
// Отменяем все корутины val timeAfterFlags = System.currentTimeMillis()
android.util.Log.d(TAG, "[STOP] Flags set in ${timeAfterFlags - timeAfterCheck}ms")
// Отменяем ВСЕ корутины сначала, чтобы они не пытались обрабатывать данные во время отключения
workoutJob?.cancel() workoutJob?.cancel()
workoutJob = null workoutJob = null
weightPositionJob?.cancel()
weightPositionJob = null
heartRateJob?.cancel()
heartRateJob = null
// Отключаем соединения val timeAfterCancel = System.currentTimeMillis()
try { android.util.Log.d(TAG, "[STOP] Jobs cancelled in ${timeAfterCancel - timeAfterFlags}ms")
tcpClient.disconnect()
} catch (e: Exception) {
android.util.Log.e("WorkoutViewModel", "Error disconnecting TCP", e)
}
try { // Сохраняем данные сессии ДО отключения соединений
heartRateMonitor.disconnect()
} catch (e: Exception) {
android.util.Log.e("WorkoutViewModel", "Error disconnecting Bluetooth", e)
}
// Сохраняем сессию
val session = _currentSession.value val session = _currentSession.value
if (session != null) { val endTime = System.currentTimeMillis()
val endTime = System.currentTimeMillis() val actualStartTime = if (timerStartTime > 0) timerStartTime else session?.startTime ?: endTime
// Используем timerStartTime если таймер был запущен, иначе 0 (тренировка не началась) val duration = if (timerStartTime > 0) {
val actualStartTime = if (timerStartTime > 0) timerStartTime else session.startTime (endTime - actualStartTime) / 1000
val duration = if (timerStartTime > 0) { } else {
(endTime - actualStartTime) / 1000 0L // Тренировка не началась (таймер не запустился)
} else { }
0L // Тренировка не началась (таймер не запустился)
} // Копируем данные пульса для безопасного вычисления (synchronized list)
val heartRateCopy = synchronized(heartRateReadings) { heartRateReadings.toList() }
val avgHeartRate = if (heartRateReadings.isNotEmpty()) { val avgHeartRate = if (heartRateCopy.isNotEmpty()) {
heartRateReadings.average().toInt() heartRateCopy.average().toInt()
} else null } else null
val updatedSession = session.copy( // Вычисляем результат, если задан норматив и тренировка завершилась естественно (не вручную)
startTime = actualStartTime, // Обновляем startTime на реальное время начала val currentLifts = _completedLifts.value
endTime = endTime, val result = if (!isManualStop) {
completedLifts = _completedLifts.value, standardValue?.let { standard ->
durationSeconds = duration, when (exerciseMode) {
averageHeartRate = avgHeartRate, // Для TIME_BASED: норматив - минимальное количество подъемов
maxHeartRate = heartRateReadings.maxOrNull(), ExerciseMode.TIME_BASED -> {
minHeartRate = heartRateReadings.minOrNull() if (currentLifts >= standard) WorkoutResult.PASSED else WorkoutResult.FAILED
) }
// Для COUNT_BASED: норматив - максимальное время в секундах
viewModelScope.launch { ExerciseMode.COUNT_BASED -> {
try { if (duration <= standard) WorkoutResult.PASSED else WorkoutResult.FAILED
repository.updateSession(updatedSession) }
_currentSession.value = updatedSession
} catch (e: Exception) {
android.util.Log.e("WorkoutViewModel", "Error updating session", e)
} finally {
isStopping = false
} }
} }
} else { } else null
isStopping = false _workoutResult.value = result
val updatedSession = session?.copy(
startTime = actualStartTime,
endTime = endTime,
completedLifts = _completedLifts.value,
durationSeconds = duration,
averageHeartRate = avgHeartRate,
maxHeartRate = heartRateCopy.maxOrNull(),
minHeartRate = heartRateCopy.minOrNull()
)
val timeAfterSessionPrep = System.currentTimeMillis()
android.util.Log.d(TAG, "[STOP] Session prepared in ${timeAfterSessionPrep - timeAfterCancel}ms")
android.util.Log.d(TAG, "[STOP] Total time before coroutine launch: ${timeAfterSessionPrep - startTime}ms")
// Выполняем отключение соединений и сохранение в фоновом потоке
viewModelScope.launch(Dispatchers.IO) {
val coroutineStartTime = System.currentTimeMillis()
android.util.Log.d(TAG, "[STOP] Coroutine started on IO dispatcher at ${coroutineStartTime}, delay from function start: ${coroutineStartTime - startTime}ms")
// Отключаем соединения в IO потоке (не блокируем Main Thread)
val tcpDisconnectStart = System.currentTimeMillis()
try {
android.util.Log.d(TAG, "[STOP] Calling tcpClient.disconnect()...")
tcpClient.disconnect()
val tcpDisconnectEnd = System.currentTimeMillis()
android.util.Log.d(TAG, "[STOP] tcpClient.disconnect() completed in ${tcpDisconnectEnd - tcpDisconnectStart}ms")
} catch (e: Exception) {
android.util.Log.e(TAG, "[STOP] Error disconnecting TCP", e)
}
val btDisconnectStart = System.currentTimeMillis()
try {
android.util.Log.d(TAG, "[STOP] Calling heartRateMonitor.disconnect()...")
heartRateMonitor.disconnect()
val btDisconnectEnd = System.currentTimeMillis()
android.util.Log.d(TAG, "[STOP] heartRateMonitor.disconnect() completed in ${btDisconnectEnd - btDisconnectStart}ms")
} catch (e: Exception) {
android.util.Log.e(TAG, "[STOP] Error disconnecting Bluetooth", e)
}
// Сохраняем сессию
if (updatedSession != null) {
val dbUpdateStart = System.currentTimeMillis()
try {
android.util.Log.d(TAG, "[STOP] Calling repository.updateSession()...")
repository.updateSession(updatedSession)
val dbUpdateEnd = System.currentTimeMillis()
android.util.Log.d(TAG, "[STOP] repository.updateSession() completed in ${dbUpdateEnd - dbUpdateStart}ms")
withContext(Dispatchers.Main) {
_currentSession.value = updatedSession
}
} catch (e: Exception) {
android.util.Log.e(TAG, "[STOP] Error updating session", e)
}
}
val finalStart = System.currentTimeMillis()
withContext(Dispatchers.Main) {
isStopping = false
// Перезапускаем наблюдатели для следующей тренировки
observeWeightPosition()
observeHeartRate()
val finalEnd = System.currentTimeMillis()
android.util.Log.d(TAG, "[STOP] Final cleanup completed. Total coroutine time: ${finalEnd - coroutineStartTime}ms, Total function time: ${finalEnd - startTime}ms")
}
} }
val timeAfterLaunch = System.currentTimeMillis()
android.util.Log.d(TAG, "[STOP] Coroutine launched, function returning. Time to launch: ${timeAfterLaunch - startTime}ms")
}
fun clearWorkoutResult() {
_workoutResult.value = null
} }
fun disconnectAll() { fun disconnectAll() {
@@ -310,7 +400,21 @@ class WorkoutViewModel(
workoutJob?.cancel() workoutJob?.cancel()
weightPositionJob?.cancel() weightPositionJob?.cancel()
heartRateJob?.cancel() heartRateJob?.cancel()
disconnectAll()
// НЕ вызываем destroy() - TcpClient это синглтон на уровне Application,
// и его CoroutineScope должен жить всё время работы приложения.
// Вызываем только disconnect() для освобождения текущего соединения.
try {
tcpClient.disconnect()
} catch (e: Exception) {
android.util.Log.e(TAG, "Error disconnecting TCP", e)
}
try {
heartRateMonitor.disconnect()
} catch (e: Exception) {
android.util.Log.e(TAG, "Error disconnecting Bluetooth", e)
}
} }
companion object { companion object {