first commit

This commit is contained in:
2025-11-30 00:58:24 +03:00
commit ad20ba3325
79 changed files with 5158 additions and 0 deletions

15
.gitignore vendored Normal file
View File

@@ -0,0 +1,15 @@
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties

3
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml

1
.idea/.name generated Normal file
View File

@@ -0,0 +1 @@
Тренировка

6
.idea/AndroidProjectSystem.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidProjectSystem">
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
</component>
</project>

123
.idea/codeStyles/Project.xml generated Normal file
View File

@@ -0,0 +1,123 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<JetCodeStyleSettings>
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>
<codeStyleSettings language="XML">
<option name="FORCE_REARRANGE_MODE" value="1" />
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" />
</indentOptions>
<arrangement>
<rules>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:android</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:id</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>style</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
<order>ANDROID_ATTRIBUTE_ORDER</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>.*</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
</rules>
</arrangement>
</codeStyleSettings>
<codeStyleSettings language="kotlin">
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</codeStyleSettings>
</code_scheme>
</component>

5
.idea/codeStyles/codeStyleConfig.xml generated Normal file
View File

@@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>

6
.idea/compiler.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" />
</component>
</project>

10
.idea/deploymentTargetSelector.xml generated Normal file
View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
</SelectionState>
</selectionStates>
</component>
</project>

18
.idea/gradle.xml generated Normal file
View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
</set>
</option>
</GradleProjectSettings>
</option>
</component>
</project>

8
.idea/markdown.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MarkdownSettings">
<option name="previewPanelProviderInfo">
<ProviderInfo name="Compose (experimental)" className="com.intellij.markdown.compose.preview.ComposePanelProvider" />
</option>
</component>
</project>

10
.idea/migrations.xml generated Normal file
View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectMigrations">
<option name="MigrateToGradleLocalJavaHome">
<set>
<option value="$PROJECT_DIR$" />
</set>
</option>
</component>
</project>

10
.idea/misc.xml generated Normal file
View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>

17
.idea/runConfigurations.xml generated Normal file
View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RunConfigurationProducerService">
<option name="ignoredProducers">
<set>
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
</set>
</option>
</component>
</project>

114
README.md Normal file
View File

@@ -0,0 +1,114 @@
# Грузоблочный тренажер - Android приложение
Android приложение для управления грузоблочным тренажером с ESP32 микроконтроллером.
## Функциональность
1. **Регистрация участника** - добавление и выбор участника тренировки
2. **Выбор режима упражнений** - выбор готового сценария или создание индивидуального
3. **Пуск и останов упражнения** - управление тренировкой
4. **Подсчет подъемов** - в режиме на время считаются выполненные подъемы
5. **Отсчет времени** - в режиме на количество отсчитывается время выполнения
6. **Кардиодатчик** - отображение показаний пульса в реальном времени через Bluetooth
7. **Печать отчетов** - вывод результатов тренировки на принтер
## Технологии
- Kotlin
- Jetpack Compose
- Room Database
- WebSocket (OkHttp) для связи с ESP32
- Bluetooth LE для кардиодатчика
- Navigation Component
## Настройка
### WebSocket подключение к ESP32
В файле `MainActivity.kt` измените URL WebSocket:
```kotlin
val websocketUrl = "ws://192.168.1.100:81" // Замените на IP вашего ESP32
```
ESP32 должен отправлять сообщения в формате JSON:
```json
{"position": "top"} // или "bottom"
```
### Bluetooth кардиодатчик
Приложение автоматически сканирует и подключается к Bluetooth устройствам с Heart Rate Service (UUID: 0000180d-0000-1000-8000-00805f9b34fb).
При первом запуске приложение запросит разрешения:
- Bluetooth
- Location (требуется для Bluetooth LE сканирования на Android 12+)
## Структура проекта
```
app/src/main/java/ru/kgeu/training/
├── data/
│ ├── dao/ # Data Access Objects для Room
│ ├── database/ # Room Database
│ ├── model/ # Модели данных
│ └── repository/ # Репозиторий для работы с данными
├── network/
│ └── WebSocketClient.kt # WebSocket клиент для ESP32
├── bluetooth/
│ └── HeartRateMonitor.kt # Bluetooth сервис для кардиодатчика
├── ui/
│ ├── navigation/ # Навигация
│ ├── screen/ # UI экраны
│ ├── theme/ # Тема приложения
│ └── viewmodel/ # ViewModels
└── util/
└── PrintUtil.kt # Утилита для печати
```
## Режимы упражнений
### Режим на время
- Устанавливается целевое время (в секундах)
- Считается количество выполненных подъемов
- Тренировка завершается по истечении времени
### Режим на количество
- Устанавливается целевое количество подъемов
- Отсчитывается время выполнения
- Тренировка завершается при достижении цели
## База данных
Приложение использует Room Database для хранения:
- Участников тренировок
- Сценариев упражнений (готовых и пользовательских)
- Сессий тренировок с результатами
## Печать
Функция печати использует стандартную систему печати Android и поддерживает:
- Печать на принтеры через Wi-Fi
- Сохранение в PDF
- Отправку по email
## Требования
- Android 8.0 (API 26) или выше
- Поддержка Bluetooth LE
- Подключение к сети для WebSocket
## Разработка
Для сборки проекта используйте:
```bash
./gradlew assembleDebug
```
Для установки на устройство:
```bash
./gradlew installDebug
```

1
app/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

77
app/build.gradle.kts Normal file
View File

@@ -0,0 +1,77 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.ksp)
}
android {
namespace = "ru.kgeu.training"
compileSdk {
version = release(36)
}
defaultConfig {
applicationId = "ru.kgeu.training"
minSdk = 34
targetSdk = 36
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = "11"
}
buildFeatures {
compose = true
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
// Navigation
implementation(libs.navigation.compose)
// Room Database
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler)
// WebSocket
implementation(libs.okhttp)
// ViewModel
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.lifecycle.runtime.compose)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.test.manifest)
}

21
app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@@ -0,0 +1,24 @@
package ru.kgeu.training
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("ru.kgeu.training", appContext.packageName)
}
}

View File

@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Internet permission for WebSocket -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Bluetooth permissions -->
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- Location permission for Bluetooth LE scanning (Android 12+) -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<!-- Bluetooth features -->
<uses-feature android:name="android.hardware.bluetooth" android:required="false" />
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false" />
<application
android:name=".TrainingApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Тренировка"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.Тренировка">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,79 @@
package ru.kgeu.training
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import androidx.navigation.compose.rememberNavController
import ru.kgeu.training.ui.navigation.MainContainer
import ru.kgeu.training.ui.theme.ТренировкаTheme
import ru.kgeu.training.util.PrintUtil
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val application = applicationContext as TrainingApplication
// TCP подключение к ESP32 - можно настроить в настройках приложения
val tcpHost = "192.168.4.1" // Замените на реальный IP ESP32
val tcpPort = 8080 // Замените на реальный порт ESP32
setContent {
ТренировкаTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
val navController = rememberNavController()
MainContainer(
navController = navController,
tcpHost = tcpHost,
tcpPort = tcpPort,
application = application,
onPrint = { session, participantName, scenarioName ->
PrintUtil.printWorkoutSession(
context = this@MainActivity,
session = session,
participantName = participantName,
scenarioName = scenarioName
)
}
)
}
}
}
}
override fun onDestroy() {
super.onDestroy()
Log.d("MainActivity", "Activity destroyed, disconnecting TCP and Bluetooth")
try {
val application = applicationContext as? TrainingApplication
application?.tcpClient?.disconnect()
application?.heartRateMonitor?.disconnect()
} catch (e: Exception) {
Log.e("MainActivity", "Error disconnecting on destroy", e)
}
}
override fun onStop() {
super.onStop()
// Отключаем соединения при остановке активности (например, при переходе в фоновый режим)
Log.d("MainActivity", "Activity stopped, disconnecting TCP and Bluetooth")
try {
val application = applicationContext as? TrainingApplication
application?.tcpClient?.disconnect()
application?.heartRateMonitor?.disconnect()
} catch (e: Exception) {
Log.e("MainActivity", "Error disconnecting on stop", e)
}
}
}

View File

@@ -0,0 +1,32 @@
package ru.kgeu.training
import android.app.Application
import androidx.room.Room
import ru.kgeu.training.data.database.TrainingDatabase
import ru.kgeu.training.data.repository.TrainingRepository
import ru.kgeu.training.network.TcpClient
import ru.kgeu.training.bluetooth.HeartRateMonitor
class TrainingApplication : Application() {
val database by lazy {
Room.databaseBuilder(
applicationContext,
TrainingDatabase::class.java,
TrainingDatabase.DATABASE_NAME
)
.fallbackToDestructiveMigration() // Для отладки - удаляет старую БД при изменении схемы
.build()
}
val repository by lazy {
TrainingRepository(
participantDao = database.participantDao(),
scenarioDao = database.exerciseScenarioDao(),
sessionDao = database.workoutSessionDao()
)
}
val tcpClient by lazy { TcpClient() }
val heartRateMonitor by lazy { HeartRateMonitor(applicationContext) }
}

View File

@@ -0,0 +1,169 @@
package ru.kgeu.training.bluetooth
import android.bluetooth.*
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanFilter
import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings
import android.content.Context
import android.util.Log
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.util.*
class HeartRateMonitor(private val context: Context) {
private val bluetoothManager: BluetoothManager? =
context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager
private val bluetoothAdapter: BluetoothAdapter? = bluetoothManager?.adapter
private val bluetoothLeScanner = bluetoothAdapter?.bluetoothLeScanner
private var bluetoothGatt: BluetoothGatt? = null
private val _heartRate = MutableStateFlow<Int?>(null)
val heartRate: StateFlow<Int?> = _heartRate.asStateFlow()
private val _connectionState = MutableStateFlow<BluetoothConnectionState>(BluetoothConnectionState.Disconnected)
val connectionState: StateFlow<BluetoothConnectionState> = _connectionState.asStateFlow()
// Heart Rate Service UUIDs
private val HEART_RATE_SERVICE_UUID = UUID.fromString("0000180d-0000-1000-8000-00805f9b34fb")
private val HEART_RATE_MEASUREMENT_CHARACTERISTIC_UUID = UUID.fromString("00002a37-0000-1000-8000-00805f9b34fb")
private val scanCallback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
val device = result.device
val scanRecord = result.scanRecord
// Check if device has Heart Rate Service
val serviceUuids = scanRecord?.serviceUuids
val heartRateServiceUuid = android.os.ParcelUuid(HEART_RATE_SERVICE_UUID)
if (serviceUuids != null && serviceUuids.contains(heartRateServiceUuid)) {
Log.d(TAG, "Found heart rate device: ${device.address}")
stopScan()
connectToDevice(device)
}
}
override fun onScanFailed(errorCode: Int) {
Log.e(TAG, "Scan failed with error code: $errorCode")
_connectionState.value = BluetoothConnectionState.Error
}
}
private val gattCallback = object : BluetoothGattCallback() {
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
when (newState) {
BluetoothProfile.STATE_CONNECTED -> {
Log.d(TAG, "Connected to GATT server")
_connectionState.value = BluetoothConnectionState.Connected
gatt.discoverServices()
}
BluetoothProfile.STATE_DISCONNECTED -> {
Log.d(TAG, "Disconnected from GATT server")
_connectionState.value = BluetoothConnectionState.Disconnected
_heartRate.value = null
}
}
}
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
if (status == BluetoothGatt.GATT_SUCCESS) {
val service = gatt.getService(HEART_RATE_SERVICE_UUID)
val characteristic = service?.getCharacteristic(HEART_RATE_MEASUREMENT_CHARACTERISTIC_UUID)
if (characteristic != null) {
val enabled = gatt.setCharacteristicNotification(characteristic, true)
if (enabled) {
val descriptor = characteristic.getDescriptor(
UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
)
if (descriptor != null) {
// Используем deprecated методы для совместимости со всеми версиями Android
@Suppress("DEPRECATION")
descriptor.setValue(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE)
@Suppress("DEPRECATION")
gatt.writeDescriptor(descriptor)
}
}
}
}
}
override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
if (characteristic.uuid == HEART_RATE_MEASUREMENT_CHARACTERISTIC_UUID) {
// Используем deprecated свойство для совместимости
@Suppress("DEPRECATION")
val value = characteristic.value
val heartRateValue = parseHeartRate(value ?: byteArrayOf())
_heartRate.value = heartRateValue
Log.d(TAG, "Heart rate: $heartRateValue bpm")
}
}
}
private fun parseHeartRate(value: ByteArray): Int {
// Heart Rate Measurement Format according to Bluetooth spec
val flag = value[0].toInt()
val format = (flag and 0x01) != 0 // 0 = 8-bit, 1 = 16-bit
return if (format && value.size >= 3) {
// 16-bit value
((value[2].toInt() and 0xFF) shl 8) or (value[1].toInt() and 0xFF)
} else if (value.size >= 2) {
// 8-bit value
value[1].toInt() and 0xFF
} else {
0
}
}
fun startScan() {
if (bluetoothLeScanner == null) {
Log.e(TAG, "Bluetooth LE Scanner not available")
_connectionState.value = BluetoothConnectionState.Error
return
}
_connectionState.value = BluetoothConnectionState.Scanning
val filter = ScanFilter.Builder()
.setServiceUuid(android.os.ParcelUuid(HEART_RATE_SERVICE_UUID))
.build()
val settings = ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
.build()
bluetoothLeScanner.startScan(listOf(filter), settings, scanCallback)
}
fun stopScan() {
bluetoothLeScanner?.stopScan(scanCallback)
}
private fun connectToDevice(device: BluetoothDevice) {
_connectionState.value = BluetoothConnectionState.Connecting
bluetoothGatt = device.connectGatt(context, false, gattCallback)
}
fun disconnect() {
bluetoothGatt?.disconnect()
bluetoothGatt?.close()
bluetoothGatt = null
_connectionState.value = BluetoothConnectionState.Disconnected
_heartRate.value = null
}
companion object {
private const val TAG = "HeartRateMonitor"
}
}
enum class BluetoothConnectionState {
Disconnected,
Scanning,
Connecting,
Connected,
Error
}

View File

@@ -0,0 +1,36 @@
package ru.kgeu.training.data.dao
import androidx.room.*
import kotlinx.coroutines.flow.Flow
import ru.kgeu.training.data.model.ExerciseScenario
@Dao
interface ExerciseScenarioDao {
@Query("SELECT * FROM exercise_scenarios ORDER BY createdAt DESC")
fun getAllScenarios(): Flow<List<ExerciseScenario>>
@Query("SELECT * FROM exercise_scenarios WHERE isCustom = 0 ORDER BY createdAt DESC")
fun getReadyScenarios(): Flow<List<ExerciseScenario>>
@Query("SELECT * FROM exercise_scenarios WHERE isCustom = 1 ORDER BY createdAt DESC")
fun getCustomScenarios(): Flow<List<ExerciseScenario>>
@Query("SELECT COUNT(*) FROM exercise_scenarios WHERE isCustom = 0")
suspend fun getReadyScenariosCount(): Int
@Query("SELECT * FROM exercise_scenarios WHERE id = :id")
suspend fun getScenarioById(id: Long): ExerciseScenario?
@Insert
suspend fun insertScenario(scenario: ExerciseScenario): Long
@Update
suspend fun updateScenario(scenario: ExerciseScenario)
@Delete
suspend fun deleteScenario(scenario: ExerciseScenario)
@Query("DELETE FROM exercise_scenarios WHERE isCustom = 0")
suspend fun deleteAllReadyScenarios()
}

View File

@@ -0,0 +1,24 @@
package ru.kgeu.training.data.dao
import androidx.room.*
import kotlinx.coroutines.flow.Flow
import ru.kgeu.training.data.model.Participant
@Dao
interface ParticipantDao {
@Query("SELECT * FROM participants ORDER BY registrationDate DESC")
fun getAllParticipants(): Flow<List<Participant>>
@Query("SELECT * FROM participants WHERE id = :id")
suspend fun getParticipantById(id: Long): Participant?
@Insert
suspend fun insertParticipant(participant: Participant): Long
@Update
suspend fun updateParticipant(participant: Participant)
@Delete
suspend fun deleteParticipant(participant: Participant)
}

View File

@@ -0,0 +1,30 @@
package ru.kgeu.training.data.dao
import androidx.room.*
import kotlinx.coroutines.flow.Flow
import ru.kgeu.training.data.model.WorkoutSession
@Dao
interface WorkoutSessionDao {
@Query("SELECT * FROM workout_sessions ORDER BY startTime DESC")
fun getAllSessions(): Flow<List<WorkoutSession>>
@Query("SELECT * FROM workout_sessions WHERE participantId = :participantId ORDER BY startTime DESC")
fun getSessionsByParticipant(participantId: Long): Flow<List<WorkoutSession>>
@Query("SELECT * FROM workout_sessions WHERE id = :id")
suspend fun getSessionById(id: Long): WorkoutSession?
@Insert
suspend fun insertSession(session: WorkoutSession): Long
@Update
suspend fun updateSession(session: WorkoutSession)
@Delete
suspend fun deleteSession(session: WorkoutSession)
@Query("DELETE FROM workout_sessions WHERE participantId = :participantId")
suspend fun deleteSessionsByParticipant(participantId: Long)
}

View File

@@ -0,0 +1,26 @@
package ru.kgeu.training.data.database
import androidx.room.Database
import androidx.room.RoomDatabase
import ru.kgeu.training.data.dao.ExerciseScenarioDao
import ru.kgeu.training.data.dao.ParticipantDao
import ru.kgeu.training.data.dao.WorkoutSessionDao
import ru.kgeu.training.data.model.ExerciseScenario
import ru.kgeu.training.data.model.Participant
import ru.kgeu.training.data.model.WorkoutSession
@Database(
entities = [Participant::class, ExerciseScenario::class, WorkoutSession::class],
version = 1,
exportSchema = false
)
abstract class TrainingDatabase : RoomDatabase() {
abstract fun participantDao(): ParticipantDao
abstract fun exerciseScenarioDao(): ExerciseScenarioDao
abstract fun workoutSessionDao(): WorkoutSessionDao
companion object {
const val DATABASE_NAME = "training_database"
}
}

View File

@@ -0,0 +1,22 @@
package ru.kgeu.training.data.model
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "exercise_scenarios")
data class ExerciseScenario(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
val name: String,
val description: String? = null,
val exerciseMode: ExerciseMode,
val targetValue: Int, // количество подъемов или время в секундах
val isCustom: Boolean = false, // true для пользовательских, false для готовых
val createdAt: Long = System.currentTimeMillis()
)
enum class ExerciseMode {
TIME_BASED, // Режим на время - считаем подъемы
COUNT_BASED // Режим на количество - отсчитываем время
}

View File

@@ -0,0 +1,16 @@
package ru.kgeu.training.data.model
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "participants")
data class Participant(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
val name: String,
val age: Int? = null,
val weight: Float? = null,
val height: Float? = null,
val registrationDate: Long = System.currentTimeMillis()
)

View File

@@ -0,0 +1,8 @@
package ru.kgeu.training.data.model
enum class WeightPosition {
TOP, // Груз поднят
MIDDLE, // Груз в движении (не верх и не низ)
BOTTOM // Груз опущен
}

View File

@@ -0,0 +1,20 @@
package ru.kgeu.training.data.model
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "workout_sessions")
data class WorkoutSession(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
val participantId: Long,
val scenarioId: Long,
val startTime: Long,
val endTime: Long? = null,
val completedLifts: Int = 0,
val durationSeconds: Long = 0,
val averageHeartRate: Int? = null,
val maxHeartRate: Int? = null,
val minHeartRate: Int? = null
)

View File

@@ -0,0 +1,53 @@
package ru.kgeu.training.data.repository
import android.util.Log
import kotlinx.coroutines.flow.Flow
import ru.kgeu.training.data.dao.ExerciseScenarioDao
import ru.kgeu.training.data.dao.ParticipantDao
import ru.kgeu.training.data.dao.WorkoutSessionDao
import ru.kgeu.training.data.model.ExerciseScenario
import ru.kgeu.training.data.model.Participant
import ru.kgeu.training.data.model.WorkoutSession
class TrainingRepository(
private val participantDao: ParticipantDao,
private val scenarioDao: ExerciseScenarioDao,
private val sessionDao: WorkoutSessionDao
) {
// Participants
fun getAllParticipants(): Flow<List<Participant>> = participantDao.getAllParticipants()
suspend fun getParticipantById(id: Long): Participant? = participantDao.getParticipantById(id)
suspend fun insertParticipant(participant: Participant): Long {
Log.d("TrainingRepository", "Inserting participant: name='${participant.name}', age=${participant.age}")
val id = participantDao.insertParticipant(participant)
Log.d("TrainingRepository", "Participant inserted with id: $id")
return id
}
suspend fun updateParticipant(participant: Participant) = participantDao.updateParticipant(participant)
suspend fun deleteParticipant(participant: Participant) {
// Сначала удаляем все сессии тренировок этого участника
sessionDao.deleteSessionsByParticipant(participant.id)
// Затем удаляем самого участника
participantDao.deleteParticipant(participant)
}
// Scenarios
fun getAllScenarios(): Flow<List<ExerciseScenario>> = scenarioDao.getAllScenarios()
fun getReadyScenarios(): Flow<List<ExerciseScenario>> = scenarioDao.getReadyScenarios()
fun getCustomScenarios(): Flow<List<ExerciseScenario>> = scenarioDao.getCustomScenarios()
suspend fun getReadyScenariosCount(): Int = scenarioDao.getReadyScenariosCount()
suspend fun getScenarioById(id: Long): ExerciseScenario? = scenarioDao.getScenarioById(id)
suspend fun insertScenario(scenario: ExerciseScenario): Long = scenarioDao.insertScenario(scenario)
suspend fun updateScenario(scenario: ExerciseScenario) = scenarioDao.updateScenario(scenario)
suspend fun deleteScenario(scenario: ExerciseScenario) = scenarioDao.deleteScenario(scenario)
suspend fun deleteAllReadyScenarios() = scenarioDao.deleteAllReadyScenarios()
// Sessions
fun getAllSessions(): Flow<List<WorkoutSession>> = sessionDao.getAllSessions()
fun getSessionsByParticipant(participantId: Long): Flow<List<WorkoutSession>> = sessionDao.getSessionsByParticipant(participantId)
suspend fun getSessionById(id: Long): WorkoutSession? = sessionDao.getSessionById(id)
suspend fun insertSession(session: WorkoutSession): Long = sessionDao.insertSession(session)
suspend fun updateSession(session: WorkoutSession) = sessionDao.updateSession(session)
suspend fun deleteSession(session: WorkoutSession) = sessionDao.deleteSession(session)
}

View File

@@ -0,0 +1,228 @@
package ru.kgeu.training.network
import android.util.Log
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.json.JSONObject
import ru.kgeu.training.data.model.WeightPosition
import java.io.BufferedReader
import java.io.InputStreamReader
import java.net.Socket
import java.net.SocketTimeoutException
class TcpClient {
private var socket: Socket? = null
private var reader: BufferedReader? = null
private var readJob: Job? = null
private val clientScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
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()
fun connect(host: String, port: Int) {
Log.d(TAG, "Attempting to connect to TCP: $host:$port")
// Отключаемся если уже подключены
if (_connectionState.value == ConnectionState.Connected) {
Log.d(TAG, "Already connected, disconnecting first")
disconnect()
}
// Отменяем предыдущую задачу чтения если есть
readJob?.cancel()
readJob = null
_connectionState.value = ConnectionState.Connecting
clientScope.launch {
try {
// Небольшая задержка для завершения отключения
delay(100)
Log.d(TAG, "Creating socket connection...")
val newSocket = Socket()
// Оптимизация TCP: отключаем алгоритм Nagle для уменьшения задержек
newSocket.tcpNoDelay = true
// Уменьшаем таймаут чтения для более быстрой реакции
newSocket.soTimeout = 1000 // 1 секунда вместо 10
newSocket.connect(java.net.InetSocketAddress(host, port), 5000) // 5 секунд таймаут на подключение
socket = newSocket
reader = BufferedReader(InputStreamReader(socket!!.getInputStream(), "UTF-8"), 8192) // Увеличиваем размер буфера
Log.d(TAG, "TCP connection established to $host:$port")
withContext(Dispatchers.Main) {
_connectionState.value = ConnectionState.Connected
}
// Запускаем чтение данных в отдельной корутине
readJob = clientScope.launch {
readMessages()
}
} catch (e: SocketTimeoutException) {
Log.e(TAG, "Connection timeout: $host:$port", e)
withContext(Dispatchers.Main) {
_connectionState.value = ConnectionState.Disconnected
}
socket?.close()
socket = null
reader = null
} 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
} 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
} 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
}
}
}
private suspend fun readMessages() {
val currentReader = reader
val currentSocket = socket
if (currentReader == null || currentSocket == null) {
Log.w(TAG, "Cannot read messages: reader or socket is null")
return
}
try {
Log.d(TAG, "Starting to read messages from TCP stream...")
while (currentSocket.isConnected && !currentSocket.isClosed) {
try {
val line = currentReader.readLine()
if (line == null) {
Log.d(TAG, "End of stream reached")
break
}
// Обрабатываем сообщение асинхронно, не блокируя чтение
processMessage(line)
} catch (e: SocketTimeoutException) {
// Таймаут на чтение - это нормально, продолжаем
// Не логируем каждый таймаут, чтобы не засорять лог
continue
} catch (e: Exception) {
Log.e(TAG, "Error reading message", e)
break
}
}
} 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
}
}
}
private fun processMessage(message: String) {
try {
val json = JSONObject(message)
val position = json.optString("position", "")
val weightPos = when (position.lowercase()) {
"top", "up" -> WeightPosition.TOP
"middle", "moving", "in_motion", "motion" -> WeightPosition.MIDDLE
"bottom", "down" -> WeightPosition.BOTTOM
else -> null
}
if (weightPos != null) {
// Обновляем только если значение изменилось (защита от дублирующихся сообщений)
// Используем tryEmit для неблокирующего обновления
val currentValue = _weightPosition.value
if (currentValue != weightPos) {
// Обновляем синхронно на IO диспетчере, затем переключаемся на Main для UI
clientScope.launch(Dispatchers.Main) {
_weightPosition.value = weightPos
Log.d(TAG, "Position changed: $currentValue -> $weightPos")
}
}
} else {
Log.w(TAG, "Unknown position value: '$position' in message: $message")
}
} catch (e: Exception) {
Log.e(TAG, "Error parsing message: $message", e)
}
}
fun disconnect() {
Log.d(TAG, "Disconnecting TCP connection...")
readJob?.cancel()
readJob = null
try {
reader?.close()
} catch (e: Exception) {
Log.e(TAG, "Error closing reader", e)
}
reader = null
try {
socket?.close()
} catch (e: Exception) {
Log.e(TAG, "Error closing socket", e)
}
socket = null
clientScope.launch(Dispatchers.Main) {
_connectionState.value = ConnectionState.Disconnected
_weightPosition.value = null
}
Log.d(TAG, "TCP connection disconnected")
}
fun sendMessage(message: String) {
clientScope.launch {
try {
val currentSocket = socket
if (currentSocket != null && currentSocket.isConnected && !currentSocket.isClosed) {
val writer = java.io.OutputStreamWriter(currentSocket.getOutputStream(), "UTF-8")
writer.write(message)
writer.write("\n")
writer.flush()
Log.d(TAG, "Sent message: $message")
} else {
Log.w(TAG, "Cannot send message: socket is not connected")
}
} catch (e: Exception) {
Log.e(TAG, "Error sending message: $message", e)
}
}
}
companion object {
private const val TAG = "TcpClient"
}
}

View File

@@ -0,0 +1,281 @@
package ru.kgeu.training.network
import android.util.Log
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import okhttp3.*
import org.json.JSONObject
import ru.kgeu.training.data.model.WeightPosition
import java.util.concurrent.TimeUnit
class WebSocketClient {
private val client = OkHttpClient.Builder()
.pingInterval(30, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.connectTimeout(10, TimeUnit.SECONDS)
.writeTimeout(10, TimeUnit.SECONDS)
.build()
private var webSocket: WebSocket? = null
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()
private var connectionTimeoutJob: Job? = null
private val listener = object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
Log.d(TAG, "WebSocket connected, response: ${response.code}")
connectionTimeoutJob?.cancel()
connectionTimeoutJob = null
// Обновляем состояние на главном потоке через Dispatchers.Main
CoroutineScope(Dispatchers.Main).launch {
_connectionState.value = ConnectionState.Connected
}
}
override fun onMessage(webSocket: WebSocket, text: String) {
Log.d(TAG, "Received message: $text")
// Проверяем, что это сообщение от текущего активного соединения
if (this@WebSocketClient.webSocket != webSocket) {
Log.w(TAG, "Received message from closed/stale WebSocket, ignoring")
return
}
// Если получили сообщение, значит соединение установлено
// Обновляем статус, если он еще не Connected
if (_connectionState.value != ConnectionState.Connected) {
Log.d(TAG, "Connection confirmed by message, updating state to Connected")
connectionTimeoutJob?.cancel()
connectionTimeoutJob = null
CoroutineScope(Dispatchers.Main).launch {
_connectionState.value = ConnectionState.Connected
}
}
try {
val json = JSONObject(text)
val position = json.optString("position", "")
val weightPos = when (position.lowercase()) {
"top", "up" -> WeightPosition.TOP
"middle", "moving", "in_motion", "motion" -> WeightPosition.MIDDLE
"bottom", "down" -> WeightPosition.BOTTOM
else -> null
}
if (weightPos != null) {
// Обновляем только если значение изменилось (защита от дублирующихся сообщений)
CoroutineScope(Dispatchers.Main).launch {
if (_weightPosition.value != weightPos) {
Log.d(TAG, "Position changed: ${_weightPosition.value} -> $weightPos")
_weightPosition.value = weightPos
} else {
Log.d(TAG, "Position unchanged: $weightPos (duplicate message ignored)")
}
}
} else {
Log.w(TAG, "Unknown position value: '$position' in message: $text")
}
} catch (e: Exception) {
Log.e(TAG, "Error parsing message: $text", e)
e.printStackTrace()
}
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
val errorType = t.javaClass.simpleName
Log.e(TAG, "WebSocket failure: $errorType - ${t.message}")
when {
t is java.net.SocketTimeoutException -> {
Log.e(TAG, "Connection timeout - ESP32 is not responding")
Log.e(TAG, "Check if ESP32 is running and accessible at the configured URL")
}
t is java.net.UnknownHostException -> {
Log.e(TAG, "Unknown host - cannot resolve ESP32 IP address")
Log.e(TAG, "Check if IP address is correct and device is on the same network")
}
t is java.net.ConnectException -> {
Log.e(TAG, "Connection refused - ESP32 is not accepting connections")
Log.e(TAG, "Check if ESP32 WebSocket server is running on the correct port")
}
t is java.net.ProtocolException -> {
Log.e(TAG, "Protocol error - ESP32 sent invalid WebSocket frame")
Log.e(TAG, "This is usually an ESP32 issue. Connection will be closed.")
Log.e(TAG, "The connection may still work, but ESP32 should fix the protocol implementation")
}
else -> {
Log.e(TAG, "Unexpected error: ${t.message}")
}
}
Log.e(TAG, "Response: ${response?.code} ${response?.message}")
if (response != null) {
try {
val responseBody = response.peekBody(1024).string()
if (responseBody.isNotEmpty()) {
Log.e(TAG, "Response body preview: $responseBody")
}
} catch (e: Exception) {
// Игнорируем ошибки чтения response body
}
}
t.printStackTrace()
connectionTimeoutJob?.cancel()
connectionTimeoutJob = null
// Для ProtocolException не обнуляем webSocket сразу, так как соединение может еще работать
// Обнуляем только если это не ProtocolException
if (t !is java.net.ProtocolException) {
CoroutineScope(Dispatchers.Main).launch {
_connectionState.value = ConnectionState.Disconnected
_weightPosition.value = null
}
// Обнуляем ссылку только если это текущее соединение
if (this@WebSocketClient.webSocket == webSocket) {
this@WebSocketClient.webSocket = null
}
} else {
// Для ProtocolException только обновляем состояние, но не обнуляем webSocket
// Это позволит продолжать получать сообщения, если соединение все еще работает
Log.w(TAG, "ProtocolException occurred, but keeping connection alive")
CoroutineScope(Dispatchers.Main).launch {
// Не меняем состояние на Disconnected для ProtocolException
// Соединение может продолжать работать
}
}
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
Log.d(TAG, "WebSocket closing: code=$code, reason=$reason")
CoroutineScope(Dispatchers.Main).launch {
_connectionState.value = ConnectionState.Disconnected
}
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
Log.d(TAG, "WebSocket closed: code=$code, reason=$reason")
CoroutineScope(Dispatchers.Main).launch {
_connectionState.value = ConnectionState.Disconnected
_weightPosition.value = null
}
// Обнуляем ссылку на webSocket после полного закрытия
if (this@WebSocketClient.webSocket == webSocket) {
this@WebSocketClient.webSocket = null
}
}
}
fun connect(url: String) {
Log.d(TAG, "Attempting to connect to WebSocket: $url")
try {
// Валидация URL
if (!url.startsWith("ws://") && !url.startsWith("wss://")) {
Log.e(TAG, "Invalid WebSocket URL format: $url (must start with ws:// or wss://)")
_connectionState.value = ConnectionState.Disconnected
return
}
if (_connectionState.value == ConnectionState.Connected) {
Log.d(TAG, "Already connected, disconnecting first")
disconnect()
// Даем время на отключение
Thread.sleep(100)
}
// Отменяем предыдущий таймаут если есть
connectionTimeoutJob?.cancel()
_connectionState.value = ConnectionState.Connecting
Log.d(TAG, "Creating WebSocket request to: $url")
val request = Request.Builder()
.url(url)
.build()
Log.d(TAG, "Calling client.newWebSocket()...")
webSocket = client.newWebSocket(request, listener)
Log.d(TAG, "WebSocket created, waiting for connection...")
// Таймаут для подключения - если через 10 секунд не подключились, считаем ошибкой
// Увеличил до 10 секунд, так как connectTimeout тоже 10 секунд
connectionTimeoutJob = CoroutineScope(Dispatchers.Default).launch {
delay(10000)
if (_connectionState.value == ConnectionState.Connecting) {
Log.w(TAG, "WebSocket connection timeout after 10 seconds. URL: $url")
Log.w(TAG, "Current state: ${_connectionState.value}, webSocket: ${webSocket != null}")
Log.w(TAG, "Possible issues:")
Log.w(TAG, " 1. ESP32 is not running or not accessible")
Log.w(TAG, " 2. Wrong IP address or port")
Log.w(TAG, " 3. Device and ESP32 are not on the same network")
Log.w(TAG, " 4. Firewall blocking the connection")
CoroutineScope(Dispatchers.Main).launch {
_connectionState.value = ConnectionState.Disconnected
}
}
}
} catch (e: Exception) {
Log.e(TAG, "Error connecting to WebSocket: $url", e)
Log.e(TAG, "Exception type: ${e.javaClass.simpleName}, message: ${e.message}")
e.printStackTrace()
_connectionState.value = ConnectionState.Disconnected
}
}
fun disconnect() {
Log.d(TAG, "Disconnecting WebSocket...")
connectionTimeoutJob?.cancel()
connectionTimeoutJob = null
val socket = webSocket
if (socket != null) {
try {
// Закрываем соединение с кодом 1000 (нормальное закрытие)
// НЕ обнуляем webSocket здесь - это сделает onClosed callback
val closed = socket.close(1000, "Disconnecting")
Log.d(TAG, "WebSocket close() called, result: $closed")
// Обновляем состояние сразу, но не обнуляем webSocket
// onClosed callback обнулит его после полного закрытия
CoroutineScope(Dispatchers.Main).launch {
_connectionState.value = ConnectionState.Disconnected
}
} catch (e: Exception) {
Log.e(TAG, "Error closing WebSocket", e)
e.printStackTrace()
// В случае ошибки все равно обнуляем
webSocket = null
CoroutineScope(Dispatchers.Main).launch {
_connectionState.value = ConnectionState.Disconnected
_weightPosition.value = null
}
}
} else {
Log.d(TAG, "WebSocket is already null, nothing to disconnect")
CoroutineScope(Dispatchers.Main).launch {
_connectionState.value = ConnectionState.Disconnected
_weightPosition.value = null
}
}
}
fun sendMessage(message: String) {
webSocket?.send(message)
}
companion object {
private const val TAG = "WebSocketClient"
}
}
enum class ConnectionState {
Disconnected,
Connecting,
Connected
}

View File

@@ -0,0 +1,81 @@
package ru.kgeu.training.ui.navigation
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.filled.Home
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavHostController
import androidx.navigation.compose.currentBackStackEntryAsState
import ru.kgeu.training.TrainingApplication
import ru.kgeu.training.ui.screen.*
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainContainer(
navController: NavHostController,
tcpHost: String,
tcpPort: Int,
application: TrainingApplication,
onPrint: (ru.kgeu.training.data.model.WorkoutSession, String, String) -> Unit
) {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
// Определяем, показывать ли bottom navigation
val showBottomNav = currentDestination?.route in listOf(
Screen.Main.route,
Screen.Statistics.route
)
Scaffold(
bottomBar = {
if (showBottomNav) {
NavigationBar {
NavigationBarItem(
icon = { Icon(Icons.Default.Home, contentDescription = null) },
label = { Text("Тренировка") },
selected = currentDestination?.hierarchy?.any { it.route == Screen.Main.route } == true,
onClick = {
navController.navigate(Screen.Main.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
)
NavigationBarItem(
icon = { Icon(Icons.AutoMirrored.Filled.List, contentDescription = null) },
label = { Text("Статистика") },
selected = currentDestination?.hierarchy?.any { it.route == Screen.Statistics.route } == true,
onClick = {
navController.navigate(Screen.Statistics.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
)
}
}
}
) { innerPadding ->
NavGraph(
navController = navController,
tcpHost = tcpHost,
tcpPort = tcpPort,
application = application,
onPrint = onPrint,
modifier = Modifier.padding(innerPadding)
)
}
}

View File

@@ -0,0 +1,164 @@
package ru.kgeu.training.ui.navigation
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import ru.kgeu.training.TrainingApplication
import ru.kgeu.training.data.model.ExerciseScenario
import ru.kgeu.training.data.model.Participant
import ru.kgeu.training.ui.screen.*
import ru.kgeu.training.ui.viewmodel.*
sealed class Screen(val route: String) {
object Main : Screen("main")
object Statistics : Screen("statistics")
object ParticipantRegistration : Screen("participant_registration")
object ScenarioSelection : Screen("scenario_selection")
object CustomScenario : Screen("custom_scenario")
object Workout : Screen("workout")
}
@Composable
fun NavGraph(
navController: NavHostController,
tcpHost: String,
tcpPort: Int,
application: TrainingApplication,
onPrint: (ru.kgeu.training.data.model.WorkoutSession, String, String) -> Unit,
modifier: Modifier = Modifier
) {
NavHost(
navController = navController,
startDestination = Screen.Main.route,
modifier = modifier
) {
composable(Screen.Statistics.route) {
val viewModelFactory = application.createViewModelFactory()
val viewModel: StatisticsViewModel = viewModel(factory = viewModelFactory)
StatisticsScreen(
viewModel = viewModel,
onPrint = onPrint
)
}
composable(Screen.Main.route) {
MainScreen(
onStartWorkout = {
navController.navigate(Screen.ParticipantRegistration.route)
}
)
}
composable(Screen.ParticipantRegistration.route) {
val viewModelFactory = application.createViewModelFactory()
val viewModel: ParticipantViewModel = viewModel(factory = viewModelFactory)
ParticipantRegistrationScreen(
viewModel = viewModel,
onParticipantSelected = { participant ->
NavigationState.setParticipant(participant)
navController.navigate(Screen.ScenarioSelection.route) {
popUpTo(Screen.ParticipantRegistration.route) { inclusive = true }
}
},
onNavigateBack = {
navController.popBackStack()
}
)
}
composable(Screen.ScenarioSelection.route) {
val viewModelFactory = application.createViewModelFactory()
val viewModel: ScenarioViewModel = viewModel(factory = viewModelFactory)
ScenarioSelectionScreen(
viewModel = viewModel,
onScenarioSelected = { scenario ->
NavigationState.setScenario(scenario)
navController.navigate(Screen.Workout.route) {
popUpTo(Screen.Main.route) { inclusive = false }
}
},
onNavigateBack = {
navController.popBackStack()
},
onCreateCustom = {
navController.navigate(Screen.CustomScenario.route)
}
)
}
composable(Screen.CustomScenario.route) {
val viewModelFactory = application.createViewModelFactory()
val viewModel: ScenarioViewModel = viewModel(factory = viewModelFactory)
CustomScenarioScreen(
viewModel = viewModel,
onScenarioCreated = {
navController.popBackStack()
},
onNavigateBack = {
navController.popBackStack()
}
)
}
composable(Screen.Workout.route) {
val participant = NavigationState.selectedParticipant
val scenario = NavigationState.selectedScenario
if (participant != null && scenario != null) {
val viewModelFactory = application.createViewModelFactory()
val viewModel: WorkoutViewModel = viewModel(factory = viewModelFactory)
val scope = rememberCoroutineScope()
// Используем ключ для remember, чтобы состояние сохранялось между рекомпозициями
var navigationHandled by remember(participant.id, scenario.id) { mutableStateOf(false) }
WorkoutScreen(
participant = participant,
scenario = scenario,
tcpHost = tcpHost,
tcpPort = tcpPort,
viewModel = viewModel,
onWorkoutFinished = {
if (!navigationHandled) {
navigationHandled = true
NavigationState.clear()
// Возвращаемся на главный экран, очищая весь стек
scope.launch {
delay(100)
val currentRoute = navController.currentBackStackEntry?.destination?.route
if (currentRoute == Screen.Workout.route) {
navController.navigate(Screen.Main.route) {
popUpTo(Screen.Main.route) {
inclusive = true
}
launchSingleTop = true
}
}
}
}
}
)
} else {
// Fallback - вернуться на главный экран только если мы еще не там
LaunchedEffect(Unit) {
val currentRoute = navController.currentBackStackEntry?.destination?.route
if (currentRoute != Screen.Main.route && currentRoute != null) {
navController.navigate(Screen.Main.route) {
popUpTo(Screen.Main.route) {
inclusive = true
}
launchSingleTop = true
}
}
}
}
}
}
}

View File

@@ -0,0 +1,23 @@
package ru.kgeu.training.ui.navigation
import ru.kgeu.training.data.model.ExerciseScenario
import ru.kgeu.training.data.model.Participant
object NavigationState {
var selectedParticipant: Participant? = null
var selectedScenario: ExerciseScenario? = null
fun setParticipant(participant: Participant) {
selectedParticipant = participant
}
fun setScenario(scenario: ExerciseScenario) {
selectedScenario = scenario
}
fun clear() {
selectedParticipant = null
selectedScenario = null
}
}

View File

@@ -0,0 +1,120 @@
package ru.kgeu.training.ui.screen
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import ru.kgeu.training.data.model.ExerciseMode
import ru.kgeu.training.ui.viewmodel.ScenarioViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CustomScenarioScreen(
viewModel: ScenarioViewModel,
onScenarioCreated: () -> Unit,
onNavigateBack: () -> Unit
) {
var name by remember { mutableStateOf("") }
var description by remember { mutableStateOf("") }
var selectedMode by remember { mutableStateOf(ExerciseMode.TIME_BASED) }
var targetValue by remember { mutableStateOf("") }
val scope = rememberCoroutineScope()
Scaffold(
topBar = {
TopAppBar(
title = { Text("Создать сценарий") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Назад"
)
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Название *") },
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = description,
onValueChange = { description = it },
label = { Text("Описание") },
modifier = Modifier.fillMaxWidth(),
maxLines = 3
)
Text("Режим упражнения:")
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
FilterChip(
selected = selectedMode == ExerciseMode.TIME_BASED,
onClick = { selectedMode = ExerciseMode.TIME_BASED },
label = { Text("На время") }
)
FilterChip(
selected = selectedMode == ExerciseMode.COUNT_BASED,
onClick = { selectedMode = ExerciseMode.COUNT_BASED },
label = { Text("На количество") }
)
}
OutlinedTextField(
value = targetValue,
onValueChange = { targetValue = it },
label = {
Text(
when (selectedMode) {
ExerciseMode.TIME_BASED -> "Время (секунды) *"
ExerciseMode.COUNT_BASED -> "Количество подъемов *"
}
)
},
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.weight(1f))
Button(
onClick = {
val target = targetValue.toIntOrNull()
if (name.isNotBlank() && target != null && target > 0) {
scope.launch {
viewModel.createCustomScenario(
name = name,
description = description.ifBlank { null },
mode = selectedMode,
targetValue = target
)
onScenarioCreated()
}
}
},
modifier = Modifier.fillMaxWidth(),
enabled = name.isNotBlank() && targetValue.toIntOrNull() != null
) {
Text("Создать")
}
}
}
}

View File

@@ -0,0 +1,52 @@
package ru.kgeu.training.ui.screen
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreen(
onStartWorkout: () -> Unit
) {
Scaffold(
topBar = {
CenterAlignedTopAppBar(
title = { Text("Ударный молот") }
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "Добро пожаловать!",
style = MaterialTheme.typography.headlineMedium
)
Spacer(modifier = Modifier.height(32.dp))
Button(
onClick = onStartWorkout,
modifier = Modifier
.fillMaxWidth()
.height(56.dp)
) {
Text(
text = "Начать тренировку",
style = MaterialTheme.typography.titleMedium
)
}
}
}
}

View File

@@ -0,0 +1,403 @@
package ru.kgeu.training.ui.screen
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInParent
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import ru.kgeu.training.data.model.Participant
import ru.kgeu.training.ui.viewmodel.ParticipantViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ParticipantRegistrationScreen(
viewModel: ParticipantViewModel,
onParticipantSelected: (Participant) -> Unit,
onNavigateBack: () -> Unit
) {
var showAddDialog by remember { mutableStateOf(false) }
var showEditDialog by remember { mutableStateOf(false) }
var showDeleteConfirmDialog by remember { mutableStateOf<Participant?>(null) }
var editingParticipant by remember { mutableStateOf<Participant?>(null) }
var name by remember { mutableStateOf("") }
var age by remember { mutableStateOf("") }
var weight by remember { mutableStateOf("") }
var height by remember { mutableStateOf("") }
val participants by viewModel.participants.collectAsState()
val scope = rememberCoroutineScope()
Scaffold(
topBar = {
TopAppBar(
title = { Text("Регистрация участника") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Назад"
)
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp)
) {
Button(
onClick = { showAddDialog = true },
modifier = Modifier.fillMaxWidth()
) {
Text("Добавить нового участника")
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Выберите участника:",
style = MaterialTheme.typography.titleMedium
)
Spacer(modifier = Modifier.height(8.dp))
if (participants.isEmpty()) {
Spacer(modifier = Modifier.weight(1f))
Text(
text = "Нет зарегистрированных участников",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.weight(1f))
} else {
LazyColumn {
items(participants) { participant ->
var buttonsBounds by remember { mutableStateOf<Pair<Offset, Offset>?>(null) }
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.pointerInput(Unit) {
detectTapGestures { tapOffset ->
// Проверяем, не попал ли клик в область кнопок
val isInButtonsArea = buttonsBounds?.let { (topLeft, bottomRight) ->
tapOffset.x >= topLeft.x && tapOffset.x <= bottomRight.x &&
tapOffset.y >= topLeft.y && tapOffset.y <= bottomRight.y
} ?: false
if (!isInButtonsArea) {
onParticipantSelected(participant)
}
}
}
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier
.weight(1f)
.padding(end = 8.dp)
) {
Text(
text = participant.name.ifBlank { "Без имени" },
style = MaterialTheme.typography.titleMedium
)
participant.age?.let {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Возраст: $it лет",
style = MaterialTheme.typography.bodyMedium
)
}
participant.weight?.let {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Вес: $it кг",
style = MaterialTheme.typography.bodyMedium
)
}
participant.height?.let {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Рост: $it см",
style = MaterialTheme.typography.bodyMedium
)
}
}
// Кнопки в отдельном контейнере
Box(
modifier = Modifier.onGloballyPositioned { coordinates ->
val position = coordinates.positionInParent()
val size = coordinates.size
buttonsBounds = Pair(
position,
Offset(position.x + size.width, position.y + size.height)
)
}
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
IconButton(
onClick = {
editingParticipant = participant
name = participant.name
age = participant.age?.toString() ?: ""
weight = participant.weight?.toString() ?: ""
height = participant.height?.toString() ?: ""
showEditDialog = true
}
) {
Icon(
imageVector = Icons.Default.Edit,
contentDescription = "Редактировать",
tint = MaterialTheme.colorScheme.primary
)
}
IconButton(
onClick = {
showDeleteConfirmDialog = participant
}
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Удалить",
tint = MaterialTheme.colorScheme.error
)
}
}
}
}
}
}
}
}
}
}
if (showAddDialog) {
AlertDialog(
onDismissRequest = { showAddDialog = false },
title = { Text("Новый участник") },
text = {
Column {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Имя *") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = age,
onValueChange = { age = it },
label = { Text("Возраст") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = weight,
onValueChange = { weight = it },
label = { Text("Вес (кг)") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = height,
onValueChange = { height = it },
label = { Text("Рост (см)") },
modifier = Modifier.fillMaxWidth()
)
}
},
confirmButton = {
Button(
onClick = {
val trimmedName = name.trim()
if (trimmedName.isNotBlank()) {
val ageInt = age.toIntOrNull()
val weightFloat = weight.toFloatOrNull()
val heightFloat = height.toFloatOrNull()
scope.launch {
try {
viewModel.addParticipant(trimmedName, ageInt, weightFloat, heightFloat)
// Очищаем поля и закрываем диалог только после успешного сохранения
name = ""
age = ""
weight = ""
height = ""
showAddDialog = false
} catch (e: Exception) {
android.util.Log.e("ParticipantRegistration", "Error adding participant", e)
// Можно показать сообщение об ошибке пользователю
}
}
}
}
) {
Text("Добавить")
}
},
dismissButton = {
TextButton(onClick = { showAddDialog = false }) {
Text("Отмена")
}
}
)
}
// Диалог редактирования
if (showEditDialog && editingParticipant != null) {
AlertDialog(
onDismissRequest = {
showEditDialog = false
editingParticipant = null
name = ""
age = ""
weight = ""
height = ""
},
title = { Text("Редактировать участника") },
text = {
Column {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Имя *") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = age,
onValueChange = { age = it },
label = { Text("Возраст") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = weight,
onValueChange = { weight = it },
label = { Text("Вес (кг)") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = height,
onValueChange = { height = it },
label = { Text("Рост (см)") },
modifier = Modifier.fillMaxWidth()
)
}
},
confirmButton = {
Button(
onClick = {
val trimmedName = name.trim()
if (trimmedName.isNotBlank() && editingParticipant != null) {
val ageInt = age.toIntOrNull()
val weightFloat = weight.toFloatOrNull()
val heightFloat = height.toFloatOrNull()
scope.launch {
try {
val updatedParticipant = editingParticipant!!.copy(
name = trimmedName,
age = ageInt,
weight = weightFloat,
height = heightFloat
)
viewModel.updateParticipant(updatedParticipant)
showEditDialog = false
editingParticipant = null
name = ""
age = ""
weight = ""
height = ""
} catch (e: Exception) {
android.util.Log.e("ParticipantRegistration", "Error updating participant", e)
}
}
}
}
) {
Text("Сохранить")
}
},
dismissButton = {
TextButton(onClick = {
showEditDialog = false
editingParticipant = null
name = ""
age = ""
weight = ""
height = ""
}) {
Text("Отмена")
}
}
)
}
// Диалог подтверждения удаления
showDeleteConfirmDialog?.let { participant ->
AlertDialog(
onDismissRequest = { showDeleteConfirmDialog = null },
title = { Text("Удалить участника?") },
text = {
Text("Вы уверены, что хотите удалить участника \"${participant.name}\"? Это действие нельзя отменить.")
},
confirmButton = {
Button(
onClick = {
scope.launch {
try {
viewModel.deleteParticipant(participant)
showDeleteConfirmDialog = null
} catch (e: Exception) {
android.util.Log.e("ParticipantRegistration", "Error deleting participant", e)
showDeleteConfirmDialog = null
}
}
},
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error
)
) {
Text("Удалить")
}
},
dismissButton = {
TextButton(onClick = { showDeleteConfirmDialog = null }) {
Text("Отмена")
}
}
)
}
}

View File

@@ -0,0 +1,244 @@
package ru.kgeu.training.ui.screen
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
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.ui.viewmodel.ScenarioViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ScenarioSelectionScreen(
viewModel: ScenarioViewModel,
onScenarioSelected: (ExerciseScenario) -> Unit,
onNavigateBack: () -> Unit,
onCreateCustom: () -> Unit
) {
var selectedTab by remember { mutableStateOf(0) }
var showDeleteAllDialog by remember { mutableStateOf(false) }
var scenarioToDelete by remember { mutableStateOf<ExerciseScenario?>(null) }
val scope = rememberCoroutineScope()
val readyScenarios by viewModel.readyScenarios.collectAsState()
val customScenarios by viewModel.customScenarios.collectAsState()
Scaffold(
topBar = {
TopAppBar(
title = { Text("Выбор сценария") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Назад"
)
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
TabRow(selectedTabIndex = selectedTab) {
Tab(
selected = selectedTab == 0,
onClick = { selectedTab = 0 },
text = { Text("Готовые") }
)
Tab(
selected = selectedTab == 1,
onClick = { selectedTab = 1 },
text = { Text("Пользовательские") }
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Button(
onClick = onCreateCustom,
modifier = Modifier.weight(1f)
) {
Text("Создать индивидуальный сценарий")
}
// Кнопка очистки готовых сценариев (только для вкладки "Готовые")
if (selectedTab == 0 && readyScenarios.isNotEmpty()) {
OutlinedButton(
onClick = { showDeleteAllDialog = true },
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text("Очистить")
}
}
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
val scenarios = if (selectedTab == 0) readyScenarios else customScenarios
items(scenarios) { scenario ->
ScenarioCard(
scenario = scenario,
onClick = { onScenarioSelected(scenario) },
onDelete = {
if (scenario.isCustom) {
// Для пользовательских сценариев - удаляем сразу
scope.launch {
viewModel.deleteScenario(scenario)
}
} else {
// Для готовых сценариев - показываем диалог подтверждения
scenarioToDelete = scenario
}
},
showDeleteButton = true
)
}
}
}
}
// Диалог подтверждения удаления одного готового сценария
scenarioToDelete?.let { scenario ->
AlertDialog(
onDismissRequest = { scenarioToDelete = null },
title = { Text("Удалить сценарий?") },
text = {
Text("Вы уверены, что хотите удалить сценарий \"${scenario.name}\"? После удаления всех готовых сценариев они будут автоматически пересозданы.")
},
confirmButton = {
Button(
onClick = {
scope.launch {
viewModel.deleteScenario(scenario)
scenarioToDelete = null
}
},
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)
) {
Text("Удалить")
}
},
dismissButton = {
TextButton(onClick = { scenarioToDelete = null }) {
Text("Отмена")
}
}
)
}
// Диалог подтверждения удаления всех готовых сценариев
if (showDeleteAllDialog) {
AlertDialog(
onDismissRequest = { showDeleteAllDialog = false },
title = { Text("Очистить все готовые сценарии?") },
text = {
Text("Вы уверены, что хотите удалить все готовые сценарии (${readyScenarios.size} шт.)? После удаления будут автоматически созданы 4 базовых сценария.")
},
confirmButton = {
Button(
onClick = {
scope.launch {
viewModel.deleteAllReadyScenarios()
showDeleteAllDialog = false
}
},
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)
) {
Text("Очистить все")
}
},
dismissButton = {
TextButton(onClick = { showDeleteAllDialog = false }) {
Text("Отмена")
}
}
)
}
}
@Composable
fun ScenarioCard(
scenario: ExerciseScenario,
onClick: () -> Unit,
onDelete: () -> Unit,
showDeleteButton: Boolean = false
) {
Card(
onClick = onClick,
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = scenario.name,
style = MaterialTheme.typography.titleMedium
)
scenario.description?.let {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = it,
style = MaterialTheme.typography.bodyMedium
)
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = when (scenario.exerciseMode) {
ExerciseMode.TIME_BASED -> "Режим: на время (${scenario.targetValue} сек)"
ExerciseMode.COUNT_BASED -> "Режим: на количество (${scenario.targetValue} подъемов)"
},
style = MaterialTheme.typography.bodySmall
)
}
if (showDeleteButton) {
IconButton(
onClick = onDelete,
modifier = Modifier.size(48.dp)
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Удалить",
tint = MaterialTheme.colorScheme.error
)
}
}
}
}
}

View File

@@ -0,0 +1,599 @@
package ru.kgeu.training.ui.screen
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import ru.kgeu.training.data.model.WorkoutSession
import ru.kgeu.training.ui.viewmodel.StatisticsViewModel
import java.text.SimpleDateFormat
import java.util.*
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun StatisticsScreen(
viewModel: StatisticsViewModel,
onPrint: (WorkoutSession, String, String) -> Unit
) {
val participantStatistics by viewModel.participantStatistics.collectAsState()
val selectedParticipantId by viewModel.selectedParticipantId.collectAsState()
val selectedSession by viewModel.selectedSession.collectAsState()
val scope = rememberCoroutineScope()
var participantName by remember { mutableStateOf<String?>(null) }
var scenarioName by remember { mutableStateOf<String?>(null) }
var sessionToDelete by remember { mutableStateOf<WorkoutSession?>(null) }
val displayedSessions = if (selectedParticipantId != null) {
viewModel.getSessionsForParticipant(selectedParticipantId!!)
} else {
emptyList()
}
LaunchedEffect(selectedSession) {
selectedSession?.let { session ->
scope.launch {
participantName = viewModel.getParticipantName(session.participantId)
scenarioName = viewModel.getScenarioName(session.scenarioId)
}
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Статистика тренировок") }
)
}
) { padding ->
if (participantStatistics.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center
) {
Text(
text = "Нет данных о тренировках",
style = MaterialTheme.typography.bodyLarge
)
}
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
items(participantStatistics) { stats ->
ParticipantStatisticsCard(
statistics = stats,
isExpanded = selectedParticipantId == stats.participant.id,
onExpandClick = {
viewModel.selectParticipant(
if (selectedParticipantId == stats.participant.id) null
else stats.participant.id
)
},
sessions = if (selectedParticipantId == stats.participant.id) displayedSessions else emptyList(),
onSessionClick = { session ->
viewModel.selectSession(session)
},
onPrint = { session ->
scope.launch {
val pName = viewModel.getParticipantName(session.participantId) ?: "Неизвестно"
val sName = viewModel.getScenarioName(session.scenarioId) ?: "Неизвестно"
onPrint(session, pName, sName)
}
},
onDelete = { session ->
sessionToDelete = session
}
)
}
}
}
}
// Диалог с деталями сессии
selectedSession?.let { session ->
AlertDialog(
onDismissRequest = { viewModel.selectSession(null) },
title = { Text("Детали тренировки") },
text = {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text("Участник: ${participantName ?: "Загрузка..."}")
Text("Сценарий: ${scenarioName ?: "Загрузка..."}")
Text("Дата: ${formatDate(session.startTime)}")
Text("Длительность: ${formatDuration(session.durationSeconds)}")
Text("Подъемов: ${session.completedLifts}")
session.averageHeartRate?.let {
Text("Средний пульс: $it уд/мин")
}
session.maxHeartRate?.let {
Text("Максимальный пульс: $it уд/мин")
}
session.minHeartRate?.let {
Text("Минимальный пульс: $it уд/мин")
}
}
},
confirmButton = {
Button(
onClick = {
scope.launch {
val pName = viewModel.getParticipantName(session.participantId) ?: "Неизвестно"
val sName = viewModel.getScenarioName(session.scenarioId) ?: "Неизвестно"
onPrint(session, pName, sName)
}
viewModel.selectSession(null)
}
) {
Text("Печать")
}
},
dismissButton = {
TextButton(onClick = { viewModel.selectSession(null) }) {
Text("Закрыть")
}
}
)
}
// Диалог подтверждения удаления
sessionToDelete?.let { session ->
AlertDialog(
onDismissRequest = { sessionToDelete = null },
title = { Text("Удалить запись?") },
text = {
Text("Вы уверены, что хотите удалить эту запись тренировки от ${formatDate(session.startTime)}?")
},
confirmButton = {
Button(
onClick = {
scope.launch {
viewModel.deleteSession(session)
sessionToDelete = null
}
},
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)
) {
Text("Удалить")
}
},
dismissButton = {
TextButton(onClick = { sessionToDelete = null }) {
Text("Отмена")
}
}
)
}
}
@Composable
fun ParticipantStatisticsCard(
statistics: ru.kgeu.training.ui.viewmodel.ParticipantStatistics,
isExpanded: Boolean,
onExpandClick: () -> Unit,
sessions: List<WorkoutSession>,
onSessionClick: (WorkoutSession) -> Unit,
onPrint: (WorkoutSession) -> Unit,
onDelete: (WorkoutSession) -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
onClick = onExpandClick
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = statistics.participant.name,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Тренировок: ${statistics.totalSessions}",
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "Всего подъемов: ${statistics.totalLifts}",
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "Среднее подъемов: ${String.format("%.1f", statistics.averageLifts)}",
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "Средняя длительность: ${formatDuration(statistics.averageDuration.toLong())}",
style = MaterialTheme.typography.bodyMedium
)
statistics.averageHeartRate?.let {
Text(
text = "Средний пульс: ${String.format("%.0f", it)} уд/мин",
style = MaterialTheme.typography.bodyMedium
)
}
}
}
if (isExpanded && sessions.isNotEmpty()) {
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(16.dp))
// График подъемов по времени
Text(
text = "Подъемы по времени",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
LiftsChart(
sessions = sessions,
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
)
Spacer(modifier = Modifier.height(16.dp))
// График пульса по времени (если есть данные)
val sessionsWithHeartRate = sessions.filter { it.averageHeartRate != null }
if (sessionsWithHeartRate.isNotEmpty()) {
Text(
text = "Пульс по времени",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
HeartRateChart(
sessions = sessionsWithHeartRate,
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
)
Spacer(modifier = Modifier.height(16.dp))
}
// Список сессий
Text(
text = "История тренировок",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
sessions.forEach { session ->
SessionCard(
session = session,
onClick = { onSessionClick(session) },
onPrint = { onPrint(session) },
onDelete = { onDelete(session) },
modifier = Modifier.padding(vertical = 4.dp)
)
}
}
}
}
}
@Composable
fun LiftsChart(
sessions: List<WorkoutSession>,
modifier: Modifier = Modifier
) {
if (sessions.isEmpty()) return
val maxLifts = sessions.maxOfOrNull { it.completedLifts }?.toFloat() ?: 1f
val minLifts = sessions.minOfOrNull { it.completedLifts }?.toFloat() ?: 0f
val range = maxLifts - minLifts
val primaryColor = MaterialTheme.colorScheme.primary
val onSurfaceVariantColor = MaterialTheme.colorScheme.onSurfaceVariant
val typography = MaterialTheme.typography
Card(
modifier = modifier,
shape = RoundedCornerShape(8.dp)
) {
Box(modifier = Modifier.fillMaxSize().padding(16.dp)) {
Canvas(modifier = Modifier.fillMaxSize()) {
val width = size.width
val height = size.height
val padding = 40.dp.toPx()
val chartWidth = width - padding * 2
val chartHeight = height - padding * 2
// Оси
drawLine(
color = Color.Gray,
start = Offset(padding, padding),
end = Offset(padding, padding + chartHeight),
strokeWidth = 2f
)
drawLine(
color = Color.Gray,
start = Offset(padding, padding + chartHeight),
end = Offset(padding + chartWidth, padding + chartHeight),
strokeWidth = 2f
)
// Линия графика
if (sessions.size > 1) {
val path = Path()
val stepX = chartWidth / (sessions.size - 1)
sessions.forEachIndexed { index, session ->
val x = padding + stepX * index
val normalizedValue = if (range > 0) {
(session.completedLifts - minLifts) / range
} else 0.5f
val y = padding + chartHeight - (normalizedValue * chartHeight)
if (index == 0) {
path.moveTo(x, y)
} else {
path.lineTo(x, y)
}
}
drawPath(
path = path,
color = primaryColor,
style = Stroke(width = 3f)
)
// Точки
sessions.forEachIndexed { index, session ->
val x = padding + stepX * index
val normalizedValue = if (range > 0) {
(session.completedLifts - minLifts) / range
} else 0.5f
val y = padding + chartHeight - (normalizedValue * chartHeight)
drawCircle(
color = primaryColor,
radius = 6.dp.toPx(),
center = Offset(x, y)
)
}
}
}
// Подписи осей
Column(
modifier = Modifier.align(Alignment.BottomStart).padding(start = 8.dp, bottom = 8.dp)
) {
Text(
text = "0",
style = typography.bodySmall,
color = onSurfaceVariantColor
)
}
Column(
modifier = Modifier.align(Alignment.TopEnd).padding(end = 8.dp, top = 8.dp)
) {
Text(
text = "${maxLifts.toInt()}",
style = typography.bodySmall,
color = onSurfaceVariantColor
)
}
}
}
}
@Composable
fun HeartRateChart(
sessions: List<WorkoutSession>,
modifier: Modifier = Modifier
) {
if (sessions.isEmpty()) return
val heartRates = sessions.mapNotNull { it.averageHeartRate?.toFloat() }
val maxHR = heartRates.maxOrNull() ?: 1f
val minHR = heartRates.minOrNull() ?: 0f
val range = maxHR - minHR
val errorColor = MaterialTheme.colorScheme.error
val onSurfaceVariantColor = MaterialTheme.colorScheme.onSurfaceVariant
val typography = MaterialTheme.typography
Card(
modifier = modifier,
shape = RoundedCornerShape(8.dp)
) {
Box(modifier = Modifier.fillMaxSize().padding(16.dp)) {
Canvas(modifier = Modifier.fillMaxSize()) {
val width = size.width
val height = size.height
val padding = 40.dp.toPx()
val chartWidth = width - padding * 2
val chartHeight = height - padding * 2
// Оси
drawLine(
color = Color.Gray,
start = Offset(padding, padding),
end = Offset(padding, padding + chartHeight),
strokeWidth = 2f
)
drawLine(
color = Color.Gray,
start = Offset(padding, padding + chartHeight),
end = Offset(padding + chartWidth, padding + chartHeight),
strokeWidth = 2f
)
// Линия графика
if (sessions.size > 1) {
val path = Path()
val stepX = chartWidth / (sessions.size - 1)
sessions.forEachIndexed { index, session ->
session.averageHeartRate?.let { hr ->
val x = padding + stepX * index
val normalizedValue = if (range > 0) {
(hr - minHR) / range
} else 0.5f
val y = padding + chartHeight - (normalizedValue * chartHeight)
if (index == 0) {
path.moveTo(x, y)
} else {
path.lineTo(x, y)
}
}
}
drawPath(
path = path,
color = errorColor,
style = Stroke(width = 3f)
)
// Точки
sessions.forEachIndexed { index, session ->
session.averageHeartRate?.let { hr ->
val x = padding + stepX * index
val normalizedValue = if (range > 0) {
(hr - minHR) / range
} else 0.5f
val y = padding + chartHeight - (normalizedValue * chartHeight)
drawCircle(
color = errorColor,
radius = 6.dp.toPx(),
center = Offset(x, y)
)
}
}
}
}
// Подписи осей
Column(
modifier = Modifier.align(Alignment.BottomStart).padding(start = 8.dp, bottom = 8.dp)
) {
Text(
text = "${minHR.toInt()}",
style = typography.bodySmall,
color = onSurfaceVariantColor
)
}
Column(
modifier = Modifier.align(Alignment.TopEnd).padding(end = 8.dp, top = 8.dp)
) {
Text(
text = "${maxHR.toInt()}",
style = typography.bodySmall,
color = onSurfaceVariantColor
)
}
}
}
}
@Composable
fun SessionCard(
session: WorkoutSession,
onClick: () -> Unit,
onPrint: () -> Unit,
onDelete: () -> Unit,
modifier: Modifier = Modifier
) {
Card(
onClick = onClick,
modifier = modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = formatDate(session.startTime),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Длительность: ${formatDuration(session.durationSeconds)}",
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "Подъемов: ${session.completedLifts}",
style = MaterialTheme.typography.bodyMedium
)
session.averageHeartRate?.let {
Text(
text = "Пульс: $it уд/мин",
style = MaterialTheme.typography.bodySmall
)
}
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
TextButton(onClick = onPrint) {
Text("Печать")
}
IconButton(
onClick = onDelete,
modifier = Modifier.size(48.dp)
) {
Icon(
imageVector = Icons.Filled.Delete,
contentDescription = "Удалить",
tint = MaterialTheme.colorScheme.error
)
}
}
}
}
}
}
fun formatDate(timestamp: Long): String {
val dateFormat = SimpleDateFormat("dd.MM.yyyy HH:mm", Locale.getDefault())
return dateFormat.format(Date(timestamp))
}
fun formatDuration(seconds: Long): String {
val hours = seconds / 3600
val minutes = (seconds % 3600) / 60
val secs = seconds % 60
return if (hours > 0) {
String.format("%d:%02d:%02d", hours, minutes, secs)
} else {
String.format("%d:%02d", minutes, secs)
}
}

View File

@@ -0,0 +1,339 @@
package ru.kgeu.training.ui.screen
import android.util.Log
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.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
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.WorkoutViewModel
import java.util.concurrent.TimeUnit
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun WorkoutScreen(
participant: Participant,
scenario: ExerciseScenario,
tcpHost: String,
tcpPort: Int,
viewModel: WorkoutViewModel,
onWorkoutFinished: () -> Unit
) {
val isActive by viewModel.isWorkoutActive.collectAsState()
val isTimerStarted by viewModel.isTimerStarted.collectAsState()
val completedLifts by viewModel.completedLifts.collectAsState()
val elapsedTime by viewModel.elapsedTime.collectAsState()
val heartRate by viewModel.heartRate.collectAsState()
val currentSession by viewModel.currentSession.collectAsState()
val connectionState by viewModel.tcpConnectionState.collectAsState()
val bluetoothState by viewModel.bluetoothConnectionState.collectAsState()
val scope = rememberCoroutineScope()
// Получаем текущее положение веса для отображения
val weightPosition by viewModel.weightPosition.collectAsState()
var workoutStarted by remember { mutableStateOf(false) }
LaunchedEffect(participant.id, scenario.id) {
if (!workoutStarted && !isActive && participant.id > 0 && scenario.id > 0) {
workoutStarted = true
try {
viewModel.startWorkout(
participantId = participant.id,
scenarioId = scenario.id,
mode = scenario.exerciseMode,
target = scenario.targetValue,
tcpHost = tcpHost,
tcpPort = tcpPort
)
} catch (e: Exception) {
Log.e("WorkoutScreen", "Error starting workout", e)
workoutStarted = false
}
}
}
DisposableEffect(Unit) {
onDispose {
Log.d("WorkoutScreen", "WorkoutScreen disposed, stopping workout and disconnecting")
if (isActive) {
viewModel.stopWorkout()
} else {
// Если тренировка не активна, но мы выходим с экрана, все равно отключаем соединения
// чтобы они не оставались активными
try {
viewModel.disconnectAll()
} catch (e: Exception) {
Log.e("WorkoutScreen", "Error disconnecting on dispose", e)
}
}
}
}
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
)
) {
Text("Остановить")
}
}
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
// Участник
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Участник: ${participant.name}",
style = MaterialTheme.typography.titleMedium
)
}
}
// Статус подключений и таймера
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
StatusChip(
label = "ESP32",
isConnected = connectionState == ru.kgeu.training.network.ConnectionState.Connected
)
StatusChip(
label = "Кардиодатчик",
isConnected = bluetoothState == ru.kgeu.training.bluetooth.BluetoothConnectionState.Connected
)
}
// Статус таймера
if (!isTimerStarted) {
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = MaterialTheme.shapes.small
) {
Text(
text = "Ожидание",
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
// Текущее положение веса
weightPosition?.let { position ->
Surface(
color = when (position) {
ru.kgeu.training.data.model.WeightPosition.TOP -> MaterialTheme.colorScheme.primaryContainer
ru.kgeu.training.data.model.WeightPosition.MIDDLE -> MaterialTheme.colorScheme.secondaryContainer
ru.kgeu.training.data.model.WeightPosition.BOTTOM -> MaterialTheme.colorScheme.tertiaryContainer
},
shape = MaterialTheme.shapes.small
) {
Text(
text = "Положение: ${
when (position) {
ru.kgeu.training.data.model.WeightPosition.TOP -> "Верх"
ru.kgeu.training.data.model.WeightPosition.MIDDLE -> "В движении"
ru.kgeu.training.data.model.WeightPosition.BOTTOM -> "Низ"
}
}",
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
style = MaterialTheme.typography.bodySmall
)
}
}
}
// Показания кардиодатчика
if (heartRate != null) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Пульс",
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "$heartRate",
fontSize = 48.sp,
fontWeight = FontWeight.Bold
)
Text(
text = "уд/мин",
style = MaterialTheme.typography.bodySmall
)
}
}
}
// Основные показатели
when (scenario.exerciseMode) {
ExerciseMode.TIME_BASED -> {
// Режим на время - показываем количество подъемов
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer
)
) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Выполнено подъемов",
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "$completedLifts",
fontSize = 64.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Text(
text = "Цель: ${scenario.targetValue} сек",
style = MaterialTheme.typography.bodySmall
)
}
}
// Таймер
Text(
text = formatTime(elapsedTime),
fontSize = 32.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
ExerciseMode.COUNT_BASED -> {
// Режим на количество - показываем таймер
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer
)
) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Время",
style = MaterialTheme.typography.bodyMedium
)
Text(
text = formatTime(elapsedTime),
fontSize = 64.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Text(
text = "Цель: ${scenario.targetValue} подъемов",
style = MaterialTheme.typography.bodySmall
)
}
}
// Количество подъемов
Text(
text = "$completedLifts / ${scenario.targetValue}",
fontSize = 32.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
}
}
}
}
@Composable
fun StatusChip(label: String, isConnected: Boolean) {
Surface(
color = if (isConnected) MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.errorContainer,
shape = MaterialTheme.shapes.small
) {
Text(
text = "$label: ${if (isConnected) "✓" else "✗"}",
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
style = MaterialTheme.typography.bodySmall
)
}
}
fun formatTime(seconds: Long): String {
val hours = TimeUnit.SECONDS.toHours(seconds)
val minutes = TimeUnit.SECONDS.toMinutes(seconds) % 60
val secs = seconds % 60
return if (hours > 0) {
String.format("%02d:%02d:%02d", hours, minutes, secs)
} else {
String.format("%02d:%02d", minutes, secs)
}
}

View File

@@ -0,0 +1,11 @@
package ru.kgeu.training.ui.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)

View File

@@ -0,0 +1,58 @@
package ru.kgeu.training.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80
)
private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40
/* Other default colors to override
background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE),
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color.White,
onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F),
*/
)
@Composable
fun ТренировкаTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}

View File

@@ -0,0 +1,34 @@
package ru.kgeu.training.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// Set of Material typography styles to start with
val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
)
/* Other default text styles to override
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
*/
)

View File

@@ -0,0 +1,74 @@
package ru.kgeu.training.ui.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import ru.kgeu.training.data.model.Participant
import ru.kgeu.training.data.repository.TrainingRepository
class ParticipantViewModel(private val repository: TrainingRepository) : ViewModel() {
private val _participants = MutableStateFlow<List<Participant>>(emptyList())
val participants: StateFlow<List<Participant>> = _participants.asStateFlow()
private val _selectedParticipant = MutableStateFlow<Participant?>(null)
val selectedParticipant: StateFlow<Participant?> = _selectedParticipant.asStateFlow()
init {
loadParticipants()
}
private fun loadParticipants() {
viewModelScope.launch {
repository.getAllParticipants().collect { list ->
_participants.value = list
}
}
}
fun selectParticipant(participant: Participant) {
_selectedParticipant.value = participant
}
suspend fun addParticipant(name: String, age: Int?, weight: Float?, height: Float?): Long {
// Имя уже обрезано в UI, но на всякий случай обрезаем еще раз
val trimmedName = name.trim()
android.util.Log.d("ParticipantViewModel", "Adding participant with name: '$trimmedName' (length: ${trimmedName.length})")
if (trimmedName.isEmpty()) {
android.util.Log.e("ParticipantViewModel", "Cannot add participant with empty name")
throw IllegalArgumentException("Name cannot be empty")
}
val participant = Participant(
name = trimmedName,
age = age,
weight = weight,
height = height
)
val id = repository.insertParticipant(participant)
android.util.Log.d("ParticipantViewModel", "Participant added with id: $id, name: '${participant.name}'")
// Flow автоматически обновится после вставки, так как Room отслеживает изменения
return id
}
suspend fun updateParticipant(participant: Participant) {
val trimmedName = participant.name.trim()
if (trimmedName.isEmpty()) {
throw IllegalArgumentException("Name cannot be empty")
}
val updatedParticipant = participant.copy(name = trimmedName)
repository.updateParticipant(updatedParticipant)
android.util.Log.d("ParticipantViewModel", "Participant updated: id=${updatedParticipant.id}, name='${updatedParticipant.name}'")
}
suspend fun deleteParticipant(participant: Participant) {
repository.deleteParticipant(participant)
android.util.Log.d("ParticipantViewModel", "Participant deleted: id=${participant.id}, name='${participant.name}'")
}
}

View File

@@ -0,0 +1,115 @@
package ru.kgeu.training.ui.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import ru.kgeu.training.data.model.ExerciseMode
import ru.kgeu.training.data.model.ExerciseScenario
import ru.kgeu.training.data.repository.TrainingRepository
class ScenarioViewModel(private val repository: TrainingRepository) : ViewModel() {
private val _readyScenarios = MutableStateFlow<List<ExerciseScenario>>(emptyList())
val readyScenarios: StateFlow<List<ExerciseScenario>> = _readyScenarios.asStateFlow()
private val _customScenarios = MutableStateFlow<List<ExerciseScenario>>(emptyList())
val customScenarios: StateFlow<List<ExerciseScenario>> = _customScenarios.asStateFlow()
private val _selectedScenario = MutableStateFlow<ExerciseScenario?>(null)
val selectedScenario: StateFlow<ExerciseScenario?> = _selectedScenario.asStateFlow()
init {
viewModelScope.launch {
// Сначала инициализируем дефолтные сценарии, если их еще нет
initializeDefaultScenarios()
}
// Затем загружаем сценарии из базы
loadScenarios()
}
private fun loadScenarios() {
viewModelScope.launch {
repository.getReadyScenarios().collect { list ->
_readyScenarios.value = list
}
}
viewModelScope.launch {
repository.getCustomScenarios().collect { list ->
_customScenarios.value = list
}
}
}
private suspend fun initializeDefaultScenarios() {
// Проверяем количество готовых сценариев в базе данных
val count = repository.getReadyScenariosCount()
// Если сценариев нет, создаем дефолтные (только 4 базовых)
if (count == 0) {
val defaultScenarios = listOf(
ExerciseScenario(
name = "Тренировка на время - 60 секунд",
description = "Выполните максимальное количество подъемов за 60 секунд",
exerciseMode = ExerciseMode.TIME_BASED,
targetValue = 60,
isCustom = false
),
ExerciseScenario(
name = "Тренировка на время - 120 секунд",
description = "Выполните максимальное количество подъемов за 120 секунд",
exerciseMode = ExerciseMode.TIME_BASED,
targetValue = 120,
isCustom = false
),
ExerciseScenario(
name = "Тренировка на количество - 10 подъемов",
description = "Выполните 10 подъемов на время",
exerciseMode = ExerciseMode.COUNT_BASED,
targetValue = 10,
isCustom = false
),
ExerciseScenario(
name = "Тренировка на количество - 20 подъемов",
description = "Выполните 20 подъемов на время",
exerciseMode = ExerciseMode.COUNT_BASED,
targetValue = 20,
isCustom = false
)
)
defaultScenarios.forEach { repository.insertScenario(it) }
}
}
fun selectScenario(scenario: ExerciseScenario) {
_selectedScenario.value = scenario
}
suspend fun createCustomScenario(
name: String,
description: String?,
mode: ExerciseMode,
targetValue: Int
): Long {
val scenario = ExerciseScenario(
name = name,
description = description,
exerciseMode = mode,
targetValue = targetValue,
isCustom = true
)
return repository.insertScenario(scenario)
}
suspend fun deleteScenario(scenario: ExerciseScenario) {
repository.deleteScenario(scenario)
}
suspend fun deleteAllReadyScenarios() {
repository.deleteAllReadyScenarios()
// После удаления всех готовых сценариев, пересоздаем базовые
initializeDefaultScenarios()
}
}

View File

@@ -0,0 +1,121 @@
package ru.kgeu.training.ui.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import ru.kgeu.training.data.model.Participant
import ru.kgeu.training.data.model.WorkoutSession
import ru.kgeu.training.data.repository.TrainingRepository
data class ParticipantStatistics(
val participant: Participant,
val sessions: List<WorkoutSession>,
val totalSessions: Int,
val totalLifts: Int,
val averageLifts: Double,
val averageDuration: Double,
val averageHeartRate: Double?
)
class StatisticsViewModel(private val repository: TrainingRepository) : ViewModel() {
private val _sessions = MutableStateFlow<List<WorkoutSession>>(emptyList())
val sessions: StateFlow<List<WorkoutSession>> = _sessions.asStateFlow()
private val _participants = MutableStateFlow<List<Participant>>(emptyList())
val participants: StateFlow<List<Participant>> = _participants.asStateFlow()
private val _participantStatistics = MutableStateFlow<List<ParticipantStatistics>>(emptyList())
val participantStatistics: StateFlow<List<ParticipantStatistics>> = _participantStatistics.asStateFlow()
private val _selectedSession = MutableStateFlow<WorkoutSession?>(null)
val selectedSession: StateFlow<WorkoutSession?> = _selectedSession.asStateFlow()
private val _selectedParticipantId = MutableStateFlow<Long?>(null)
val selectedParticipantId: StateFlow<Long?> = _selectedParticipantId.asStateFlow()
init {
loadData()
}
private fun loadData() {
viewModelScope.launch {
repository.getAllSessions().collect { sessionsList ->
_sessions.value = sessionsList
updateParticipantStatistics(sessionsList)
}
}
viewModelScope.launch {
repository.getAllParticipants().collect { participantsList ->
_participants.value = participantsList
// Обновляем статистику при изменении участников, чтобы отобразить актуальные имена
updateParticipantStatistics(_sessions.value)
}
}
}
private suspend fun updateParticipantStatistics(sessions: List<WorkoutSession>) {
val participantsMap = _participants.value.associateBy { it.id }
val groupedSessions = sessions.groupBy { it.participantId }
val statistics = groupedSessions.map { (participantId, participantSessions) ->
val participant = participantsMap[participantId] ?: return@map null
val totalLifts = participantSessions.sumOf { it.completedLifts }
val averageLifts = if (participantSessions.isNotEmpty()) {
totalLifts.toDouble() / participantSessions.size
} else 0.0
val averageDuration = if (participantSessions.isNotEmpty()) {
participantSessions.map { it.durationSeconds }.average()
} else 0.0
val heartRates = participantSessions.mapNotNull { it.averageHeartRate }
val averageHeartRate = if (heartRates.isNotEmpty()) {
heartRates.average()
} else null
ParticipantStatistics(
participant = participant,
sessions = participantSessions.sortedByDescending { it.startTime },
totalSessions = participantSessions.size,
totalLifts = totalLifts,
averageLifts = averageLifts,
averageDuration = averageDuration,
averageHeartRate = averageHeartRate
)
}.filterNotNull().sortedByDescending { it.sessions.firstOrNull()?.startTime ?: 0L }
_participantStatistics.value = statistics
}
fun selectSession(session: WorkoutSession?) {
_selectedSession.value = session
}
fun selectParticipant(participantId: Long?) {
_selectedParticipantId.value = participantId
}
fun getSessionsForParticipant(participantId: Long): List<WorkoutSession> {
return _sessions.value.filter { it.participantId == participantId }
.sortedByDescending { it.startTime }
}
suspend fun getParticipantName(participantId: Long): String? {
return repository.getParticipantById(participantId)?.name
}
suspend fun getScenarioName(scenarioId: Long): String? {
return repository.getScenarioById(scenarioId)?.name
}
suspend fun deleteSession(session: WorkoutSession) {
repository.deleteSession(session)
// Статистика обновится автоматически через Flow
}
}

View File

@@ -0,0 +1,39 @@
package ru.kgeu.training.ui.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import ru.kgeu.training.TrainingApplication
import ru.kgeu.training.bluetooth.HeartRateMonitor
import ru.kgeu.training.data.repository.TrainingRepository
import ru.kgeu.training.network.TcpClient
class ViewModelFactory(
private val repository: TrainingRepository,
private val tcpClient: TcpClient,
private val heartRateMonitor: HeartRateMonitor
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return when {
modelClass.isAssignableFrom(ParticipantViewModel::class.java) -> {
ParticipantViewModel(repository) as T
}
modelClass.isAssignableFrom(ScenarioViewModel::class.java) -> {
ScenarioViewModel(repository) as T
}
modelClass.isAssignableFrom(WorkoutViewModel::class.java) -> {
WorkoutViewModel(repository, tcpClient, heartRateMonitor) as T
}
modelClass.isAssignableFrom(StatisticsViewModel::class.java) -> {
StatisticsViewModel(repository) as T
}
else -> throw IllegalArgumentException("Unknown ViewModel class")
}
}
}
fun TrainingApplication.createViewModelFactory(): ViewModelFactory {
return ViewModelFactory(repository, tcpClient, heartRateMonitor)
}

View File

@@ -0,0 +1,320 @@
package ru.kgeu.training.ui.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
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 ru.kgeu.training.data.model.ExerciseMode
import ru.kgeu.training.data.model.WeightPosition
import ru.kgeu.training.data.model.WorkoutSession
import ru.kgeu.training.data.repository.TrainingRepository
import ru.kgeu.training.network.TcpClient
import ru.kgeu.training.bluetooth.HeartRateMonitor
class WorkoutViewModel(
private val repository: TrainingRepository,
private val tcpClient: TcpClient,
private val heartRateMonitor: HeartRateMonitor
) : ViewModel() {
private val _isWorkoutActive = MutableStateFlow(false)
val isWorkoutActive: StateFlow<Boolean> = _isWorkoutActive.asStateFlow()
private val _isTimerStarted = MutableStateFlow(false)
val isTimerStarted: StateFlow<Boolean> = _isTimerStarted.asStateFlow()
private val _completedLifts = MutableStateFlow(0)
val completedLifts: StateFlow<Int> = _completedLifts.asStateFlow()
private val _elapsedTime = MutableStateFlow(0L) // в секундах
val elapsedTime: StateFlow<Long> = _elapsedTime.asStateFlow()
private val _remainingTime = MutableStateFlow(0L) // для режима на количество
val remainingTime: StateFlow<Long> = _remainingTime.asStateFlow()
private val _heartRate = MutableStateFlow<Int?>(null)
val heartRate: StateFlow<Int?> = _heartRate.asStateFlow()
private val _currentSession = MutableStateFlow<WorkoutSession?>(null)
val currentSession: StateFlow<WorkoutSession?> = _currentSession.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
private var workoutJob: Job? = null
private var weightPositionJob: Job? = null
private var heartRateJob: Job? = null
private var lastPosition: WeightPosition? = null
private var isLiftingUp: Boolean = false // Флаг: находимся ли мы в процессе подъема (BOTTOM -> MIDDLE -> TOP)
private var targetValue: Int = 0
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>()
init {
observeWeightPosition()
observeHeartRate()
}
private fun observeWeightPosition() {
weightPositionJob?.cancel()
weightPositionJob = viewModelScope.launch {
tcpClient.weightPosition.collect { position ->
if (_isWorkoutActive.value && !isStopping && position != null) {
handleWeightPositionChange(position)
}
}
}
}
private fun observeHeartRate() {
heartRateJob?.cancel()
heartRateJob = viewModelScope.launch {
heartRateMonitor.heartRate.collect { hr ->
if (!isStopping) {
_heartRate.value = hr
if (hr != null && _isWorkoutActive.value) {
heartRateReadings.add(hr)
}
}
}
}
}
private fun handleWeightPositionChange(position: WeightPosition) {
when {
// Начало подъема: BOTTOM -> MIDDLE
lastPosition == WeightPosition.BOTTOM && position == WeightPosition.MIDDLE -> {
isLiftingUp = true // Начинаем подъем
android.util.Log.d("WorkoutViewModel", "Lift started: BOTTOM -> MIDDLE")
// Запускаем таймер только при первом подъеме
if (!_isTimerStarted.value) {
_isTimerStarted.value = true
timerStartTime = System.currentTimeMillis()
// Обновляем startTime в сессии на момент реального запуска таймера
val session = _currentSession.value
if (session != null) {
_currentSession.value = session.copy(startTime = timerStartTime)
viewModelScope.launch {
try {
repository.updateSession(_currentSession.value!!)
} catch (e: Exception) {
android.util.Log.e("WorkoutViewModel", "Error updating session start time", e)
}
}
}
android.util.Log.d("WorkoutViewModel", "Timer started - first lift began")
}
}
// Завершение подъема: MIDDLE -> TOP (считаем подъем только здесь!)
lastPosition == WeightPosition.MIDDLE && position == WeightPosition.TOP && isLiftingUp -> {
_completedLifts.value++
isLiftingUp = false // Подъем завершен
android.util.Log.d("WorkoutViewModel", "Lift completed: MIDDLE -> TOP. Total lifts: ${_completedLifts.value}")
// Если режим на количество и достигнута цель
if (exerciseMode == ExerciseMode.COUNT_BASED &&
_completedLifts.value >= targetValue) {
stopWorkout()
}
}
// Начало опускания: TOP -> MIDDLE
lastPosition == WeightPosition.TOP && position == WeightPosition.MIDDLE -> {
isLiftingUp = false // Опускаемся вниз
android.util.Log.d("WorkoutViewModel", "Lowering started: TOP -> MIDDLE")
}
// Завершение опускания: MIDDLE -> BOTTOM (готовы к следующему подъему)
lastPosition == WeightPosition.MIDDLE && position == WeightPosition.BOTTOM -> {
isLiftingUp = false // Вернулись в исходное положение
android.util.Log.d("WorkoutViewModel", "Lowering completed: MIDDLE -> BOTTOM. Ready for next lift")
}
}
lastPosition = position
}
fun startWorkout(
participantId: Long,
scenarioId: Long,
mode: ExerciseMode,
target: Int,
tcpHost: String,
tcpPort: Int
) {
if (_isWorkoutActive.value) return
this.participantId = participantId
this.scenarioId = scenarioId
this.exerciseMode = mode
this.targetValue = target
_completedLifts.value = 0
_elapsedTime.value = 0
_isTimerStarted.value = false // Таймер запустится только при переходе BOTTOM -> MIDDLE
_remainingTime.value = if (mode == ExerciseMode.COUNT_BASED) target.toLong() else 0
lastPosition = null
isLiftingUp = false // Сбрасываем флаг подъема
timerStartTime = 0
heartRateReadings.clear()
// Пытаемся подключиться к TCP, но не блокируем запуск тренировки если не удалось
try {
tcpClient.connect(tcpHost, tcpPort)
} catch (e: Exception) {
android.util.Log.e("WorkoutViewModel", "Failed to connect TCP", e)
}
// Запускаем сканирование Bluetooth, но не блокируем если не удалось
try {
heartRateMonitor.startScan()
} catch (e: Exception) {
android.util.Log.e("WorkoutViewModel", "Failed to start Bluetooth scan", e)
}
val session = WorkoutSession(
participantId = participantId,
scenarioId = scenarioId,
startTime = System.currentTimeMillis()
)
viewModelScope.launch {
try {
val sessionId = repository.insertSession(session)
_currentSession.value = session.copy(id = sessionId)
} catch (e: Exception) {
android.util.Log.e("WorkoutViewModel", "Failed to insert session", e)
// Создаем временную сессию без ID для отображения
_currentSession.value = session
}
}
_isWorkoutActive.value = true
// Таймер запускается только когда _isTimerStarted становится true (при переходе BOTTOM -> MIDDLE)
workoutJob = viewModelScope.launch {
while (_isWorkoutActive.value) {
delay(1000)
// Обновляем таймер только если он был запущен
if (_isTimerStarted.value) {
if (exerciseMode == ExerciseMode.TIME_BASED) {
_elapsedTime.value++
if (_elapsedTime.value >= target) {
stopWorkout()
}
} else if (exerciseMode == ExerciseMode.COUNT_BASED) {
_elapsedTime.value++
}
}
}
}
}
fun stopWorkout() {
if (isStopping || !_isWorkoutActive.value) return
isStopping = true
_isWorkoutActive.value = false
_isTimerStarted.value = false
// Отменяем все корутины
workoutJob?.cancel()
workoutJob = null
// Отключаем соединения
try {
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
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
}
}
} else {
isStopping = false
}
}
fun disconnectAll() {
android.util.Log.d(TAG, "Disconnecting all connections")
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)
}
}
override fun onCleared() {
super.onCleared()
android.util.Log.d(TAG, "WorkoutViewModel cleared, disconnecting all")
isStopping = true
workoutJob?.cancel()
weightPositionJob?.cancel()
heartRateJob?.cancel()
disconnectAll()
}
companion object {
private const val TAG = "WorkoutViewModel"
}
}

View File

@@ -0,0 +1,92 @@
package ru.kgeu.training.util
import android.content.Context
import android.print.PrintAttributes
import android.print.PrintDocumentAdapter
import android.print.PrintManager
import android.webkit.WebView
import android.webkit.WebViewClient
import ru.kgeu.training.data.model.WorkoutSession
import java.text.SimpleDateFormat
import java.util.*
object PrintUtil {
fun printWorkoutSession(context: Context, session: WorkoutSession, participantName: String, scenarioName: String) {
val webView = WebView(context)
webView.webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
createPrintJob(context, webView)
}
}
val html = generateHtmlReport(session, participantName, scenarioName)
webView.loadDataWithBaseURL(null, html, "text/html", "UTF-8", null)
}
private fun createPrintJob(context: Context, webView: WebView) {
val printManager = context.getSystemService(Context.PRINT_SERVICE) as PrintManager
val printAdapter = webView.createPrintDocumentAdapter("WorkoutReport")
val jobName = "Тренировка - ${Date()}"
printManager.print(
jobName,
printAdapter,
PrintAttributes.Builder()
.setMediaSize(PrintAttributes.MediaSize.ISO_A4)
.setResolution(PrintAttributes.Resolution("pdf", "pdf", 600, 600))
.setMinMargins(PrintAttributes.Margins.NO_MARGINS)
.build()
)
}
private fun generateHtmlReport(session: WorkoutSession, participantName: String, scenarioName: String): String {
val dateFormat = SimpleDateFormat("dd.MM.yyyy HH:mm", Locale.getDefault())
val startTime = dateFormat.format(Date(session.startTime))
val endTime = session.endTime?.let { dateFormat.format(Date(it)) } ?: "В процессе"
val duration = formatDuration(session.durationSeconds)
return """
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body { font-family: Arial, sans-serif; padding: 20px; }
h1 { color: #333; }
table { width: 100%; border-collapse: collapse; margin-top: 20px; }
th, td { padding: 10px; text-align: left; border-bottom: 1px solid #ddd; }
th { background-color: #f2f2f2; }
</style>
</head>
<body>
<h1>Отчет о тренировке</h1>
<table>
<tr><th>Параметр</th><th>Значение</th></tr>
<tr><td>Участник</td><td>$participantName</td></tr>
<tr><td>Сценарий</td><td>$scenarioName</td></tr>
<tr><td>Начало</td><td>$startTime</td></tr>
<tr><td>Окончание</td><td>$endTime</td></tr>
<tr><td>Длительность</td><td>$duration</td></tr>
<tr><td>Выполнено подъемов</td><td>${session.completedLifts}</td></tr>
${session.averageHeartRate?.let { "<tr><td>Средний пульс</td><td>$it уд/мин</td></tr>" } ?: ""}
${session.maxHeartRate?.let { "<tr><td>Максимальный пульс</td><td>$it уд/мин</td></tr>" } ?: ""}
${session.minHeartRate?.let { "<tr><td>Минимальный пульс</td><td>$it уд/мин</td></tr>" } ?: ""}
</table>
</body>
</html>
""".trimIndent()
}
private fun formatDuration(seconds: Long): String {
val hours = seconds / 3600
val minutes = (seconds % 3600) / 60
val secs = seconds % 60
return if (hours > 0) {
String.format("%d:%02d:%02d", hours, minutes, secs)
} else {
String.format("%d:%02d", minutes, secs)
}
}
}

View File

@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>

View File

@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>

View File

@@ -0,0 +1,3 @@
<resources>
<string name="app_name">Тренировка</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Тренировка" parent="android:Theme.Material.Light.NoActionBar" />
</resources>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older than API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>

View File

@@ -0,0 +1,17 @@
package ru.kgeu.training
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}

6
build.gradle.kts Normal file
View File

@@ -0,0 +1,6 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
}

23
gradle.properties Normal file
View File

@@ -0,0 +1,23 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. For more details, visit
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true

45
gradle/libs.versions.toml Normal file
View File

@@ -0,0 +1,45 @@
[versions]
agp = "8.13.0"
kotlin = "2.0.21"
coreKtx = "1.10.1"
junit = "4.13.2"
junitVersion = "1.1.5"
espressoCore = "3.5.1"
lifecycleRuntimeKtx = "2.6.1"
activityCompose = "1.8.0"
composeBom = "2024.09.00"
room = "2.6.1"
navigation = "2.7.6"
okhttp = "4.12.0"
ksp = "2.0.21-1.0.25"
navigationCompose = "2.9.6"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" }
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" }
navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,8 @@
#Thu Nov 27 01:36:28 MSK 2025
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

251
gradlew vendored Executable file
View File

@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
gradlew.bat vendored Normal file
View File

@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

23
settings.gradle.kts Normal file
View File

@@ -0,0 +1,23 @@
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "Тренировка"
include(":app")