├── .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 |
6 | 7 | 8 | 9 |
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 | ![Logo](https://github.com/user-attachments/assets/7c151081-692f-424a-96c5-9398e484304e) 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 | --------------------------------------------------------------------------------