cleaned structure
This commit is contained in:
1
Code/app/.gitignore
vendored
Normal file
1
Code/app/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
/build
|
||||
73
Code/app/build.gradle.kts
Normal file
73
Code/app/build.gradle.kts
Normal file
@ -0,0 +1,73 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.tomhempel.labrecorder"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.tomhempel.labrecorder"
|
||||
minSdk = 24
|
||||
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
|
||||
viewBinding = 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.ui)
|
||||
implementation(libs.androidx.ui.graphics)
|
||||
implementation(libs.androidx.ui.tooling.preview)
|
||||
implementation(libs.androidx.material3)
|
||||
implementation(libs.material)
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.androidx.constraintlayout)
|
||||
implementation(libs.androidx.navigation.fragment.ktx)
|
||||
implementation(libs.androidx.navigation.ui.ktx)
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
androidTestImplementation(libs.androidx.ui.test.junit4)
|
||||
debugImplementation(libs.androidx.ui.tooling)
|
||||
debugImplementation(libs.androidx.ui.test.manifest)
|
||||
implementation("androidx.activity:activity-compose:1.9.0")
|
||||
implementation("androidx.compose.material:material-icons-extended-android:1.6.8")
|
||||
implementation(platform("androidx.compose:compose-bom:2024.06.00"))
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
implementation("com.patrykandpatrick.vico:compose-m3:1.14.0")
|
||||
implementation("com.patrykandpatrick.vico:core:1.14.0")
|
||||
implementation("com.github.PhilJay:MPAndroidChart:v3.1.0")
|
||||
}
|
||||
21
Code/app/proguard-rules.pro
vendored
Normal file
21
Code/app/proguard-rules.pro
vendored
Normal 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
|
||||
BIN
Code/app/release/baselineProfiles/0/app-release.dm
Normal file
BIN
Code/app/release/baselineProfiles/0/app-release.dm
Normal file
Binary file not shown.
BIN
Code/app/release/baselineProfiles/1/app-release.dm
Normal file
BIN
Code/app/release/baselineProfiles/1/app-release.dm
Normal file
Binary file not shown.
BIN
Code/app/release/lab-recorder.apk
Normal file
BIN
Code/app/release/lab-recorder.apk
Normal file
Binary file not shown.
37
Code/app/release/output-metadata.json
Normal file
37
Code/app/release/output-metadata.json
Normal file
@ -0,0 +1,37 @@
|
||||
{
|
||||
"version": 3,
|
||||
"artifactType": {
|
||||
"type": "APK",
|
||||
"kind": "Directory"
|
||||
},
|
||||
"applicationId": "com.tomhempel.labrecorder",
|
||||
"variantName": "release",
|
||||
"elements": [
|
||||
{
|
||||
"type": "SINGLE",
|
||||
"filters": [],
|
||||
"attributes": [],
|
||||
"versionCode": 1,
|
||||
"versionName": "1.0",
|
||||
"outputFile": "app-release.apk"
|
||||
}
|
||||
],
|
||||
"elementType": "File",
|
||||
"baselineProfiles": [
|
||||
{
|
||||
"minApi": 28,
|
||||
"maxApi": 30,
|
||||
"baselineProfiles": [
|
||||
"baselineProfiles/1/app-release.dm"
|
||||
]
|
||||
},
|
||||
{
|
||||
"minApi": 31,
|
||||
"maxApi": 2147483647,
|
||||
"baselineProfiles": [
|
||||
"baselineProfiles/0/app-release.dm"
|
||||
]
|
||||
}
|
||||
],
|
||||
"minSdkVersionForDexing": 24
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
package com.tomhempel.labrecorder
|
||||
|
||||
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("com.tomhempel.labrecorder", appContext.packageName)
|
||||
}
|
||||
}
|
||||
63
Code/app/src/main/AndroidManifest.xml
Normal file
63
Code/app/src/main/AndroidManifest.xml
Normal file
@ -0,0 +1,63 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.bluetooth"
|
||||
android:required="false" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.bluetooth_le"
|
||||
android:required="false" />
|
||||
|
||||
<uses-permission
|
||||
android:name="android.permission.BLUETOOTH"
|
||||
android:maxSdkVersion="30" />
|
||||
<uses-permission
|
||||
android:name="android.permission.BLUETOOTH_ADMIN"
|
||||
android:maxSdkVersion="30" />
|
||||
<uses-permission
|
||||
android:name="android.permission.ACCESS_FINE_LOCATION"
|
||||
android:maxSdkVersion="30" />
|
||||
<uses-permission
|
||||
android:name="android.permission.BLUETOOTH_SCAN"
|
||||
android:usesPermissionFlags="neverForLocation"
|
||||
tools:targetApi="s" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/logo"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.LabRecorder">
|
||||
|
||||
<activity
|
||||
android:name=".ParticipantSelectionActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.LabRecorder">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.LabRecorder" />
|
||||
|
||||
<activity
|
||||
android:name=".DataInspectionActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.LabRecorder" />
|
||||
|
||||
<activity
|
||||
android:name=".TimeNormalizedPlotActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.LabRecorder" />
|
||||
|
||||
</application>
|
||||
</manifest>
|
||||
@ -0,0 +1,221 @@
|
||||
package com.tomhempel.labrecorder
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.os.Environment
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
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.filled.ArrowBack
|
||||
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.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.tomhempel.labrecorder.ui.theme.LabRecorderTheme
|
||||
import java.io.File
|
||||
|
||||
class DataInspectionActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent {
|
||||
LabRecorderTheme {
|
||||
DataInspectionScreen(
|
||||
onNavigateBack = { finish() },
|
||||
onRecordingSelected = { recordingPath, isSingleParticipant ->
|
||||
val intent = Intent(this, TimeNormalizedPlotActivity::class.java).apply {
|
||||
putExtra("RECORDING_PATH", recordingPath)
|
||||
}
|
||||
startActivity(intent)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class RecordingType {
|
||||
SINGLE, GROUP
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DataInspectionScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
onRecordingSelected: (String, Boolean) -> Unit
|
||||
) {
|
||||
val baseDir = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS), "LabRecorder")
|
||||
var recordings by remember { mutableStateOf(listRecordings(baseDir)) }
|
||||
var selectedTab by remember { mutableStateOf(RecordingType.SINGLE) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
Column {
|
||||
TopAppBar(
|
||||
title = { Text("Recorded Data") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
}
|
||||
)
|
||||
TabRow(
|
||||
selectedTabIndex = if (selectedTab == RecordingType.SINGLE) 0 else 1
|
||||
) {
|
||||
Tab(
|
||||
selected = selectedTab == RecordingType.SINGLE,
|
||||
onClick = { selectedTab = RecordingType.SINGLE },
|
||||
text = { Text("Single Recordings") }
|
||||
)
|
||||
Tab(
|
||||
selected = selectedTab == RecordingType.GROUP,
|
||||
onClick = { selectedTab = RecordingType.GROUP },
|
||||
text = { Text("Group Recordings") }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
) { padding ->
|
||||
val filteredRecordings = recordings.filter {
|
||||
when (selectedTab) {
|
||||
RecordingType.SINGLE -> it.isSingleParticipant
|
||||
RecordingType.GROUP -> !it.isSingleParticipant
|
||||
}
|
||||
}
|
||||
|
||||
if (filteredRecordings.isEmpty()) {
|
||||
// Show empty state
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = when (selectedTab) {
|
||||
RecordingType.SINGLE -> "No single participant recordings found"
|
||||
RecordingType.GROUP -> "No group recordings found"
|
||||
},
|
||||
fontSize = 18.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
contentPadding = PaddingValues(
|
||||
start = 16.dp,
|
||||
end = 16.dp,
|
||||
top = 16.dp,
|
||||
bottom = 24.dp
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(filteredRecordings) { recording ->
|
||||
RecordingItem(
|
||||
recording = recording,
|
||||
onClick = {
|
||||
val recordingPath = if (recording.isSingleParticipant) {
|
||||
File(baseDir, "SingleRecordings/${recording.name}").absolutePath
|
||||
} else {
|
||||
File(baseDir, recording.name).absolutePath
|
||||
}
|
||||
onRecordingSelected(recordingPath, recording.isSingleParticipant)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun RecordingItem(
|
||||
recording: RecordingInfo,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = onClick
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = recording.name,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = if (recording.isSingleParticipant)
|
||||
"Participant ID: ${recording.name}"
|
||||
else
|
||||
"Group ID: ${recording.name}",
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Files: ${recording.fileCount}",
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class RecordingInfo(
|
||||
val name: String,
|
||||
val isSingleParticipant: Boolean,
|
||||
val fileCount: Int
|
||||
)
|
||||
|
||||
private fun listRecordings(baseDir: File): List<RecordingInfo> {
|
||||
if (!baseDir.exists()) return emptyList()
|
||||
|
||||
val recordings = mutableListOf<RecordingInfo>()
|
||||
|
||||
// Check SingleRecordings directory
|
||||
val singleRecordingsDir = File(baseDir, "SingleRecordings")
|
||||
if (singleRecordingsDir.exists()) {
|
||||
singleRecordingsDir.listFiles()?.forEach { participantDir ->
|
||||
if (participantDir.isDirectory) {
|
||||
recordings.add(
|
||||
RecordingInfo(
|
||||
name = participantDir.name,
|
||||
isSingleParticipant = true,
|
||||
fileCount = participantDir.listFiles()?.size ?: 0
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check group recordings (directories directly in baseDir except SingleRecordings)
|
||||
baseDir.listFiles()?.forEach { dir ->
|
||||
if (dir.isDirectory && dir.name != "SingleRecordings") {
|
||||
recordings.add(
|
||||
RecordingInfo(
|
||||
name = dir.name,
|
||||
isSingleParticipant = false,
|
||||
fileCount = dir.listFiles()?.size ?: 0
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return recordings.sortedBy { it.name }
|
||||
}
|
||||
822
Code/app/src/main/java/com/tomhempel/labrecorder/MainActivity.kt
Normal file
822
Code/app/src/main/java/com/tomhempel/labrecorder/MainActivity.kt
Normal file
@ -0,0 +1,822 @@
|
||||
package com.tomhempel.labrecorder
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Application
|
||||
import android.bluetooth.BluetoothGatt
|
||||
import android.bluetooth.BluetoothGattCallback
|
||||
import android.bluetooth.BluetoothGattCharacteristic
|
||||
import android.bluetooth.BluetoothGattDescriptor
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.bluetooth.BluetoothProfile
|
||||
import android.bluetooth.le.ScanCallback
|
||||
import android.bluetooth.le.ScanResult
|
||||
import android.bluetooth.le.ScanSettings
|
||||
import android.content.Context
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Environment
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Stop
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.tomhempel.labrecorder.ui.theme.LabRecorderTheme
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.BufferedWriter
|
||||
import java.io.File
|
||||
import java.io.FileWriter
|
||||
import java.io.IOException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
import android.content.Intent
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
|
||||
private const val TAG = "LabRecorder"
|
||||
|
||||
// Standard Bluetooth Service and Characteristic UUIDs
|
||||
private val HR_SERVICE_UUID: UUID = UUID.fromString("0000180d-0000-1000-8000-00805f9b34fb")
|
||||
private val HR_MEASUREMENT_CHAR_UUID: UUID = UUID.fromString("00002a37-0000-1000-8000-00805f9b34fb")
|
||||
private val CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
|
||||
|
||||
data class BleDevice(val name: String, val address: String)
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
// --- Bluetooth & System Services ---
|
||||
private val bluetoothManager: BluetoothManager by lazy {
|
||||
application.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||
}
|
||||
private val bluetoothAdapter by lazy { bluetoothManager.adapter }
|
||||
private val bluetoothLeScanner by lazy { bluetoothAdapter.bluetoothLeScanner }
|
||||
|
||||
// --- UI State Management ---
|
||||
val isScanning = mutableStateOf(false)
|
||||
val discoveredDevices = mutableStateListOf<BleDevice>()
|
||||
val groupId = mutableStateOf("")
|
||||
val participantId = mutableStateOf("") // New state for single participant ID
|
||||
val isRecording = mutableStateOf(false)
|
||||
val isIntervalRunning = mutableStateOf(false)
|
||||
val connectionState1 = mutableStateOf("Disconnected")
|
||||
val connectionState2 = mutableStateOf("Disconnected")
|
||||
val hrValue1 = mutableStateOf(0)
|
||||
val hrValue2 = mutableStateOf(0)
|
||||
val logMessages = mutableStateListOf<String>()
|
||||
val selectedDevice1 = mutableStateOf<BleDevice?>(null)
|
||||
val selectedDevice2 = mutableStateOf<BleDevice?>(null)
|
||||
val numberOfParticipants = mutableStateOf(0) // New state for number of participants
|
||||
|
||||
|
||||
// --- Connection & File Management ---
|
||||
private var gatt1: BluetoothGatt? = null
|
||||
private var gatt2: BluetoothGatt? = null
|
||||
private var hrWriter1: BufferedWriter? = null
|
||||
private var rrWriter1: BufferedWriter? = null
|
||||
private var hrWriter2: BufferedWriter? = null
|
||||
private var rrWriter2: BufferedWriter? = null
|
||||
private var timestampWriter: BufferedWriter? = null
|
||||
private var scanTimeoutJob: Job? = null
|
||||
|
||||
init {
|
||||
addLog("App initialized. Welcome!")
|
||||
}
|
||||
|
||||
// --- Logging Helper ---
|
||||
private fun addLog(message: String) {
|
||||
val timeStamp = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date())
|
||||
val logEntry = "$timeStamp: $message"
|
||||
logMessages.add(0, logEntry)
|
||||
if (logMessages.size > 100) {
|
||||
logMessages.removeLast()
|
||||
}
|
||||
Log.d(TAG, message)
|
||||
}
|
||||
|
||||
// --- GATT Callback for Connection & Data Events ---
|
||||
private val gattCallback = object : BluetoothGattCallback() {
|
||||
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
|
||||
val deviceAddress = gatt.device.address
|
||||
val (stateToUpdate, hrToUpdate, participant) = if (deviceAddress == gatt1?.device?.address) {
|
||||
Triple(connectionState1, hrValue1, "P1")
|
||||
} else {
|
||||
Triple(connectionState2, hrValue2, "P2")
|
||||
}
|
||||
|
||||
if (status == BluetoothGatt.GATT_SUCCESS) {
|
||||
if (newState == BluetoothProfile.STATE_CONNECTED) {
|
||||
stateToUpdate.value = "Connected"
|
||||
addLog("[$participant] Connected to $deviceAddress. Discovering services...")
|
||||
gatt.discoverServices()
|
||||
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
|
||||
stateToUpdate.value = "Disconnected"
|
||||
hrToUpdate.value = 0
|
||||
addLog("[$participant] Disconnected from $deviceAddress.")
|
||||
gatt.close()
|
||||
}
|
||||
} else {
|
||||
stateToUpdate.value = "Error"
|
||||
hrToUpdate.value = 0
|
||||
addLog("[$participant] ERROR: GATT Error for $deviceAddress. Status: $status")
|
||||
gatt.close()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
|
||||
val participant = if (gatt.device.address == gatt1?.device?.address) "P1" else "P2"
|
||||
if (status == BluetoothGatt.GATT_SUCCESS) {
|
||||
addLog("[$participant] Services discovered. Enabling HR notifications.")
|
||||
enableHeartRateNotifications(gatt)
|
||||
} else {
|
||||
addLog("[$participant] ERROR: Service discovery failed.")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray) {
|
||||
if (characteristic.uuid == HR_MEASUREMENT_CHAR_UUID) {
|
||||
parseAndRecordData(gatt.device.address, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Scanning Logic ---
|
||||
private val scanCallback = object : ScanCallback() {
|
||||
override fun onScanResult(callbackType: Int, result: ScanResult) {
|
||||
val deviceName = result.device.name
|
||||
if (deviceName?.startsWith("Polar", ignoreCase = true) == true) {
|
||||
val device = BleDevice(deviceName, result.device.address)
|
||||
if (discoveredDevices.none { it.address == device.address }) {
|
||||
addLog("Found device: ${device.name}")
|
||||
discoveredDevices.add(device)
|
||||
if (discoveredDevices.size >= numberOfParticipants.value) {
|
||||
addLog("Found ${numberOfParticipants.value} Polar device(s). Stopping scan.")
|
||||
stopBleScan()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onScanFailed(errorCode: Int) {
|
||||
super.onScanFailed(errorCode)
|
||||
addLog("ERROR: BLE Scan Failed with code: $errorCode")
|
||||
isScanning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// --- Data Processing ---
|
||||
private fun parseAndRecordData(deviceAddress: String, data: ByteArray) {
|
||||
val isDevice1 = deviceAddress == gatt1?.device?.address
|
||||
val hrState = if (isDevice1) hrValue1 else hrValue2
|
||||
val currentHrWriter = if (isDevice1) hrWriter1 else hrWriter2
|
||||
val currentRrWriter = if (isDevice1) rrWriter1 else rrWriter2
|
||||
|
||||
val flags = data[0].toInt()
|
||||
val is16BitFormat = (flags and 0x01) != 0
|
||||
val hrValue = if (is16BitFormat) {
|
||||
(data[1].toInt() and 0xFF) or ((data[2].toInt() and 0xFF) shl 8)
|
||||
} else {
|
||||
data[1].toInt() and 0xFF
|
||||
}
|
||||
hrState.value = hrValue
|
||||
|
||||
currentHrWriter?.let { writer ->
|
||||
try {
|
||||
val timestamp = System.currentTimeMillis()
|
||||
writer.write("$timestamp,$hrValue\n")
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "Error writing HR data for $deviceAddress", e)
|
||||
}
|
||||
}
|
||||
|
||||
val rrIntervalsPresent = (flags and 0x10) != 0
|
||||
if (rrIntervalsPresent) {
|
||||
var offset = if (is16BitFormat) 3 else 2
|
||||
while (offset < data.size) {
|
||||
val rrValue = ((data[offset].toInt() and 0xFF) or ((data[offset + 1].toInt() and 0xFF) shl 8))
|
||||
currentRrWriter?.let { writer ->
|
||||
try {
|
||||
val timestamp = System.currentTimeMillis()
|
||||
writer.write("$timestamp,$rrValue\n")
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "Error writing RR data for $deviceAddress", e)
|
||||
}
|
||||
}
|
||||
offset += 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- BLE Actions ---
|
||||
private fun enableHeartRateNotifications(gatt: BluetoothGatt) {
|
||||
val hrCharacteristic = gatt.getService(HR_SERVICE_UUID)?.getCharacteristic(HR_MEASUREMENT_CHAR_UUID)
|
||||
if (hrCharacteristic == null) {
|
||||
addLog("ERROR: Heart Rate characteristic not found on ${gatt.device.address}")
|
||||
return
|
||||
}
|
||||
gatt.setCharacteristicNotification(hrCharacteristic, true)
|
||||
val descriptor = hrCharacteristic.getDescriptor(CCCD_UUID)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
gatt.writeDescriptor(descriptor, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE)
|
||||
} else {
|
||||
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
|
||||
gatt.writeDescriptor(descriptor)
|
||||
}
|
||||
addLog("Enabling notifications for ${gatt.device.address}")
|
||||
}
|
||||
|
||||
fun startBleScan() {
|
||||
addLog("Starting automatic BLE scan (2s or 2 devices)...")
|
||||
disconnectAll()
|
||||
discoveredDevices.clear()
|
||||
selectedDevice1.value = null
|
||||
selectedDevice2.value = null
|
||||
isScanning.value = true
|
||||
val settings = ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build()
|
||||
bluetoothLeScanner?.startScan(null, settings, scanCallback)
|
||||
|
||||
scanTimeoutJob?.cancel()
|
||||
scanTimeoutJob = viewModelScope.launch {
|
||||
delay(2000)
|
||||
if (isScanning.value) {
|
||||
addLog("Scan timeout (2s) reached.")
|
||||
stopBleScan()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stopBleScan() {
|
||||
if (!isScanning.value) return
|
||||
addLog("Stopping BLE scan.")
|
||||
isScanning.value = false
|
||||
scanTimeoutJob?.cancel()
|
||||
scanTimeoutJob = null
|
||||
bluetoothLeScanner?.stopScan(scanCallback)
|
||||
|
||||
// Auto-select devices after scan stops and connect
|
||||
if (discoveredDevices.isNotEmpty()) {
|
||||
selectedDevice1.value = discoveredDevices.getOrNull(0)
|
||||
selectedDevice2.value = discoveredDevices.getOrNull(1)
|
||||
connectToDevices()
|
||||
}
|
||||
}
|
||||
|
||||
fun connectToDevices() {
|
||||
val address1 = selectedDevice1.value?.address ?: ""
|
||||
val address2 = if (numberOfParticipants.value == 2) selectedDevice2.value?.address else ""
|
||||
disconnectAll()
|
||||
if (address1.isNotEmpty()) {
|
||||
val device = bluetoothAdapter?.getRemoteDevice(address1)
|
||||
addLog("Attempting to connect to P1: $address1")
|
||||
gatt1 = device?.connectGatt(getApplication(), false, gattCallback)
|
||||
}
|
||||
if (address2?.isNotEmpty() == true && address1 != address2) {
|
||||
val device = bluetoothAdapter?.getRemoteDevice(address2)
|
||||
addLog("Attempting to connect to P2: $address2")
|
||||
gatt2 = device?.connectGatt(getApplication(), false, gattCallback)
|
||||
}
|
||||
}
|
||||
|
||||
fun disconnectAll() {
|
||||
gatt1?.disconnect()
|
||||
gatt2?.disconnect()
|
||||
}
|
||||
|
||||
// --- File Recording Logic ---
|
||||
fun startRecording() {
|
||||
if (numberOfParticipants.value == 2 && groupId.value.isBlank()) {
|
||||
addLog("ERROR: Group ID cannot be empty.")
|
||||
Toast.makeText(getApplication(), "Group ID cannot be empty.", Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
} else if (numberOfParticipants.value == 1 && participantId.value.isBlank()) {
|
||||
addLog("ERROR: Participant ID cannot be empty.")
|
||||
Toast.makeText(getApplication(), "Participant ID cannot be empty.", Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
|
||||
val baseDir = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS), "LabRecorder")
|
||||
val recordingDir = if (numberOfParticipants.value == 2) {
|
||||
addLog("Starting recording for group: ${groupId.value}")
|
||||
File(baseDir, groupId.value)
|
||||
} else {
|
||||
addLog("Starting recording for participant: ${participantId.value}")
|
||||
val singleRecordingsDir = File(baseDir, "SingleRecordings")
|
||||
File(singleRecordingsDir, participantId.value)
|
||||
}
|
||||
|
||||
if (!recordingDir.exists() && !recordingDir.mkdirs()) {
|
||||
addLog("ERROR: Failed to create recording directory.")
|
||||
return
|
||||
}
|
||||
|
||||
if (gatt1?.device?.address != null) {
|
||||
val deviceDir = if (numberOfParticipants.value == 2) {
|
||||
File(recordingDir, "Participant_1")
|
||||
} else {
|
||||
recordingDir // For single participant, use the participant directory directly
|
||||
}
|
||||
deviceDir.mkdirs()
|
||||
try {
|
||||
hrWriter1 = BufferedWriter(FileWriter(File(deviceDir, "hr.csv"))).apply { write("timestamp,hr\n") }
|
||||
rrWriter1 = BufferedWriter(FileWriter(File(deviceDir, "rr.csv"))).apply { write("timestamp,rr_ms\n") }
|
||||
} catch (e: IOException) { addLog("ERROR: Failed creating writers for P1.") }
|
||||
}
|
||||
|
||||
if (numberOfParticipants.value == 2 && gatt2?.device?.address != null) {
|
||||
val deviceDir = File(recordingDir, "Participant_2")
|
||||
deviceDir.mkdirs()
|
||||
try {
|
||||
hrWriter2 = BufferedWriter(FileWriter(File(deviceDir, "hr.csv"))).apply { write("timestamp,hr\n") }
|
||||
rrWriter2 = BufferedWriter(FileWriter(File(deviceDir, "rr.csv"))).apply { write("timestamp,rr_ms\n") }
|
||||
} catch (e: IOException) { addLog("ERROR: Failed creating writers for P2.") }
|
||||
}
|
||||
|
||||
try {
|
||||
timestampWriter = BufferedWriter(FileWriter(File(recordingDir, "timestamps.csv"))).apply { write("timestamp,event_type\n") }
|
||||
} catch(e: IOException) { addLog("ERROR: Failed creating timestamp writer.") }
|
||||
|
||||
isRecording.value = true
|
||||
Toast.makeText(getApplication(), "Recording started.", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
fun stopRecording() {
|
||||
addLog("Stopping recording.")
|
||||
isRecording.value = false
|
||||
isIntervalRunning.value = false
|
||||
try {
|
||||
hrWriter1?.close()
|
||||
rrWriter1?.close()
|
||||
hrWriter2?.close()
|
||||
rrWriter2?.close()
|
||||
timestampWriter?.close()
|
||||
} catch (e: IOException) { addLog("ERROR: Failed closing file writers.") }
|
||||
|
||||
hrWriter1 = null; rrWriter1 = null; hrWriter2 = null; rrWriter2 = null; timestampWriter = null
|
||||
Toast.makeText(getApplication(), "Recording stopped.", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
fun markTimestamp(eventType: String) {
|
||||
if (!isRecording.value) {
|
||||
val msg = "Must be recording to mark a timestamp."
|
||||
addLog("INFO: $msg")
|
||||
Toast.makeText(getApplication(), msg, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
timestampWriter?.let { writer ->
|
||||
try {
|
||||
val timestamp = System.currentTimeMillis()
|
||||
writer.write("$timestamp,$eventType\n")
|
||||
writer.flush()
|
||||
val msg = "'$eventType' marked."
|
||||
addLog(msg)
|
||||
Toast.makeText(getApplication(), msg, Toast.LENGTH_SHORT).show()
|
||||
} catch (e: IOException) {
|
||||
addLog("ERROR: Could not write timestamp.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
if (isRecording.value) {
|
||||
stopRecording()
|
||||
}
|
||||
disconnectAll()
|
||||
addLog("ViewModel cleared. Resources released.")
|
||||
}
|
||||
}
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
private val viewModel: MainViewModel by viewModels()
|
||||
private var showExitDialog by mutableStateOf(false)
|
||||
|
||||
private val requestPermissionLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions ->
|
||||
if (permissions.values.all { it }) {
|
||||
// Permissions granted, start the scan
|
||||
viewModel.startBleScan()
|
||||
} else {
|
||||
Toast.makeText(this, "Bluetooth permissions are required for this app to function.", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBackPressed() {
|
||||
// If recording is in progress, show a warning dialog
|
||||
if (viewModel.isRecording.value) {
|
||||
showExitDialog = true
|
||||
} else {
|
||||
returnToSelection()
|
||||
}
|
||||
}
|
||||
|
||||
private fun returnToSelection() {
|
||||
// Stop any ongoing operations
|
||||
viewModel.stopBleScan()
|
||||
viewModel.disconnectAll()
|
||||
|
||||
// Start ParticipantSelectionActivity
|
||||
val intent = Intent(this, ParticipantSelectionActivity::class.java)
|
||||
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
startActivity(intent)
|
||||
finish()
|
||||
}
|
||||
|
||||
private fun hasRequiredBluetoothPermissions(): Boolean {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED &&
|
||||
ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED
|
||||
} else {
|
||||
ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestRelevantPermissions() {
|
||||
val permissionsToRequest = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
arrayOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT)
|
||||
} else {
|
||||
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
}
|
||||
requestPermissionLauncher.launch(permissionsToRequest)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
|
||||
// Get the number of participants from the intent
|
||||
val numberOfParticipants = intent.getIntExtra("NUMBER_OF_PARTICIPANTS", 2)
|
||||
viewModel.numberOfParticipants.value = numberOfParticipants
|
||||
|
||||
// Lock orientation to vertical to prevent activity recreation on rotation
|
||||
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||
|
||||
setContent {
|
||||
LabRecorderTheme {
|
||||
if (showExitDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showExitDialog = false },
|
||||
title = { Text("Recording in Progress") },
|
||||
text = { Text("Stop recording and return to selection screen?") },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
showExitDialog = false
|
||||
viewModel.stopRecording()
|
||||
returnToSelection()
|
||||
}) {
|
||||
Text("Yes")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showExitDialog = false }) {
|
||||
Text("No")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Scaffold { innerPadding ->
|
||||
MainScreen(
|
||||
modifier = Modifier.padding(innerPadding),
|
||||
viewModel = viewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for permissions and start scanning if granted
|
||||
if (hasRequiredBluetoothPermissions()) {
|
||||
viewModel.startBleScan()
|
||||
} else {
|
||||
requestRelevantPermissions()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MainScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: MainViewModel
|
||||
) {
|
||||
// Read state from the ViewModel
|
||||
val isScanning by viewModel.isScanning
|
||||
val discoveredDevices = viewModel.discoveredDevices
|
||||
val connectionStatus1 by viewModel.connectionState1
|
||||
val connectionStatus2 by viewModel.connectionState2
|
||||
val hrValue1 by viewModel.hrValue1
|
||||
val hrValue2 by viewModel.hrValue2
|
||||
val groupId by viewModel.groupId
|
||||
val participantId by viewModel.participantId
|
||||
val isRecording by viewModel.isRecording
|
||||
val isIntervalRunning by viewModel.isIntervalRunning
|
||||
val logMessages = viewModel.logMessages
|
||||
val selectedDevice1 by viewModel.selectedDevice1
|
||||
val selectedDevice2 by viewModel.selectedDevice2
|
||||
val numberOfParticipants by viewModel.numberOfParticipants
|
||||
|
||||
val dropdownOptions = when {
|
||||
discoveredDevices.isNotEmpty() -> discoveredDevices
|
||||
isScanning -> listOf(BleDevice("Scanning...", ""))
|
||||
else -> listOf(BleDevice("No devices found", ""))
|
||||
}
|
||||
|
||||
var expanded1 by remember { mutableStateOf(false) }
|
||||
var expanded2 by remember { mutableStateOf(false) }
|
||||
|
||||
Column(
|
||||
modifier = modifier.fillMaxSize().padding(horizontal = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
// --- Section 1: Setup ---
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
if (numberOfParticipants == 2) {
|
||||
OutlinedTextField(
|
||||
value = groupId,
|
||||
onValueChange = { viewModel.groupId.value = it },
|
||||
label = { Text("Group ID *") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
enabled = !isRecording
|
||||
)
|
||||
} else if (numberOfParticipants == 1) {
|
||||
OutlinedTextField(
|
||||
value = participantId,
|
||||
onValueChange = { viewModel.participantId.value = it },
|
||||
label = { Text("Participant ID *") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
enabled = !isRecording
|
||||
)
|
||||
}
|
||||
Button(
|
||||
onClick = { if (isScanning) viewModel.stopBleScan() else viewModel.startBleScan() },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !isRecording
|
||||
) {
|
||||
if (isScanning) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(24.dp), color = MaterialTheme.colorScheme.onPrimary)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Stop Scan")
|
||||
} else {
|
||||
Text("Scan for Polar Devices")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Section 2: Device Connection ---
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
DeviceSelectionDropdown(
|
||||
label = "Participant 1",
|
||||
options = dropdownOptions,
|
||||
selectedOption = selectedDevice1,
|
||||
onOptionSelected = { viewModel.selectedDevice1.value = it },
|
||||
expanded = expanded1,
|
||||
onExpandedChange = { expanded1 = it },
|
||||
enabled = discoveredDevices.isNotEmpty() && !isRecording,
|
||||
status = connectionStatus1,
|
||||
hrValue = hrValue1
|
||||
)
|
||||
if (numberOfParticipants == 2) {
|
||||
DeviceSelectionDropdown(
|
||||
label = "Participant 2",
|
||||
options = dropdownOptions,
|
||||
selectedOption = selectedDevice2,
|
||||
onOptionSelected = { viewModel.selectedDevice2.value = it },
|
||||
expanded = expanded2,
|
||||
onExpandedChange = { expanded2 = it },
|
||||
enabled = discoveredDevices.isNotEmpty() && !isRecording,
|
||||
status = connectionStatus2,
|
||||
hrValue = hrValue2
|
||||
)
|
||||
}
|
||||
Button(
|
||||
onClick = { viewModel.connectToDevices() },
|
||||
enabled = (selectedDevice1 != null || selectedDevice2 != null) && !isRecording,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Connect")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Section 3: Recording Controls ---
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Button(
|
||||
onClick = { if (isRecording) viewModel.stopRecording() else viewModel.startRecording() },
|
||||
enabled = (connectionStatus1 == "Connected" || connectionStatus2 == "Connected") &&
|
||||
((numberOfParticipants == 2 && groupId.isNotBlank()) ||
|
||||
(numberOfParticipants == 1 && participantId.isNotBlank())),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = if (isRecording) Color(0xFFD32F2F) else MaterialTheme.colorScheme.primary
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(if (isRecording) Icons.Filled.Stop else Icons.Filled.PlayArrow, contentDescription = null)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(if (isRecording) "Stop Recording" else "Start Recording")
|
||||
}
|
||||
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(
|
||||
onClick = {
|
||||
if (isIntervalRunning) {
|
||||
viewModel.markTimestamp("interval_end")
|
||||
viewModel.isIntervalRunning.value = false
|
||||
} else {
|
||||
viewModel.markTimestamp("interval_start")
|
||||
viewModel.isIntervalRunning.value = true
|
||||
}
|
||||
},
|
||||
enabled = isRecording,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(if (isIntervalRunning) "Stop Interval" else "Start Interval")
|
||||
}
|
||||
Button(
|
||||
onClick = { viewModel.markTimestamp("manual_mark") },
|
||||
enabled = isRecording,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text("Mark Timestamp")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Section 4: Event Log Console ---
|
||||
LogConsole(logMessages = logMessages, modifier = Modifier.weight(1f))
|
||||
Text(
|
||||
text = "Developed by Tom Hempel",
|
||||
fontSize = 12.sp,
|
||||
color = LocalContentColor.current.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun DeviceSelectionDropdown(
|
||||
label: String,
|
||||
options: List<BleDevice>,
|
||||
selectedOption: BleDevice?,
|
||||
onOptionSelected: (BleDevice) -> Unit,
|
||||
expanded: Boolean,
|
||||
onExpandedChange: (Boolean) -> Unit,
|
||||
enabled: Boolean,
|
||||
status: String,
|
||||
hrValue: Int
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded && enabled,
|
||||
onExpandedChange = onExpandedChange,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
TextField(
|
||||
modifier = Modifier.menuAnchor().fillMaxWidth(),
|
||||
readOnly = true,
|
||||
value = selectedOption?.name ?: if(enabled) "Select a device" else "Scan first",
|
||||
onValueChange = {},
|
||||
label = { Text(label) },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
enabled = enabled
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded && enabled,
|
||||
onDismissRequest = { onExpandedChange(false) },
|
||||
) {
|
||||
options.forEach { device ->
|
||||
if (device.address.isNotEmpty()) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(device.name) },
|
||||
onClick = {
|
||||
onOptionSelected(device)
|
||||
onExpandedChange(false)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.width(100.dp)) {
|
||||
val statusColor = when(status) {
|
||||
"Connected" -> Color(0xFF2E7D32) // Dark Green
|
||||
"Error" -> MaterialTheme.colorScheme.error
|
||||
else -> LocalContentColor.current.copy(alpha = 0.7f)
|
||||
}
|
||||
Text(status, fontSize = 14.sp, color = statusColor, fontWeight = FontWeight.Bold)
|
||||
|
||||
if (status == "Connected") {
|
||||
Text(
|
||||
text = "$hrValue BPM",
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LogConsole(logMessages: List<String>, modifier: Modifier = Modifier) {
|
||||
val listState = rememberLazyListState()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(logMessages.size) {
|
||||
if (logMessages.isNotEmpty()) {
|
||||
coroutineScope.launch {
|
||||
listState.animateScrollToItem(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedCard(modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||
Text("Event Log", style = MaterialTheme.typography.titleMedium)
|
||||
Divider(modifier = Modifier.padding(vertical = 4.dp))
|
||||
LazyColumn(state = listState, reverseLayout = true, modifier = Modifier.fillMaxSize()) {
|
||||
items(logMessages) { msg ->
|
||||
val color = when {
|
||||
msg.contains("ERROR") -> MaterialTheme.colorScheme.error
|
||||
msg.contains("Connected") || msg.contains("Recording started") -> Color(0xFF2E7D32) // Dark Green
|
||||
msg.contains("Stopping") || msg.contains("Disconnected") -> Color(0xFFC62828) // Dark Red
|
||||
else -> LocalContentColor.current
|
||||
}
|
||||
Text(
|
||||
text = msg,
|
||||
color = color,
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
modifier = Modifier.padding(bottom = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ParticipantSelectionDialog(
|
||||
onDismiss: (Int) -> Unit
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { /* Do nothing, force selection */ },
|
||||
title = { Text("Select Number of Participants") },
|
||||
text = { Text("Please select the number of participants for this recording session.") },
|
||||
confirmButton = {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
Button(
|
||||
onClick = { onDismiss(1) },
|
||||
modifier = Modifier.weight(1f).padding(end = 8.dp)
|
||||
) {
|
||||
Text("1 Participant")
|
||||
}
|
||||
Button(
|
||||
onClick = { onDismiss(2) },
|
||||
modifier = Modifier.weight(1f).padding(start = 8.dp)
|
||||
) {
|
||||
Text("2 Participants")
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton = null // No dismiss button to force selection
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,181 @@
|
||||
package com.tomhempel.labrecorder
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.Image
|
||||
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.res.painterResource
|
||||
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 com.tomhempel.labrecorder.ui.theme.LabRecorderTheme
|
||||
|
||||
class ParticipantSelectionActivity : ComponentActivity() {
|
||||
private var showExitDialog by mutableStateOf(false)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent {
|
||||
LabRecorderTheme {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
if (showExitDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showExitDialog = false },
|
||||
title = { Text("Exit App") },
|
||||
text = { Text("Do you want to exit the app?") },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
showExitDialog = false
|
||||
finish()
|
||||
}) {
|
||||
Text("Yes")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showExitDialog = false }) {
|
||||
Text("No")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
ParticipantSelectionScreen(
|
||||
onSelectionMade = { numberOfParticipants ->
|
||||
val intent = Intent(this, MainActivity::class.java).apply {
|
||||
putExtra("NUMBER_OF_PARTICIPANTS", numberOfParticipants)
|
||||
}
|
||||
startActivity(intent)
|
||||
finish() // Close this activity
|
||||
},
|
||||
onInspectData = {
|
||||
val intent = Intent(this, DataInspectionActivity::class.java)
|
||||
startActivity(intent)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBackPressed() {
|
||||
showExitDialog = true
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ParticipantSelectionScreen(
|
||||
onSelectionMade: (Int) -> Unit,
|
||||
onInspectData: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
// Logo
|
||||
Image(
|
||||
painter = painterResource(id = R.mipmap.logo),
|
||||
contentDescription = "App Logo",
|
||||
modifier = Modifier.size(120.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Title
|
||||
Text(
|
||||
text = "Lab Recorder",
|
||||
fontSize = 28.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
// Description
|
||||
Text(
|
||||
text = "Select the number of participants for this recording session",
|
||||
fontSize = 16.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = 32.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
// Selection Buttons
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Button(
|
||||
onClick = { onSelectionMade(1) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(56.dp)
|
||||
.padding(horizontal = 32.dp)
|
||||
) {
|
||||
Text("Single Participant", fontSize = 18.sp)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = { onSelectionMade(2) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(56.dp)
|
||||
.padding(horizontal = 32.dp)
|
||||
) {
|
||||
Text("Two Participants", fontSize = 18.sp)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "Developed by Tom Hempel",
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = LocalContentColor.current.copy(alpha = 0.7f),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
|
||||
// Divider and Data Inspection Section
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 30.dp) // Added extra padding at the bottom
|
||||
) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 12.dp),
|
||||
thickness = 2.dp
|
||||
)
|
||||
|
||||
OutlinedButton(
|
||||
onClick = onInspectData,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(56.dp)
|
||||
.padding(horizontal = 32.dp)
|
||||
) {
|
||||
Text("Inspect Recorded Data", fontSize = 16.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,452 @@
|
||||
package com.tomhempel.labrecorder
|
||||
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.github.mikephil.charting.charts.LineChart
|
||||
import com.github.mikephil.charting.components.Legend
|
||||
import com.github.mikephil.charting.components.XAxis
|
||||
import com.github.mikephil.charting.data.Entry
|
||||
import com.github.mikephil.charting.data.LineData
|
||||
import com.github.mikephil.charting.data.LineDataSet
|
||||
import java.io.File
|
||||
import java.io.FileReader
|
||||
import java.io.BufferedReader
|
||||
import android.view.ViewGroup
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.cardview.widget.CardView
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import com.github.mikephil.charting.components.YAxis
|
||||
import com.github.mikephil.charting.highlight.Highlight
|
||||
import com.github.mikephil.charting.listener.OnChartValueSelectedListener
|
||||
import com.github.mikephil.charting.interfaces.datasets.ILineDataSet
|
||||
|
||||
class TimeNormalizedPlotActivity : AppCompatActivity() {
|
||||
private lateinit var hrChart: LineChart
|
||||
private lateinit var rrChart: LineChart
|
||||
private val intervalColors = listOf(
|
||||
Color.argb(128, 128, 128, 128), // Gray
|
||||
Color.argb(128, 128, 64, 64), // Dark Red
|
||||
Color.argb(128, 64, 128, 64), // Dark Green
|
||||
Color.argb(128, 64, 64, 128), // Dark Blue
|
||||
Color.argb(128, 128, 128, 64), // Dark Yellow
|
||||
Color.argb(128, 128, 64, 128) // Dark Purple
|
||||
)
|
||||
|
||||
data class TimestampEvent(
|
||||
val timestamp: Long,
|
||||
val eventType: String
|
||||
)
|
||||
|
||||
data class DataPoint(
|
||||
val timestamp: Long,
|
||||
val value: Float
|
||||
)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// Create ScrollView for scrollable content
|
||||
val scrollView = android.widget.ScrollView(this).apply {
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
isFillViewport = true
|
||||
}
|
||||
|
||||
// Create layout programmatically
|
||||
val rootLayout = LinearLayout(this).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
setPadding(16, 32, 16, 32) // Added top and bottom padding
|
||||
}
|
||||
|
||||
// Create stats cards container
|
||||
val statsContainer = LinearLayout(this).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
).apply {
|
||||
setMargins(0, 0, 0, 16)
|
||||
}
|
||||
weightSum = 2f
|
||||
}
|
||||
|
||||
// Create stats cards for each participant
|
||||
val participant1Stats = CardView(this).apply {
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
0,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||
1f
|
||||
).apply {
|
||||
setMargins(0, 0, 8, 0)
|
||||
}
|
||||
radius = 12f
|
||||
cardElevation = 8f
|
||||
setCardBackgroundColor(Color.parseColor("#1F1F1F"))
|
||||
}
|
||||
|
||||
val participant2Stats = CardView(this).apply {
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
0,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||
1f
|
||||
).apply {
|
||||
setMargins(8, 0, 0, 0)
|
||||
}
|
||||
radius = 12f
|
||||
cardElevation = 8f
|
||||
setCardBackgroundColor(Color.parseColor("#1F1F1F"))
|
||||
}
|
||||
|
||||
// Create TextViews for stats with better styling
|
||||
val participant1Text = TextView(this).apply {
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
setPadding(20, 20, 20, 20)
|
||||
textSize = 14f
|
||||
setTextColor(Color.WHITE)
|
||||
setLineSpacing(4f, 1.2f)
|
||||
}
|
||||
|
||||
val participant2Text = TextView(this).apply {
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
setPadding(20, 20, 20, 20)
|
||||
textSize = 14f
|
||||
setTextColor(Color.WHITE)
|
||||
setLineSpacing(4f, 1.2f)
|
||||
}
|
||||
|
||||
participant1Stats.addView(participant1Text)
|
||||
participant2Stats.addView(participant2Text)
|
||||
statsContainer.addView(participant1Stats)
|
||||
statsContainer.addView(participant2Stats)
|
||||
rootLayout.addView(statsContainer)
|
||||
|
||||
// Create HR Chart Card
|
||||
val hrCard = CardView(this).apply {
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
0,
|
||||
1f
|
||||
).apply {
|
||||
setMargins(0, 0, 0, 16)
|
||||
}
|
||||
radius = 8f
|
||||
cardElevation = 4f
|
||||
}
|
||||
hrChart = LineChart(this).apply {
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
description.isEnabled = false
|
||||
}
|
||||
hrCard.addView(hrChart)
|
||||
rootLayout.addView(hrCard)
|
||||
|
||||
// Create RR Chart Card
|
||||
val rrCard = CardView(this).apply {
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
0,
|
||||
1f
|
||||
).apply {
|
||||
setMargins(0, 0, 0, 16)
|
||||
}
|
||||
radius = 8f
|
||||
cardElevation = 4f
|
||||
}
|
||||
rrChart = LineChart(this).apply {
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
description.isEnabled = false
|
||||
}
|
||||
rrCard.addView(rrChart)
|
||||
rootLayout.addView(rrCard)
|
||||
|
||||
// Add ScrollView to the content
|
||||
scrollView.addView(rootLayout)
|
||||
setContentView(scrollView)
|
||||
|
||||
// Configure charts
|
||||
setupChart(hrChart, "Heart Rate (BPM)")
|
||||
setupChart(rrChart, "RR Interval (ms)")
|
||||
|
||||
// Load and display data
|
||||
val recordingPath = intent.getStringExtra("RECORDING_PATH") ?: return
|
||||
loadAndDisplayData(recordingPath, participant1Text, participant2Text)
|
||||
}
|
||||
|
||||
private fun setupChart(chart: LineChart, yAxisLabel: String) {
|
||||
chart.apply {
|
||||
// Disable interactions
|
||||
setTouchEnabled(false)
|
||||
setPinchZoom(false)
|
||||
setScaleEnabled(false)
|
||||
isDragEnabled = false
|
||||
|
||||
// Configure legend
|
||||
legend.apply {
|
||||
horizontalAlignment = Legend.LegendHorizontalAlignment.CENTER
|
||||
verticalAlignment = Legend.LegendVerticalAlignment.TOP
|
||||
orientation = Legend.LegendOrientation.HORIZONTAL
|
||||
setDrawInside(false)
|
||||
form = Legend.LegendForm.LINE
|
||||
}
|
||||
|
||||
// Configure axes
|
||||
xAxis.apply {
|
||||
position = XAxis.XAxisPosition.BOTTOM
|
||||
setDrawGridLines(true)
|
||||
labelRotationAngle = 0f
|
||||
axisMinimum = 0f
|
||||
textSize = 12f
|
||||
valueFormatter = object : com.github.mikephil.charting.formatter.ValueFormatter() {
|
||||
override fun getFormattedValue(value: Float): String {
|
||||
return "${value.toInt()}s"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
axisLeft.apply {
|
||||
setDrawGridLines(true)
|
||||
textSize = 12f
|
||||
// Fix: Use description instead of axisLabel
|
||||
description.text = yAxisLabel
|
||||
}
|
||||
|
||||
axisRight.isEnabled = false
|
||||
|
||||
// Add padding for better visibility
|
||||
setExtraOffsets(8f, 16f, 8f, 8f)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadAndDisplayData(
|
||||
recordingPath: String,
|
||||
participant1Text: TextView,
|
||||
participant2Text: TextView
|
||||
) {
|
||||
val recordingDir = File(recordingPath)
|
||||
val timestampsFile = File(recordingDir, "timestamps.csv")
|
||||
|
||||
// Load global events
|
||||
val events = loadTimestamps(timestampsFile)
|
||||
val firstTimestamp = events.minOfOrNull { it.timestamp } ?: 0L
|
||||
|
||||
// Load participant data
|
||||
val participants = listOf("Participant_1", "Participant_2")
|
||||
val participantColors = listOf(Color.BLUE, Color.RED)
|
||||
|
||||
val hrDataSets = mutableListOf<ILineDataSet>()
|
||||
val rrDataSets = mutableListOf<ILineDataSet>()
|
||||
val statsBuilders = Array(2) { StringBuilder() }
|
||||
|
||||
participants.forEachIndexed { index, participant ->
|
||||
val participantDir = File(recordingDir, participant)
|
||||
val hrFile = File(participantDir, "hr.csv")
|
||||
val rrFile = File(participantDir, "rr.csv")
|
||||
|
||||
statsBuilders[index].append("$participant\n\n")
|
||||
|
||||
// Load HR data
|
||||
val hrData = loadDataPoints(hrFile)
|
||||
if (hrData.isNotEmpty()) {
|
||||
val hrEntries = hrData.map {
|
||||
Entry(
|
||||
(it.timestamp - firstTimestamp) / 1000f,
|
||||
it.value
|
||||
)
|
||||
}
|
||||
val hrDataSet = LineDataSet(hrEntries, "$participant HR").apply {
|
||||
color = participantColors[index]
|
||||
setDrawCircles(true)
|
||||
circleRadius = 2f
|
||||
circleColors = listOf(participantColors[index])
|
||||
lineWidth = 2f
|
||||
mode = LineDataSet.Mode.LINEAR
|
||||
}
|
||||
hrDataSets.add(hrDataSet)
|
||||
|
||||
// Calculate statistics
|
||||
statsBuilders[index].append("Heart Rate:\n")
|
||||
statsBuilders[index].append(" Min: ${hrData.minOf { it.value }.toInt()} BPM\n")
|
||||
statsBuilders[index].append(" Max: ${hrData.maxOf { it.value }.toInt()} BPM\n")
|
||||
statsBuilders[index].append(" Avg: ${hrData.map { it.value }.average().toInt()} BPM\n\n")
|
||||
}
|
||||
|
||||
// Load RR data
|
||||
val rrData = loadDataPoints(rrFile)
|
||||
if (rrData.isNotEmpty()) {
|
||||
val rrEntries = rrData.map {
|
||||
Entry(
|
||||
(it.timestamp - firstTimestamp) / 1000f,
|
||||
it.value
|
||||
)
|
||||
}
|
||||
val rrDataSet = LineDataSet(rrEntries, "$participant RR").apply {
|
||||
color = participantColors[index]
|
||||
setDrawCircles(true)
|
||||
circleRadius = 2f
|
||||
circleColors = listOf(participantColors[index])
|
||||
lineWidth = 2f
|
||||
mode = LineDataSet.Mode.LINEAR
|
||||
}
|
||||
rrDataSets.add(rrDataSet)
|
||||
|
||||
// Calculate statistics
|
||||
statsBuilders[index].append("RR Intervals:\n")
|
||||
statsBuilders[index].append(" Min: ${rrData.minOf { it.value }.toInt()} ms\n")
|
||||
statsBuilders[index].append(" Max: ${rrData.maxOf { it.value }.toInt()} ms\n")
|
||||
statsBuilders[index].append(" Avg: ${rrData.map { it.value }.average().toInt()} ms")
|
||||
}
|
||||
}
|
||||
|
||||
// Update statistics
|
||||
participant1Text.text = statsBuilders[0].toString()
|
||||
participant2Text.text = statsBuilders[1].toString()
|
||||
|
||||
// Set chart data
|
||||
hrChart.setData(LineData(hrDataSets))
|
||||
rrChart.setData(LineData(rrDataSets))
|
||||
|
||||
// Add event markers and intervals
|
||||
addEventMarkersAndIntervals(events, firstTimestamp, hrChart)
|
||||
addEventMarkersAndIntervals(events, firstTimestamp, rrChart)
|
||||
|
||||
// Refresh charts
|
||||
hrChart.invalidate()
|
||||
rrChart.invalidate()
|
||||
}
|
||||
|
||||
private fun loadTimestamps(file: File): List<TimestampEvent> {
|
||||
if (!file.exists()) return emptyList()
|
||||
|
||||
return try {
|
||||
BufferedReader(FileReader(file)).useLines { lines ->
|
||||
lines.drop(1) // Skip header
|
||||
.map { line ->
|
||||
val parts = line.split(",")
|
||||
TimestampEvent(
|
||||
timestamp = parts[0].toLong(),
|
||||
eventType = parts[1]
|
||||
)
|
||||
}.toList()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadDataPoints(file: File): List<DataPoint> {
|
||||
if (!file.exists()) return emptyList()
|
||||
|
||||
return try {
|
||||
BufferedReader(FileReader(file)).useLines { lines ->
|
||||
lines.drop(1) // Skip header
|
||||
.map { line ->
|
||||
val parts = line.split(",")
|
||||
DataPoint(
|
||||
timestamp = parts[0].toLong(),
|
||||
value = parts[1].toFloat()
|
||||
)
|
||||
}.toList()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun addEventMarkersAndIntervals(
|
||||
events: List<TimestampEvent>,
|
||||
firstTimestamp: Long,
|
||||
chart: LineChart
|
||||
) {
|
||||
var currentIntervalStart: Float? = null
|
||||
var intervalIndex = 0
|
||||
val intervalDataSets = mutableListOf<ILineDataSet>()
|
||||
|
||||
events.forEach { event ->
|
||||
val normalizedTime = (event.timestamp - firstTimestamp) / 1000f
|
||||
|
||||
when (event.eventType) {
|
||||
"manual_mark" -> {
|
||||
// Add vertical line for manual mark - white and prominent
|
||||
val line = com.github.mikephil.charting.components.LimitLine(normalizedTime).apply {
|
||||
lineWidth = 1f
|
||||
lineColor = Color.WHITE
|
||||
enableDashedLine(15f, 8f, 0f)
|
||||
label = "" // No label
|
||||
}
|
||||
chart.xAxis.addLimitLine(line)
|
||||
}
|
||||
"interval_start" -> {
|
||||
currentIntervalStart = normalizedTime
|
||||
}
|
||||
"interval_end" -> {
|
||||
currentIntervalStart?.let { start ->
|
||||
// Create filled area for interval using a background dataset
|
||||
var color = intervalColors[intervalIndex % intervalColors.size]
|
||||
|
||||
// Create entries for the interval area
|
||||
val entries = listOf(
|
||||
Entry(start, chart.axisLeft.axisMinimum),
|
||||
Entry(start, chart.axisLeft.axisMaximum),
|
||||
Entry(normalizedTime, chart.axisLeft.axisMaximum),
|
||||
Entry(normalizedTime, chart.axisLeft.axisMinimum)
|
||||
)
|
||||
|
||||
val intervalDataSet = LineDataSet(entries, "Interval ${intervalIndex + 1}").apply {
|
||||
setDrawFilled(true)
|
||||
fillColor = color
|
||||
fillAlpha = 128
|
||||
color = Color.TRANSPARENT
|
||||
setDrawCircles(false)
|
||||
setDrawValues(false)
|
||||
isHighlightEnabled = false
|
||||
axisDependency = YAxis.AxisDependency.LEFT
|
||||
}
|
||||
|
||||
intervalDataSets.add(intervalDataSet)
|
||||
intervalIndex++
|
||||
}
|
||||
currentIntervalStart = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add interval datasets to the chart
|
||||
if (intervalDataSets.isNotEmpty()) {
|
||||
// Get current datasets
|
||||
val currentDataSets = chart.data?.dataSets?.toMutableList() ?: mutableListOf()
|
||||
|
||||
// Create combined dataset list
|
||||
val allDataSets = mutableListOf<ILineDataSet>()
|
||||
allDataSets.addAll(intervalDataSets)
|
||||
allDataSets.addAll(currentDataSets)
|
||||
|
||||
// Create and set new data
|
||||
val newData = LineData(allDataSets)
|
||||
chart.setData(newData)
|
||||
chart.invalidate()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
package com.tomhempel.labrecorder.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)
|
||||
@ -0,0 +1,57 @@
|
||||
package com.tomhempel.labrecorder.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 LabRecorderTheme(
|
||||
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
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,34 @@
|
||||
package com.tomhempel.labrecorder.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
|
||||
)
|
||||
*/
|
||||
)
|
||||
170
Code/app/src/main/res/drawable/ic_launcher_background.xml
Normal file
170
Code/app/src/main/res/drawable/ic_launcher_background.xml
Normal 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>
|
||||
30
Code/app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
30
Code/app/src/main/res/drawable/ic_launcher_foreground.xml
Normal 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>
|
||||
33
Code/app/src/main/res/layout/activity_analyse.xml
Normal file
33
Code/app/src/main/res/layout/activity_analyse.xml
Normal file
@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fitsSystemWindows="true"
|
||||
tools:context=".AnalyseActivity">
|
||||
|
||||
<com.google.android.material.appbar.AppBarLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:fitsSystemWindows="true">
|
||||
|
||||
<com.google.android.material.appbar.MaterialToolbar
|
||||
android:id="@+id/toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/actionBarSize" />
|
||||
|
||||
</com.google.android.material.appbar.AppBarLayout>
|
||||
|
||||
<include layout="@layout/content_analyse" />
|
||||
|
||||
<com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
android:id="@+id/fab"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom|end"
|
||||
android:layout_marginEnd="@dimen/fab_margin"
|
||||
android:layout_marginBottom="16dp"
|
||||
app:srcCompat="@android:drawable/ic_dialog_email" />
|
||||
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
19
Code/app/src/main/res/layout/content_analyse.xml
Normal file
19
Code/app/src/main/res/layout/content_analyse.xml
Normal file
@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
app:layout_behavior="@string/appbar_scrolling_view_behavior">
|
||||
|
||||
<fragment
|
||||
android:id="@+id/nav_host_fragment_content_analyse"
|
||||
android:name="androidx.navigation.fragment.NavHostFragment"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
app:defaultNavHost="true"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:navGraph="@navigation/nav_graph" />
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
35
Code/app/src/main/res/layout/fragment_first.xml
Normal file
35
Code/app/src/main/res/layout/fragment_first.xml
Normal file
@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:context=".FirstFragment">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:padding="16dp">
|
||||
|
||||
<Button
|
||||
android:id="@+id/button_first"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/next"
|
||||
app:layout_constraintBottom_toTopOf="@id/textview_first"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textview_first"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:text="@string/lorem_ipsum"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/button_first" />
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
</androidx.core.widget.NestedScrollView>
|
||||
35
Code/app/src/main/res/layout/fragment_second.xml
Normal file
35
Code/app/src/main/res/layout/fragment_second.xml
Normal file
@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:context=".SecondFragment">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:padding="16dp">
|
||||
|
||||
<Button
|
||||
android:id="@+id/button_second"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/previous"
|
||||
app:layout_constraintBottom_toTopOf="@id/textview_second"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textview_second"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:text="@string/lorem_ipsum"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/button_second" />
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
</androidx.core.widget.NestedScrollView>
|
||||
@ -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>
|
||||
BIN
Code/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
Normal file
BIN
Code/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
BIN
Code/app/src/main/res/mipmap-hdpi/logo.webp
Normal file
BIN
Code/app/src/main/res/mipmap-hdpi/logo.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 795 KiB |
BIN
Code/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
Normal file
BIN
Code/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
BIN
Code/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
Normal file
BIN
Code/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
BIN
Code/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
Normal file
BIN
Code/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.8 KiB |
BIN
Code/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
Normal file
BIN
Code/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.6 KiB |
28
Code/app/src/main/res/navigation/nav_graph.xml
Normal file
28
Code/app/src/main/res/navigation/nav_graph.xml
Normal file
@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/nav_graph"
|
||||
app:startDestination="@id/FirstFragment">
|
||||
|
||||
<fragment
|
||||
android:id="@+id/FirstFragment"
|
||||
android:name="com.tomhempel.labrecorder.FirstFragment"
|
||||
android:label="@string/first_fragment_label"
|
||||
tools:layout="@layout/fragment_first">
|
||||
|
||||
<action
|
||||
android:id="@+id/action_FirstFragment_to_SecondFragment"
|
||||
app:destination="@id/SecondFragment" />
|
||||
</fragment>
|
||||
<fragment
|
||||
android:id="@+id/SecondFragment"
|
||||
android:name="com.tomhempel.labrecorder.SecondFragment"
|
||||
android:label="@string/second_fragment_label"
|
||||
tools:layout="@layout/fragment_second">
|
||||
|
||||
<action
|
||||
android:id="@+id/action_SecondFragment_to_FirstFragment"
|
||||
app:destination="@id/FirstFragment" />
|
||||
</fragment>
|
||||
</navigation>
|
||||
3
Code/app/src/main/res/values-land/dimens.xml
Normal file
3
Code/app/src/main/res/values-land/dimens.xml
Normal file
@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<dimen name="fab_margin">48dp</dimen>
|
||||
</resources>
|
||||
7
Code/app/src/main/res/values-night/themes.xml
Normal file
7
Code/app/src/main/res/values-night/themes.xml
Normal file
@ -0,0 +1,7 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- Base application theme. -->
|
||||
<style name="Base.Theme.LabRecorder" parent="Theme.Material3.DayNight.NoActionBar">
|
||||
<!-- Customize your dark theme here. -->
|
||||
<!-- <item name="colorPrimary">@color/my_dark_primary</item> -->
|
||||
</style>
|
||||
</resources>
|
||||
9
Code/app/src/main/res/values-v23/themes.xml
Normal file
9
Code/app/src/main/res/values-v23/themes.xml
Normal file
@ -0,0 +1,9 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<style name="Theme.LabRecorder" parent="Base.Theme.LabRecorder">
|
||||
<!-- Transparent system bars for edge-to-edge. -->
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:windowLightStatusBar">?attr/isLightTheme</item>
|
||||
</style>
|
||||
</resources>
|
||||
3
Code/app/src/main/res/values-w1240dp/dimens.xml
Normal file
3
Code/app/src/main/res/values-w1240dp/dimens.xml
Normal file
@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<dimen name="fab_margin">200dp</dimen>
|
||||
</resources>
|
||||
3
Code/app/src/main/res/values-w600dp/dimens.xml
Normal file
3
Code/app/src/main/res/values-w600dp/dimens.xml
Normal file
@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<dimen name="fab_margin">48dp</dimen>
|
||||
</resources>
|
||||
10
Code/app/src/main/res/values/colors.xml
Normal file
10
Code/app/src/main/res/values/colors.xml
Normal 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>
|
||||
3
Code/app/src/main/res/values/dimens.xml
Normal file
3
Code/app/src/main/res/values/dimens.xml
Normal file
@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<dimen name="fab_margin">16dp</dimen>
|
||||
</resources>
|
||||
45
Code/app/src/main/res/values/strings.xml
Normal file
45
Code/app/src/main/res/values/strings.xml
Normal file
@ -0,0 +1,45 @@
|
||||
<resources>
|
||||
<string name="app_name">Lab Recorder</string>
|
||||
<!-- Strings used for fragments for navigation -->
|
||||
<string name="first_fragment_label">First Fragment</string>
|
||||
<string name="second_fragment_label">Second Fragment</string>
|
||||
<string name="next">Next</string>
|
||||
<string name="previous">Previous</string>
|
||||
|
||||
<string name="lorem_ipsum">
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam in scelerisque sem. Mauris
|
||||
volutpat, dolor id interdum ullamcorper, risus dolor egestas lectus, sit amet mattis purus
|
||||
dui nec risus. Maecenas non sodales nisi, vel dictum dolor. Class aptent taciti sociosqu ad
|
||||
litora torquent per conubia nostra, per inceptos himenaeos. Suspendisse blandit eleifend
|
||||
diam, vel rutrum tellus vulputate quis. Aliquam eget libero aliquet, imperdiet nisl a,
|
||||
ornare ex. Sed rhoncus est ut libero porta lobortis. Fusce in dictum tellus.\n\n
|
||||
Suspendisse interdum ornare ante. Aliquam nec cursus lorem. Morbi id magna felis. Vivamus
|
||||
egestas, est a condimentum egestas, turpis nisl iaculis ipsum, in dictum tellus dolor sed
|
||||
neque. Morbi tellus erat, dapibus ut sem a, iaculis tincidunt dui. Interdum et malesuada
|
||||
fames ac ante ipsum primis in faucibus. Curabitur et eros porttitor, ultricies urna vitae,
|
||||
molestie nibh. Phasellus at commodo eros, non aliquet metus. Sed maximus nisl nec dolor
|
||||
bibendum, vel congue leo egestas.\n\n
|
||||
Sed interdum tortor nibh, in sagittis risus mollis quis. Curabitur mi odio, condimentum sit
|
||||
amet auctor at, mollis non turpis. Nullam pretium libero vestibulum, finibus orci vel,
|
||||
molestie quam. Fusce blandit tincidunt nulla, quis sollicitudin libero facilisis et. Integer
|
||||
interdum nunc ligula, et fermentum metus hendrerit id. Vestibulum lectus felis, dictum at
|
||||
lacinia sit amet, tristique id quam. Cras eu consequat dui. Suspendisse sodales nunc ligula,
|
||||
in lobortis sem porta sed. Integer id ultrices magna, in luctus elit. Sed a pellentesque
|
||||
est.\n\n
|
||||
Aenean nunc velit, lacinia sed dolor sed, ultrices viverra nulla. Etiam a venenatis nibh.
|
||||
Morbi laoreet, tortor sed facilisis varius, nibh orci rhoncus nulla, id elementum leo dui
|
||||
non lorem. Nam mollis ipsum quis auctor varius. Quisque elementum eu libero sed commodo. In
|
||||
eros nisl, imperdiet vel imperdiet et, scelerisque a mauris. Pellentesque varius ex nunc,
|
||||
quis imperdiet eros placerat ac. Duis finibus orci et est auctor tincidunt. Sed non viverra
|
||||
ipsum. Nunc quis augue egestas, cursus lorem at, molestie sem. Morbi a consectetur ipsum, a
|
||||
placerat diam. Etiam vulputate dignissim convallis. Integer faucibus mauris sit amet finibus
|
||||
convallis.\n\n
|
||||
Phasellus in aliquet mi. Pellentesque habitant morbi tristique senectus et netus et
|
||||
malesuada fames ac turpis egestas. In volutpat arcu ut felis sagittis, in finibus massa
|
||||
gravida. Pellentesque id tellus orci. Integer dictum, lorem sed efficitur ullamcorper,
|
||||
libero justo consectetur ipsum, in mollis nisl ex sed nisl. Donec maximus ullamcorper
|
||||
sodales. Praesent bibendum rhoncus tellus nec feugiat. In a ornare nulla. Donec rhoncus
|
||||
libero vel nunc consequat, quis tincidunt nisl eleifend. Cras bibendum enim a justo luctus
|
||||
vestibulum. Fusce dictum libero quis erat maximus, vitae volutpat diam dignissim.
|
||||
</string>
|
||||
</resources>
|
||||
10
Code/app/src/main/res/values/themes.xml
Normal file
10
Code/app/src/main/res/values/themes.xml
Normal file
@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<style name="Theme.LabRecorder" parent="android:Theme.Material.Light.NoActionBar" />
|
||||
<!-- Base application theme. -->
|
||||
<style name="Base.Theme.LabRecorder" parent="Theme.Material3.DayNight.NoActionBar">
|
||||
<!-- Customize your light theme here. -->
|
||||
<!-- <item name="colorPrimary">@color/my_light_primary</item> -->
|
||||
</style>
|
||||
</resources>
|
||||
13
Code/app/src/main/res/xml/backup_rules.xml
Normal file
13
Code/app/src/main/res/xml/backup_rules.xml
Normal 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>
|
||||
19
Code/app/src/main/res/xml/data_extraction_rules.xml
Normal file
19
Code/app/src/main/res/xml/data_extraction_rules.xml
Normal 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>
|
||||
@ -0,0 +1,17 @@
|
||||
package com.tomhempel.labrecorder
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user