├── .gitignore
├── .idea
└── .gitignore
├── README.md
├── app
├── .gitignore
├── build.gradle.kts
├── proguard-rules.pro
├── schemas
│ └── com.supersonic.heartrate.db.HearRateDatabase
│ │ ├── 1.json
│ │ └── 2.json
└── src
│ ├── androidTest
│ └── java
│ │ └── com
│ │ └── supersonic
│ │ └── heartrate
│ │ └── ExampleInstrumentedTest.kt
│ ├── main
│ ├── AndroidManifest.xml
│ ├── ic_launcher-playstore.png
│ ├── java
│ │ └── com
│ │ │ └── supersonic
│ │ │ └── heartrate
│ │ │ ├── App.kt
│ │ │ ├── AppModule.kt
│ │ │ ├── components
│ │ │ ├── AnimatedLinearProgressIndicator.kt
│ │ │ ├── BackgroundedSurface.kt
│ │ │ ├── DropdownList.kt
│ │ │ ├── HistoryCard.kt
│ │ │ ├── ResultCard.kt
│ │ │ ├── ScreenTemplate.kt
│ │ │ └── TopBar.kt
│ │ │ ├── db
│ │ │ ├── HearRateDatabase.kt
│ │ │ ├── HeartRateDao.kt
│ │ │ └── HeartRateRepository.kt
│ │ │ ├── models
│ │ │ ├── HeartRate.kt
│ │ │ └── OnboardingPage.kt
│ │ │ ├── navigation
│ │ │ ├── NavigationDestination.kt
│ │ │ └── RootAppNavigation.kt
│ │ │ ├── screens
│ │ │ ├── MainActivity.kt
│ │ │ ├── history
│ │ │ │ ├── HistoryScreen.kt
│ │ │ │ ├── HistoryViewModel.kt
│ │ │ │ └── SwipeToDeleteContainer.kt
│ │ │ ├── homepage
│ │ │ │ └── HomepageScreen.kt
│ │ │ ├── loading
│ │ │ │ └── LoadingScreen.kt
│ │ │ ├── measurement
│ │ │ │ ├── CameraPreview.kt
│ │ │ │ ├── MeasurementScreen.kt
│ │ │ │ └── MeasurementViewModel.kt
│ │ │ ├── onboarding
│ │ │ │ └── OnboardingScreen.kt
│ │ │ └── result
│ │ │ │ ├── ResultScreen.kt
│ │ │ │ └── ResultScreenViewModel.kt
│ │ │ ├── ui
│ │ │ └── theme
│ │ │ │ ├── Color.kt
│ │ │ │ ├── Theme.kt
│ │ │ │ └── Type.kt
│ │ │ └── util
│ │ │ ├── ColorTextIdentifyers.kt
│ │ │ ├── heartRateMeasurement
│ │ │ └── HeartRateAnalyzer.kt
│ │ │ └── onboarding
│ │ │ └── OnboardingPages.kt
│ └── res
│ │ ├── drawable-uk
│ │ ├── measurement_accuracy.png
│ │ └── result_card.png
│ │ ├── drawable
│ │ ├── background.png
│ │ ├── button.xml
│ │ ├── hands_phone.png
│ │ ├── heart.xml
│ │ ├── heart2.xml
│ │ ├── ic_launcher_foreground.xml
│ │ ├── icon_time_machine.png
│ │ ├── measurement_accuracy.png
│ │ ├── onboarding1.png
│ │ ├── onboarding2.png
│ │ ├── onboarding3.png
│ │ └── result_card.png
│ │ ├── mipmap-anydpi-v26
│ │ ├── ic_launcher.xml
│ │ └── ic_launcher_round.xml
│ │ ├── mipmap-hdpi
│ │ ├── ic_launcher.webp
│ │ └── ic_launcher_round.webp
│ │ ├── mipmap-mdpi
│ │ ├── ic_launcher.webp
│ │ └── ic_launcher_round.webp
│ │ ├── mipmap-xhdpi
│ │ ├── ic_launcher.webp
│ │ └── ic_launcher_round.webp
│ │ ├── mipmap-xxhdpi
│ │ ├── ic_launcher.webp
│ │ └── ic_launcher_round.webp
│ │ ├── mipmap-xxxhdpi
│ │ ├── ic_launcher.webp
│ │ └── ic_launcher_round.webp
│ │ ├── values-uk
│ │ └── strings-uk.xml
│ │ ├── values
│ │ ├── colors.xml
│ │ ├── ic_launcher_background.xml
│ │ ├── strings.xml
│ │ └── themes.xml
│ │ └── xml
│ │ ├── backup_rules.xml
│ │ └── data_extraction_rules.xml
│ └── test
│ └── java
│ └── com
│ └── supersonic
│ └── heartrate
│ └── ExampleUnitTest.kt
├── build.gradle.kts
├── gradle.properties
├── gradle
├── libs.versions.toml
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
└── settings.gradle.kts
/.gitignore:
--------------------------------------------------------------------------------
1 | *.iml
2 | .gradle
3 | /local.properties
4 | /.idea/caches
5 | /.idea/libraries
6 | /.idea/modules.xml
7 | /.idea/workspace.xml
8 | /.idea/navEditor.xml
9 | /.idea/assetWizardSettings.xml
10 | .DS_Store
11 | /build
12 | /captures
13 | .externalNativeBuild
14 | .cxx
15 | local.properties
16 |
--------------------------------------------------------------------------------
/.idea/.gitignore:
--------------------------------------------------------------------------------
1 | # Default ignored files
2 | /shelf/
3 | /workspace.xml
4 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Heart Rate – Pulse Monitoring App
2 |
3 | Heart Rate is an Android app that measures your heart rate using your phone’s camera. Whether you’re tracking your fitness, monitoring your health, or just curious about your pulse, this app provides instant results with a user-friendly interface.
4 |
5 |
10 |
11 | ## DISCLAIMER ⚠️
12 |
13 | **IMPORTANT:** This application is **NOT A MEDICAL DEVICE** and should not be used for medical purposes or to diagnose health conditions. The heart rate measurement provided by this app is based on the phone's camera and may **NOT BE ACCURATE**. Always consult with a qualified healthcare professional for accurate heart rate measurements and medical advice.
14 |
15 | This app is intended for general wellness and fitness purposes only.
16 |
17 | ## Key Features 🔑
18 |
19 | - **Instant Heart Rate Measurement:** Simply place your finger on the camera, and the app will detect your pulse in a matter of seconds.
20 | - **Detailed History:** Keep track of all your past measurements in one place.
21 | - **User-Friendly Design:** Easy-to-navigate interface that guides you through the measurement process and displays results in a clear and concise manner.
22 | - **No Additional Hardware Required:** All you need is your smartphone camera—no extra devices or wearables are necessary.
23 |
24 | ## How It Works 🧑🏻💻
25 |
26 | The app utilizes your phone’s camera and flash to detect the blood flow in your finger. When you place your finger on the camera lens, the app analyzes the changes in light absorption and calculates your heart rate.
27 |
28 | ## Tech Stack 💻
29 |
30 | **UI:** Material Design 3, Jetpack Compose, Accompanist, Jetpack Navigation Compose
31 |
32 | **Storage:** Room, Room KTX
33 |
34 | **Lifecycle & Architecture:** Lifecycle Components, ViewModel Compose
35 |
36 | **Dependency Injection:** Hilt, Hilt Navigation Compose
37 |
38 | **Camera & Sensors:** CameraX, Camera Lifecycle & View
39 |
40 | **Architecture:** MVVM (Model-View-ViewModel)
41 |
42 | ## Installation Instructions 📲
43 |
44 | Follow these steps to install the Heart Rate app on your Android device:
45 |
46 | ### Method 1: Install from GitHub Releases
47 |
48 | 1. **Download the APK**
49 | - Go to the [Releases page](https://github.com/6SUPER6SONIC6/HeartRate/releases) of this repository.
50 | - Find the latest release and download the `HeartRate-release.apk` file. Or [Download directly](https://github.com/6SUPER6SONIC6/HeartRate/releases/download/v1.0.0/HeartRate-release.apk)
51 |
52 | 2. **Allow Installation from Unknown Sources**
53 | - Before installing, you may need to allow your device to install apps from unknown sources. To do this:
54 | - Open your device's **Settings**.
55 | - Go to **Security** or **Privacy** (this may vary depending on your device).
56 | - Enable the option to **Install unknown apps** and select the browser or file manager you are using.
57 |
58 | 3. **Install the APK**
59 | - Locate the downloaded APK file in your **Downloads** folder or the location where you saved it.
60 | - Tap on the APK file to begin the installation process.
61 | - Follow the on-screen instructions to complete the installation.
62 |
63 | 4. **Launch the App**
64 | - Once installed, you can find the Heart Rate app icon in your app drawer. Tap on it to launch the app and start measuring your pulse!
65 |
66 | ### Method 2: Build from Source
67 |
68 | If you prefer to build the app from the source code:
69 |
70 | 1. **Clone the Repository**
71 | - Open your terminal and run the following command to clone the repository:
72 | ```bash
73 | git clone https://github.com/6SUPER6SONIC6/HeartRate.git
74 | ```
75 |
76 | 2. **Open the Project in Android Studio**
77 | - Launch Android Studio and select **Open an Existing Project**.
78 | - Navigate to the cloned repository and select the project folder.
79 |
80 | 3. **Sync Gradle and Resolve Dependencies**
81 | - Once the project is open, Android Studio will automatically start syncing Gradle. Wait for the sync process to complete.
82 | - Make sure all dependencies are resolved. If not, click on **File** > **Sync Project with Gradle Files**.
83 |
84 | 4. **Build and Run the App**
85 | - Connect your Android device or use an emulator.
86 | - Click on the **Run** button (green triangle) in Android Studio to build and install the app on your device.
87 |
88 | 5. **Start Measuring!**
89 | - Once the app is installed, you can launch it from your device and start measuring your heart rate.
90 |
91 | ### Troubleshooting
92 |
93 | - **Installation Blocked?** If your device blocks the installation, check the security settings again to make sure you have allowed installations from unknown sources.
94 | - **Build Errors?** Ensure that you have the latest version of Android Studio and the required SDKs installed. Also, check that your Gradle dependencies are up-to-date.
95 |
96 | Enjoy using Heart Rate to monitor your pulse with ease!
97 |
98 | ## Feedback ✉️
99 |
100 | If you have any feedback, please reach out to me at vadym.tantsiura@gmail.com or [Telegram](http://t.me/VTantsiura)
101 |
102 | 
103 |
--------------------------------------------------------------------------------
/app/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/app/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.android.application)
3 | alias(libs.plugins.jetbrains.kotlin.android)
4 | alias(libs.plugins.kapt)
5 | alias(libs.plugins.ksp)
6 | alias(libs.plugins.hilt)
7 | }
8 |
9 | android {
10 | namespace = "com.supersonic.heartrate"
11 | compileSdk = 34
12 |
13 | defaultConfig {
14 | applicationId = "com.supersonic.heartrate"
15 | minSdk = 26
16 | targetSdk = 34
17 | versionCode = 1
18 | versionName = "1.0"
19 |
20 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
21 | vectorDrawables {
22 | useSupportLibrary = true
23 | }
24 | }
25 |
26 | bundle {
27 | language{
28 | enableSplit = false
29 | }
30 | }
31 |
32 | ksp{
33 | arg("room.schemaLocation", "$projectDir/schemas")
34 | }
35 |
36 | buildTypes {
37 | release {
38 | isMinifyEnabled = false
39 | proguardFiles(
40 | getDefaultProguardFile("proguard-android-optimize.txt"),
41 | "proguard-rules.pro"
42 | )
43 | }
44 | }
45 | compileOptions {
46 | sourceCompatibility = JavaVersion.VERSION_1_8
47 | targetCompatibility = JavaVersion.VERSION_1_8
48 | }
49 | kotlinOptions {
50 | jvmTarget = "1.8"
51 | }
52 | buildFeatures {
53 | compose = true
54 | }
55 | composeOptions {
56 | kotlinCompilerExtensionVersion = "1.5.1"
57 | }
58 | packaging {
59 | resources {
60 | excludes += "/META-INF/{AL2.0,LGPL2.1}"
61 | }
62 | }
63 |
64 | kapt {
65 | correctErrorTypes = true
66 | }
67 | }
68 |
69 | dependencies {
70 |
71 | implementation(libs.androidx.core.ktx)
72 | implementation(libs.androidx.lifecycle.runtime.ktx)
73 | implementation(libs.androidx.activity.compose)
74 | implementation(platform(libs.androidx.compose.bom))
75 | implementation(libs.androidx.ui)
76 | implementation(libs.androidx.ui.graphics)
77 | implementation(libs.androidx.ui.tooling.preview)
78 | implementation(libs.androidx.material3)
79 | testImplementation(libs.junit)
80 | androidTestImplementation(libs.androidx.junit)
81 | androidTestImplementation(libs.androidx.espresso.core)
82 | androidTestImplementation(platform(libs.androidx.compose.bom))
83 | androidTestImplementation(libs.androidx.ui.test.junit4)
84 | debugImplementation(libs.androidx.ui.tooling)
85 | debugImplementation(libs.androidx.ui.test.manifest)
86 |
87 | //Room
88 | implementation(libs.room.runtime)
89 | implementation(libs.room.ktx)
90 | ksp(libs.room.compiler)
91 |
92 | //Lifecycle
93 | implementation(libs.androidx.lifecycle.runtimeCompose)
94 | implementation(libs.androidx.lifecycle.viewModelCompose)
95 |
96 | //Hilt
97 | implementation(libs.hilt.android.core)
98 | implementation(libs.androidx.hilt.navigation.compose)
99 | kapt(libs.hilt.compiler)
100 |
101 | //Navigation
102 | implementation(libs.androidx.navigation.compose)
103 |
104 | //CameraX
105 | implementation(libs.androidx.camera.lifecycle)
106 | implementation(libs.androidx.camera.camera2)
107 | implementation(libs.androidx.camera.view)
108 |
109 | //Permission
110 | implementation(libs.accompanist.permissions)
111 |
112 |
113 | }
--------------------------------------------------------------------------------
/app/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
--------------------------------------------------------------------------------
/app/schemas/com.supersonic.heartrate.db.HearRateDatabase/1.json:
--------------------------------------------------------------------------------
1 | {
2 | "formatVersion": 1,
3 | "database": {
4 | "version": 1,
5 | "identityHash": "2e977047a03ee81c31c42f9674d5a073",
6 | "entities": [
7 | {
8 | "tableName": "heart_rates",
9 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bpm` INTEGER NOT NULL, `time` TEXT NOT NULL, `date` TEXT NOT NULL, `measurementAccuracy` INTEGER NOT NULL)",
10 | "fields": [
11 | {
12 | "fieldPath": "id",
13 | "columnName": "id",
14 | "affinity": "INTEGER",
15 | "notNull": true
16 | },
17 | {
18 | "fieldPath": "bpm",
19 | "columnName": "bpm",
20 | "affinity": "INTEGER",
21 | "notNull": true
22 | },
23 | {
24 | "fieldPath": "time",
25 | "columnName": "time",
26 | "affinity": "TEXT",
27 | "notNull": true
28 | },
29 | {
30 | "fieldPath": "date",
31 | "columnName": "date",
32 | "affinity": "TEXT",
33 | "notNull": true
34 | },
35 | {
36 | "fieldPath": "measurementAccuracy",
37 | "columnName": "measurementAccuracy",
38 | "affinity": "INTEGER",
39 | "notNull": true
40 | }
41 | ],
42 | "primaryKey": {
43 | "autoGenerate": true,
44 | "columnNames": [
45 | "id"
46 | ]
47 | },
48 | "indices": [],
49 | "foreignKeys": []
50 | }
51 | ],
52 | "views": [],
53 | "setupQueries": [
54 | "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
55 | "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '2e977047a03ee81c31c42f9674d5a073')"
56 | ]
57 | }
58 | }
--------------------------------------------------------------------------------
/app/schemas/com.supersonic.heartrate.db.HearRateDatabase/2.json:
--------------------------------------------------------------------------------
1 | {
2 | "formatVersion": 1,
3 | "database": {
4 | "version": 2,
5 | "identityHash": "2e977047a03ee81c31c42f9674d5a073",
6 | "entities": [
7 | {
8 | "tableName": "heart_rates",
9 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bpm` INTEGER NOT NULL, `time` TEXT NOT NULL, `date` TEXT NOT NULL, `measurementAccuracy` INTEGER NOT NULL)",
10 | "fields": [
11 | {
12 | "fieldPath": "id",
13 | "columnName": "id",
14 | "affinity": "INTEGER",
15 | "notNull": true
16 | },
17 | {
18 | "fieldPath": "bpm",
19 | "columnName": "bpm",
20 | "affinity": "INTEGER",
21 | "notNull": true
22 | },
23 | {
24 | "fieldPath": "time",
25 | "columnName": "time",
26 | "affinity": "TEXT",
27 | "notNull": true
28 | },
29 | {
30 | "fieldPath": "date",
31 | "columnName": "date",
32 | "affinity": "TEXT",
33 | "notNull": true
34 | },
35 | {
36 | "fieldPath": "measurementAccuracy",
37 | "columnName": "measurementAccuracy",
38 | "affinity": "INTEGER",
39 | "notNull": true
40 | }
41 | ],
42 | "primaryKey": {
43 | "autoGenerate": true,
44 | "columnNames": [
45 | "id"
46 | ]
47 | },
48 | "indices": [],
49 | "foreignKeys": []
50 | }
51 | ],
52 | "views": [],
53 | "setupQueries": [
54 | "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
55 | "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '2e977047a03ee81c31c42f9674d5a073')"
56 | ]
57 | }
58 | }
--------------------------------------------------------------------------------
/app/src/androidTest/java/com/supersonic/heartrate/ExampleInstrumentedTest.kt:
--------------------------------------------------------------------------------
1 | package com.supersonic.heartrate
2 |
3 | import androidx.test.platform.app.InstrumentationRegistry
4 | import androidx.test.ext.junit.runners.AndroidJUnit4
5 |
6 | import org.junit.Test
7 | import org.junit.runner.RunWith
8 |
9 | import org.junit.Assert.*
10 |
11 | /**
12 | * Instrumented test, which will execute on an Android device.
13 | *
14 | * See [testing documentation](http://d.android.com/tools/testing).
15 | */
16 | @RunWith(AndroidJUnit4::class)
17 | class ExampleInstrumentedTest {
18 | @Test
19 | fun useAppContext() {
20 | // Context of the app under test.
21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext
22 | assertEquals("com.supersonic.heartrate", appContext.packageName)
23 | }
24 | }
--------------------------------------------------------------------------------
/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
6 |
7 |
8 |
19 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
--------------------------------------------------------------------------------
/app/src/main/ic_launcher-playstore.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/6SUPER6SONIC6/HeartRate/9188e8c2bd601522ec56452a8934ca70143b1bc9/app/src/main/ic_launcher-playstore.png
--------------------------------------------------------------------------------
/app/src/main/java/com/supersonic/heartrate/App.kt:
--------------------------------------------------------------------------------
1 | package com.supersonic.heartrate
2 |
3 | import android.app.Application
4 | import dagger.hilt.android.HiltAndroidApp
5 |
6 | @HiltAndroidApp
7 | class App : Application()
8 |
--------------------------------------------------------------------------------
/app/src/main/java/com/supersonic/heartrate/AppModule.kt:
--------------------------------------------------------------------------------
1 | package com.supersonic.heartrate
2 |
3 | import android.content.Context
4 | import com.supersonic.heartrate.db.HearRateDatabase
5 | import com.supersonic.heartrate.db.HeartRateDao
6 | import dagger.Module
7 | import dagger.Provides
8 | import dagger.hilt.InstallIn
9 | import dagger.hilt.android.qualifiers.ApplicationContext
10 | import dagger.hilt.components.SingletonComponent
11 | import javax.inject.Singleton
12 |
13 | @Module
14 | @InstallIn(SingletonComponent::class)
15 | object AppModule {
16 |
17 | @Provides
18 | @Singleton
19 | fun provideHearRateDatabase(@ApplicationContext context: Context): HearRateDatabase {
20 | return HearRateDatabase.getDatabase(context)
21 | }
22 |
23 | @Provides
24 | @Singleton
25 | fun provideHeartRateDao(hearRateDatabase: HearRateDatabase): HeartRateDao {
26 | return hearRateDatabase.heartRateDao()
27 | }
28 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/supersonic/heartrate/components/AnimatedLinearProgressIndicator.kt:
--------------------------------------------------------------------------------
1 | package com.supersonic.heartrate.components
2 |
3 | import androidx.compose.animation.core.FastOutSlowInEasing
4 | import androidx.compose.animation.core.animateFloatAsState
5 | import androidx.compose.animation.core.tween
6 | import androidx.compose.foundation.border
7 | import androidx.compose.foundation.layout.Box
8 | import androidx.compose.foundation.layout.fillMaxWidth
9 | import androidx.compose.foundation.layout.height
10 | import androidx.compose.foundation.layout.padding
11 | import androidx.compose.foundation.shape.CircleShape
12 | import androidx.compose.material3.LinearProgressIndicator
13 | import androidx.compose.material3.MaterialTheme.colorScheme
14 | import androidx.compose.material3.MaterialTheme.typography
15 | import androidx.compose.material3.ProgressIndicatorDefaults
16 | import androidx.compose.material3.Text
17 | import androidx.compose.runtime.Composable
18 | import androidx.compose.runtime.LaunchedEffect
19 | import androidx.compose.runtime.getValue
20 | import androidx.compose.runtime.mutableFloatStateOf
21 | import androidx.compose.runtime.saveable.rememberSaveable
22 | import androidx.compose.runtime.setValue
23 | import androidx.compose.ui.Alignment
24 | import androidx.compose.ui.Modifier
25 | import androidx.compose.ui.graphics.Color
26 | import androidx.compose.ui.graphics.StrokeCap
27 | import androidx.compose.ui.tooling.preview.Preview
28 | import androidx.compose.ui.unit.dp
29 | import com.supersonic.heartrate.ui.theme.HeartRateTheme
30 |
31 | @Composable
32 | fun AnimatedLinearProgressIndicator(
33 | modifier: Modifier = Modifier,
34 | indicatorProgress: Float = 1F,
35 | animationDuration: Int = 1_500,
36 | color: Color = ProgressIndicatorDefaults.linearColor,
37 | trackColor: Color = ProgressIndicatorDefaults.linearTrackColor,
38 | strokeCap: StrokeCap = ProgressIndicatorDefaults.LinearStrokeCap,
39 | onLoadFinish: () -> Unit
40 | ) {
41 | var progress by rememberSaveable { mutableFloatStateOf(0F) }
42 | val progressAnimation by animateFloatAsState(
43 | targetValue = progress,
44 | animationSpec = tween(
45 | durationMillis = animationDuration,
46 | easing = FastOutSlowInEasing
47 | ),
48 | finishedListener = { _ -> onLoadFinish.invoke()},
49 | label = "LinearProgressIndicator animation"
50 | )
51 | Box(
52 | modifier = modifier,
53 | contentAlignment = Alignment.Center
54 | ) {
55 | LinearProgressIndicator(
56 | modifier = Modifier
57 | .fillMaxWidth()
58 | .height(20.dp)
59 | .border(1.dp, colorScheme.primary, CircleShape),
60 | progress = { progressAnimation },
61 | color = color,
62 | trackColor = trackColor,
63 | strokeCap = strokeCap
64 | )
65 | Text(
66 | text = "${(progressAnimation * 100).toInt()}%",
67 | style = typography.bodyMedium,
68 | color = colorScheme.onPrimary,
69 | modifier = Modifier
70 | .padding(vertical = 2.dp)
71 | )
72 | }
73 | LaunchedEffect(Unit) {
74 | progress = indicatorProgress
75 | }
76 | }
77 |
78 | @Preview
79 | @Composable
80 | private fun AnimatedLinearProgressIndicatorPreview() {
81 | HeartRateTheme {
82 | AnimatedLinearProgressIndicator(onLoadFinish = {})
83 | }
84 |
85 |
86 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/supersonic/heartrate/components/BackgroundedSurface.kt:
--------------------------------------------------------------------------------
1 | package com.supersonic.heartrate.components
2 |
3 | import androidx.compose.foundation.BorderStroke
4 | import androidx.compose.foundation.Image
5 | import androidx.compose.foundation.layout.fillMaxSize
6 | import androidx.compose.material3.MaterialTheme
7 | import androidx.compose.material3.Surface
8 | import androidx.compose.material3.contentColorFor
9 | import androidx.compose.runtime.Composable
10 | import androidx.compose.ui.Modifier
11 | import androidx.compose.ui.graphics.Color
12 | import androidx.compose.ui.graphics.RectangleShape
13 | import androidx.compose.ui.graphics.Shape
14 | import androidx.compose.ui.layout.ContentScale
15 | import androidx.compose.ui.res.painterResource
16 | import androidx.compose.ui.tooling.preview.Preview
17 | import androidx.compose.ui.unit.Dp
18 | import androidx.compose.ui.unit.dp
19 | import com.supersonic.heartrate.R
20 | import com.supersonic.heartrate.ui.theme.HeartRateTheme
21 |
22 | @Composable
23 | fun BackgroundedSurface(
24 | modifier: Modifier = Modifier,
25 | shape: Shape = RectangleShape,
26 | color: Color = MaterialTheme.colorScheme.surface,
27 | contentColor: Color = contentColorFor(color),
28 | tonalElevation: Dp = 0.dp,
29 | shadowElevation: Dp = 0.dp,
30 | border: BorderStroke? = null,
31 | content: @Composable () -> Unit
32 | ) {
33 | Surface(
34 | modifier = modifier,
35 | shape = shape,
36 | contentColor = contentColor,
37 | tonalElevation = tonalElevation,
38 | shadowElevation = shadowElevation,
39 | border = border
40 | ){
41 | Image(
42 | modifier = Modifier.fillMaxSize(),
43 | painter = painterResource(id = R.drawable.background),
44 | contentScale = ContentScale.FillBounds,
45 | contentDescription = null
46 | )
47 |
48 | content.invoke()
49 | }
50 | }
51 |
52 | @Preview
53 | @Composable
54 | private fun BackgroundedSurfacePreview() {
55 | HeartRateTheme {
56 | BackgroundedSurface {
57 |
58 | }
59 | }
60 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/supersonic/heartrate/components/DropdownList.kt:
--------------------------------------------------------------------------------
1 | package com.supersonic.heartrate.components
2 |
3 | import androidx.compose.foundation.background
4 | import androidx.compose.foundation.layout.Box
5 | import androidx.compose.foundation.layout.Column
6 | import androidx.compose.foundation.layout.size
7 | import androidx.compose.foundation.shape.CircleShape
8 | import androidx.compose.material3.DropdownMenuItem
9 | import androidx.compose.material3.ExperimentalMaterial3Api
10 | import androidx.compose.material3.ExposedDropdownMenuBox
11 | import androidx.compose.material3.ExposedDropdownMenuDefaults
12 | import androidx.compose.material3.MaterialTheme.typography
13 | import androidx.compose.material3.Text
14 | import androidx.compose.material3.TextField
15 | import androidx.compose.runtime.Composable
16 | import androidx.compose.runtime.getValue
17 | import androidx.compose.runtime.mutableStateOf
18 | import androidx.compose.runtime.remember
19 | import androidx.compose.runtime.setValue
20 | import androidx.compose.ui.Modifier
21 | import androidx.compose.ui.res.stringResource
22 | import androidx.compose.ui.tooling.preview.Preview
23 | import androidx.compose.ui.unit.dp
24 | import com.supersonic.heartrate.ui.theme.HeartRateTheme
25 | import com.supersonic.heartrate.util.identifyAccuracyColor
26 |
27 |
28 | @OptIn(ExperimentalMaterial3Api::class)
29 | @Composable
30 | fun DropdownList(
31 | modifier: Modifier = Modifier,
32 | itemList: List,
33 | selectedItemResource: Int,
34 | onItemClick:(Int) -> Unit = {},
35 | ) {
36 |
37 | var showDropdown by remember { mutableStateOf(false) }
38 |
39 | ExposedDropdownMenuBox(
40 | modifier = modifier,
41 | expanded = showDropdown,
42 | onExpandedChange = { showDropdown = it },
43 | ) {
44 | TextField(
45 | modifier = modifier.menuAnchor(),
46 | value = stringResource(selectedItemResource),
47 | textStyle = typography.displaySmall,
48 | onValueChange = {},
49 | readOnly = true,
50 | singleLine = true,
51 | leadingIcon = {
52 | Box(modifier = Modifier
53 | .size(12.dp)
54 | .background(identifyAccuracyColor(selectedItemResource), CircleShape)
55 | )
56 | },
57 | trailingIcon = {ExposedDropdownMenuDefaults.TrailingIcon(expanded = showDropdown)},
58 | colors = ExposedDropdownMenuDefaults.textFieldColors()
59 | )
60 |
61 | ExposedDropdownMenu(
62 | expanded = showDropdown, onDismissRequest = { showDropdown = false }) {
63 | itemList.forEach { item ->
64 | DropdownMenuItem(
65 | text = {
66 | Text(
67 | text = stringResource(id = item),
68 | style = typography.displaySmall
69 | )
70 | },
71 | leadingIcon = {
72 | Box(modifier = Modifier
73 | .size(12.dp)
74 | .background(identifyAccuracyColor(item), CircleShape)
75 | )
76 | },
77 | onClick = {
78 | onItemClick(item)
79 | showDropdown = false
80 | },
81 | contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding
82 | )
83 |
84 | }
85 | }
86 |
87 |
88 |
89 | }
90 | }
91 |
92 |
93 | @Preview
94 | @Composable
95 | private fun DropdownListPreview() {
96 |
97 | val map = mapOf(
98 | Pair("Низька", 10),
99 | Pair("Середня", 20),
100 | Pair("Висока", 30),
101 | )
102 |
103 | val keysList = map.keys.toList()
104 |
105 | var selectedItem by remember {
106 | mutableStateOf(0)
107 | }
108 |
109 | HeartRateTheme {
110 | Column {
111 | DropdownList(
112 | itemList = listOf(),
113 | selectedItemResource = selectedItem,
114 | onItemClick = {selectedItem = it}
115 | )
116 |
117 | }
118 | }
119 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/supersonic/heartrate/components/HistoryCard.kt:
--------------------------------------------------------------------------------
1 | package com.supersonic.heartrate.components
2 |
3 | import androidx.compose.foundation.background
4 | import androidx.compose.foundation.layout.Arrangement
5 | import androidx.compose.foundation.layout.Box
6 | import androidx.compose.foundation.layout.Column
7 | import androidx.compose.foundation.layout.Row
8 | import androidx.compose.foundation.layout.fillMaxHeight
9 | import androidx.compose.foundation.layout.fillMaxWidth
10 | import androidx.compose.foundation.layout.height
11 | import androidx.compose.foundation.layout.padding
12 | import androidx.compose.foundation.layout.width
13 | import androidx.compose.foundation.shape.RoundedCornerShape
14 | import androidx.compose.material3.Card
15 | import androidx.compose.material3.CardDefaults
16 | import androidx.compose.material3.MaterialTheme.typography
17 | import androidx.compose.material3.Text
18 | import androidx.compose.runtime.Composable
19 | import androidx.compose.ui.Alignment
20 | import androidx.compose.ui.Modifier
21 | import androidx.compose.ui.graphics.Color
22 | import androidx.compose.ui.res.stringResource
23 | import androidx.compose.ui.tooling.preview.Preview
24 | import androidx.compose.ui.unit.dp
25 | import androidx.compose.ui.unit.sp
26 | import com.supersonic.heartrate.R
27 | import com.supersonic.heartrate.models.HeartRate
28 | import com.supersonic.heartrate.ui.theme.HeartRateTheme
29 | import com.supersonic.heartrate.util.identifyAccuracyColor
30 | import java.util.Locale
31 |
32 | @Composable
33 | fun HistoryCard(
34 | modifier: Modifier = Modifier,
35 | heartRate: HeartRate
36 | ) {
37 | Card(
38 | modifier = modifier
39 | .height(86.dp),
40 | colors = CardDefaults.cardColors(
41 | containerColor = Color(0xFFFEFEFE)
42 | )
43 | ) {
44 | Row(
45 | modifier = Modifier
46 | .padding(8.dp)
47 | .fillMaxWidth(),
48 | verticalAlignment = Alignment.CenterVertically,
49 | horizontalArrangement = Arrangement.SpaceBetween
50 | ) {
51 | Text(
52 | text = "${heartRate.bpm} " + stringResource(id = R.string.bpm).uppercase(Locale.ROOT),
53 | modifier = Modifier
54 | .weight(3F),
55 | style = typography.displayMedium,
56 | color = Color.Black
57 | )
58 |
59 | Box(
60 | modifier = Modifier
61 | .fillMaxHeight()
62 | .weight(.1F)
63 | .width(6.dp)
64 | .background(
65 | color = identifyAccuracyColor(heartRate.measurementAccuracy),
66 | RoundedCornerShape(4.dp)
67 | )
68 | .padding(horizontal = 8.dp)
69 | )
70 |
71 | Column(
72 | modifier = Modifier
73 | .weight(3F)
74 | .padding(start = 16.dp),
75 | horizontalAlignment = Alignment.Start,
76 | verticalArrangement = Arrangement.Center
77 | ) {
78 | Text(
79 | text = heartRate.time,
80 | style = typography.displaySmall.copy(fontSize = 24.sp),
81 | color = Color.Gray
82 | )
83 |
84 | Text(
85 | text = heartRate.date,
86 | style = typography.displaySmall.copy(fontSize = 24.sp),
87 | color = Color.Gray
88 | )
89 | }
90 | }
91 | }
92 |
93 | }
94 |
95 | @Preview
96 | @Composable
97 | private fun HistoryCardPreview() {
98 | HeartRateTheme {
99 | HistoryCard(
100 | heartRate = HeartRate(0,100, "12:54", "19/06/2024", R.string.measurementAccuracy_mid)
101 | )
102 | }
103 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/supersonic/heartrate/components/ResultCard.kt:
--------------------------------------------------------------------------------
1 | package com.supersonic.heartrate.components
2 |
3 | import androidx.compose.animation.core.animateDpAsState
4 | import androidx.compose.animation.core.tween
5 | import androidx.compose.foundation.background
6 | import androidx.compose.foundation.border
7 | import androidx.compose.foundation.layout.Arrangement
8 | import androidx.compose.foundation.layout.Box
9 | import androidx.compose.foundation.layout.Column
10 | import androidx.compose.foundation.layout.Row
11 | import androidx.compose.foundation.layout.Spacer
12 | import androidx.compose.foundation.layout.fillMaxWidth
13 | import androidx.compose.foundation.layout.height
14 | import androidx.compose.foundation.layout.offset
15 | import androidx.compose.foundation.layout.padding
16 | import androidx.compose.foundation.layout.size
17 | import androidx.compose.foundation.layout.width
18 | import androidx.compose.foundation.shape.CircleShape
19 | import androidx.compose.foundation.shape.RoundedCornerShape
20 | import androidx.compose.material3.Card
21 | import androidx.compose.material3.CardDefaults
22 | import androidx.compose.material3.MaterialTheme.colorScheme
23 | import androidx.compose.material3.MaterialTheme.typography
24 | import androidx.compose.material3.Text
25 | import androidx.compose.runtime.Composable
26 | import androidx.compose.runtime.getValue
27 | import androidx.compose.ui.Alignment
28 | import androidx.compose.ui.Modifier
29 | import androidx.compose.ui.draw.clip
30 | import androidx.compose.ui.graphics.Color
31 | import androidx.compose.ui.res.stringResource
32 | import androidx.compose.ui.text.font.FontWeight
33 | import androidx.compose.ui.tooling.preview.Preview
34 | import androidx.compose.ui.unit.Dp
35 | import androidx.compose.ui.unit.dp
36 | import com.supersonic.heartrate.R
37 | import com.supersonic.heartrate.models.HeartRate
38 | import com.supersonic.heartrate.ui.theme.HeartRateTheme
39 | import com.supersonic.heartrate.util.highColor
40 | import com.supersonic.heartrate.util.identifyAccuracyColor
41 | import com.supersonic.heartrate.util.identifyResultColor
42 | import com.supersonic.heartrate.util.identifyResultText
43 | import com.supersonic.heartrate.util.lowColor
44 | import com.supersonic.heartrate.util.midColor
45 |
46 | @Composable
47 | fun ResultCard(
48 | modifier: Modifier = Modifier,
49 | heartRate: HeartRate
50 | ) {
51 | Card(
52 | modifier = modifier,
53 | colors = CardDefaults.cardColors(
54 | containerColor = colorScheme.secondaryContainer
55 | )
56 | ){
57 | Column(
58 | modifier = Modifier
59 | .fillMaxWidth()
60 | .padding(16.dp)
61 | ) {
62 | Row(
63 | modifier = Modifier
64 | .fillMaxWidth(),
65 | horizontalArrangement = Arrangement.SpaceBetween,
66 | verticalAlignment = Alignment.CenterVertically
67 | ){
68 | Column(
69 | modifier = Modifier
70 |
71 | ) {
72 | Text(
73 | text = stringResource(R.string.resultCard_title),
74 | style = typography.bodyLarge,
75 | )
76 |
77 | Text(
78 | text = stringResource(identifyResultText(heartRate.bpm)),
79 | modifier = Modifier.padding(top = 2.dp),
80 | style = typography.titleLarge,
81 | color = identifyResultColor(heartRate.bpm)
82 | )
83 |
84 | }
85 |
86 | Text(
87 | text = "${heartRate.bpm} " + stringResource(R.string.bpm),
88 | style = typography.displaySmall
89 | )
90 | }
91 |
92 | // Indicator
93 | Column(
94 | modifier = Modifier.padding(top = 2.dp),
95 | horizontalAlignment = Alignment.CenterHorizontally,
96 | verticalArrangement = Arrangement.Center
97 | ){
98 |
99 | Box(
100 | modifier = Modifier
101 | // .padding(vertical = 16.dp)
102 | .fillMaxWidth()
103 | .height(24.dp)
104 | ) {
105 | Row(
106 | modifier = Modifier
107 | .fillMaxWidth()
108 | .padding(vertical = 6.dp)
109 | .clip(CircleShape)
110 | ) {
111 | Box(
112 | modifier = Modifier
113 | .weight(1F)
114 | .background(lowColor)
115 | .height(24.dp)
116 | )
117 | Box(
118 | modifier = Modifier
119 | .weight(1F)
120 | .background(midColor)
121 | .height(24.dp)
122 | )
123 | Box(
124 | modifier = Modifier
125 | .weight(1F)
126 | .background(highColor)
127 | .height(24.dp)
128 | )
129 | }
130 | val indicatorOffset by animateDpAsState(
131 | targetValue = calculateIndicatorOffset(heartRate.bpm, 340.dp),
132 | animationSpec = tween(
133 | durationMillis = 1_500
134 | ),
135 | label = "indicator animation"
136 | )
137 | Box(
138 | modifier = Modifier
139 | .align(Alignment.CenterStart)
140 | .offset(x = indicatorOffset)
141 | .background(Color.White, shape = RoundedCornerShape(4.dp))
142 | .size(width = 6.dp, height = 20.dp)
143 | .border(1.dp, Color.Gray, RoundedCornerShape(4.dp))
144 | )
145 | }
146 |
147 | Row(
148 | modifier = Modifier.padding(vertical = 2.dp),
149 | horizontalArrangement = Arrangement.Center,
150 | verticalAlignment = Alignment.CenterVertically
151 | ){
152 | Text(
153 | text = stringResource(R.string.resultCard_measurementAccuracy_text),
154 | style = typography.bodySmall
155 | )
156 | Text(
157 | text = stringResource(heartRate.measurementAccuracy),
158 | color = identifyAccuracyColor(heartRate.measurementAccuracy),
159 | style = typography.bodySmall.copy(fontWeight = FontWeight.Bold)
160 | )
161 | }
162 | }
163 |
164 | Column(
165 | modifier = Modifier
166 | .fillMaxWidth(),
167 | ) {
168 |
169 | Row(
170 | modifier = Modifier
171 | .fillMaxWidth(),
172 | verticalAlignment = Alignment.CenterVertically,
173 | horizontalArrangement = Arrangement.SpaceBetween
174 | ) {
175 | Box(
176 | modifier = Modifier
177 | .padding(vertical = 4.dp)
178 | .background(
179 | color = colorScheme.primaryContainer,
180 | shape = RoundedCornerShape(4.dp)
181 | )
182 | .width(140.dp)
183 | ){
184 | Row(
185 | modifier = Modifier.padding(2.dp),
186 | verticalAlignment = Alignment.CenterVertically
187 | ) {
188 | Box(
189 | modifier = Modifier
190 | .padding(vertical = 4.dp)
191 | .size(8.dp)
192 | .background(
193 | color = lowColor,
194 | shape = CircleShape
195 | )
196 | )
197 | Spacer(modifier = Modifier.width(6.dp))
198 | Text(
199 | text = stringResource(id = R.string.resultCard_delayed),
200 | style = typography.bodySmall,
201 | )
202 | }
203 | }
204 |
205 | Box(modifier = Modifier){
206 | Text(
207 | text = stringResource(id = R.string.resultCard_delayed_value),
208 | style = typography.bodySmall,
209 | color = if (identifyResultColor(heartRate.bpm) == lowColor) Color.Black
210 | else Color.Gray
211 | )
212 | }
213 | }
214 |
215 | Row(
216 | modifier = Modifier
217 | .fillMaxWidth(),
218 | verticalAlignment = Alignment.CenterVertically,
219 | horizontalArrangement = Arrangement.SpaceBetween
220 | ) {
221 | Box(
222 | modifier = Modifier
223 | .padding(vertical = 4.dp)
224 | .background(
225 | color = colorScheme.primaryContainer,
226 | shape = RoundedCornerShape(4.dp)
227 | )
228 | .width(140.dp)
229 | ){
230 | Row(
231 | modifier = Modifier.padding(2.dp),
232 | verticalAlignment = Alignment.CenterVertically
233 | ){
234 | Box(modifier = Modifier
235 | .size(8.dp)
236 | .background(
237 | color = midColor,
238 | shape = CircleShape
239 | ))
240 | Spacer(modifier = Modifier.width(6.dp))
241 | Text(
242 | text = stringResource(id = R.string.resultCard_standard),
243 | style = typography.bodySmall,
244 | )
245 | }
246 | }
247 |
248 | Box(modifier = Modifier){
249 | Text(
250 | text = stringResource(id = R.string.resultCard_standard_value),
251 | style = typography.bodySmall,
252 | color = if (identifyResultColor(heartRate.bpm) == midColor) Color.Black
253 | else Color.Gray
254 | )
255 | }
256 |
257 | }
258 |
259 | Row(
260 | modifier = Modifier
261 | .fillMaxWidth(),
262 | verticalAlignment = Alignment.CenterVertically,
263 | horizontalArrangement = Arrangement.SpaceBetween
264 | ) {
265 | Box(
266 | modifier = Modifier
267 | .padding(vertical = 4.dp)
268 | .background(
269 | color = colorScheme.primaryContainer,
270 | shape = RoundedCornerShape(4.dp)
271 | )
272 | .width(140.dp)
273 | ){
274 | Row(
275 | modifier = Modifier.padding(2.dp),
276 | verticalAlignment = Alignment.CenterVertically
277 | ){
278 | Box(modifier = Modifier
279 | .size(8.dp)
280 | .background(
281 | color = highColor,
282 | shape = CircleShape
283 | ))
284 | Spacer(modifier = Modifier.width(6.dp))
285 | Text(
286 | text = stringResource(id = R.string.resultCard_frequent),
287 | style = typography.bodySmall,
288 | )
289 | }
290 | }
291 |
292 | Box(modifier = Modifier){
293 | Text(
294 | text = stringResource(id = R.string.resultCard_frequent_value),
295 | style = typography.bodySmall,
296 | color = if (identifyResultColor(heartRate.bpm) == highColor) Color.Black
297 | else Color.Gray
298 | )
299 | }
300 | }
301 |
302 | }
303 |
304 | }
305 | }
306 | }
307 |
308 | @Preview
309 | @Composable
310 | private fun ResultCardPreview() {
311 | HeartRateTheme {
312 | ResultCard(
313 | heartRate = HeartRate(0,85, "12:54", "19/06/2024", R.string.measurementAccuracy_high),
314 | modifier = Modifier.padding(30.dp)
315 | )
316 | }
317 | }
318 |
319 |
320 |
321 | private fun calculateIndicatorOffset(bpm: Int, maxWidth: Dp): Dp {
322 |
323 | val offsetFraction = when {
324 | bpm < 59 -> bpm / 60f * 0.33f
325 | bpm in 59..99 -> 0.33f + (bpm - 60) / 60f * 0.33f
326 | else -> 0.66f + (bpm - 100) / 60f * 0.34f
327 | }
328 |
329 | val calculatedOffset = maxWidth * offsetFraction
330 |
331 | return if (calculatedOffset > maxWidth) maxWidth else calculatedOffset
332 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/supersonic/heartrate/components/ScreenTemplate.kt:
--------------------------------------------------------------------------------
1 | package com.supersonic.heartrate.components
2 |
3 | import androidx.compose.foundation.layout.Column
4 | import androidx.compose.foundation.layout.ColumnScope
5 | import androidx.compose.foundation.layout.fillMaxSize
6 | import androidx.compose.foundation.layout.padding
7 | import androidx.compose.material3.Scaffold
8 | import androidx.compose.runtime.Composable
9 | import androidx.compose.ui.Modifier
10 |
11 | @Composable
12 | fun ScreenTemplate(
13 | modifier: Modifier = Modifier,
14 | topBar: @Composable () -> Unit = {},
15 | content: @Composable (ColumnScope.() -> Unit)
16 | ) {
17 | Scaffold(
18 | topBar = topBar
19 | ) {
20 | Column(
21 | modifier = modifier
22 | .padding(it)
23 | .fillMaxSize(),
24 | content = content
25 | )
26 |
27 | }
28 |
29 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/supersonic/heartrate/components/TopBar.kt:
--------------------------------------------------------------------------------
1 | package com.supersonic.heartrate.components
2 |
3 | import androidx.compose.foundation.layout.RowScope
4 | import androidx.compose.material.icons.Icons
5 | import androidx.compose.material.icons.automirrored.filled.ArrowBack
6 | import androidx.compose.material3.ExperimentalMaterial3Api
7 | import androidx.compose.material3.Icon
8 | import androidx.compose.material3.IconButton
9 | import androidx.compose.material3.IconButtonDefaults
10 | import androidx.compose.material3.MaterialTheme.colorScheme
11 | import androidx.compose.material3.MaterialTheme.typography
12 | import androidx.compose.material3.Text
13 | import androidx.compose.material3.TopAppBar
14 | import androidx.compose.material3.TopAppBarDefaults
15 | import androidx.compose.runtime.Composable
16 | import androidx.compose.ui.Modifier
17 | import androidx.compose.ui.res.painterResource
18 | import androidx.compose.ui.text.style.TextOverflow
19 | import androidx.compose.ui.tooling.preview.Preview
20 | import com.supersonic.heartrate.R
21 | import com.supersonic.heartrate.ui.theme.HeartRateTheme
22 |
23 | @OptIn(ExperimentalMaterial3Api::class)
24 | @Composable
25 | fun TopBar(
26 | modifier: Modifier = Modifier,
27 | title: String? = null,
28 | onBackClick: () -> Unit = {},
29 | isBackIconEnabled: Boolean = false,
30 | actions: @Composable RowScope.() -> Unit = {}
31 | ) {
32 | TopAppBar(
33 | title = {
34 | if (title != null){
35 | Text(
36 | text = title,
37 | style = typography.titleSmall,
38 | maxLines = 1,
39 | overflow = TextOverflow.Ellipsis,
40 | color = colorScheme.onPrimary
41 | )
42 | }
43 | },
44 | modifier = modifier,
45 | colors = TopAppBarDefaults.topAppBarColors(colorScheme.primary),
46 | navigationIcon = {
47 | if (isBackIconEnabled){
48 | IconButton(
49 | onClick = onBackClick,
50 | colors = IconButtonDefaults.iconButtonColors(
51 | contentColor = colorScheme.onPrimary
52 | )
53 | ) {
54 | Icon(
55 | imageVector = Icons.AutoMirrored.Filled.ArrowBack,
56 | contentDescription = null)
57 | }
58 | }
59 | },
60 | actions = actions
61 | )
62 | }
63 |
64 | @Preview
65 | @Composable
66 | private fun TobBarPreview() {
67 | HeartRateTheme {
68 | TopBar(
69 | title = "Історія",
70 | isBackIconEnabled = true,
71 | actions = {
72 | Text(
73 | text = "Історія",
74 | style = typography.titleSmall,
75 | color = colorScheme.onPrimary
76 | )
77 | IconButton(
78 | onClick = {},
79 | colors = IconButtonDefaults.iconButtonColors(
80 | contentColor = colorScheme.onPrimary
81 | )
82 | ) {
83 | Icon(painter = painterResource(id = R.drawable.icon_time_machine), contentDescription = null)
84 | }
85 | }
86 | )
87 | }
88 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/supersonic/heartrate/db/HearRateDatabase.kt:
--------------------------------------------------------------------------------
1 | package com.supersonic.heartrate.db
2 |
3 | import android.content.Context
4 | import androidx.room.Database
5 | import androidx.room.Room
6 | import androidx.room.RoomDatabase
7 | import com.supersonic.heartrate.models.HeartRate
8 |
9 | @Database(
10 | entities = [HeartRate::class],
11 | version = 2
12 | )
13 | abstract class HearRateDatabase : RoomDatabase() {
14 | abstract fun heartRateDao(): HeartRateDao
15 |
16 | companion object {
17 | @Volatile
18 | private var Instance: HearRateDatabase? = null
19 |
20 | fun getDatabase(context: Context): HearRateDatabase {
21 | return Instance ?: synchronized(this){
22 | Room.databaseBuilder(
23 | context,
24 | HearRateDatabase::class.java,
25 | "heartRate_database"
26 | )
27 | .fallbackToDestructiveMigration()
28 | .build()
29 | .also { Instance = it }
30 | }
31 | }
32 | }
33 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/supersonic/heartrate/db/HeartRateDao.kt:
--------------------------------------------------------------------------------
1 | package com.supersonic.heartrate.db
2 |
3 | import androidx.room.Dao
4 | import androidx.room.Delete
5 | import androidx.room.Insert
6 | import androidx.room.OnConflictStrategy
7 | import androidx.room.Query
8 | import com.supersonic.heartrate.models.HeartRate
9 | import kotlinx.coroutines.flow.Flow
10 |
11 | @Dao
12 | interface HeartRateDao {
13 | @Insert(onConflict = OnConflictStrategy.IGNORE)
14 | suspend fun insertHeartRate(heartRate: HeartRate): Long
15 |
16 | @Delete
17 | suspend fun deleteHeartRate(heartRate: HeartRate)
18 |
19 | @Query("SELECT * from heart_rates WHERE id = :id")
20 | fun getHearRate(id: Int): Flow
21 |
22 | @Query("SELECT * from heart_rates")
23 | fun getAllHeartRates(): Flow>
24 |
25 | @Query("DELETE from heart_rates")
26 | suspend fun deleteAllHeartRates()
27 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/supersonic/heartrate/db/HeartRateRepository.kt:
--------------------------------------------------------------------------------
1 | package com.supersonic.heartrate.db
2 |
3 | import com.supersonic.heartrate.models.HeartRate
4 | import kotlinx.coroutines.flow.Flow
5 | import javax.inject.Inject
6 |
7 | class HeartRateRepository @Inject constructor(private val heartRateDao: HeartRateDao) {
8 |
9 | fun getAllHeartRatesStream(): Flow> = heartRateDao.getAllHeartRates()
10 |
11 | fun getHeartRateStream(id: Int): Flow = heartRateDao.getHearRate(id)
12 |
13 | suspend fun insertHeartRate(heartRate: HeartRate): Long = heartRateDao.insertHeartRate(heartRate)
14 |
15 | suspend fun deleteHeartRate(heartRate: HeartRate) = heartRateDao.deleteHeartRate(heartRate)
16 |
17 | suspend fun deleteAllHeartRates() = heartRateDao.deleteAllHeartRates()
18 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/supersonic/heartrate/models/HeartRate.kt:
--------------------------------------------------------------------------------
1 | package com.supersonic.heartrate.models
2 |
3 | import androidx.room.Entity
4 | import androidx.room.PrimaryKey
5 |
6 | @Entity(tableName = "heart_rates")
7 | data class HeartRate(
8 | @PrimaryKey(autoGenerate = true)
9 | val id: Int = 0,
10 | val bpm: Int,
11 | val time: String,
12 | val date: String,
13 | val measurementAccuracy: Int
14 | )
15 |
--------------------------------------------------------------------------------
/app/src/main/java/com/supersonic/heartrate/models/OnboardingPage.kt:
--------------------------------------------------------------------------------
1 | package com.supersonic.heartrate.models
2 |
3 | data class OnboardingPage(
4 | val imageRes: Int,
5 | val titleRes: Int,
6 | val bodyRes: Int,
7 | val cameraPermission: Boolean = false
8 | )
--------------------------------------------------------------------------------
/app/src/main/java/com/supersonic/heartrate/navigation/NavigationDestination.kt:
--------------------------------------------------------------------------------
1 | package com.supersonic.heartrate.navigation
2 |
3 | interface NavigationDestination {
4 | val route: String
5 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/supersonic/heartrate/navigation/RootAppNavigation.kt:
--------------------------------------------------------------------------------
1 | package com.supersonic.heartrate.navigation
2 |
3 | import android.content.Context
4 | import androidx.compose.foundation.layout.fillMaxSize
5 | import androidx.compose.runtime.Composable
6 | import androidx.compose.ui.Modifier
7 | import androidx.compose.ui.platform.LocalContext
8 | import androidx.hilt.navigation.compose.hiltViewModel
9 | import androidx.navigation.NavHostController
10 | import androidx.navigation.NavType
11 | import androidx.navigation.compose.NavHost
12 | import androidx.navigation.compose.composable
13 | import androidx.navigation.compose.rememberNavController
14 | import androidx.navigation.navArgument
15 | import com.supersonic.heartrate.screens.history.HistoryScreen
16 | import com.supersonic.heartrate.screens.history.HistoryScreenDestination
17 | import com.supersonic.heartrate.screens.history.HistoryViewModel
18 | import com.supersonic.heartrate.screens.homepage.HomepageScreen
19 | import com.supersonic.heartrate.screens.homepage.HomepageScreenDestination
20 | import com.supersonic.heartrate.screens.loading.LoadingScreen
21 | import com.supersonic.heartrate.screens.loading.LoadingScreenDestination
22 | import com.supersonic.heartrate.screens.measurement.MeasurementScreen
23 | import com.supersonic.heartrate.screens.measurement.MeasurementScreenDestination
24 | import com.supersonic.heartrate.screens.measurement.MeasurementViewModel
25 | import com.supersonic.heartrate.screens.onboarding.OnboardingScreen
26 | import com.supersonic.heartrate.screens.onboarding.OnboardingScreenDestination
27 | import com.supersonic.heartrate.screens.result.ResultScreen
28 | import com.supersonic.heartrate.screens.result.ResultScreenDestination
29 | import com.supersonic.heartrate.screens.result.ResultScreenViewModel
30 |
31 | @Composable
32 | fun RootAppNavigation(
33 | modifier: Modifier = Modifier,
34 | navController: NavHostController = rememberNavController(),
35 | startDestination: String = HomepageScreenDestination.route
36 | ) {
37 |
38 |
39 | NavHost(
40 | modifier = modifier,
41 | navController = navController,
42 | startDestination = startDestination
43 | ) {
44 | //Loading Screen
45 | composable(route = LoadingScreenDestination.route) {
46 | LoadingScreen(
47 | onNavigationNext = {
48 | navController.navigate(HomepageScreenDestination.route) {
49 | popUpTo(0)
50 | }
51 | }
52 | )
53 | }
54 |
55 | //Onboarding Screen
56 | composable(route = OnboardingScreenDestination.route) {
57 | val context = LocalContext.current
58 | OnboardingScreen(
59 | onOnboardingFinish = {
60 | saveOnboardingCompleted(context)
61 | navController.navigate(MeasurementScreenDestination.route) {
62 | popUpTo(HomepageScreenDestination.route)
63 | }
64 | }
65 | )
66 | }
67 |
68 | //Homepage Screen
69 | composable(route = HomepageScreenDestination.route) {
70 | val context = LocalContext.current
71 | HomepageScreen(
72 | needDisplayOnboarding = isFirstRun(context),
73 | onNavigationNext = {
74 | if (isFirstRun(context)) navController.navigate(OnboardingScreenDestination.route)
75 | else navController.navigate(MeasurementScreenDestination.route)
76 | },
77 | onNavigateToResultHistory = {
78 | navController.navigate(HistoryScreenDestination.route)
79 | }
80 | )
81 | }
82 |
83 | // Measurement Screen
84 | composable(route = MeasurementScreenDestination.route) {
85 | val viewModel = hiltViewModel()
86 | MeasurementScreen(
87 | viewModel = viewModel,
88 | onNavigateToResultHistory = {
89 | navController.navigate(HistoryScreenDestination.route)
90 | },
91 | onNavigationToResult = {
92 | navController.navigate("${ResultScreenDestination.route}/${it}")
93 | },
94 | onNavigateBack = { navController.navigateUp() }
95 | )
96 | }
97 |
98 | //Result Screen
99 | composable(
100 | route = ResultScreenDestination.routeWithArgs,
101 | arguments = listOf(navArgument("heartRateId"){
102 | type = NavType.IntType
103 | })) {
104 | val viewModel = hiltViewModel()
105 | ResultScreen(
106 | viewModel = viewModel,
107 | onNavigateToHomepage = {
108 | navController.navigate(HomepageScreenDestination.route){
109 | popUpTo(0)
110 | }
111 | },
112 | onNavigationToHistory = {
113 | navController.navigate(HistoryScreenDestination.route)
114 | }
115 | )
116 | }
117 |
118 | //Result History Screen
119 | composable(route = HistoryScreenDestination.route) {
120 | val viewModel = hiltViewModel()
121 | HistoryScreen(
122 | modifier = Modifier.fillMaxSize(),
123 | viewModel = viewModel,
124 | onBackClick = {
125 | navController.navigate(HomepageScreenDestination.route){
126 | popUpTo(0)
127 | }
128 | }
129 | )
130 | }
131 |
132 | }
133 | }
134 |
135 | const val PREFS_NAME = "onboarding_prefs"
136 | const val KEY_FIRST_RUN = "is_first_run"
137 |
138 | fun saveOnboardingCompleted(context: Context) {
139 | val sharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
140 | val editor = sharedPreferences.edit()
141 | editor.putBoolean(KEY_FIRST_RUN, false)
142 | editor.apply()
143 | }
144 |
145 | fun isFirstRun(context: Context): Boolean {
146 | val sharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
147 | return sharedPreferences.getBoolean(KEY_FIRST_RUN, true)
148 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/supersonic/heartrate/screens/MainActivity.kt:
--------------------------------------------------------------------------------
1 | package com.supersonic.heartrate.screens
2 |
3 | import android.content.pm.ActivityInfo
4 | import android.os.Bundle
5 | import androidx.activity.ComponentActivity
6 | import androidx.activity.compose.setContent
7 | import com.supersonic.heartrate.navigation.RootAppNavigation
8 | import com.supersonic.heartrate.ui.theme.HeartRateTheme
9 | import dagger.hilt.android.AndroidEntryPoint
10 |
11 | @AndroidEntryPoint
12 | class MainActivity : ComponentActivity() {
13 | override fun onCreate(savedInstanceState: Bundle?) {
14 | super.onCreate(savedInstanceState)
15 | this.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
16 | setContent {
17 | HeartRateTheme {
18 | RootAppNavigation()
19 | }
20 | }
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/app/src/main/java/com/supersonic/heartrate/screens/history/HistoryScreen.kt:
--------------------------------------------------------------------------------
1 | package com.supersonic.heartrate.screens.history
2 |
3 | import androidx.activity.compose.BackHandler
4 | import androidx.compose.foundation.background
5 | import androidx.compose.foundation.layout.Box
6 | import androidx.compose.foundation.layout.Column
7 | import androidx.compose.foundation.layout.Row
8 | import androidx.compose.foundation.layout.Spacer
9 | import androidx.compose.foundation.layout.fillMaxWidth
10 | import androidx.compose.foundation.layout.height
11 | import androidx.compose.foundation.layout.padding
12 | import androidx.compose.foundation.layout.size
13 | import androidx.compose.foundation.lazy.LazyColumn
14 | import androidx.compose.foundation.lazy.items
15 | import androidx.compose.foundation.shape.CircleShape
16 | import androidx.compose.material.icons.Icons
17 | import androidx.compose.material.icons.outlined.Delete
18 | import androidx.compose.material.icons.outlined.Info
19 | import androidx.compose.material3.AlertDialog
20 | import androidx.compose.material3.ButtonDefaults
21 | import androidx.compose.material3.Icon
22 | import androidx.compose.material3.IconButton
23 | import androidx.compose.material3.IconButtonDefaults
24 | import androidx.compose.material3.MaterialTheme.colorScheme
25 | import androidx.compose.material3.MaterialTheme.typography
26 | import androidx.compose.material3.Text
27 | import androidx.compose.material3.TextButton
28 | import androidx.compose.runtime.Composable
29 | import androidx.compose.runtime.collectAsState
30 | import androidx.compose.runtime.getValue
31 | import androidx.compose.runtime.mutableStateOf
32 | import androidx.compose.runtime.remember
33 | import androidx.compose.runtime.rememberCoroutineScope
34 | import androidx.compose.runtime.setValue
35 | import androidx.compose.ui.Alignment
36 | import androidx.compose.ui.Modifier
37 | import androidx.compose.ui.graphics.Color
38 | import androidx.compose.ui.res.stringResource
39 | import androidx.compose.ui.text.style.TextAlign
40 | import androidx.compose.ui.unit.dp
41 | import com.supersonic.heartrate.R
42 | import com.supersonic.heartrate.components.HistoryCard
43 | import com.supersonic.heartrate.components.ScreenTemplate
44 | import com.supersonic.heartrate.components.TopBar
45 | import com.supersonic.heartrate.models.HeartRate
46 | import com.supersonic.heartrate.navigation.NavigationDestination
47 | import com.supersonic.heartrate.util.highColor
48 | import com.supersonic.heartrate.util.lowColor
49 | import com.supersonic.heartrate.util.midColor
50 | import kotlinx.coroutines.launch
51 |
52 | object HistoryScreenDestination : NavigationDestination {
53 | override val route = "history"
54 | }
55 |
56 | @Composable
57 | fun HistoryScreen(
58 | modifier: Modifier = Modifier,
59 | viewModel: HistoryViewModel,
60 | onBackClick: () -> Unit
61 | ) {
62 | val heartRatesList by viewModel.heartRatesList.collectAsState()
63 | val scope = rememberCoroutineScope()
64 |
65 | var showClearHistoryDialog by remember { mutableStateOf(false) }
66 | var showInfoDialog by remember { mutableStateOf(false) }
67 |
68 |
69 | BackHandler {
70 | onBackClick.invoke()
71 | }
72 |
73 | if (showClearHistoryDialog){
74 | ClearHistoryDialog(
75 | onDismiss = { showClearHistoryDialog = false },
76 | onConfirm = { scope.launch {
77 | viewModel.clearHistory()
78 | }})
79 | }
80 |
81 | if (showInfoDialog){
82 | InfoDialog(onDismiss = {showInfoDialog = false})
83 | }
84 |
85 | ScreenTemplate(
86 | topBar = {
87 | ResultHistoryTopBar(
88 | onBackClick = onBackClick,
89 | onClearHistoryClick = { showClearHistoryDialog = true },
90 | onInfoClick = { showInfoDialog = true }
91 | )
92 | }
93 | ) {
94 | ResultHistoryScreenContent(
95 | modifier = modifier,
96 | heartRatesList = heartRatesList,
97 | onItemDelete = {
98 | scope.launch {
99 | viewModel.deleteItem(it)
100 | }
101 | }
102 | )
103 | }
104 |
105 | }
106 |
107 | @Composable
108 | private fun ResultHistoryTopBar(
109 | modifier: Modifier = Modifier,
110 | onBackClick: () -> Unit,
111 | onClearHistoryClick: () -> Unit,
112 | onInfoClick: () -> Unit
113 | ) {
114 | TopBar(
115 | modifier = modifier,
116 | title = stringResource(id = R.string.topAppBar_title_history),
117 | isBackIconEnabled = true,
118 | onBackClick = onBackClick,
119 | actions = {
120 |
121 | IconButton(
122 | onClick = onInfoClick,
123 | colors = IconButtonDefaults.iconButtonColors(
124 | contentColor = colorScheme.onPrimary
125 | )
126 | ) {
127 | Icon(imageVector = Icons.Outlined.Info, contentDescription = null)
128 | }
129 |
130 | IconButton(
131 | onClick = onClearHistoryClick,
132 | colors = IconButtonDefaults.iconButtonColors(
133 | contentColor = colorScheme.onPrimary
134 | )
135 | ) {
136 | Icon(imageVector = Icons.Outlined.Delete, contentDescription = null)
137 | }
138 | }
139 | )
140 | }
141 | @Composable
142 | private fun ResultHistoryScreenContent(
143 | modifier: Modifier = Modifier,
144 | heartRatesList: List,
145 | onItemDelete: (HeartRate) -> Unit
146 | ) {
147 | if (heartRatesList.isNotEmpty()){
148 | LazyColumn(
149 | modifier = modifier
150 | ) {
151 | items(heartRatesList, key = { it.id }) { heartRate ->
152 | SwipeToDeleteContainer(
153 | item = heartRate,
154 | onDelete = {
155 | onItemDelete(heartRate)
156 | }
157 | ){
158 | HistoryCard(
159 | heartRate = it,
160 | modifier = Modifier.padding(8.dp)
161 | )
162 | }
163 | }
164 | }
165 |
166 | } else {
167 | Text(
168 | text = stringResource(R.string.history_empty_text),
169 | style = typography.titleLarge,
170 | textAlign = TextAlign.Center,
171 | modifier = Modifier
172 | .padding(top = 32.dp)
173 | .fillMaxWidth()
174 | )
175 | }
176 | }
177 |
178 | @Composable
179 | fun InfoDialog(
180 | onDismiss: () -> Unit,
181 | ) {
182 |
183 | AlertDialog(
184 | onDismissRequest = onDismiss,
185 | icon = {
186 | Icon(
187 | modifier = Modifier.size(28.dp),
188 | imageVector = Icons.Outlined.Info,
189 | tint = colorScheme.primary,
190 | contentDescription = null
191 | )
192 | },
193 | title = {
194 | Text(
195 | text = stringResource(R.string.history_info_dialog_title),
196 | textAlign = TextAlign.Center,
197 | style = typography.titleMedium,
198 | )
199 | },
200 | text = {
201 | Column {
202 | Text(
203 | text = stringResource(R.string.history_info_dialog_body),
204 | textAlign = TextAlign.Start
205 | )
206 | Spacer(modifier = Modifier.height(8.dp))
207 | Row(
208 | verticalAlignment = Alignment.CenterVertically,
209 | modifier = Modifier.padding(vertical = 4.dp)
210 | ) {
211 | Box(modifier = Modifier
212 | .size(12.dp)
213 | .background(lowColor, CircleShape))
214 | Text(
215 | text = "–",
216 | modifier = Modifier.padding(horizontal = 4.dp),
217 | style = typography.bodyMedium
218 | )
219 | Text(
220 | text = stringResource(R.string.history_info_dialog_body_low_accuracy),
221 | style = typography.bodyMedium
222 | )
223 | }
224 |
225 | Row(
226 | verticalAlignment = Alignment.CenterVertically,
227 | modifier = Modifier.padding(vertical = 4.dp)
228 | ) {
229 | Box(modifier = Modifier
230 | .size(12.dp)
231 | .background(midColor, CircleShape))
232 | Text(
233 | text = "–",
234 | modifier = Modifier.padding(horizontal = 4.dp),
235 | style = typography.bodyMedium
236 | )
237 | Text(
238 | text = stringResource(R.string.history_info_dialog_body_mid_accuracy),
239 | style = typography.bodyMedium
240 | )
241 | }
242 |
243 | Row(
244 | verticalAlignment = Alignment.CenterVertically,
245 | modifier = Modifier.padding(vertical = 4.dp)
246 | ) {
247 | Box(modifier = Modifier
248 | .size(12.dp)
249 | .background(highColor, CircleShape))
250 | Text(
251 | text = "–",
252 | modifier = Modifier.padding(horizontal = 4.dp),
253 | style = typography.bodyMedium
254 | )
255 | Text(
256 | text = stringResource(R.string.history_info_dialog_body_high_accuracy),
257 | style = typography.bodyMedium
258 | )
259 | }
260 | }
261 | },
262 | confirmButton = {
263 | TextButton(onClick = onDismiss) {
264 | Text(text = stringResource(R.string.history_info_dialog_confirmButton))
265 | }
266 | }
267 | )
268 |
269 | }
270 |
271 | @Composable
272 | fun ClearHistoryDialog(
273 | onDismiss: () -> Unit,
274 | onConfirm: () -> Unit
275 | ) {
276 | AlertDialog(
277 | onDismissRequest = onDismiss,
278 | icon = {
279 | Icon(
280 | modifier = Modifier.size(28.dp),
281 | imageVector = Icons.Outlined.Delete,
282 | tint = colorScheme.primary,
283 | contentDescription = null
284 | )
285 | },
286 | title = {
287 | Text(
288 | text = stringResource(R.string.history_clearHistory_dialog_title),
289 | textAlign = TextAlign.Center,
290 | style = typography.titleMedium,
291 | )
292 | },
293 | text = {
294 | Text(
295 | text = stringResource(R.string.history_clearHistory_dialog_body),
296 | textAlign = TextAlign.Center
297 | )
298 | },
299 | confirmButton = {
300 | TextButton(
301 | onClick = {
302 | onConfirm.invoke()
303 | onDismiss.invoke()
304 | },
305 | ) {
306 | Text(text = stringResource(R.string.history_clearHistory_dialog_confirmButton))
307 | }
308 | },
309 | dismissButton = {
310 | TextButton(
311 | onClick = onDismiss,
312 | colors = ButtonDefaults.textButtonColors(contentColor = Color.Gray)
313 | ) {
314 | Text(text = stringResource(R.string.history_clearHistory_dialog_dismissButton))
315 | }
316 | }
317 | )
318 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/supersonic/heartrate/screens/history/HistoryViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.supersonic.heartrate.screens.history
2 |
3 | import androidx.lifecycle.ViewModel
4 | import androidx.lifecycle.viewModelScope
5 | import com.supersonic.heartrate.db.HeartRateRepository
6 | import com.supersonic.heartrate.models.HeartRate
7 | import dagger.hilt.android.lifecycle.HiltViewModel
8 | import kotlinx.coroutines.flow.SharingStarted
9 | import kotlinx.coroutines.flow.StateFlow
10 | import kotlinx.coroutines.flow.stateIn
11 | import javax.inject.Inject
12 |
13 | @HiltViewModel
14 | class HistoryViewModel @Inject constructor(
15 | private val heartRateRepository: HeartRateRepository
16 | ): ViewModel() {
17 |
18 | val heartRatesList: StateFlow> =
19 | heartRateRepository.getAllHeartRatesStream()
20 | .stateIn(
21 | scope = viewModelScope,
22 | started = SharingStarted.WhileSubscribed(5_000),
23 | initialValue = emptyList()
24 | )
25 |
26 | suspend fun clearHistory(){
27 | heartRateRepository.deleteAllHeartRates()
28 | }
29 |
30 | suspend fun deleteItem(heartRate: HeartRate){
31 | heartRateRepository.deleteHeartRate(heartRate)
32 | }
33 |
34 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/supersonic/heartrate/screens/history/SwipeToDeleteContainer.kt:
--------------------------------------------------------------------------------
1 | package com.supersonic.heartrate.screens.history
2 |
3 | import androidx.compose.animation.AnimatedVisibility
4 | import androidx.compose.animation.core.tween
5 | import androidx.compose.animation.fadeOut
6 | import androidx.compose.animation.shrinkVertically
7 | import androidx.compose.foundation.background
8 | import androidx.compose.foundation.layout.Box
9 | import androidx.compose.foundation.layout.fillMaxSize
10 | import androidx.compose.foundation.layout.padding
11 | import androidx.compose.foundation.layout.size
12 | import androidx.compose.foundation.shape.CircleShape
13 | import androidx.compose.material.icons.Icons
14 | import androidx.compose.material.icons.outlined.Delete
15 | import androidx.compose.material3.AlertDialog
16 | import androidx.compose.material3.ButtonDefaults
17 | import androidx.compose.material3.ExperimentalMaterial3Api
18 | import androidx.compose.material3.Icon
19 | import androidx.compose.material3.MaterialTheme.colorScheme
20 | import androidx.compose.material3.MaterialTheme.typography
21 | import androidx.compose.material3.SwipeToDismissBox
22 | import androidx.compose.material3.SwipeToDismissBoxValue
23 | import androidx.compose.material3.Text
24 | import androidx.compose.material3.TextButton
25 | import androidx.compose.material3.rememberSwipeToDismissBoxState
26 | import androidx.compose.runtime.Composable
27 | import androidx.compose.runtime.LaunchedEffect
28 | import androidx.compose.runtime.getValue
29 | import androidx.compose.runtime.mutableStateOf
30 | import androidx.compose.runtime.remember
31 | import androidx.compose.runtime.setValue
32 | import androidx.compose.ui.Alignment
33 | import androidx.compose.ui.Modifier
34 | import androidx.compose.ui.graphics.Color
35 | import androidx.compose.ui.res.stringResource
36 | import androidx.compose.ui.text.style.TextAlign
37 | import androidx.compose.ui.unit.dp
38 | import com.supersonic.heartrate.R
39 | import kotlinx.coroutines.delay
40 |
41 | @OptIn(ExperimentalMaterial3Api::class)
42 | @Composable
43 | fun SwipeToDeleteContainer(
44 | item: T,
45 | onDelete: (T) -> Unit,
46 | animationDuration: Int = 500,
47 | content: @Composable (T) -> Unit
48 | ) {
49 | var isRemoved by remember {
50 | mutableStateOf(false)
51 | }
52 | var showDeleteDialog by remember {
53 | mutableStateOf(false)
54 | }
55 | val state = rememberSwipeToDismissBoxState(
56 | confirmValueChange = { value ->
57 | if (value == SwipeToDismissBoxValue.EndToStart) {
58 | showDeleteDialog = true
59 | isRemoved
60 | } else {
61 | false
62 | }
63 | }
64 | )
65 |
66 | if (showDeleteDialog){
67 | AlertDialog(
68 | onDismissRequest = {
69 | showDeleteDialog = false
70 | isRemoved = false
71 | },
72 | icon = {
73 | Icon(
74 | modifier = Modifier.size(28.dp),
75 | imageVector = Icons.Outlined.Delete,
76 | tint = colorScheme.primary,
77 | contentDescription = null
78 | )
79 | },
80 | title = {
81 | Text(text = stringResource(R.string.history_deleteMeasurement_dialog_title),
82 | style = typography.titleMedium,
83 | textAlign = TextAlign.Center
84 | )
85 | },
86 | text = {
87 | Text(
88 | text = stringResource(R.string.history_deleteMeasurement_dialog_body),
89 | textAlign = TextAlign.Center
90 | )
91 | },
92 | confirmButton = {
93 | TextButton(
94 | onClick = {
95 | isRemoved = true
96 | showDeleteDialog = false
97 | }) {
98 | Text(text = stringResource(id = R.string.history_clearHistory_dialog_confirmButton))
99 | }
100 | },
101 | dismissButton = {
102 | TextButton(
103 | onClick = {
104 | isRemoved = false
105 | showDeleteDialog = false
106 | },
107 | colors = ButtonDefaults.textButtonColors(contentColor = Color.Gray)
108 | ) {
109 | Text(text = stringResource(id = R.string.history_clearHistory_dialog_dismissButton))
110 | }
111 | }
112 | )
113 | }
114 |
115 | LaunchedEffect(key1 = isRemoved) {
116 | if(isRemoved) {
117 | delay(animationDuration.toLong())
118 | onDelete(item)
119 | }
120 | }
121 |
122 | AnimatedVisibility(
123 | visible = !isRemoved,
124 | exit = shrinkVertically(
125 | animationSpec = tween(durationMillis = animationDuration),
126 | shrinkTowards = Alignment.Top
127 | ) + fadeOut()
128 | ) {
129 | SwipeToDismissBox(
130 | state = state,
131 | backgroundContent = {
132 | DeleteBackground()
133 | },
134 | enableDismissFromStartToEnd = false,
135 | content = { content(item) }
136 | )
137 | }
138 | }
139 |
140 | @Composable
141 | fun DeleteBackground() {
142 |
143 | Box(
144 | modifier = Modifier
145 | .fillMaxSize()
146 | .padding(16.dp),
147 | contentAlignment = Alignment.CenterEnd
148 | ) {
149 | Icon(
150 | modifier = Modifier
151 | .background(Color.Red, CircleShape)
152 | .padding(8.dp),
153 | imageVector = Icons.Outlined.Delete,
154 | contentDescription = null,
155 | tint = colorScheme.onPrimary
156 | )
157 | }
158 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/supersonic/heartrate/screens/homepage/HomepageScreen.kt:
--------------------------------------------------------------------------------
1 | package com.supersonic.heartrate.screens.homepage
2 |
3 | import androidx.compose.animation.core.RepeatMode
4 | import androidx.compose.animation.core.animateFloat
5 | import androidx.compose.animation.core.infiniteRepeatable
6 | import androidx.compose.animation.core.rememberInfiniteTransition
7 | import androidx.compose.animation.core.tween
8 | import androidx.compose.foundation.Image
9 | import androidx.compose.foundation.background
10 | import androidx.compose.foundation.layout.Box
11 | import androidx.compose.foundation.layout.fillMaxSize
12 | import androidx.compose.foundation.layout.padding
13 | import androidx.compose.foundation.layout.size
14 | import androidx.compose.foundation.shape.CircleShape
15 | import androidx.compose.material3.Icon
16 | import androidx.compose.material3.IconButton
17 | import androidx.compose.material3.IconButtonDefaults
18 | import androidx.compose.material3.MaterialTheme.colorScheme
19 | import androidx.compose.material3.MaterialTheme.typography
20 | import androidx.compose.material3.Scaffold
21 | import androidx.compose.material3.Text
22 | import androidx.compose.runtime.Composable
23 | import androidx.compose.runtime.getValue
24 | import androidx.compose.ui.Alignment
25 | import androidx.compose.ui.Modifier
26 | import androidx.compose.ui.draw.alpha
27 | import androidx.compose.ui.res.painterResource
28 | import androidx.compose.ui.res.stringResource
29 | import androidx.compose.ui.text.style.TextAlign
30 | import androidx.compose.ui.tooling.preview.Preview
31 | import androidx.compose.ui.unit.dp
32 | import com.supersonic.heartrate.R
33 | import com.supersonic.heartrate.components.ScreenTemplate
34 | import com.supersonic.heartrate.components.TopBar
35 | import com.supersonic.heartrate.navigation.NavigationDestination
36 | import com.supersonic.heartrate.ui.theme.HeartRateTheme
37 |
38 | object HomepageScreenDestination : NavigationDestination {
39 | override val route = "homepage"
40 | }
41 |
42 | @Composable
43 | fun HomepageScreen(
44 | modifier: Modifier = Modifier,
45 | needDisplayOnboarding: Boolean = true,
46 | onNavigationNext: () -> Unit,
47 | onNavigateToResultHistory: () -> Unit
48 | ) {
49 |
50 | ScreenTemplate(
51 | topBar = { HomeTopBar( onNavigateToResultHistory = onNavigateToResultHistory ) }
52 | ) {
53 | HomepageScreenContent(
54 | modifier = modifier
55 | .fillMaxSize(),
56 | isFirstMeasurement = needDisplayOnboarding,
57 | onMeasurementButtonClick = onNavigationNext
58 | )
59 | }
60 | }
61 |
62 | @Composable
63 | fun HomeTopBar(
64 | modifier: Modifier = Modifier,
65 | onNavigateToResultHistory: () -> Unit,
66 | isBackIconEnabled: Boolean = false,
67 | onBackClick: () -> Unit = {},
68 | ) {
69 | TopBar(
70 | modifier = modifier,
71 | isBackIconEnabled = isBackIconEnabled,
72 | onBackClick = onBackClick,
73 | actions = {
74 | Text(
75 | text = stringResource(R.string.topAppBar_title_history),
76 | style = typography.titleSmall,
77 | color = colorScheme.onPrimary
78 | )
79 | IconButton(
80 | onClick = onNavigateToResultHistory,
81 | colors = IconButtonDefaults.iconButtonColors(
82 | contentColor = colorScheme.onPrimary
83 | )
84 | ) {
85 | Icon(
86 | modifier = Modifier.size(24.dp),
87 | painter = painterResource(id = R.drawable.icon_time_machine),
88 | contentDescription = null
89 | )
90 | }
91 | }
92 | )
93 | }
94 |
95 | @Composable
96 | private fun HomepageScreenContent(
97 | modifier: Modifier = Modifier,
98 | isFirstMeasurement: Boolean = true,
99 | onMeasurementButtonClick: () -> Unit
100 | ) {
101 |
102 | val infiniteTransition = rememberInfiniteTransition(label = "InfiniteTransition")
103 | val animatedAlpha by infiniteTransition.animateFloat(
104 | initialValue = 0F,
105 | targetValue = 1F,
106 | animationSpec = infiniteRepeatable(tween(1000), RepeatMode.Reverse),
107 | label = "alpha"
108 | )
109 |
110 | Box(
111 | modifier = modifier,
112 | ) {
113 | if (isFirstMeasurement){
114 | Text(
115 | text = stringResource(R.string.homePage_title1),
116 | style = typography.titleLarge,
117 | textAlign = TextAlign.Center,
118 | modifier = Modifier
119 | .align(Alignment.TopCenter)
120 | .padding(top = 24.dp)
121 | )
122 | }
123 |
124 | Image(
125 | painter = painterResource(R.drawable.heart),
126 | modifier = Modifier
127 | .align(Alignment.Center),
128 | contentDescription = null
129 | )
130 |
131 | Box(
132 | modifier = Modifier
133 | .align(Alignment.BottomCenter)
134 | .padding(bottom = 16.dp),
135 | contentAlignment = Alignment.Center
136 | ){
137 |
138 | Box(
139 | modifier = Modifier
140 | .alpha(if(isFirstMeasurement) animatedAlpha else 0F)
141 | .background(color = colorScheme.secondary, CircleShape)
142 | .size(112.dp)
143 | )
144 |
145 | IconButton(
146 | onClick = onMeasurementButtonClick,
147 | modifier = Modifier
148 | .size(120.dp)
149 | ) {
150 | Image(
151 | painter = painterResource(id = R.drawable.button),
152 | modifier = Modifier.size(100.dp),
153 | contentDescription = null
154 | )
155 | }
156 | }
157 | }
158 | }
159 |
160 |
161 |
162 | @Preview
163 | @Composable
164 | private fun HomepageScreenContentPreview() {
165 | HeartRateTheme {
166 | Scaffold {
167 | HomepageScreenContent(modifier = Modifier
168 | .padding(it)
169 | .fillMaxSize(),
170 | onMeasurementButtonClick = {}
171 | )
172 | }
173 | }
174 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/supersonic/heartrate/screens/loading/LoadingScreen.kt:
--------------------------------------------------------------------------------
1 | package com.supersonic.heartrate.screens.loading
2 |
3 | import androidx.compose.foundation.Image
4 | import androidx.compose.foundation.layout.Arrangement
5 | import androidx.compose.foundation.layout.Column
6 | import androidx.compose.foundation.layout.fillMaxSize
7 | import androidx.compose.foundation.layout.fillMaxWidth
8 | import androidx.compose.foundation.layout.padding
9 | import androidx.compose.material3.MaterialTheme.colorScheme
10 | import androidx.compose.material3.MaterialTheme.typography
11 | import androidx.compose.material3.Scaffold
12 | import androidx.compose.material3.Text
13 | import androidx.compose.runtime.Composable
14 | import androidx.compose.ui.Alignment
15 | import androidx.compose.ui.Modifier
16 | import androidx.compose.ui.graphics.StrokeCap
17 | import androidx.compose.ui.res.painterResource
18 | import androidx.compose.ui.res.stringResource
19 | import androidx.compose.ui.text.font.FontWeight
20 | import androidx.compose.ui.tooling.preview.Preview
21 | import com.supersonic.heartrate.R
22 | import com.supersonic.heartrate.components.AnimatedLinearProgressIndicator
23 | import com.supersonic.heartrate.navigation.NavigationDestination
24 | import com.supersonic.heartrate.ui.theme.HeartRateTheme
25 |
26 | object LoadingScreenDestination : NavigationDestination {
27 | override val route = "loading"
28 | }
29 | @Composable
30 | fun LoadingScreen(
31 | modifier: Modifier = Modifier,
32 | onNavigationNext: () -> Unit,
33 | ) {
34 | Scaffold(
35 | modifier = modifier,
36 | ) {
37 | LoadingScreenContent(
38 | modifier = modifier.padding(it),
39 | onNavigationNext = onNavigationNext
40 | )
41 | }
42 | }
43 |
44 | @Composable
45 | fun LoadingScreenContent(
46 | modifier: Modifier = Modifier,
47 | onNavigationNext: () -> Unit,
48 | ) {
49 | Column(
50 | modifier = modifier
51 | .fillMaxSize(),
52 | horizontalAlignment = Alignment.CenterHorizontally,
53 | verticalArrangement = Arrangement.Center
54 | ) {
55 | Column(
56 | modifier = Modifier
57 | .weight(5F),
58 | verticalArrangement = Arrangement.Center,
59 | horizontalAlignment = Alignment.CenterHorizontally
60 | ) {
61 | Image(
62 | painter = painterResource(R.drawable.heart),
63 | contentDescription = null
64 | )
65 | Text(
66 | text = stringResource(R.string.app_name),
67 | style = typography.displayLarge.copy(fontWeight = FontWeight.Bold)
68 | )
69 | }
70 | AnimatedLinearProgressIndicator(
71 | animationDuration = 2_000,
72 | modifier = Modifier
73 | .fillMaxWidth(.8f)
74 | .weight(1F),
75 | strokeCap = StrokeCap.Round,
76 | trackColor = colorScheme.secondary,
77 | onLoadFinish = onNavigationNext
78 | )
79 | }
80 | }
81 |
82 | @Preview(device = "spec:id=reference_phone,shape=Normal,width=411,height=891,unit=dp,dpi=420", showBackground = true, showSystemUi = true)
83 | @Composable
84 | private fun LoadingScreenContentPreview() {
85 | HeartRateTheme {
86 | LoadingScreenContent(onNavigationNext = {})
87 | }
88 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/supersonic/heartrate/screens/measurement/CameraPreview.kt:
--------------------------------------------------------------------------------
1 | package com.supersonic.heartrate.screens.measurement
2 |
3 | import android.util.Log
4 | import androidx.camera.core.Camera
5 | import androidx.camera.core.CameraSelector
6 | import androidx.camera.core.ImageAnalysis
7 | import androidx.camera.core.Preview
8 | import androidx.camera.lifecycle.ProcessCameraProvider
9 | import androidx.camera.view.PreviewView
10 | import androidx.compose.foundation.layout.fillMaxSize
11 | import androidx.compose.runtime.Composable
12 | import androidx.compose.runtime.DisposableEffect
13 | import androidx.compose.runtime.getValue
14 | import androidx.compose.runtime.mutableStateOf
15 | import androidx.compose.runtime.remember
16 | import androidx.compose.runtime.setValue
17 | import androidx.compose.ui.Modifier
18 | import androidx.compose.ui.platform.LocalContext
19 | import androidx.compose.ui.platform.LocalLifecycleOwner
20 | import androidx.compose.ui.viewinterop.AndroidView
21 | import androidx.core.content.ContextCompat
22 | import com.supersonic.heartrate.util.heartRateMeasurement.HeartRateAnalyzer
23 |
24 | @Composable
25 | fun CameraPreview(
26 | onHeartRateCalculated: (Int) -> Unit,
27 | isFingerDetected: (Boolean) -> Unit,
28 | ) {
29 | val context = LocalContext.current
30 | val lifecycleOwner = LocalLifecycleOwner.current
31 |
32 | val previewView = remember { PreviewView(context) }
33 |
34 | AndroidView(factory = { previewView }, modifier = Modifier.fillMaxSize())
35 |
36 | val cameraProviderFuture = remember {
37 | ProcessCameraProvider.getInstance(context)
38 | }
39 |
40 | var camera: Camera? by remember { mutableStateOf(null) }
41 |
42 | DisposableEffect(Unit) {
43 | val executor = ContextCompat.getMainExecutor(context)
44 | val cameraProvider = cameraProviderFuture.get()
45 |
46 | val preview: Preview = Preview.Builder().build().also {
47 | it.setSurfaceProvider(previewView.surfaceProvider)
48 | }
49 |
50 | val imageAnalyzer = ImageAnalysis.Builder()
51 | .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
52 | .build()
53 | .also {
54 | it.setAnalyzer(
55 | executor,
56 | HeartRateAnalyzer(onHeartRateCalculated, isFingerDetected)
57 | )
58 | }
59 |
60 | val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
61 |
62 | // Function to start the camera
63 | fun startCamera() {
64 | try {
65 | cameraProvider.unbindAll()
66 | camera = cameraProvider.bindToLifecycle(
67 | lifecycleOwner,
68 | cameraSelector,
69 | preview,
70 | imageAnalyzer
71 | )
72 | camera?.cameraControl?.enableTorch(true)
73 | } catch (exc: Exception) {
74 | Log.e("CameraPreview", "Use case binding failed", exc)
75 | }
76 | }
77 |
78 | // Start the camera when the effect is launched
79 | startCamera()
80 |
81 | onDispose {
82 | // Turn off the torch when the composable is disposed
83 | camera?.cameraControl?.enableTorch(false)
84 | // Unbind all use cases to release the camera
85 | cameraProvider.unbindAll()
86 | }
87 | }
88 | }
89 |
90 |
--------------------------------------------------------------------------------
/app/src/main/java/com/supersonic/heartrate/screens/measurement/MeasurementScreen.kt:
--------------------------------------------------------------------------------
1 | package com.supersonic.heartrate.screens.measurement
2 |
3 | import android.Manifest
4 | import android.util.Log
5 | import androidx.compose.animation.core.RepeatMode
6 | import androidx.compose.animation.core.animateFloat
7 | import androidx.compose.animation.core.infiniteRepeatable
8 | import androidx.compose.animation.core.rememberInfiniteTransition
9 | import androidx.compose.animation.core.tween
10 | import androidx.compose.foundation.Image
11 | import androidx.compose.foundation.background
12 | import androidx.compose.foundation.layout.Arrangement
13 | import androidx.compose.foundation.layout.Box
14 | import androidx.compose.foundation.layout.Column
15 | import androidx.compose.foundation.layout.fillMaxSize
16 | import androidx.compose.foundation.layout.fillMaxWidth
17 | import androidx.compose.foundation.layout.height
18 | import androidx.compose.foundation.layout.padding
19 | import androidx.compose.foundation.layout.size
20 | import androidx.compose.foundation.layout.width
21 | import androidx.compose.foundation.shape.CircleShape
22 | import androidx.compose.material3.MaterialTheme.colorScheme
23 | import androidx.compose.material3.MaterialTheme.typography
24 | import androidx.compose.material3.Text
25 | import androidx.compose.runtime.Composable
26 | import androidx.compose.runtime.LaunchedEffect
27 | import androidx.compose.runtime.getValue
28 | import androidx.compose.runtime.mutableIntStateOf
29 | import androidx.compose.runtime.mutableStateOf
30 | import androidx.compose.runtime.remember
31 | import androidx.compose.runtime.rememberCoroutineScope
32 | import androidx.compose.runtime.setValue
33 | import androidx.compose.ui.Alignment
34 | import androidx.compose.ui.Modifier
35 | import androidx.compose.ui.draw.alpha
36 | import androidx.compose.ui.draw.clip
37 | import androidx.compose.ui.graphics.StrokeCap
38 | import androidx.compose.ui.res.painterResource
39 | import androidx.compose.ui.res.stringResource
40 | import androidx.compose.ui.text.style.TextAlign
41 | import androidx.compose.ui.unit.dp
42 | import com.google.accompanist.permissions.ExperimentalPermissionsApi
43 | import com.google.accompanist.permissions.rememberPermissionState
44 | import com.supersonic.heartrate.R
45 | import com.supersonic.heartrate.components.AnimatedLinearProgressIndicator
46 | import com.supersonic.heartrate.components.DropdownList
47 | import com.supersonic.heartrate.components.ScreenTemplate
48 | import com.supersonic.heartrate.models.HeartRate
49 | import com.supersonic.heartrate.navigation.NavigationDestination
50 | import com.supersonic.heartrate.screens.homepage.HomeTopBar
51 | import com.supersonic.heartrate.screens.onboarding.CameraPermissionNotGrantedDialog
52 | import kotlinx.coroutines.launch
53 | import java.time.LocalDateTime
54 | import java.time.format.DateTimeFormatter
55 |
56 | object MeasurementScreenDestination : NavigationDestination {
57 | override val route = "measurement"
58 | }
59 |
60 | @OptIn(ExperimentalPermissionsApi::class)
61 | @Composable
62 | fun MeasurementScreen(
63 | modifier: Modifier = Modifier,
64 | viewModel: MeasurementViewModel,
65 | onNavigateToResultHistory: () -> Unit,
66 | onNavigationToResult: (Int) -> Unit,
67 | onNavigateBack:() -> Unit
68 | ) {
69 |
70 | val scope = rememberCoroutineScope()
71 | val id = viewModel.insertedHeartRateId
72 |
73 | val cameraPermissionState = rememberPermissionState(Manifest.permission.CAMERA)
74 |
75 | if (!cameraPermissionState.hasPermission){
76 | CameraPermissionNotGrantedDialog(
77 | onDismiss = onNavigateBack,
78 | clickOutsideDismiss = false
79 | )
80 | }
81 |
82 | ScreenTemplate(
83 | topBar = {
84 | HomeTopBar(
85 | onNavigateToResultHistory = onNavigateToResultHistory,
86 | isBackIconEnabled = true,
87 | onBackClick = onNavigateBack
88 | )
89 | },
90 | ) {
91 | MeasurementContent(
92 | modifier = modifier.fillMaxSize(),
93 | measurementAccuracyMap = viewModel.measurementAccuracyMap,
94 | onMeasurementFinish = {
95 | scope.launch {
96 | viewModel.onMeasurementFinish(it)
97 | }
98 | }
99 | )
100 | }
101 |
102 | LaunchedEffect(id) {
103 | Log.d("insertedHeartRateId", "insertedHeartRateId: $id")
104 | if(id > 0){
105 | onNavigationToResult(id)
106 | }
107 | }
108 | }
109 |
110 | @Composable
111 | fun MeasurementContent(
112 | modifier: Modifier = Modifier,
113 | measurementAccuracyMap: Map,
114 | onMeasurementFinish: (HeartRate) -> Unit
115 | ) {
116 | var currentHeartRate by remember { mutableIntStateOf(0) }
117 | var isFingerDetected by remember { mutableStateOf(false) }
118 |
119 | val measurementAccuracyKeysList = measurementAccuracyMap.keys.toList()
120 | var selectedItem by remember {
121 | mutableIntStateOf(measurementAccuracyKeysList.first())
122 | }
123 |
124 | Box(
125 | modifier = modifier
126 | ) {
127 | // Camera
128 | Column(
129 | modifier = Modifier
130 | .fillMaxWidth()
131 | .align(Alignment.TopCenter),
132 | horizontalAlignment = Alignment.CenterHorizontally,
133 | verticalArrangement = Arrangement.Center
134 | ){
135 | Box(
136 | modifier = Modifier
137 | .padding(top = 8.dp)
138 | .size(64.dp)
139 | .clip(CircleShape),
140 | ) {
141 | CameraPreview(
142 | isFingerDetected = { isFingerDetected = it },
143 | onHeartRateCalculated = { currentHeartRate = it }
144 | )
145 | }
146 |
147 | Column(
148 | horizontalAlignment = Alignment.CenterHorizontally,
149 | verticalArrangement = Arrangement.Center,
150 | modifier = Modifier.padding(top = 16.dp)
151 | ) {
152 | Text(
153 | text = if (isFingerDetected)
154 | stringResource(R.string.measurement_fingerDetected_text1)
155 | else stringResource(R.string.measurement_fingerNotDetected_text1),
156 | style = typography.bodyLarge,
157 | textAlign = TextAlign.Center
158 | )
159 | Text(
160 | text = if (isFingerDetected)
161 | stringResource(R.string.measurement_fingerDetected_text2)
162 | else stringResource(R.string.measurement_fingerNotDetected_text2),
163 | style = typography.bodyMedium,
164 | color = colorScheme.onPrimary,
165 | textAlign = TextAlign.Center
166 | )
167 | }
168 | }
169 |
170 |
171 | // Heart
172 | Box(
173 | contentAlignment = Alignment.Center,
174 | modifier = Modifier.align(Alignment.Center)
175 | ) {
176 | Image(
177 | painter = painterResource(id = R.drawable.heart2),
178 | contentDescription = null
179 | )
180 | Column(
181 | modifier = Modifier
182 | .padding(bottom = 8.dp),
183 | horizontalAlignment = Alignment.CenterHorizontally,
184 | verticalArrangement = Arrangement.Center
185 | ) {
186 | Text(
187 | text = if (isFingerDetected) "$currentHeartRate" else "--",
188 | style = typography.displayLarge,
189 | color = colorScheme.onPrimary,
190 | textAlign = TextAlign.Center
191 | )
192 | Text(
193 | text = stringResource(R.string.bpm),
194 | style = typography.displaySmall,
195 | color = colorScheme.onPrimary,
196 | textAlign = TextAlign.Center
197 | )
198 | }
199 |
200 | }
201 |
202 | // Progress Bar / Measurement Accuracy Choosing
203 | val infiniteTransition = rememberInfiniteTransition(label = "InfiniteTransition")
204 | val progressBarAlpha by infiniteTransition.animateFloat(
205 | initialValue = 0F,
206 | targetValue = 1F,
207 | animationSpec = infiniteRepeatable(tween(1_000), RepeatMode.Reverse),
208 | label = "alpha"
209 | )
210 |
211 | Box(
212 | modifier = Modifier
213 | .fillMaxWidth()
214 | .align(Alignment.BottomCenter)
215 | .padding(bottom = 80.dp),
216 | contentAlignment = Alignment.Center
217 | ){
218 | if (isFingerDetected) {
219 | measurementAccuracyMap[selectedItem]?.let {
220 | Box(
221 | modifier = Modifier.fillMaxWidth(),
222 | contentAlignment = Alignment.Center
223 | ){
224 |
225 | Box(
226 | modifier = Modifier
227 | .alpha(progressBarAlpha)
228 | .fillMaxWidth(.82F)
229 | .height(28.dp)
230 | .background(color = colorScheme.primary, CircleShape)
231 | )
232 |
233 | AnimatedLinearProgressIndicator(
234 | modifier = Modifier
235 | .fillMaxWidth(.8f),
236 | animationDuration = it,
237 | strokeCap = StrokeCap.Round,
238 | trackColor = colorScheme.secondary,
239 | onLoadFinish = {
240 | onMeasurementFinish(
241 | HeartRate(
242 | bpm = currentHeartRate,
243 | time = getCurrentTime(),
244 | date = getCurrentDate(),
245 | measurementAccuracy = when (selectedItem) {
246 | R.string.measurementAccuracy_low_sec -> R.string.measurementAccuracy_low
247 | R.string.measurementAccuracy_mid_sec -> R.string.measurementAccuracy_mid
248 | R.string.measurementAccuracy_high_sec -> R.string.measurementAccuracy_high
249 |
250 | else -> R.string.measurementAccuracy_mid
251 | },
252 | )
253 | )
254 | }
255 | )
256 | }
257 | }
258 | } else {
259 | Column(
260 | verticalArrangement = Arrangement.Center,
261 | horizontalAlignment = Alignment.CenterHorizontally
262 | ){
263 | Text(
264 | text = stringResource(R.string.measurement_chooseMeasurementAccuracy),
265 | style = typography.bodyLarge,
266 | textAlign = TextAlign.Center,
267 | modifier = Modifier.padding(bottom = 4.dp)
268 | )
269 | DropdownList(
270 | modifier = Modifier.width(320.dp),
271 | itemList = measurementAccuracyKeysList,
272 | selectedItemResource = selectedItem,
273 | onItemClick = { selectedItem = it }
274 | )
275 | }
276 | }
277 | }
278 | }
279 |
280 | }
281 |
282 | private fun getCurrentTime(): String{
283 | val currentDateTime: LocalDateTime = LocalDateTime.now()
284 | val timeFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm")
285 | val currentTime: String = currentDateTime.format(timeFormatter)
286 |
287 | return currentTime
288 | }
289 | private fun getCurrentDate(): String{
290 | val currentDateTime: LocalDateTime = LocalDateTime.now()
291 |
292 | val dateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("dd/MM/yyyy")
293 | val currentDate: String = currentDateTime.format(dateFormatter)
294 |
295 | return currentDate
296 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/supersonic/heartrate/screens/measurement/MeasurementViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.supersonic.heartrate.screens.measurement
2 |
3 | import androidx.compose.runtime.getValue
4 | import androidx.compose.runtime.mutableIntStateOf
5 | import androidx.compose.runtime.setValue
6 | import androidx.lifecycle.ViewModel
7 | import com.supersonic.heartrate.R
8 | import com.supersonic.heartrate.db.HeartRateRepository
9 | import com.supersonic.heartrate.models.HeartRate
10 | import dagger.hilt.android.lifecycle.HiltViewModel
11 | import javax.inject.Inject
12 |
13 | @HiltViewModel
14 | class MeasurementViewModel @Inject constructor(
15 | private val heartRateRepository: HeartRateRepository
16 | ): ViewModel(){
17 |
18 | val measurementAccuracyMap = mapOf(
19 | R.string.measurementAccuracy_low_sec to 10_000,
20 | R.string.measurementAccuracy_mid_sec to 20_000,
21 | R.string.measurementAccuracy_high_sec to 30_000,
22 | )
23 |
24 | var insertedHeartRateId by mutableIntStateOf(0)
25 | private set
26 |
27 | suspend fun onMeasurementFinish(heartRate: HeartRate){
28 | val id = saveHeartRate(heartRate)
29 | insertedHeartRateId = id
30 | }
31 |
32 | private suspend fun saveHeartRate(heartRate: HeartRate): Int {
33 | val id = heartRateRepository.insertHeartRate(heartRate).toInt()
34 | return id
35 | }
36 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/supersonic/heartrate/screens/onboarding/OnboardingScreen.kt:
--------------------------------------------------------------------------------
1 | package com.supersonic.heartrate.screens.onboarding
2 |
3 | import android.Manifest
4 | import android.content.Intent
5 | import android.net.Uri
6 | import android.provider.Settings
7 | import androidx.compose.animation.core.EaseInCubic
8 | import androidx.compose.animation.core.animateDpAsState
9 | import androidx.compose.animation.core.tween
10 | import androidx.compose.foundation.ExperimentalFoundationApi
11 | import androidx.compose.foundation.Image
12 | import androidx.compose.foundation.background
13 | import androidx.compose.foundation.clickable
14 | import androidx.compose.foundation.layout.Arrangement
15 | import androidx.compose.foundation.layout.Box
16 | import androidx.compose.foundation.layout.Column
17 | import androidx.compose.foundation.layout.Row
18 | import androidx.compose.foundation.layout.fillMaxSize
19 | import androidx.compose.foundation.layout.fillMaxWidth
20 | import androidx.compose.foundation.layout.height
21 | import androidx.compose.foundation.layout.padding
22 | import androidx.compose.foundation.layout.size
23 | import androidx.compose.foundation.pager.HorizontalPager
24 | import androidx.compose.foundation.pager.rememberPagerState
25 | import androidx.compose.foundation.shape.RoundedCornerShape
26 | import androidx.compose.material3.AlertDialog
27 | import androidx.compose.material3.Button
28 | import androidx.compose.material3.ButtonDefaults
29 | import androidx.compose.material3.MaterialTheme.colorScheme
30 | import androidx.compose.material3.MaterialTheme.typography
31 | import androidx.compose.material3.Scaffold
32 | import androidx.compose.material3.Text
33 | import androidx.compose.material3.TextButton
34 | import androidx.compose.runtime.Composable
35 | import androidx.compose.runtime.getValue
36 | import androidx.compose.runtime.mutableStateOf
37 | import androidx.compose.runtime.remember
38 | import androidx.compose.runtime.rememberCoroutineScope
39 | import androidx.compose.runtime.setValue
40 | import androidx.compose.ui.Alignment
41 | import androidx.compose.ui.Modifier
42 | import androidx.compose.ui.graphics.Color
43 | import androidx.compose.ui.platform.LocalContext
44 | import androidx.compose.ui.res.painterResource
45 | import androidx.compose.ui.res.stringResource
46 | import androidx.compose.ui.text.style.TextAlign
47 | import androidx.compose.ui.tooling.preview.Preview
48 | import androidx.compose.ui.unit.Dp
49 | import androidx.compose.ui.unit.dp
50 | import com.google.accompanist.permissions.ExperimentalPermissionsApi
51 | import com.google.accompanist.permissions.rememberPermissionState
52 | import com.supersonic.heartrate.R
53 | import com.supersonic.heartrate.models.OnboardingPage
54 | import com.supersonic.heartrate.navigation.NavigationDestination
55 | import com.supersonic.heartrate.ui.theme.HeartRateTheme
56 | import com.supersonic.heartrate.util.onboarding.OnboardingPages
57 | import kotlinx.coroutines.launch
58 |
59 | object OnboardingScreenDestination : NavigationDestination {
60 | override val route = "onboarding"
61 | }
62 | @Composable
63 | fun OnboardingScreen(
64 | modifier: Modifier = Modifier,
65 | onOnboardingFinish: () -> Unit
66 | ) {
67 | Scaffold(
68 | modifier = modifier
69 | ) {
70 | OnboardingScreenContent(
71 | modifier = Modifier
72 | .padding(it),
73 | onboardPagesList = OnboardingPages.pagesList,
74 | onFinish = onOnboardingFinish
75 | )
76 | }
77 | }
78 |
79 | @OptIn(ExperimentalFoundationApi::class, ExperimentalPermissionsApi::class)
80 | @Composable
81 | private fun OnboardingScreenContent(
82 | modifier: Modifier = Modifier,
83 | onboardPagesList: List = listOf(),
84 | onFinish: () -> Unit
85 | ) {
86 | val scope = rememberCoroutineScope()
87 | val cameraPermissionState = rememberPermissionState(Manifest.permission.CAMERA)
88 | var showCameraPermissionNotGrantedDialog by remember { mutableStateOf(false) }
89 |
90 | if (showCameraPermissionNotGrantedDialog){
91 | CameraPermissionNotGrantedDialog(
92 | onDismiss = {showCameraPermissionNotGrantedDialog = false}
93 | )
94 | }
95 |
96 | val pagerState = rememberPagerState(
97 | initialPage = 0,
98 | initialPageOffsetFraction = 0f
99 | ) {
100 | onboardPagesList.size
101 | }
102 | Column(
103 | modifier = modifier.fillMaxSize(),
104 | horizontalAlignment = Alignment.CenterHorizontally,
105 | verticalArrangement = Arrangement.Center
106 | ){
107 |
108 | HorizontalPager(
109 | modifier = Modifier.weight(5F),
110 | state = pagerState,
111 | ) { page ->
112 | val currentOnboardingPage = onboardPagesList[page]
113 | Column(
114 | modifier = Modifier.fillMaxSize(),
115 | horizontalAlignment = Alignment.CenterHorizontally,
116 | verticalArrangement = Arrangement.Center
117 | ) {
118 | Image(
119 | painter = painterResource(currentOnboardingPage.imageRes),
120 | modifier = Modifier
121 | .size(256.dp),
122 | contentDescription = null,
123 | )
124 |
125 | Column(
126 | verticalArrangement = Arrangement.Center,
127 | horizontalAlignment = Alignment.CenterHorizontally,
128 | modifier = Modifier.padding(top = 80.dp, start = 16.dp, end = 16.dp)
129 | ){
130 | Text(
131 | text = stringResource(currentOnboardingPage.titleRes),
132 | style = typography.titleMedium,
133 | modifier = Modifier.padding(bottom = 16.dp)
134 | )
135 | Text(
136 | text = stringResource(id = currentOnboardingPage.bodyRes),
137 | textAlign = TextAlign.Center,
138 | style = typography.bodyMedium,
139 | modifier = Modifier
140 | )
141 |
142 | if (currentOnboardingPage.cameraPermission){
143 | TextButton(
144 | modifier = Modifier.padding(top = 16.dp),
145 | enabled = !cameraPermissionState.hasPermission,
146 | onClick = {
147 | if (cameraPermissionState.permissionRequested) showCameraPermissionNotGrantedDialog = true
148 | else cameraPermissionState.launchPermissionRequest()
149 | }
150 | ) {
151 | Text(text = stringResource(R.string.onboardingPage2_button_camera))
152 | }
153 | }
154 | }
155 |
156 | }
157 |
158 | }
159 |
160 | Column(
161 | modifier = Modifier
162 | .fillMaxWidth()
163 | .weight(1F),
164 | horizontalAlignment = Alignment.CenterHorizontally,
165 | verticalArrangement = Arrangement.Center
166 | ) {
167 | Row(
168 | modifier = Modifier
169 | .height(32.dp),
170 | ) {
171 | repeat(onboardPagesList.size) {
172 |
173 | val width: Dp by animateDpAsState(
174 | targetValue = if (pagerState.currentPage == it) 44.dp else 14.dp,
175 | animationSpec = tween(
176 | durationMillis = 200,
177 | easing = EaseInCubic
178 | ),
179 | label = "OnboardingPages"
180 | )
181 |
182 |
183 | Box(
184 | modifier = Modifier
185 | .padding(4.dp)
186 | .size(width = width, height = 14.dp)
187 | .background(
188 | if (pagerState.currentPage == it) colorScheme.primary
189 | else colorScheme.secondary,
190 | shape = RoundedCornerShape(8.dp)
191 | )
192 | .clickable {
193 | scope.launch {
194 | pagerState.animateScrollToPage(it)
195 | }
196 | }
197 | )
198 | }
199 | }
200 |
201 | Button(
202 | enabled = when{
203 | pagerState.currentPage == onboardPagesList.lastIndex && cameraPermissionState.hasPermission -> true
204 | pagerState.currentPage != onboardPagesList.lastIndex -> true
205 | else -> false
206 | },
207 | onClick = {
208 | scope.launch{
209 | pagerState.animateScrollToPage(pagerState.currentPage + 1)
210 | }
211 | if (pagerState.currentPage == onboardPagesList.lastIndex && cameraPermissionState.hasPermission){
212 | onFinish.invoke()
213 | }
214 | },
215 | modifier = Modifier
216 | .fillMaxWidth()
217 | .height(62.dp)
218 | .padding(horizontal = 16.dp, vertical = 8.dp)
219 | ) {
220 | Text(
221 | text =
222 | if (pagerState.currentPage == onboardPagesList.lastIndex)
223 | stringResource(id = R.string.onboardingPage_button1)
224 | else
225 | stringResource(R.string.onboardingPage_button2),
226 | style = typography.labelMedium
227 | )
228 | }
229 | }
230 | }
231 | }
232 |
233 | @Composable
234 | fun CameraPermissionNotGrantedDialog(
235 | onDismiss: () -> Unit = {},
236 | clickOutsideDismiss: Boolean = true
237 | ) {
238 |
239 | val context = LocalContext.current
240 |
241 | AlertDialog(
242 | onDismissRequest = { if(clickOutsideDismiss) onDismiss.invoke() },
243 | title = {
244 | Text(
245 | text = stringResource(R.string.onboardingScreen_cameraPermission_dialog_title),
246 | textAlign = TextAlign.Center,
247 | style = typography.titleMedium
248 | )
249 | },
250 | text = {
251 | Text(
252 | text = stringResource(R.string.onboardingScreen_cameraPermission_dialog_body),
253 | textAlign = TextAlign.Center,
254 | style = typography.bodyMedium
255 | )
256 | },
257 | confirmButton = {
258 | TextButton(onClick = {
259 | context.startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
260 | data = Uri.fromParts("package", context.packageName, null)
261 | })
262 | onDismiss.invoke()
263 | }) {
264 | Text(text = stringResource(R.string.onboardingScreen_cameraPermission_dialog_confirmButton))
265 | }
266 | },
267 | dismissButton = {
268 | TextButton(
269 | colors = ButtonDefaults.textButtonColors(contentColor = Color.Gray),
270 | onClick = onDismiss
271 | ) {
272 | Text(text = stringResource(R.string.onboardingScreen_cameraPermission_dialog_dismissButton))
273 | }
274 | }
275 | )
276 | }
277 |
278 | @Preview
279 | @Composable
280 | private fun OnboardingScreenContentPreview() {
281 | HeartRateTheme {
282 | Scaffold {
283 | OnboardingScreenContent(
284 | modifier = Modifier.padding(it),
285 | onFinish = {},
286 | onboardPagesList = OnboardingPages.pagesList
287 | )
288 | }
289 | }
290 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/supersonic/heartrate/screens/result/ResultScreen.kt:
--------------------------------------------------------------------------------
1 | package com.supersonic.heartrate.screens.result
2 |
3 | import androidx.activity.compose.BackHandler
4 | import androidx.compose.foundation.layout.Arrangement
5 | import androidx.compose.foundation.layout.Box
6 | import androidx.compose.foundation.layout.Column
7 | import androidx.compose.foundation.layout.fillMaxSize
8 | import androidx.compose.foundation.layout.fillMaxWidth
9 | import androidx.compose.foundation.layout.height
10 | import androidx.compose.foundation.layout.padding
11 | import androidx.compose.material3.Button
12 | import androidx.compose.material3.Icon
13 | import androidx.compose.material3.IconButton
14 | import androidx.compose.material3.IconButtonDefaults
15 | import androidx.compose.material3.MaterialTheme.colorScheme
16 | import androidx.compose.material3.MaterialTheme.typography
17 | import androidx.compose.material3.Scaffold
18 | import androidx.compose.material3.Text
19 | import androidx.compose.runtime.Composable
20 | import androidx.compose.ui.Alignment
21 | import androidx.compose.ui.Modifier
22 | import androidx.compose.ui.res.painterResource
23 | import androidx.compose.ui.res.stringResource
24 | import androidx.compose.ui.unit.dp
25 | import com.supersonic.heartrate.R
26 | import com.supersonic.heartrate.components.ResultCard
27 | import com.supersonic.heartrate.components.TopBar
28 | import com.supersonic.heartrate.models.HeartRate
29 | import com.supersonic.heartrate.navigation.NavigationDestination
30 |
31 | object ResultScreenDestination : NavigationDestination {
32 | override val route = "result"
33 | const val heartRateIdArg = "heartRateId"
34 | val routeWithArgs = "$route/{$heartRateIdArg}"
35 | }
36 |
37 | @Composable
38 | fun ResultScreen(
39 | modifier: Modifier = Modifier,
40 | viewModel: ResultScreenViewModel,
41 | onNavigateToHomepage: () -> Unit,
42 | onNavigationToHistory: () -> Unit
43 | ) {
44 | val heartRate = viewModel.heartRate
45 |
46 | BackHandler {
47 | onNavigateToHomepage.invoke()
48 | }
49 |
50 | Scaffold(
51 | topBar = {
52 | ResultTopBar(
53 | onNavigationToResultsHistory = onNavigationToHistory
54 | )
55 | }
56 | ){
57 | ResultScreenContent(
58 | modifier = modifier.padding(it),
59 | heartRate = heartRate,
60 | onNavigateToHomepage = onNavigateToHomepage
61 | )
62 | }
63 |
64 | }
65 |
66 | @Composable
67 | fun ResultTopBar(
68 | onNavigationToResultsHistory: () -> Unit
69 | ) {
70 | TopBar(
71 | actions = {
72 | Text(
73 | text = stringResource(R.string.topAppBar_title_history),
74 | style = typography.titleSmall,
75 | color = colorScheme.onPrimary
76 | )
77 | IconButton(
78 | onClick = onNavigationToResultsHistory,
79 | colors = IconButtonDefaults.iconButtonColors(
80 | contentColor = colorScheme.onPrimary
81 | )
82 | ) {
83 | Icon(painter = painterResource(id = R.drawable.icon_time_machine), contentDescription = null)
84 | }
85 | }
86 | )
87 | }
88 |
89 | @Composable
90 | private fun ResultScreenContent(
91 | modifier: Modifier = Modifier,
92 | heartRate: HeartRate,
93 | onNavigateToHomepage: () -> Unit
94 | ) {
95 | Column(
96 | modifier = modifier,
97 | horizontalAlignment = Alignment.CenterHorizontally,
98 | verticalArrangement = Arrangement.Center
99 | ) {
100 | Box(
101 | modifier = Modifier
102 | .fillMaxSize()
103 | .weight(5F),
104 | contentAlignment = Alignment.Center
105 | ){
106 | ResultCard(
107 | modifier = Modifier.padding(16.dp),
108 | heartRate = heartRate
109 | )
110 | }
111 |
112 | Box(
113 | modifier = Modifier
114 | .weight(1F),
115 | contentAlignment = Alignment.Center
116 | ) {
117 | Button(
118 | onClick = onNavigateToHomepage,
119 | modifier = Modifier
120 | .fillMaxWidth()
121 | .height(62.dp)
122 | .padding(horizontal = 16.dp, vertical = 8.dp),
123 | ) {
124 | Text(
125 | text = stringResource(id = R.string.resultPage_button1),
126 | modifier = Modifier,
127 | style = typography.labelMedium
128 | )
129 | }
130 | }
131 | }
132 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/supersonic/heartrate/screens/result/ResultScreenViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.supersonic.heartrate.screens.result
2 |
3 | import androidx.compose.runtime.getValue
4 | import androidx.compose.runtime.mutableStateOf
5 | import androidx.compose.runtime.setValue
6 | import androidx.lifecycle.SavedStateHandle
7 | import androidx.lifecycle.ViewModel
8 | import androidx.lifecycle.viewModelScope
9 | import com.supersonic.heartrate.R
10 | import com.supersonic.heartrate.db.HeartRateRepository
11 | import com.supersonic.heartrate.models.HeartRate
12 | import dagger.hilt.android.lifecycle.HiltViewModel
13 | import kotlinx.coroutines.flow.filterNotNull
14 | import kotlinx.coroutines.flow.first
15 | import kotlinx.coroutines.launch
16 | import javax.inject.Inject
17 |
18 | @HiltViewModel
19 | class ResultScreenViewModel @Inject constructor(
20 | savedStateHandle: SavedStateHandle,
21 | private val heartRateRepository: HeartRateRepository
22 | ) : ViewModel() {
23 |
24 | private val hearRateId: Int = checkNotNull(savedStateHandle[ResultScreenDestination.heartRateIdArg])
25 |
26 | var heartRate by mutableStateOf(HeartRate(0,0,"","", R.string.measurementAccuracy_mid))
27 | private set
28 |
29 | init {
30 | viewModelScope.launch {
31 | heartRate = heartRateRepository.getHeartRateStream(hearRateId)
32 | .filterNotNull()
33 | .first()
34 | }
35 | }
36 |
37 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/supersonic/heartrate/ui/theme/Color.kt:
--------------------------------------------------------------------------------
1 | package com.supersonic.heartrate.ui.theme
2 |
3 | import androidx.compose.ui.graphics.Color
4 | val primary = Color(0xFFFF6B6B)
5 | val onPrimary = Color(0xFFFFFFFF)
6 | val secondary = Color(0xFFFFACAC)
7 | val primaryContainer = Color(0xFFB2DEFB)
8 | val onPrimaryContainer = Color(0xFF000000)
9 | val secondaryContainer = Color(0xFFECF7FF)
10 | val background = Color(0xFFA6E0FE)
--------------------------------------------------------------------------------
/app/src/main/java/com/supersonic/heartrate/ui/theme/Theme.kt:
--------------------------------------------------------------------------------
1 | package com.supersonic.heartrate.ui.theme
2 |
3 | import androidx.activity.ComponentActivity
4 | import androidx.activity.SystemBarStyle
5 | import androidx.activity.enableEdgeToEdge
6 | import androidx.compose.foundation.isSystemInDarkTheme
7 | import androidx.compose.material3.MaterialTheme
8 | import androidx.compose.material3.lightColorScheme
9 | import androidx.compose.runtime.Composable
10 | import androidx.compose.ui.graphics.toArgb
11 | import androidx.compose.ui.platform.LocalContext
12 |
13 |
14 | private val LightColorScheme = lightColorScheme(
15 | primary = primary,
16 | onPrimary = onPrimary,
17 | secondary = secondary,
18 | primaryContainer = primaryContainer,
19 | onPrimaryContainer = onPrimaryContainer,
20 | secondaryContainer = secondaryContainer,
21 | background = background
22 |
23 | /* Other default colors to override
24 | background = Color(0xFFFFFBFE),
25 | surface = Color(0xFFFFFBFE),
26 | onPrimary = Color.White,
27 | onSecondary = Color.White,
28 | onTertiary = Color.White,
29 | onBackground = Color(0xFF1C1B1F),
30 | onSurface = Color(0xFF1C1B1F),
31 | */
32 | )
33 |
34 | @Composable
35 | fun HeartRateTheme(
36 | darkTheme: Boolean = isSystemInDarkTheme(),
37 | // Dynamic color is available on Android 12+
38 | dynamicColor: Boolean = true,
39 | content: @Composable () -> Unit
40 | ) {
41 | val colorScheme = LightColorScheme
42 | val context = LocalContext.current as ComponentActivity
43 |
44 | context.enableEdgeToEdge(
45 | statusBarStyle = SystemBarStyle.light(primary.toArgb(), primary.toArgb()),
46 | navigationBarStyle = SystemBarStyle.light(background.toArgb(), background.toArgb())
47 | )
48 |
49 | MaterialTheme(
50 | colorScheme = colorScheme,
51 | typography = Typography,
52 | content = content
53 | )
54 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/supersonic/heartrate/ui/theme/Type.kt:
--------------------------------------------------------------------------------
1 | package com.supersonic.heartrate.ui.theme
2 |
3 | import androidx.compose.material3.Typography
4 | import androidx.compose.ui.text.TextStyle
5 | import androidx.compose.ui.text.font.FontFamily
6 | import androidx.compose.ui.text.font.FontWeight
7 | import androidx.compose.ui.unit.sp
8 |
9 | // Set of Material typography styles to start with
10 | val Typography = Typography(
11 |
12 | bodyLarge = TextStyle(
13 | fontFamily = FontFamily.Default,
14 | fontWeight = FontWeight(600),
15 | fontSize = 18.sp,
16 | lineHeight = 22.sp,
17 | ),
18 | bodyMedium = TextStyle(
19 | fontFamily = FontFamily.Default,
20 | fontWeight = FontWeight(400),
21 | fontSize = 16.sp,
22 | lineHeight = 22.sp,
23 | ),
24 | bodySmall = TextStyle(
25 | fontFamily = FontFamily.Default,
26 | fontWeight = FontWeight(400),
27 | fontSize = 12.sp,
28 | lineHeight = 14.sp,
29 | ),
30 | titleLarge = TextStyle(
31 | fontFamily = FontFamily.Default,
32 | fontWeight = FontWeight(700),
33 | fontSize = 26.sp,
34 | lineHeight = 26.sp,
35 | ),
36 | titleMedium = TextStyle(
37 | fontFamily = FontFamily.Default,
38 | fontWeight = FontWeight(600),
39 | fontSize = 24.sp,
40 | lineHeight = 28.sp,
41 | ),
42 | titleSmall = TextStyle(
43 | fontFamily = FontFamily.Default,
44 | fontWeight = FontWeight(400),
45 | fontSize = 20.sp,
46 | lineHeight = 24.sp,
47 | ),
48 | labelMedium = TextStyle(
49 | fontFamily = FontFamily.Default,
50 | fontWeight = FontWeight(500),
51 | fontSize = 16.sp,
52 | lineHeight = 18.sp,
53 | ),
54 | displayLarge = TextStyle(
55 | fontFamily = FontFamily.Default,
56 | fontWeight = FontWeight(700),
57 | fontSize = 62.sp,
58 | lineHeight = 22.sp,
59 | ),
60 | displayMedium = TextStyle(
61 | fontFamily = FontFamily.Default,
62 | fontWeight = FontWeight(400),
63 | fontSize = 36.sp,
64 | lineHeight = 42.sp,
65 | ),
66 | displaySmall = TextStyle(
67 | fontFamily = FontFamily.Default,
68 | fontWeight = FontWeight(400),
69 | fontSize = 22.sp,
70 | lineHeight = 22.sp,
71 | )
72 | /* Other default text styles to override
73 | titleLarge = TextStyle(
74 | fontFamily = FontFamily.Default,
75 | fontWeight = FontWeight.Normal,
76 | fontSize = 22.sp,
77 | lineHeight = 28.sp,
78 | letterSpacing = 0.sp
79 | ),
80 | labelSmall = TextStyle(
81 | fontFamily = FontFamily.Default,
82 | fontWeight = FontWeight.Medium,
83 | fontSize = 11.sp,
84 | lineHeight = 16.sp,
85 | letterSpacing = 0.5.sp
86 | )
87 | */
88 | )
--------------------------------------------------------------------------------
/app/src/main/java/com/supersonic/heartrate/util/ColorTextIdentifyers.kt:
--------------------------------------------------------------------------------
1 | package com.supersonic.heartrate.util
2 |
3 | import androidx.compose.ui.graphics.Color
4 | import com.supersonic.heartrate.R
5 |
6 |
7 | val lowColor = Color(0xFF40C4FF)
8 | val midColor = Color(0xFF00C853)
9 | val highColor = Color(0xFFFF5252)
10 |
11 | fun identifyResultText(bpm: Int): Int{
12 | return when{
13 | bpm < 60 -> R.string.resultCard_delayed
14 | bpm in 60..100 -> R.string.resultCard_standard
15 |
16 | else -> R.string.resultCard_frequent
17 | }
18 | }
19 |
20 | fun identifyResultColor(bpm: Int): Color {
21 | return when {
22 | bpm < 60 -> lowColor
23 | bpm in 60..100 -> midColor
24 |
25 | else -> highColor
26 | }
27 | }
28 |
29 | fun identifyAccuracyColor(measurementAccuracy: Int): Color {
30 | return when(measurementAccuracy) {
31 | R.string.measurementAccuracy_low, R.string.measurementAccuracy_low_sec -> lowColor
32 | R.string.measurementAccuracy_mid, R.string.measurementAccuracy_mid_sec-> midColor
33 | R.string.measurementAccuracy_high, R.string.measurementAccuracy_high_sec-> highColor
34 |
35 | else -> Color.Red
36 | }
37 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/supersonic/heartrate/util/heartRateMeasurement/HeartRateAnalyzer.kt:
--------------------------------------------------------------------------------
1 | package com.supersonic.heartrate.util.heartRateMeasurement
2 |
3 | import android.graphics.Bitmap
4 | import android.graphics.BitmapFactory
5 | import android.graphics.ImageFormat
6 | import android.graphics.YuvImage
7 | import android.media.Image
8 | import android.util.Log
9 | import androidx.annotation.OptIn
10 | import androidx.camera.core.ExperimentalGetImage
11 | import androidx.camera.core.ImageAnalysis
12 | import androidx.camera.core.ImageProxy
13 | import java.io.ByteArrayOutputStream
14 |
15 | class HeartRateAnalyzer(
16 | private val onHeartRateCalculated: (Int) -> Unit,
17 | private val isFingerDetected: (Boolean) -> Unit
18 | ) : ImageAnalysis.Analyzer {
19 | private var lastAnalyzedTimestamp = 0L
20 | private val frameTimestamps = ArrayList()
21 | private val redIntensity = ArrayList()
22 |
23 | @OptIn(ExperimentalGetImage::class)
24 | override fun analyze(image: ImageProxy) {
25 | val currentTimestamp = System.currentTimeMillis()
26 |
27 | if (currentTimestamp - lastAnalyzedTimestamp >= 100) {
28 | val bitmap = yuvToRgb(image.image)
29 | if (bitmap == null) {
30 | Log.e("HeartRateAnalyzer", "Bitmap creation failed")
31 | image.close()
32 | return
33 | }
34 |
35 | if (!isFingerCoveringCamera(bitmap)) {
36 | Log.d("HeartRateAnalyzer", "Finger not detected on camera")
37 | onHeartRateCalculated(0)
38 | isFingerDetected(false)
39 | image.close()
40 | return
41 | }
42 |
43 | val avgRedIntensity = calculateAverageRedIntensity(bitmap)
44 | redIntensity.add(avgRedIntensity)
45 | frameTimestamps.add(currentTimestamp)
46 |
47 | if (frameTimestamps.size > 30) {
48 | val heartRate = calculateHeartRate(frameTimestamps, redIntensity)
49 | onHeartRateCalculated(heartRate)
50 | isFingerDetected(true)
51 | frameTimestamps.clear()
52 | redIntensity.clear()
53 | }
54 |
55 | lastAnalyzedTimestamp = currentTimestamp
56 | }
57 |
58 | image.close()
59 | }
60 |
61 | private fun calculateAverageRedIntensity(bitmap: Bitmap): Double {
62 | var redSum = 0.0
63 | var pixelCount = 0
64 |
65 | for (y in 0 until bitmap.height step 10) {
66 | for (x in 0 until bitmap.width step 10) {
67 | val pixel = bitmap.getPixel(x, y)
68 | val red = (pixel shr 16) and 0xFF
69 | redSum += red
70 | pixelCount++
71 | }
72 | }
73 |
74 | return if (pixelCount > 0) redSum / pixelCount else 0.0
75 | }
76 |
77 | private fun calculateHeartRate(timestamps: List, intensities: List): Int {
78 | val smoothedIntensities = smoothData(intensities, windowSize = 5)
79 | val peaks = findPeaks(smoothedIntensities)
80 | if (peaks.size < 2) return 0
81 |
82 | val durations = peaks.zipWithNext { a, b -> timestamps[b] - timestamps[a] }
83 | val averageDuration = durations.average()
84 |
85 | return (60000 / averageDuration).toInt()
86 | }
87 |
88 | private fun findPeaks(intensities: List): List {
89 | val peaks = mutableListOf()
90 | for (i in 1 until intensities.size - 1) {
91 | if (intensities[i] > intensities[i - 1] && intensities[i] > intensities[i + 1]) {
92 | peaks.add(i)
93 | }
94 | }
95 | return peaks
96 | }
97 |
98 | private fun smoothData(intensities: List, windowSize: Int): List {
99 | val smoothedIntensities = mutableListOf()
100 | for (i in intensities.indices) {
101 | val start = maxOf(0, i - windowSize / 2)
102 | val end = minOf(intensities.size, i + windowSize / 2)
103 | val window = intensities.subList(start, end)
104 | smoothedIntensities.add(window.average())
105 | }
106 | return smoothedIntensities
107 | }
108 | }
109 |
110 | fun yuvToRgb(image: Image?): Bitmap? {
111 | if (image == null) return null
112 |
113 | val nv21 = yuv420ToNv21(image)
114 | val yuvImage = YuvImage(nv21, ImageFormat.NV21, image.width, image.height, null)
115 | val out = ByteArrayOutputStream()
116 | yuvImage.compressToJpeg(android.graphics.Rect(0, 0, image.width, image.height), 100, out)
117 | val imageBytes = out.toByteArray()
118 | return BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
119 | }
120 |
121 | private fun yuv420ToNv21(image: Image): ByteArray {
122 | val yBuffer = image.planes[0].buffer // Y
123 | val uBuffer = image.planes[1].buffer // U
124 | val vBuffer = image.planes[2].buffer // V
125 |
126 | val ySize = yBuffer.remaining()
127 | val uSize = uBuffer.remaining()
128 | val vSize = vBuffer.remaining()
129 |
130 | val nv21 = ByteArray(ySize + uSize + vSize)
131 |
132 | yBuffer.get(nv21, 0, ySize)
133 | vBuffer.get(nv21, ySize, vSize)
134 | uBuffer.get(nv21, ySize + vSize, uSize)
135 |
136 | return nv21
137 | }
138 |
139 |
140 |
141 | private fun isFingerCoveringCamera(bitmap: Bitmap): Boolean {
142 | val regionWidth = bitmap.width / 4
143 | val regionHeight = bitmap.height / 4
144 | val startX = bitmap.width / 2 - regionWidth / 2
145 | val startY = bitmap.height / 2 - regionHeight / 2
146 |
147 | var redPixelCount = 0
148 | var totalPixelCount = 0
149 |
150 | for (y in startY until startY + regionHeight) {
151 | for (x in startX until startX + regionWidth) {
152 | val pixel = bitmap.getPixel(x, y)
153 | val red = (pixel shr 16) and 0xFF
154 | val green = (pixel shr 8) and 0xFF
155 | val blue = pixel and 0xFF
156 |
157 | if (red > green && red > blue) {
158 | redPixelCount++
159 | }
160 | totalPixelCount++
161 | }
162 | }
163 |
164 | val redRatio = redPixelCount.toDouble() / totalPixelCount
165 | Log.d("HeartRateAnalyzer", "Red pixel ratio: $redRatio")
166 | return redRatio > 0.8 // Adjust this threshold as necessary
167 | }
168 |
169 |
170 |
171 |
172 |
173 |
174 |
175 |
176 |
177 |
178 |
--------------------------------------------------------------------------------
/app/src/main/java/com/supersonic/heartrate/util/onboarding/OnboardingPages.kt:
--------------------------------------------------------------------------------
1 | package com.supersonic.heartrate.util.onboarding
2 |
3 | import com.supersonic.heartrate.R
4 | import com.supersonic.heartrate.models.OnboardingPage
5 |
6 | object OnboardingPages {
7 | val pagesList = listOf(
8 | OnboardingPage(
9 | imageRes = R.drawable.measurement_accuracy,
10 | titleRes = R.string.onboardingPage1_title,
11 | bodyRes = R.string.onboardingPage1_body
12 | ),
13 | OnboardingPage(
14 | imageRes = R.drawable.hands_phone,
15 | titleRes = R.string.onboardingPage2_title,
16 | bodyRes = R.string.onboardingPage2_body,
17 | cameraPermission = true
18 | ),
19 | OnboardingPage(
20 | imageRes = R.drawable.result_card,
21 | titleRes = R.string.onboardingPage3_title,
22 | bodyRes = R.string.onboardingPage3_body
23 | ),
24 | )
25 | }
--------------------------------------------------------------------------------
/app/src/main/res/drawable-uk/measurement_accuracy.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/6SUPER6SONIC6/HeartRate/9188e8c2bd601522ec56452a8934ca70143b1bc9/app/src/main/res/drawable-uk/measurement_accuracy.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-uk/result_card.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/6SUPER6SONIC6/HeartRate/9188e8c2bd601522ec56452a8934ca70143b1bc9/app/src/main/res/drawable-uk/result_card.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable/background.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/6SUPER6SONIC6/HeartRate/9188e8c2bd601522ec56452a8934ca70143b1bc9/app/src/main/res/drawable/background.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable/button.xml:
--------------------------------------------------------------------------------
1 |
7 |
8 |
10 |
12 |
13 |
19 |
20 |
21 |
22 |
23 |
24 |
27 |
30 |
31 |
32 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/hands_phone.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/6SUPER6SONIC6/HeartRate/9188e8c2bd601522ec56452a8934ca70143b1bc9/app/src/main/res/drawable/hands_phone.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable/heart.xml:
--------------------------------------------------------------------------------
1 |
7 |
8 |
10 |
12 |
13 |
19 |
20 |
21 |
22 |
23 |
24 |
26 |
27 |
33 |
34 |
35 |
36 |
37 |
38 |
41 |
42 |
43 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/heart2.xml:
--------------------------------------------------------------------------------
1 |
7 |
8 |
10 |
12 |
13 |
19 |
20 |
21 |
22 |
23 |
24 |
26 |
27 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_launcher_foreground.xml:
--------------------------------------------------------------------------------
1 |
7 |
11 |
12 |
14 |
16 |
17 |
23 |
24 |
25 |
26 |
27 |
28 |
30 |
31 |
37 |
38 |
39 |
40 |
41 |
42 |
45 |
46 |
47 |
48 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/icon_time_machine.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/6SUPER6SONIC6/HeartRate/9188e8c2bd601522ec56452a8934ca70143b1bc9/app/src/main/res/drawable/icon_time_machine.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable/measurement_accuracy.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/6SUPER6SONIC6/HeartRate/9188e8c2bd601522ec56452a8934ca70143b1bc9/app/src/main/res/drawable/measurement_accuracy.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable/onboarding1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/6SUPER6SONIC6/HeartRate/9188e8c2bd601522ec56452a8934ca70143b1bc9/app/src/main/res/drawable/onboarding1.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable/onboarding2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/6SUPER6SONIC6/HeartRate/9188e8c2bd601522ec56452a8934ca70143b1bc9/app/src/main/res/drawable/onboarding2.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable/onboarding3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/6SUPER6SONIC6/HeartRate/9188e8c2bd601522ec56452a8934ca70143b1bc9/app/src/main/res/drawable/onboarding3.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable/result_card.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/6SUPER6SONIC6/HeartRate/9188e8c2bd601522ec56452a8934ca70143b1bc9/app/src/main/res/drawable/result_card.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/6SUPER6SONIC6/HeartRate/9188e8c2bd601522ec56452a8934ca70143b1bc9/app/src/main/res/mipmap-hdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/6SUPER6SONIC6/HeartRate/9188e8c2bd601522ec56452a8934ca70143b1bc9/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/6SUPER6SONIC6/HeartRate/9188e8c2bd601522ec56452a8934ca70143b1bc9/app/src/main/res/mipmap-mdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/6SUPER6SONIC6/HeartRate/9188e8c2bd601522ec56452a8934ca70143b1bc9/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/6SUPER6SONIC6/HeartRate/9188e8c2bd601522ec56452a8934ca70143b1bc9/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/6SUPER6SONIC6/HeartRate/9188e8c2bd601522ec56452a8934ca70143b1bc9/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/6SUPER6SONIC6/HeartRate/9188e8c2bd601522ec56452a8934ca70143b1bc9/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/6SUPER6SONIC6/HeartRate/9188e8c2bd601522ec56452a8934ca70143b1bc9/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/6SUPER6SONIC6/HeartRate/9188e8c2bd601522ec56452a8934ca70143b1bc9/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/6SUPER6SONIC6/HeartRate/9188e8c2bd601522ec56452a8934ca70143b1bc9/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/values-uk/strings-uk.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Виконайте своє перше вимірювання!
5 | Історія
6 | Результат
7 |
8 |
9 |
10 | //Onboarding
11 | Точність вимірювання
12 | Оберіть точність з якою буде зроблене вимірювання пульсу. Чим вища точність тим точніший результат!
13 | Початок вимірювання
14 | Щільно прикладіть палець до камери, так щоб повністю її закрити. Вимірювання почнеться автоматично. Тримайте палець до закінчення процесу вимірювання пульсу.\nДля вимірювання пульсу потрібен доступ до камери.
15 | Натисніть щоб надати доступ до камери
16 | Результати
17 | Після закінчення вимірювання ви отримаєте свій результат. Ви можете побачити минулі вимірювання на сторінці "Історія"
18 | Почати!
19 | Продовжити
20 | Доступ до камери відхилено
21 | Ви не надали доступ до камери. Будь ласка зробіть це в налаштуваннях.
22 | В налаштування
23 | Відхилити
24 |
25 | //Result
26 | Готово
27 | Ваш Результат
28 | Уповільнений
29 | Звичайний
30 | Прискорений
31 | "Точність вимірювання – "
32 |
33 | //Measurement
34 | Палець не виявлено
35 | Щільно прикладіть палець до камери
36 | Йде Вимірювання.
37 | Визначаємо ваш пульс. Утримуйте!
38 |
39 | Виберіть точність вимірювання:
40 | Низька – 10 сек
41 | Середня – 20 сек
42 | Висока – 30 сек
43 |
44 | Низька
45 | Середня
46 | Висока
47 |
48 | //History
49 | Ви не зробили жодного вимірювання
50 | Що означає колір розділяючої полоси на картці результату?
51 | Точність з якою було зроблене вимірювання:
52 | Низька точнісь
53 | Cередня точнісь
54 | Висока точнісь
55 | Зрозуміло
56 | Очистити історію?
57 | Ви впевнені що хочете очистити всю історію ваших вимірів?
58 | Так
59 | Ні
60 | Видалити вимір?
61 | Ви впевнені що хочете видалити цей вимір?
62 |
63 |
--------------------------------------------------------------------------------
/app/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #FFBB86FC
4 | #FF6200EE
5 | #FF3700B3
6 | #FF03DAC5
7 | #FF018786
8 | #FF000000
9 | #FFFFFFFF
10 |
--------------------------------------------------------------------------------
/app/src/main/res/values/ic_launcher_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #B2DEFB
4 |
--------------------------------------------------------------------------------
/app/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Heart Rate
5 | Make your first measurement!
6 | History
7 | Result
8 | BPM
9 |
10 | //Onboarding
11 | Measurement accuracy
12 | Select the accuracy with which the heart rate measurement will be made. The higher the accuracy, the more accurate the result!
13 | Start of measurement
14 | Press your finger firmly against your phone camera until it is completely closed. The measurement will start automatically. Hold your finger until the heart rate measurement is complete.\nAccess to the camera is required to measure heart rate.
15 | Tap to provide access to the camera
16 | Results
17 | After the measurement is complete, you will receive your result. You can see past measurements on the History page
18 | Start!
19 | Continue
20 | Camera access denied
21 | You have not granted access to the camera. Please do it in the settings.
22 | Go to settings
23 | Dismiss
24 |
25 | //Result
26 | Done
27 | Your Result
28 | Slowed
29 | Regular
30 | Accelerated
31 | "Measurement accuracy – "
32 | <60 BPM
33 | 60–100 BPM
34 | >100 BPM
35 |
36 | //Measurement
37 | Finger not detected
38 | Press your finger firmly against the camera
39 | Measurement is in progress.
40 | Determining your pulse. Hold this!
41 | Select the measurement accuracy:
42 |
43 | Low - 10 sec
44 | Medium - 20 sec
45 | High - 30 sec
46 | Low
47 | Medium
48 | High
49 |
50 | //History
51 |
52 | You have not taken any measurements
53 | What does the color of the dividing bar on the result card mean?
54 | The accuracy with which the measurement was made:
55 | Low accuracy
56 | Medium accuracy
57 | High accuracy
58 | Understood
59 | Clear your history?
60 | Are you sure you want to clear your entire measurement history?
61 | Yes
62 | No
63 | Delete the measurement?
64 | Are you sure you want to delete this measurement?
65 |
66 |
--------------------------------------------------------------------------------
/app/src/main/res/values/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
7 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/backup_rules.xml:
--------------------------------------------------------------------------------
1 |
8 |
9 |
13 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/data_extraction_rules.xml:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
12 |
13 |
19 |
--------------------------------------------------------------------------------
/app/src/test/java/com/supersonic/heartrate/ExampleUnitTest.kt:
--------------------------------------------------------------------------------
1 | package com.supersonic.heartrate
2 |
3 | import org.junit.Test
4 |
5 | import org.junit.Assert.*
6 |
7 | /**
8 | * Example local unit test, which will execute on the development machine (host).
9 | *
10 | * See [testing documentation](http://d.android.com/tools/testing).
11 | */
12 | class ExampleUnitTest {
13 | @Test
14 | fun addition_isCorrect() {
15 | assertEquals(4, 2 + 2)
16 | }
17 | }
--------------------------------------------------------------------------------
/build.gradle.kts:
--------------------------------------------------------------------------------
1 | // Top-level build file where you can add configuration options common to all sub-projects/modules.
2 | plugins {
3 | alias(libs.plugins.android.application) apply false
4 | alias(libs.plugins.jetbrains.kotlin.android) apply false
5 | alias(libs.plugins.kapt) apply false
6 | alias(libs.plugins.ksp) apply false
7 | alias(libs.plugins.hilt) apply false
8 | }
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | # Project-wide Gradle settings.
2 | # IDE (e.g. Android Studio) users:
3 | # Gradle settings configured through the IDE *will override*
4 | # any settings specified in this file.
5 | # For more details on how to configure your build environment visit
6 | # http://www.gradle.org/docs/current/userguide/build_environment.html
7 | # Specifies the JVM arguments used for the daemon process.
8 | # The setting is particularly useful for tweaking memory settings.
9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
10 | # When configured, Gradle will run in incubating parallel mode.
11 | # This option should only be used with decoupled projects. For more details, visit
12 | # https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
13 | # org.gradle.parallel=true
14 | # AndroidX package structure to make it clearer which packages are bundled with the
15 | # Android operating system, and which are packaged with your app's APK
16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn
17 | android.useAndroidX=true
18 | # Kotlin code style for this project: "official" or "obsolete":
19 | kotlin.code.style=official
20 | # Enables namespacing of each library's R class so that its R class includes only the
21 | # resources declared in the library itself and none from the library's dependencies,
22 | # thereby reducing the size of the R class for that library
23 | android.nonTransitiveRClass=true
--------------------------------------------------------------------------------
/gradle/libs.versions.toml:
--------------------------------------------------------------------------------
1 | [versions]
2 | agp = "8.4.1"
3 | kotlin = "1.9.0"
4 | coreKtx = "1.13.1"
5 | junit = "4.13.2"
6 | junitVersion = "1.1.5"
7 | espressoCore = "3.5.1"
8 | lifecycleRuntimeKtx = "2.8.0"
9 | activityCompose = "1.9.0"
10 | composeBom = "2024.05.00"
11 | ksp = "1.9.21-1.0.15"
12 |
13 | room = "2.6.1"
14 | navigation = "2.7.7"
15 | hiltNavigationCompose = "1.2.0"
16 | lifecycle = "2.8.0"
17 | hilt = "2.51.1"
18 | cameraX = "1.3.3"
19 | permissions = "0.18.0"
20 |
21 | [libraries]
22 | androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
23 | junit = { group = "junit", name = "junit", version.ref = "junit" }
24 | androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
25 | androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
26 | androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
27 | androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
28 | androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
29 | androidx-ui = { group = "androidx.compose.ui", name = "ui" }
30 | androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
31 | androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
32 | androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
33 | androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
34 | androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
35 | androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
36 |
37 | room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
38 | room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
39 | room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
40 |
41 | androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
42 | androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" }
43 |
44 | androidx-lifecycle-viewModelCompose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
45 | androidx-lifecycle-runtimeCompose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
46 |
47 | hilt-android-core = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
48 | hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }
49 |
50 | androidx-camera-camera2 = {group = "androidx.camera", name = "camera-camera2", version.ref = "cameraX"}
51 | androidx-camera-lifecycle = {group = "androidx.camera", name = "camera-lifecycle", version.ref = "cameraX"}
52 | androidx-camera-view = {group = "androidx.camera", name = "camera-view", version.ref = "cameraX"}
53 |
54 | accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "permissions" }
55 |
56 |
57 | [plugins]
58 | android-application = { id = "com.android.application", version.ref = "agp" }
59 | jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
60 | hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
61 | kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" }
62 | ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
63 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/6SUPER6SONIC6/HeartRate/9188e8c2bd601522ec56452a8934ca70143b1bc9/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | #Wed May 29 13:38:36 EEST 2024
2 | distributionBase=GRADLE_USER_HOME
3 | distributionPath=wrapper/dists
4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip
5 | zipStoreBase=GRADLE_USER_HOME
6 | zipStorePath=wrapper/dists
7 |
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | #
4 | # Copyright 2015 the original author or authors.
5 | #
6 | # Licensed under the Apache License, Version 2.0 (the "License");
7 | # you may not use this file except in compliance with the License.
8 | # You may obtain a copy of the License at
9 | #
10 | # https://www.apache.org/licenses/LICENSE-2.0
11 | #
12 | # Unless required by applicable law or agreed to in writing, software
13 | # distributed under the License is distributed on an "AS IS" BASIS,
14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 | # See the License for the specific language governing permissions and
16 | # limitations under the License.
17 | #
18 |
19 | ##############################################################################
20 | ##
21 | ## Gradle start up script for UN*X
22 | ##
23 | ##############################################################################
24 |
25 | # Attempt to set APP_HOME
26 | # Resolve links: $0 may be a link
27 | PRG="$0"
28 | # Need this for relative symlinks.
29 | while [ -h "$PRG" ] ; do
30 | ls=`ls -ld "$PRG"`
31 | link=`expr "$ls" : '.*-> \(.*\)$'`
32 | if expr "$link" : '/.*' > /dev/null; then
33 | PRG="$link"
34 | else
35 | PRG=`dirname "$PRG"`"/$link"
36 | fi
37 | done
38 | SAVED="`pwd`"
39 | cd "`dirname \"$PRG\"`/" >/dev/null
40 | APP_HOME="`pwd -P`"
41 | cd "$SAVED" >/dev/null
42 |
43 | APP_NAME="Gradle"
44 | APP_BASE_NAME=`basename "$0"`
45 |
46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
48 |
49 | # Use the maximum available, or set MAX_FD != -1 to use that value.
50 | MAX_FD="maximum"
51 |
52 | warn () {
53 | echo "$*"
54 | }
55 |
56 | die () {
57 | echo
58 | echo "$*"
59 | echo
60 | exit 1
61 | }
62 |
63 | # OS specific support (must be 'true' or 'false').
64 | cygwin=false
65 | msys=false
66 | darwin=false
67 | nonstop=false
68 | case "`uname`" in
69 | CYGWIN* )
70 | cygwin=true
71 | ;;
72 | Darwin* )
73 | darwin=true
74 | ;;
75 | MINGW* )
76 | msys=true
77 | ;;
78 | NONSTOP* )
79 | nonstop=true
80 | ;;
81 | esac
82 |
83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
84 |
85 |
86 | # Determine the Java command to use to start the JVM.
87 | if [ -n "$JAVA_HOME" ] ; then
88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
89 | # IBM's JDK on AIX uses strange locations for the executables
90 | JAVACMD="$JAVA_HOME/jre/sh/java"
91 | else
92 | JAVACMD="$JAVA_HOME/bin/java"
93 | fi
94 | if [ ! -x "$JAVACMD" ] ; then
95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
96 |
97 | Please set the JAVA_HOME variable in your environment to match the
98 | location of your Java installation."
99 | fi
100 | else
101 | JAVACMD="java"
102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
103 |
104 | Please set the JAVA_HOME variable in your environment to match the
105 | location of your Java installation."
106 | fi
107 |
108 | # Increase the maximum file descriptors if we can.
109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
110 | MAX_FD_LIMIT=`ulimit -H -n`
111 | if [ $? -eq 0 ] ; then
112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
113 | MAX_FD="$MAX_FD_LIMIT"
114 | fi
115 | ulimit -n $MAX_FD
116 | if [ $? -ne 0 ] ; then
117 | warn "Could not set maximum file descriptor limit: $MAX_FD"
118 | fi
119 | else
120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
121 | fi
122 | fi
123 |
124 | # For Darwin, add options to specify how the application appears in the dock
125 | if $darwin; then
126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
127 | fi
128 |
129 | # For Cygwin or MSYS, switch paths to Windows format before running java
130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"`
132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
133 |
134 | JAVACMD=`cygpath --unix "$JAVACMD"`
135 |
136 | # We build the pattern for arguments to be converted via cygpath
137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
138 | SEP=""
139 | for dir in $ROOTDIRSRAW ; do
140 | ROOTDIRS="$ROOTDIRS$SEP$dir"
141 | SEP="|"
142 | done
143 | OURCYGPATTERN="(^($ROOTDIRS))"
144 | # Add a user-defined pattern to the cygpath arguments
145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then
146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
147 | fi
148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
149 | i=0
150 | for arg in "$@" ; do
151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
153 |
154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
156 | else
157 | eval `echo args$i`="\"$arg\""
158 | fi
159 | i=`expr $i + 1`
160 | done
161 | case $i in
162 | 0) set -- ;;
163 | 1) set -- "$args0" ;;
164 | 2) set -- "$args0" "$args1" ;;
165 | 3) set -- "$args0" "$args1" "$args2" ;;
166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
172 | esac
173 | fi
174 |
175 | # Escape application args
176 | save () {
177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
178 | echo " "
179 | }
180 | APP_ARGS=`save "$@"`
181 |
182 | # Collect all arguments for the java command, following the shell quoting and substitution rules
183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
184 |
185 | exec "$JAVACMD" "$@"
186 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 |
17 | @if "%DEBUG%" == "" @echo off
18 | @rem ##########################################################################
19 | @rem
20 | @rem Gradle startup script for Windows
21 | @rem
22 | @rem ##########################################################################
23 |
24 | @rem Set local scope for the variables with windows NT shell
25 | if "%OS%"=="Windows_NT" setlocal
26 |
27 | set DIRNAME=%~dp0
28 | if "%DIRNAME%" == "" set DIRNAME=.
29 | set APP_BASE_NAME=%~n0
30 | set APP_HOME=%DIRNAME%
31 |
32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
34 |
35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
37 |
38 | @rem Find java.exe
39 | if defined JAVA_HOME goto findJavaFromJavaHome
40 |
41 | set JAVA_EXE=java.exe
42 | %JAVA_EXE% -version >NUL 2>&1
43 | if "%ERRORLEVEL%" == "0" goto execute
44 |
45 | echo.
46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
47 | echo.
48 | echo Please set the JAVA_HOME variable in your environment to match the
49 | echo location of your Java installation.
50 |
51 | goto fail
52 |
53 | :findJavaFromJavaHome
54 | set JAVA_HOME=%JAVA_HOME:"=%
55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
56 |
57 | if exist "%JAVA_EXE%" goto execute
58 |
59 | echo.
60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
61 | echo.
62 | echo Please set the JAVA_HOME variable in your environment to match the
63 | echo location of your Java installation.
64 |
65 | goto fail
66 |
67 | :execute
68 | @rem Setup the command line
69 |
70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
71 |
72 |
73 | @rem Execute Gradle
74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
75 |
76 | :end
77 | @rem End local scope for the variables with windows NT shell
78 | if "%ERRORLEVEL%"=="0" goto mainEnd
79 |
80 | :fail
81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
82 | rem the _cmd.exe /c_ return code!
83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
84 | exit /b 1
85 |
86 | :mainEnd
87 | if "%OS%"=="Windows_NT" endlocal
88 |
89 | :omega
90 |
--------------------------------------------------------------------------------
/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | pluginManagement {
2 | repositories {
3 | google {
4 | content {
5 | includeGroupByRegex("com\\.android.*")
6 | includeGroupByRegex("com\\.google.*")
7 | includeGroupByRegex("androidx.*")
8 | }
9 | }
10 | mavenCentral()
11 | gradlePluginPortal()
12 | }
13 | }
14 | dependencyResolutionManagement {
15 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
16 | repositories {
17 | google()
18 | mavenCentral()
19 | }
20 | }
21 |
22 | rootProject.name = "Heart Rate"
23 | include(":app")
24 |
--------------------------------------------------------------------------------