last commit
This commit is contained in:
@@ -19,6 +19,12 @@ class HeartRateMonitor(private val context: Context) {
|
||||
private val bluetoothLeScanner = bluetoothAdapter?.bluetoothLeScanner
|
||||
private var bluetoothGatt: BluetoothGatt? = null
|
||||
|
||||
@Volatile
|
||||
private var isDisconnecting = false
|
||||
|
||||
@Volatile
|
||||
private var isScanning = false
|
||||
|
||||
private val _heartRate = MutableStateFlow<Int?>(null)
|
||||
val heartRate: StateFlow<Int?> = _heartRate.asStateFlow()
|
||||
|
||||
@@ -124,7 +130,11 @@ class HeartRateMonitor(private val context: Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Сбрасываем флаг отключения
|
||||
isDisconnecting = false
|
||||
|
||||
_connectionState.value = BluetoothConnectionState.Scanning
|
||||
isScanning = true
|
||||
|
||||
val filter = ScanFilter.Builder()
|
||||
.setServiceUuid(android.os.ParcelUuid(HEART_RATE_SERVICE_UUID))
|
||||
@@ -134,11 +144,26 @@ class HeartRateMonitor(private val context: Context) {
|
||||
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
|
||||
.build()
|
||||
|
||||
bluetoothLeScanner.startScan(listOf(filter), settings, scanCallback)
|
||||
try {
|
||||
bluetoothLeScanner.startScan(listOf(filter), settings, scanCallback)
|
||||
Log.d(TAG, "[BT] Scan started")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "[BT] Error starting scan", e)
|
||||
isScanning = false
|
||||
_connectionState.value = BluetoothConnectionState.Error
|
||||
}
|
||||
}
|
||||
|
||||
fun stopScan() {
|
||||
bluetoothLeScanner?.stopScan(scanCallback)
|
||||
if (!isScanning) return
|
||||
|
||||
try {
|
||||
bluetoothLeScanner?.stopScan(scanCallback)
|
||||
Log.d(TAG, "[BT] Scan stopped")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "[BT] Error stopping scan", e)
|
||||
}
|
||||
isScanning = false
|
||||
}
|
||||
|
||||
private fun connectToDevice(device: BluetoothDevice) {
|
||||
@@ -147,11 +172,44 @@ class HeartRateMonitor(private val context: Context) {
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
bluetoothGatt?.disconnect()
|
||||
bluetoothGatt?.close()
|
||||
// Предотвращаем параллельные вызовы disconnect
|
||||
if (isDisconnecting) {
|
||||
Log.d(TAG, "[BT] disconnect() already in progress, skipping")
|
||||
return
|
||||
}
|
||||
isDisconnecting = true
|
||||
|
||||
val startTime = System.currentTimeMillis()
|
||||
Log.d(TAG, "[BT] disconnect() called at $startTime")
|
||||
|
||||
// Останавливаем сканирование если оно активно
|
||||
stopScan()
|
||||
|
||||
val disconnectStart = System.currentTimeMillis()
|
||||
try {
|
||||
bluetoothGatt?.disconnect()
|
||||
val disconnectEnd = System.currentTimeMillis()
|
||||
Log.d(TAG, "[BT] GATT disconnect() took ${disconnectEnd - disconnectStart}ms")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "[BT] Error in GATT disconnect()", e)
|
||||
}
|
||||
|
||||
val closeStart = System.currentTimeMillis()
|
||||
try {
|
||||
bluetoothGatt?.close()
|
||||
val closeEnd = System.currentTimeMillis()
|
||||
Log.d(TAG, "[BT] GATT close() took ${closeEnd - closeStart}ms")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "[BT] Error in GATT close()", e)
|
||||
}
|
||||
|
||||
bluetoothGatt = null
|
||||
_connectionState.value = BluetoothConnectionState.Disconnected
|
||||
_heartRate.value = null
|
||||
|
||||
val endTime = System.currentTimeMillis()
|
||||
Log.d(TAG, "[BT] disconnect() completed. Total time: ${endTime - startTime}ms")
|
||||
isDisconnecting = false
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -11,7 +11,7 @@ import ru.kgeu.training.data.model.WorkoutSession
|
||||
|
||||
@Database(
|
||||
entities = [Participant::class, ExerciseScenario::class, WorkoutSession::class],
|
||||
version = 1,
|
||||
version = 2,
|
||||
exportSchema = false
|
||||
)
|
||||
abstract class TrainingDatabase : RoomDatabase() {
|
||||
|
||||
@@ -11,6 +11,7 @@ data class ExerciseScenario(
|
||||
val description: String? = null,
|
||||
val exerciseMode: ExerciseMode,
|
||||
val targetValue: Int, // количество подъемов или время в секундах
|
||||
val standardValue: Int? = null, // норматив: для TIME_BASED - мин. кол-во подъемов, для COUNT_BASED - макс. время в секундах
|
||||
val isCustom: Boolean = false, // true для пользовательских, false для готовых
|
||||
val createdAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
@@ -16,104 +16,187 @@ class TcpClient {
|
||||
private var socket: Socket? = null
|
||||
private var reader: BufferedReader? = null
|
||||
private var readJob: Job? = null
|
||||
private var reconnectJob: Job? = null
|
||||
private val clientScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
@Volatile
|
||||
private var isDisconnecting = false
|
||||
|
||||
@Volatile
|
||||
private var shouldReconnect = false
|
||||
|
||||
// Счетчик поколений соединений - увеличивается при каждом новом connect()
|
||||
// Используется для предотвращения race conditions, когда старый finally блок
|
||||
// пытается переподключиться после того, как было создано новое соединение
|
||||
@Volatile
|
||||
private var connectionGeneration = 0
|
||||
|
||||
// Сохраняем параметры подключения для переподключения
|
||||
private var lastHost: String = ""
|
||||
private var lastPort: Int = 0
|
||||
|
||||
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
|
||||
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
|
||||
|
||||
private val _weightPosition = MutableStateFlow<WeightPosition?>(null)
|
||||
val weightPosition: StateFlow<WeightPosition?> = _weightPosition.asStateFlow()
|
||||
|
||||
companion object {
|
||||
private const val TAG = "TcpClient"
|
||||
private const val RECONNECT_DELAY_MS = 500L // Минимальная задержка перед переподключением
|
||||
private const val MAX_RECONNECT_ATTEMPTS = 10
|
||||
}
|
||||
|
||||
fun connect(host: String, port: Int) {
|
||||
Log.d(TAG, "Attempting to connect to TCP: $host:$port")
|
||||
|
||||
// Увеличиваем поколение соединения - это invalidates все старые finally блоки
|
||||
connectionGeneration++
|
||||
val currentGeneration = connectionGeneration
|
||||
Log.d(TAG, "Connection generation: $currentGeneration")
|
||||
|
||||
// Сохраняем параметры для переподключения
|
||||
lastHost = host
|
||||
lastPort = port
|
||||
shouldReconnect = true
|
||||
|
||||
// Отключаемся если уже подключены
|
||||
if (_connectionState.value == ConnectionState.Connected) {
|
||||
Log.d(TAG, "Already connected, disconnecting first")
|
||||
disconnect()
|
||||
disconnectInternal(triggerReconnect = false)
|
||||
}
|
||||
|
||||
// Отменяем предыдущую задачу чтения если есть
|
||||
// Сбрасываем флаг отключения для нового подключения
|
||||
isDisconnecting = false
|
||||
|
||||
// Отменяем предыдущие задачи
|
||||
readJob?.cancel()
|
||||
readJob = null
|
||||
reconnectJob?.cancel()
|
||||
reconnectJob = null
|
||||
|
||||
connectInternal(host, port, generation = currentGeneration)
|
||||
}
|
||||
|
||||
private fun connectInternal(host: String, port: Int, attemptNumber: Int = 1, generation: Int = connectionGeneration) {
|
||||
if (isDisconnecting || !shouldReconnect) {
|
||||
Log.d(TAG, "Skipping connection attempt: isDisconnecting=$isDisconnecting, shouldReconnect=$shouldReconnect")
|
||||
return
|
||||
}
|
||||
|
||||
// Проверяем, что это всё ещё актуальное поколение соединения
|
||||
if (generation != connectionGeneration) {
|
||||
Log.d(TAG, "Skipping connection attempt: generation mismatch (expected $connectionGeneration, got $generation)")
|
||||
return
|
||||
}
|
||||
|
||||
_connectionState.value = ConnectionState.Connecting
|
||||
|
||||
clientScope.launch {
|
||||
try {
|
||||
// Небольшая задержка для завершения отключения
|
||||
delay(100)
|
||||
if (attemptNumber > 1) {
|
||||
delay(RECONNECT_DELAY_MS)
|
||||
} else {
|
||||
delay(50)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Creating socket connection...")
|
||||
// Повторная проверка после задержки
|
||||
if (!shouldReconnect || generation != connectionGeneration) {
|
||||
Log.d(TAG, "Aborting connection after delay: shouldReconnect=$shouldReconnect, generation match=${generation == connectionGeneration}")
|
||||
return@launch
|
||||
}
|
||||
|
||||
Log.d(TAG, "Creating socket connection (attempt $attemptNumber, generation $generation)...")
|
||||
val newSocket = Socket()
|
||||
// Оптимизация TCP: отключаем алгоритм Nagle для уменьшения задержек
|
||||
newSocket.tcpNoDelay = true
|
||||
// Уменьшаем таймаут чтения для более быстрой реакции
|
||||
newSocket.soTimeout = 1000 // 1 секунда вместо 10
|
||||
newSocket.connect(java.net.InetSocketAddress(host, port), 5000) // 5 секунд таймаут на подключение
|
||||
newSocket.soTimeout = 2000 // 2 секунды
|
||||
newSocket.connect(java.net.InetSocketAddress(host, port), 3000) // 3 секунды таймаут на подключение
|
||||
|
||||
// Проверяем ещё раз перед назначением
|
||||
if (generation != connectionGeneration) {
|
||||
Log.d(TAG, "Connection established but generation changed, closing socket")
|
||||
newSocket.close()
|
||||
return@launch
|
||||
}
|
||||
|
||||
socket = newSocket
|
||||
reader = BufferedReader(InputStreamReader(socket!!.getInputStream(), "UTF-8"), 8192) // Увеличиваем размер буфера
|
||||
reader = BufferedReader(InputStreamReader(socket!!.getInputStream(), "UTF-8"), 8192)
|
||||
|
||||
Log.d(TAG, "TCP connection established to $host:$port")
|
||||
Log.d(TAG, "TCP connection established to $host:$port (generation $generation)")
|
||||
withContext(Dispatchers.Main) {
|
||||
_connectionState.value = ConnectionState.Connected
|
||||
}
|
||||
|
||||
// Запускаем чтение данных в отдельной корутине
|
||||
// Запускаем чтение данных в отдельной корутине, передаём generation
|
||||
readJob = clientScope.launch {
|
||||
readMessages()
|
||||
readMessages(generation)
|
||||
}
|
||||
|
||||
} catch (e: SocketTimeoutException) {
|
||||
Log.e(TAG, "Connection timeout: $host:$port", e)
|
||||
withContext(Dispatchers.Main) {
|
||||
_connectionState.value = ConnectionState.Disconnected
|
||||
}
|
||||
socket?.close()
|
||||
socket = null
|
||||
reader = null
|
||||
Log.e(TAG, "Connection timeout: $host:$port (attempt $attemptNumber)", e)
|
||||
handleConnectionFailure(attemptNumber, generation)
|
||||
} catch (e: java.net.UnknownHostException) {
|
||||
Log.e(TAG, "Unknown host: $host", e)
|
||||
withContext(Dispatchers.Main) {
|
||||
_connectionState.value = ConnectionState.Disconnected
|
||||
}
|
||||
socket?.close()
|
||||
socket = null
|
||||
reader = null
|
||||
Log.e(TAG, "Unknown host: $host (attempt $attemptNumber)", e)
|
||||
handleConnectionFailure(attemptNumber, generation)
|
||||
} catch (e: java.net.ConnectException) {
|
||||
Log.e(TAG, "Connection refused: $host:$port", e)
|
||||
withContext(Dispatchers.Main) {
|
||||
_connectionState.value = ConnectionState.Disconnected
|
||||
}
|
||||
socket?.close()
|
||||
socket = null
|
||||
reader = null
|
||||
Log.e(TAG, "Connection refused: $host:$port (attempt $attemptNumber)", e)
|
||||
handleConnectionFailure(attemptNumber, generation)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error connecting to TCP: $host:$port", e)
|
||||
e.printStackTrace()
|
||||
withContext(Dispatchers.Main) {
|
||||
_connectionState.value = ConnectionState.Disconnected
|
||||
}
|
||||
socket?.close()
|
||||
socket = null
|
||||
reader = null
|
||||
Log.e(TAG, "Error connecting to TCP: $host:$port (attempt $attemptNumber)", e)
|
||||
handleConnectionFailure(attemptNumber, generation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun readMessages() {
|
||||
private suspend fun handleConnectionFailure(attemptNumber: Int, generation: Int) {
|
||||
// Проверяем, что это всё ещё актуальное поколение
|
||||
if (generation != connectionGeneration) {
|
||||
Log.d(TAG, "Ignoring connection failure: generation mismatch")
|
||||
return
|
||||
}
|
||||
|
||||
socket?.close()
|
||||
socket = null
|
||||
reader = null
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
// Обновляем состояние только если generation всё ещё актуален
|
||||
if (generation == connectionGeneration) {
|
||||
_connectionState.value = ConnectionState.Disconnected
|
||||
}
|
||||
}
|
||||
|
||||
// Пытаемся переподключиться если разрешено и не превышен лимит попыток
|
||||
if (shouldReconnect && attemptNumber < MAX_RECONNECT_ATTEMPTS && lastHost.isNotEmpty() && generation == connectionGeneration) {
|
||||
Log.d(TAG, "Scheduling reconnect attempt ${attemptNumber + 1} in ${RECONNECT_DELAY_MS}ms (generation $generation)")
|
||||
delay(RECONNECT_DELAY_MS)
|
||||
if (shouldReconnect && generation == connectionGeneration) {
|
||||
connectInternal(lastHost, lastPort, attemptNumber + 1, generation)
|
||||
}
|
||||
} else if (attemptNumber >= MAX_RECONNECT_ATTEMPTS) {
|
||||
Log.e(TAG, "Max reconnect attempts reached ($MAX_RECONNECT_ATTEMPTS)")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun readMessages(generation: Int) {
|
||||
val currentReader = reader
|
||||
val currentSocket = socket
|
||||
|
||||
if (currentReader == null || currentSocket == null) {
|
||||
Log.w(TAG, "Cannot read messages: reader or socket is null")
|
||||
triggerReconnect(generation)
|
||||
return
|
||||
}
|
||||
|
||||
var consecutiveTimeouts = 0
|
||||
val maxConsecutiveTimeouts = 10 // После 10 таймаутов подряд (20 сек) считаем соединение потерянным
|
||||
|
||||
try {
|
||||
Log.d(TAG, "Starting to read messages from TCP stream...")
|
||||
while (currentSocket.isConnected && !currentSocket.isClosed) {
|
||||
Log.d(TAG, "Starting to read messages from TCP stream (generation $generation)...")
|
||||
while (currentSocket.isConnected && !currentSocket.isClosed && !isDisconnecting && generation == connectionGeneration) {
|
||||
try {
|
||||
val line = currentReader.readLine()
|
||||
if (line == null) {
|
||||
@@ -121,13 +204,21 @@ class TcpClient {
|
||||
break
|
||||
}
|
||||
|
||||
consecutiveTimeouts = 0 // Сбрасываем счетчик при получении данных
|
||||
// Обрабатываем сообщение асинхронно, не блокируя чтение
|
||||
processMessage(line)
|
||||
|
||||
} catch (e: SocketTimeoutException) {
|
||||
// Таймаут на чтение - это нормально, продолжаем
|
||||
// Не логируем каждый таймаут, чтобы не засорять лог
|
||||
// Таймаут на чтение - это нормально, но следим за их количеством
|
||||
consecutiveTimeouts++
|
||||
if (consecutiveTimeouts >= maxConsecutiveTimeouts) {
|
||||
Log.w(TAG, "Too many consecutive timeouts ($consecutiveTimeouts), connection might be dead")
|
||||
break
|
||||
}
|
||||
continue
|
||||
} catch (e: java.io.IOException) {
|
||||
Log.e(TAG, "IO Error reading message (connection lost?)", e)
|
||||
break
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error reading message", e)
|
||||
break
|
||||
@@ -136,10 +227,53 @@ class TcpClient {
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error in readMessages loop", e)
|
||||
} finally {
|
||||
Log.d(TAG, "Stopped reading messages")
|
||||
withContext(Dispatchers.Main) {
|
||||
_connectionState.value = ConnectionState.Disconnected
|
||||
_weightPosition.value = null
|
||||
Log.d(TAG, "Stopped reading messages (generation $generation), current generation=$connectionGeneration, isDisconnecting=$isDisconnecting, shouldReconnect=$shouldReconnect")
|
||||
|
||||
// Закрываем текущие ресурсы
|
||||
try { currentSocket.close() } catch (e: Exception) { }
|
||||
|
||||
// ВАЖНО: Всё остальное делаем только если generation совпадает
|
||||
// Это предотвращает race condition, когда старый finally блок влияет на новое соединение
|
||||
if (generation != connectionGeneration) {
|
||||
Log.d(TAG, "Generation mismatch in finally block, skipping cleanup (old=$generation, current=$connectionGeneration)")
|
||||
return
|
||||
}
|
||||
|
||||
// Обнуляем socket/reader только если они не были заменены новым подключением
|
||||
if (socket === currentSocket) {
|
||||
socket = null
|
||||
}
|
||||
if (reader === currentReader) {
|
||||
reader = null
|
||||
}
|
||||
|
||||
// Обновляем состояние только если это всё ещё наше соединение (не было создано новое)
|
||||
if (socket == null && generation == connectionGeneration) {
|
||||
withContext(Dispatchers.Main) {
|
||||
_connectionState.value = ConnectionState.Disconnected
|
||||
_weightPosition.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// Пытаемся переподключиться если это не было намеренным отключением И generation совпадает
|
||||
if (!isDisconnecting && shouldReconnect && generation == connectionGeneration) {
|
||||
triggerReconnect(generation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun triggerReconnect(generation: Int) {
|
||||
if (!shouldReconnect || lastHost.isEmpty() || isDisconnecting || generation != connectionGeneration) {
|
||||
Log.d(TAG, "Skipping triggerReconnect: shouldReconnect=$shouldReconnect, hasHost=${lastHost.isNotEmpty()}, isDisconnecting=$isDisconnecting, generationMatch=${generation == connectionGeneration}")
|
||||
return
|
||||
}
|
||||
|
||||
Log.d(TAG, "Triggering reconnect to $lastHost:$lastPort (generation $generation)")
|
||||
reconnectJob?.cancel()
|
||||
reconnectJob = clientScope.launch {
|
||||
delay(RECONNECT_DELAY_MS)
|
||||
if (shouldReconnect && !isDisconnecting && generation == connectionGeneration) {
|
||||
connectInternal(lastHost, lastPort, generation = generation)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -175,31 +309,74 @@ class TcpClient {
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
Log.d(TAG, "Disconnecting TCP connection...")
|
||||
disconnectInternal(triggerReconnect = false)
|
||||
}
|
||||
|
||||
private fun disconnectInternal(triggerReconnect: Boolean) {
|
||||
// Предотвращаем параллельные вызовы disconnect
|
||||
if (isDisconnecting) {
|
||||
Log.d(TAG, "[TCP] disconnect() already in progress, skipping")
|
||||
return
|
||||
}
|
||||
isDisconnecting = true
|
||||
|
||||
// Если это полное отключение - запрещаем переподключение
|
||||
if (!triggerReconnect) {
|
||||
shouldReconnect = false
|
||||
}
|
||||
|
||||
val startTime = System.currentTimeMillis()
|
||||
Log.d(TAG, "[TCP] disconnect() called at $startTime, triggerReconnect=$triggerReconnect")
|
||||
|
||||
// Отменяем задачу переподключения
|
||||
reconnectJob?.cancel()
|
||||
reconnectJob = null
|
||||
|
||||
val cancelStart = System.currentTimeMillis()
|
||||
readJob?.cancel()
|
||||
readJob = null
|
||||
val cancelEnd = System.currentTimeMillis()
|
||||
Log.d(TAG, "[TCP] Job cancelled in ${cancelEnd - cancelStart}ms")
|
||||
|
||||
try {
|
||||
reader?.close()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error closing reader", e)
|
||||
}
|
||||
reader = null
|
||||
|
||||
// ВАЖНО: Сначала закрываем SOCKET, чтобы прервать блокирующий readLine()
|
||||
// Это заставит readLine() выбросить исключение и освободить блокировку на reader
|
||||
val socketCloseStart = System.currentTimeMillis()
|
||||
try {
|
||||
socket?.close()
|
||||
val socketCloseEnd = System.currentTimeMillis()
|
||||
Log.d(TAG, "[TCP] Socket closed in ${socketCloseEnd - socketCloseStart}ms")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error closing socket", e)
|
||||
Log.e(TAG, "[TCP] Error closing socket", e)
|
||||
}
|
||||
socket = null
|
||||
|
||||
clientScope.launch(Dispatchers.Main) {
|
||||
_connectionState.value = ConnectionState.Disconnected
|
||||
_weightPosition.value = null
|
||||
// Теперь безопасно закрываем reader (блокировка уже освобождена)
|
||||
val readerCloseStart = System.currentTimeMillis()
|
||||
try {
|
||||
reader?.close()
|
||||
val readerCloseEnd = System.currentTimeMillis()
|
||||
Log.d(TAG, "[TCP] Reader closed in ${readerCloseEnd - readerCloseStart}ms")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "[TCP] Error closing reader", e)
|
||||
}
|
||||
reader = null
|
||||
|
||||
// Обновляем состояние синхронно, чтобы избежать race condition с новым подключением
|
||||
// Если connect() будет вызван сразу после disconnect(), он корректно установит shouldReconnect = true
|
||||
// и новое соединение не будет затёрто асинхронным обновлением состояния
|
||||
if (!shouldReconnect) {
|
||||
clientScope.launch(Dispatchers.Main) {
|
||||
// Повторная проверка, т.к. состояние могло измениться пока мы ждали Main thread
|
||||
if (!shouldReconnect && socket == null) {
|
||||
_connectionState.value = ConnectionState.Disconnected
|
||||
_weightPosition.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(TAG, "TCP connection disconnected")
|
||||
val endTime = System.currentTimeMillis()
|
||||
Log.d(TAG, "[TCP] disconnect() completed. Total time: ${endTime - startTime}ms")
|
||||
isDisconnecting = false
|
||||
}
|
||||
|
||||
fun sendMessage(message: String) {
|
||||
@@ -221,8 +398,16 @@ class TcpClient {
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "TcpClient"
|
||||
/**
|
||||
* Полностью освобождает ресурсы. Вызывайте при уничтожении объекта.
|
||||
*/
|
||||
fun destroy() {
|
||||
Log.d(TAG, "[TCP] destroy() called")
|
||||
shouldReconnect = false
|
||||
reconnectJob?.cancel()
|
||||
reconnectJob = null
|
||||
disconnect()
|
||||
clientScope.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ fun CustomScenarioScreen(
|
||||
var description by remember { mutableStateOf("") }
|
||||
var selectedMode by remember { mutableStateOf(ExerciseMode.TIME_BASED) }
|
||||
var targetValue by remember { mutableStateOf("") }
|
||||
var standardValue by remember { mutableStateOf("") }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Scaffold(
|
||||
@@ -92,18 +93,42 @@ fun CustomScenarioScreen(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = standardValue,
|
||||
onValueChange = { standardValue = it },
|
||||
label = {
|
||||
Text(
|
||||
when (selectedMode) {
|
||||
ExerciseMode.TIME_BASED -> "Норматив: мин. подъемов (опционально)"
|
||||
ExerciseMode.COUNT_BASED -> "Норматив: макс. время в сек. (опционально)"
|
||||
}
|
||||
)
|
||||
},
|
||||
supportingText = {
|
||||
Text(
|
||||
when (selectedMode) {
|
||||
ExerciseMode.TIME_BASED -> "Если задан - зелёный экран при выполнении, красный - при невыполнении"
|
||||
ExerciseMode.COUNT_BASED -> "Если задан - зелёный экран если уложились в время, красный - если нет"
|
||||
}
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
val target = targetValue.toIntOrNull()
|
||||
val standard = standardValue.toIntOrNull()?.takeIf { it > 0 }
|
||||
if (name.isNotBlank() && target != null && target > 0) {
|
||||
scope.launch {
|
||||
viewModel.createCustomScenario(
|
||||
name = name,
|
||||
description = description.ifBlank { null },
|
||||
mode = selectedMode,
|
||||
targetValue = target
|
||||
targetValue = target,
|
||||
standardValue = standard
|
||||
)
|
||||
onScenarioCreated()
|
||||
}
|
||||
|
||||
@@ -1,21 +1,33 @@
|
||||
package ru.kgeu.training.ui.screen
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
import ru.kgeu.training.data.model.WeightPosition
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.kgeu.training.data.model.ExerciseMode
|
||||
import ru.kgeu.training.data.model.ExerciseScenario
|
||||
import ru.kgeu.training.data.model.Participant
|
||||
import ru.kgeu.training.data.model.WorkoutSession
|
||||
import ru.kgeu.training.ui.viewmodel.WorkoutResult
|
||||
import ru.kgeu.training.ui.viewmodel.WorkoutViewModel
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@@ -35,6 +47,7 @@ fun WorkoutScreen(
|
||||
val elapsedTime by viewModel.elapsedTime.collectAsState()
|
||||
val heartRate by viewModel.heartRate.collectAsState()
|
||||
val currentSession by viewModel.currentSession.collectAsState()
|
||||
val workoutResult by viewModel.workoutResult.collectAsState()
|
||||
|
||||
val connectionState by viewModel.tcpConnectionState.collectAsState()
|
||||
val bluetoothState by viewModel.bluetoothConnectionState.collectAsState()
|
||||
@@ -43,6 +56,20 @@ fun WorkoutScreen(
|
||||
// Получаем текущее положение веса для отображения
|
||||
val weightPosition by viewModel.weightPosition.collectAsState()
|
||||
|
||||
// Яркий зеленый цвет для подсветки при достижении крайних позиций (TOP/BOTTOM)
|
||||
val flashGreen = Color(0xFF00E676) // Material Green A400 - яркий зеленый
|
||||
val isAtEndPosition = weightPosition == WeightPosition.TOP || weightPosition == WeightPosition.BOTTOM
|
||||
val cardBackgroundColor by animateColorAsState(
|
||||
targetValue = if (isAtEndPosition) flashGreen else MaterialTheme.colorScheme.secondaryContainer,
|
||||
animationSpec = tween(durationMillis = 150),
|
||||
label = "cardFlash"
|
||||
)
|
||||
val cardContentColor by animateColorAsState(
|
||||
targetValue = if (isAtEndPosition) Color.White else MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
animationSpec = tween(durationMillis = 150),
|
||||
label = "cardContentFlash"
|
||||
)
|
||||
|
||||
var workoutStarted by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(participant.id, scenario.id) {
|
||||
@@ -54,6 +81,7 @@ fun WorkoutScreen(
|
||||
scenarioId = scenario.id,
|
||||
mode = scenario.exerciseMode,
|
||||
target = scenario.targetValue,
|
||||
standard = scenario.standardValue,
|
||||
tcpHost = tcpHost,
|
||||
tcpPort = tcpPort
|
||||
)
|
||||
@@ -81,38 +109,45 @@ fun WorkoutScreen(
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
CenterAlignedTopAppBar(
|
||||
title = { Text(scenario.name) }
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.stopWorkout()
|
||||
// Небольшая задержка перед навигацией для завершения сохранения
|
||||
scope.launch {
|
||||
kotlinx.coroutines.delay(300)
|
||||
onWorkoutFinished()
|
||||
}
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error
|
||||
)
|
||||
// Полноэкранный результат тренировки (если задан норматив)
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
CenterAlignedTopAppBar(
|
||||
title = { Text(scenario.name) }
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 116.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text("Остановить")
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.stopWorkout()
|
||||
// Небольшая задержка перед навигацией для завершения сохранения
|
||||
scope.launch {
|
||||
kotlinx.coroutines.delay(200)
|
||||
// Если нет норматива - сразу выходим, иначе ждем пока пользователь закроет результат
|
||||
if (scenario.standardValue == null) {
|
||||
onWorkoutFinished()
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(0.6f)
|
||||
.height(76.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error
|
||||
)
|
||||
) {
|
||||
Text("Остановить")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
) { padding ->
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -231,7 +266,8 @@ fun WorkoutScreen(
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer
|
||||
containerColor = cardBackgroundColor,
|
||||
contentColor = cardContentColor
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
@@ -240,18 +276,21 @@ fun WorkoutScreen(
|
||||
) {
|
||||
Text(
|
||||
text = "Выполнено подъемов",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = cardContentColor
|
||||
)
|
||||
Text(
|
||||
text = "$completedLifts",
|
||||
fontSize = 64.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = cardContentColor
|
||||
)
|
||||
Text(
|
||||
text = "Цель: ${scenario.targetValue} сек",
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = cardContentColor
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -271,7 +310,8 @@ fun WorkoutScreen(
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer
|
||||
containerColor = cardBackgroundColor,
|
||||
contentColor = cardContentColor
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
@@ -280,18 +320,21 @@ fun WorkoutScreen(
|
||||
) {
|
||||
Text(
|
||||
text = "Время",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = cardContentColor
|
||||
)
|
||||
Text(
|
||||
text = formatTime(elapsedTime),
|
||||
fontSize = 64.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = cardContentColor
|
||||
)
|
||||
Text(
|
||||
text = "Цель: ${scenario.targetValue} подъемов",
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = cardContentColor
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -307,6 +350,71 @@ fun WorkoutScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Полноэкранный оверлей с результатом (зеленый/красный)
|
||||
AnimatedVisibility(
|
||||
visible = workoutResult != null,
|
||||
enter = fadeIn(animationSpec = tween(300)),
|
||||
exit = fadeOut(animationSpec = tween(300)),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.zIndex(10f)
|
||||
) {
|
||||
val resultColor = when (workoutResult) {
|
||||
WorkoutResult.PASSED -> Color(0xFF00C853) // Яркий зеленый
|
||||
WorkoutResult.FAILED -> Color(0xFFD50000) // Яркий красный
|
||||
null -> Color.Transparent
|
||||
}
|
||||
val resultText = when (workoutResult) {
|
||||
WorkoutResult.PASSED -> "НОРМАТИВ ВЫПОЛНЕН!"
|
||||
WorkoutResult.FAILED -> "НОРМАТИВ НЕ ВЫПОЛНЕН"
|
||||
null -> ""
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(resultColor)
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null
|
||||
) {
|
||||
viewModel.clearWorkoutResult()
|
||||
onWorkoutFinished()
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = resultText,
|
||||
fontSize = 36.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Text(
|
||||
text = "Подъемов: $completedLifts",
|
||||
fontSize = 24.sp,
|
||||
color = Color.White.copy(alpha = 0.9f)
|
||||
)
|
||||
Text(
|
||||
text = "Время: ${formatTime(elapsedTime)}",
|
||||
fontSize = 24.sp,
|
||||
color = Color.White.copy(alpha = 0.9f)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
Text(
|
||||
text = "Нажмите для продолжения",
|
||||
fontSize = 16.sp,
|
||||
color = Color.White.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -90,13 +90,15 @@ class ScenarioViewModel(private val repository: TrainingRepository) : ViewModel(
|
||||
name: String,
|
||||
description: String?,
|
||||
mode: ExerciseMode,
|
||||
targetValue: Int
|
||||
targetValue: Int,
|
||||
standardValue: Int? = null
|
||||
): Long {
|
||||
val scenario = ExerciseScenario(
|
||||
name = name,
|
||||
description = description,
|
||||
exerciseMode = mode,
|
||||
targetValue = targetValue,
|
||||
standardValue = standardValue,
|
||||
isCustom = true
|
||||
)
|
||||
return repository.insertScenario(scenario)
|
||||
|
||||
@@ -2,12 +2,14 @@ package ru.kgeu.training.ui.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import ru.kgeu.training.data.model.ExerciseMode
|
||||
import ru.kgeu.training.data.model.WeightPosition
|
||||
import ru.kgeu.training.data.model.WorkoutSession
|
||||
@@ -15,6 +17,11 @@ import ru.kgeu.training.data.repository.TrainingRepository
|
||||
import ru.kgeu.training.network.TcpClient
|
||||
import ru.kgeu.training.bluetooth.HeartRateMonitor
|
||||
|
||||
enum class WorkoutResult {
|
||||
PASSED, // Норматив выполнен
|
||||
FAILED // Норматив не выполнен
|
||||
}
|
||||
|
||||
class WorkoutViewModel(
|
||||
private val repository: TrainingRepository,
|
||||
private val tcpClient: TcpClient,
|
||||
@@ -41,6 +48,9 @@ class WorkoutViewModel(
|
||||
private val _currentSession = MutableStateFlow<WorkoutSession?>(null)
|
||||
val currentSession: StateFlow<WorkoutSession?> = _currentSession.asStateFlow()
|
||||
|
||||
private val _workoutResult = MutableStateFlow<WorkoutResult?>(null)
|
||||
val workoutResult: StateFlow<WorkoutResult?> = _workoutResult.asStateFlow()
|
||||
|
||||
val tcpConnectionState: StateFlow<ru.kgeu.training.network.ConnectionState> = tcpClient.connectionState
|
||||
val bluetoothConnectionState: StateFlow<ru.kgeu.training.bluetooth.BluetoothConnectionState> = heartRateMonitor.connectionState
|
||||
val weightPosition: StateFlow<ru.kgeu.training.data.model.WeightPosition?> = tcpClient.weightPosition
|
||||
@@ -51,13 +61,14 @@ class WorkoutViewModel(
|
||||
private var lastPosition: WeightPosition? = null
|
||||
private var isLiftingUp: Boolean = false // Флаг: находимся ли мы в процессе подъема (BOTTOM -> MIDDLE -> TOP)
|
||||
private var targetValue: Int = 0
|
||||
private var standardValue: Int? = null
|
||||
private var exerciseMode: ExerciseMode = ExerciseMode.TIME_BASED
|
||||
private var participantId: Long = 0
|
||||
private var scenarioId: Long = 0
|
||||
private var isStopping = false
|
||||
private var timerStartTime: Long = 0 // Время когда таймер реально запустился
|
||||
|
||||
private val heartRateReadings = mutableListOf<Int>()
|
||||
private val heartRateReadings = java.util.Collections.synchronizedList(mutableListOf<Int>())
|
||||
|
||||
init {
|
||||
observeWeightPosition()
|
||||
@@ -127,7 +138,7 @@ class WorkoutViewModel(
|
||||
// Если режим на количество и достигнута цель
|
||||
if (exerciseMode == ExerciseMode.COUNT_BASED &&
|
||||
_completedLifts.value >= targetValue) {
|
||||
stopWorkout()
|
||||
stopWorkout(isManualStop = false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,6 +163,7 @@ class WorkoutViewModel(
|
||||
scenarioId: Long,
|
||||
mode: ExerciseMode,
|
||||
target: Int,
|
||||
standard: Int?,
|
||||
tcpHost: String,
|
||||
tcpPort: Int
|
||||
) {
|
||||
@@ -161,11 +173,13 @@ class WorkoutViewModel(
|
||||
this.scenarioId = scenarioId
|
||||
this.exerciseMode = mode
|
||||
this.targetValue = target
|
||||
this.standardValue = standard
|
||||
|
||||
_completedLifts.value = 0
|
||||
_elapsedTime.value = 0
|
||||
_isTimerStarted.value = false // Таймер запустится только при переходе BOTTOM -> MIDDLE
|
||||
_remainingTime.value = if (mode == ExerciseMode.COUNT_BASED) target.toLong() else 0
|
||||
_workoutResult.value = null // Сбрасываем результат предыдущей тренировки
|
||||
lastPosition = null
|
||||
isLiftingUp = false // Сбрасываем флаг подъема
|
||||
timerStartTime = 0
|
||||
@@ -214,7 +228,7 @@ class WorkoutViewModel(
|
||||
if (exerciseMode == ExerciseMode.TIME_BASED) {
|
||||
_elapsedTime.value++
|
||||
if (_elapsedTime.value >= target) {
|
||||
stopWorkout()
|
||||
stopWorkout(isManualStop = false)
|
||||
}
|
||||
} else if (exerciseMode == ExerciseMode.COUNT_BASED) {
|
||||
_elapsedTime.value++
|
||||
@@ -224,69 +238,145 @@ class WorkoutViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun stopWorkout() {
|
||||
if (isStopping || !_isWorkoutActive.value) return
|
||||
fun stopWorkout(isManualStop: Boolean = true) {
|
||||
val startTime = System.currentTimeMillis()
|
||||
android.util.Log.d(TAG, "[STOP] stopWorkout() called at ${startTime}, isManualStop=$isManualStop")
|
||||
|
||||
if (isStopping || !_isWorkoutActive.value) {
|
||||
android.util.Log.d(TAG, "[STOP] Already stopping or not active, returning")
|
||||
return
|
||||
}
|
||||
|
||||
val timeAfterCheck = System.currentTimeMillis()
|
||||
android.util.Log.d(TAG, "[STOP] Check took ${timeAfterCheck - startTime}ms")
|
||||
|
||||
isStopping = true
|
||||
_isWorkoutActive.value = false
|
||||
_isTimerStarted.value = false
|
||||
|
||||
// Отменяем все корутины
|
||||
val timeAfterFlags = System.currentTimeMillis()
|
||||
android.util.Log.d(TAG, "[STOP] Flags set in ${timeAfterFlags - timeAfterCheck}ms")
|
||||
|
||||
// Отменяем ВСЕ корутины сначала, чтобы они не пытались обрабатывать данные во время отключения
|
||||
workoutJob?.cancel()
|
||||
workoutJob = null
|
||||
weightPositionJob?.cancel()
|
||||
weightPositionJob = null
|
||||
heartRateJob?.cancel()
|
||||
heartRateJob = null
|
||||
|
||||
// Отключаем соединения
|
||||
try {
|
||||
tcpClient.disconnect()
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("WorkoutViewModel", "Error disconnecting TCP", e)
|
||||
}
|
||||
val timeAfterCancel = System.currentTimeMillis()
|
||||
android.util.Log.d(TAG, "[STOP] Jobs cancelled in ${timeAfterCancel - timeAfterFlags}ms")
|
||||
|
||||
try {
|
||||
heartRateMonitor.disconnect()
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("WorkoutViewModel", "Error disconnecting Bluetooth", e)
|
||||
}
|
||||
|
||||
// Сохраняем сессию
|
||||
// Сохраняем данные сессии ДО отключения соединений
|
||||
val session = _currentSession.value
|
||||
if (session != null) {
|
||||
val endTime = System.currentTimeMillis()
|
||||
// Используем timerStartTime если таймер был запущен, иначе 0 (тренировка не началась)
|
||||
val actualStartTime = if (timerStartTime > 0) timerStartTime else session.startTime
|
||||
val duration = if (timerStartTime > 0) {
|
||||
(endTime - actualStartTime) / 1000
|
||||
} else {
|
||||
0L // Тренировка не началась (таймер не запустился)
|
||||
}
|
||||
|
||||
val avgHeartRate = if (heartRateReadings.isNotEmpty()) {
|
||||
heartRateReadings.average().toInt()
|
||||
} else null
|
||||
|
||||
val updatedSession = session.copy(
|
||||
startTime = actualStartTime, // Обновляем startTime на реальное время начала
|
||||
endTime = endTime,
|
||||
completedLifts = _completedLifts.value,
|
||||
durationSeconds = duration,
|
||||
averageHeartRate = avgHeartRate,
|
||||
maxHeartRate = heartRateReadings.maxOrNull(),
|
||||
minHeartRate = heartRateReadings.minOrNull()
|
||||
)
|
||||
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
repository.updateSession(updatedSession)
|
||||
_currentSession.value = updatedSession
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("WorkoutViewModel", "Error updating session", e)
|
||||
} finally {
|
||||
isStopping = false
|
||||
val endTime = System.currentTimeMillis()
|
||||
val actualStartTime = if (timerStartTime > 0) timerStartTime else session?.startTime ?: endTime
|
||||
val duration = if (timerStartTime > 0) {
|
||||
(endTime - actualStartTime) / 1000
|
||||
} else {
|
||||
0L // Тренировка не началась (таймер не запустился)
|
||||
}
|
||||
|
||||
// Копируем данные пульса для безопасного вычисления (synchronized list)
|
||||
val heartRateCopy = synchronized(heartRateReadings) { heartRateReadings.toList() }
|
||||
val avgHeartRate = if (heartRateCopy.isNotEmpty()) {
|
||||
heartRateCopy.average().toInt()
|
||||
} else null
|
||||
|
||||
// Вычисляем результат, если задан норматив и тренировка завершилась естественно (не вручную)
|
||||
val currentLifts = _completedLifts.value
|
||||
val result = if (!isManualStop) {
|
||||
standardValue?.let { standard ->
|
||||
when (exerciseMode) {
|
||||
// Для TIME_BASED: норматив - минимальное количество подъемов
|
||||
ExerciseMode.TIME_BASED -> {
|
||||
if (currentLifts >= standard) WorkoutResult.PASSED else WorkoutResult.FAILED
|
||||
}
|
||||
// Для COUNT_BASED: норматив - максимальное время в секундах
|
||||
ExerciseMode.COUNT_BASED -> {
|
||||
if (duration <= standard) WorkoutResult.PASSED else WorkoutResult.FAILED
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
isStopping = false
|
||||
} else null
|
||||
_workoutResult.value = result
|
||||
|
||||
val updatedSession = session?.copy(
|
||||
startTime = actualStartTime,
|
||||
endTime = endTime,
|
||||
completedLifts = _completedLifts.value,
|
||||
durationSeconds = duration,
|
||||
averageHeartRate = avgHeartRate,
|
||||
maxHeartRate = heartRateCopy.maxOrNull(),
|
||||
minHeartRate = heartRateCopy.minOrNull()
|
||||
)
|
||||
|
||||
val timeAfterSessionPrep = System.currentTimeMillis()
|
||||
android.util.Log.d(TAG, "[STOP] Session prepared in ${timeAfterSessionPrep - timeAfterCancel}ms")
|
||||
android.util.Log.d(TAG, "[STOP] Total time before coroutine launch: ${timeAfterSessionPrep - startTime}ms")
|
||||
|
||||
// Выполняем отключение соединений и сохранение в фоновом потоке
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val coroutineStartTime = System.currentTimeMillis()
|
||||
android.util.Log.d(TAG, "[STOP] Coroutine started on IO dispatcher at ${coroutineStartTime}, delay from function start: ${coroutineStartTime - startTime}ms")
|
||||
|
||||
// Отключаем соединения в IO потоке (не блокируем Main Thread)
|
||||
val tcpDisconnectStart = System.currentTimeMillis()
|
||||
try {
|
||||
android.util.Log.d(TAG, "[STOP] Calling tcpClient.disconnect()...")
|
||||
tcpClient.disconnect()
|
||||
val tcpDisconnectEnd = System.currentTimeMillis()
|
||||
android.util.Log.d(TAG, "[STOP] tcpClient.disconnect() completed in ${tcpDisconnectEnd - tcpDisconnectStart}ms")
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e(TAG, "[STOP] Error disconnecting TCP", e)
|
||||
}
|
||||
|
||||
val btDisconnectStart = System.currentTimeMillis()
|
||||
try {
|
||||
android.util.Log.d(TAG, "[STOP] Calling heartRateMonitor.disconnect()...")
|
||||
heartRateMonitor.disconnect()
|
||||
val btDisconnectEnd = System.currentTimeMillis()
|
||||
android.util.Log.d(TAG, "[STOP] heartRateMonitor.disconnect() completed in ${btDisconnectEnd - btDisconnectStart}ms")
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e(TAG, "[STOP] Error disconnecting Bluetooth", e)
|
||||
}
|
||||
|
||||
// Сохраняем сессию
|
||||
if (updatedSession != null) {
|
||||
val dbUpdateStart = System.currentTimeMillis()
|
||||
try {
|
||||
android.util.Log.d(TAG, "[STOP] Calling repository.updateSession()...")
|
||||
repository.updateSession(updatedSession)
|
||||
val dbUpdateEnd = System.currentTimeMillis()
|
||||
android.util.Log.d(TAG, "[STOP] repository.updateSession() completed in ${dbUpdateEnd - dbUpdateStart}ms")
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
_currentSession.value = updatedSession
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e(TAG, "[STOP] Error updating session", e)
|
||||
}
|
||||
}
|
||||
|
||||
val finalStart = System.currentTimeMillis()
|
||||
withContext(Dispatchers.Main) {
|
||||
isStopping = false
|
||||
// Перезапускаем наблюдатели для следующей тренировки
|
||||
observeWeightPosition()
|
||||
observeHeartRate()
|
||||
|
||||
val finalEnd = System.currentTimeMillis()
|
||||
android.util.Log.d(TAG, "[STOP] Final cleanup completed. Total coroutine time: ${finalEnd - coroutineStartTime}ms, Total function time: ${finalEnd - startTime}ms")
|
||||
}
|
||||
}
|
||||
|
||||
val timeAfterLaunch = System.currentTimeMillis()
|
||||
android.util.Log.d(TAG, "[STOP] Coroutine launched, function returning. Time to launch: ${timeAfterLaunch - startTime}ms")
|
||||
}
|
||||
|
||||
fun clearWorkoutResult() {
|
||||
_workoutResult.value = null
|
||||
}
|
||||
|
||||
fun disconnectAll() {
|
||||
@@ -310,7 +400,21 @@ class WorkoutViewModel(
|
||||
workoutJob?.cancel()
|
||||
weightPositionJob?.cancel()
|
||||
heartRateJob?.cancel()
|
||||
disconnectAll()
|
||||
|
||||
// НЕ вызываем destroy() - TcpClient это синглтон на уровне Application,
|
||||
// и его CoroutineScope должен жить всё время работы приложения.
|
||||
// Вызываем только disconnect() для освобождения текущего соединения.
|
||||
try {
|
||||
tcpClient.disconnect()
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e(TAG, "Error disconnecting TCP", e)
|
||||
}
|
||||
|
||||
try {
|
||||
heartRateMonitor.disconnect()
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e(TAG, "Error disconnecting Bluetooth", e)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
Reference in New Issue
Block a user