diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..36ad7bc --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +indent_style = space +indent_size = 4 +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true +max_line_length = 120 +[*.md] +indent_size = 2 +trim_trailing_whitespace = false + +[*.{kt,kts}] +ktlint_code_style = android diff --git a/README.md b/README.md index ca82169..fa7ce9c 100644 --- a/README.md +++ b/README.md @@ -32,8 +32,10 @@ Permissions requests are for specifics usage and are only requests the first tim | ACCESS_FINE_LOCATION | Google Fit Extension Requirement (maybe not, still have to test) | | ACCESS_COARSE_LOCATION | Same as above | | ACTIVITY_RECOGNITION | Device Steps Usage | +| INTERNET | Food fetching from OpenFoodFact | +| POST_NOTIFICATIONS | send notifications for water intake and device steps usage | -No other permissions are used (even the internet permission ;)). +No other permissions are used. ## Build @@ -42,6 +44,12 @@ No other permissions are used (even the internet permission ;)). - click on the debug icon for debug - it will be running on your emulator/device +## Design + +If you want to contribute to the app design you can copy and edit the following Figma file + +https://www.figma.com/file/AD63laksP2dvspRpT6MZ67/Open-Health?node-id=50995%3A3212&t=E5IFKMuqg8WDNQqc-1 + ## Contributing See [CONTRIBUTING.md](https://github.com/dzeiocom/OpenHealth/blob/master/CONTRIBUTING.md) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ecd96f1..a058775 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,8 +1,13 @@ import java.util.Properties plugins { + // Android Application? id("com.android.application") + + // Support for kotlin in Android kotlin("android") + + // Data Injection id("dagger.hilt.android.plugin") // Safe Navigation @@ -15,14 +20,45 @@ plugins { kotlin("kapt") } +// from: https://discuss.kotlinlang.org/t/use-git-hash-as-version-number-in-build-gradle-kts/19818/8 +fun String.runCommand( + workingDir: File = File("."), + timeoutAmount: Long = 60, + timeoutUnit: TimeUnit = TimeUnit.SECONDS +): String = ProcessBuilder(split("\\s(?=(?:[^'\"`]*(['\"`])[^'\"`]*\\1)*[^'\"`]*$)".toRegex())) + .directory(workingDir) + .redirectOutput(ProcessBuilder.Redirect.PIPE) + .redirectError(ProcessBuilder.Redirect.PIPE) + .start() + .apply { waitFor(timeoutAmount, timeoutUnit) } + .run { + val error = errorStream.bufferedReader().readText().trim() + if (error.isNotEmpty()) { + return@run "" + } + inputStream.bufferedReader().readText().trim() + } + +// The application ID val appID = "com.dzeio.openhealth" -// Languages +// the application supported languages val locales = listOf("en", "fr") +// minimum application required SDK version to run val sdkMin = 21 + +// target SDK version val sdkTarget = 33 +val branch = "git rev-parse --abbrev-ref HEAD".runCommand(workingDir = rootDir) +var tag = "git tag -l --points-at HEAD".runCommand(workingDir = rootDir) +if (tag == "") { + tag = "not tagged" +} +val commitId = "git rev-parse HEAD".runCommand(workingDir = rootDir) + .subSequence(0, 7) + android { signingConfigs { @@ -41,7 +77,6 @@ android { storeFile = file(keystoreProperties["storeFile"] as String) } } catch (_: Exception) {} - } } @@ -58,7 +93,7 @@ android { targetSdk = sdkTarget // Semantic Versioning - versionName = "1.0.0" + versionName = "0.1.0" versionCode = 1 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" @@ -75,6 +110,10 @@ android { "new String[]{\"" + locales.joinToString("\",\"") + "\"}" ) resourceConfigurations += locales + + buildConfigField("String", "BRANCH", "\"$branch\"") + buildConfigField("String", "TAG", "\"$tag\"") + buildConfigField("String", "COMMIT", "\"$commitId\"") } buildTypes { @@ -82,12 +121,18 @@ android { getByName("release") { // Slimmer version isMinifyEnabled = true - proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + isShrinkResources = true + isDebuggable = false + + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + signingConfig = signingConfigs.getByName("release") } getByName("debug") { - proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") applicationIdSuffix = ".dev" versionNameSuffix = "-dev" isDebuggable = true @@ -118,24 +163,26 @@ android { namespace = appID } +kapt { + correctErrorTypes = true +} + dependencies { // Dzeio Charts - implementation(project(":charts")) -// implementation(project(":CrashHandler")) + implementation("com.dzeio:charts:fe20f90654") + // Dzeio Crash Handler implementation("com.dzeio:crashhandler:1.0.1") // Core dependencies implementation("androidx.core:core-ktx:1.9.0") implementation("androidx.appcompat:appcompat:1.7.0-alpha01") implementation("javax.inject:javax.inject:1") - implementation("com.google.android.material:material:1.8.0-alpha02") + implementation("com.google.android.material:material:1.9.0-alpha01") implementation("androidx.constraintlayout:constraintlayout:2.1.4") implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.5.1") implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1") -// implementation("com.github.Aviortheking:crashhandler:0.2.3") - // Coroutines implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4") @@ -153,39 +200,24 @@ dependencies { implementation("androidx.paging:paging-runtime:3.1.1") implementation("androidx.paging:paging-runtime-ktx:3.1.1") - // Services implementation("androidx.work:work-runtime-ktx:2.7.1") + implementation("androidx.core:core-ktx:1.9.0") // Tests testImplementation("junit:junit:4.13.2") - androidTestImplementation("androidx.test.ext:junit:1.1.4") - androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0") - - // Graph - implementation("com.github.PhilJay:MPAndroidChart:v3.1.0") - - // Graphs test 2 - implementation("com.github.HackPlan:AndroidCharts:1.0.4") + androidTestImplementation("androidx.test.ext:junit:1.1.5") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") // Hilt - implementation("com.google.dagger:hilt-android:2.43.2") - kapt("com.google.dagger:hilt-compiler:2.43.2") - - // Google Fit - implementation("com.google.android.gms:play-services-fitness:21.1.0") - implementation("com.google.android.gms:play-services-auth:20.3.0") - implementation("androidx.health.connect:connect-client:1.0.0-alpha07") - - // Samsung Health - implementation(files("libs/samsung-health-data-1.5.0.aar")) - implementation("com.google.code.gson:gson:2.9.1") + implementation("com.google.dagger:hilt-android:2.44.2") + kapt("com.google.dagger:hilt-compiler:2.44.2") // ROOM - implementation("androidx.room:room-runtime:2.4.3") - kapt("androidx.room:room-compiler:2.4.3") - implementation("androidx.room:room-ktx:2.4.3") - testImplementation("androidx.room:room-testing:2.4.3") + implementation("androidx.room:room-runtime:2.5.0") + kapt("androidx.room:room-compiler:2.5.0") + implementation("androidx.room:room-ktx:2.5.0") + testImplementation("androidx.room:room-testing:2.5.0") // Futures implementation("com.google.guava:guava:31.1-jre") @@ -194,4 +226,9 @@ dependencies { // OSS Licenses implementation("com.google.android.gms:play-services-oss-licenses:17.0.0") + + // Retrofit (Open Food Fact) + implementation("com.squareup.retrofit2:retrofit:2.9.0") + implementation("com.squareup.retrofit2:converter-gson:2.9.0") + implementation("com.squareup.okhttp3:logging-interceptor:4.10.0") } diff --git a/app/libs/samsung-health-data-1.5.0.aar b/app/libs/samsung-health-data-1.5.0.aar deleted file mode 100644 index e4f8cf6..0000000 Binary files a/app/libs/samsung-health-data-1.5.0.aar and /dev/null differ diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index ff59496..3183830 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -18,4 +18,9 @@ # If you keep the line number information, uncomment this to # hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file +#-renamesourcefileattribute SourceFile + +# do not obfuscate fields with the annotation `@SerializedName` +-keepclassmembers,allowobfuscation class * { + @com.google.gson.annotations.SerializedName ; +} diff --git a/app/schemas/com.dzeio.openhealth.data.AppDatabase/1.json b/app/schemas/com.dzeio.openhealth.data.AppDatabase/1.json index ef5c56a..628f44a 100644 --- a/app/schemas/com.dzeio.openhealth.data.AppDatabase/1.json +++ b/app/schemas/com.dzeio.openhealth.data.AppDatabase/1.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "2acd5897bbf15393886259605a1df934", + "identityHash": "414712cc283c7f1d14cde8e00da277fb", "entities": [ { "tableName": "Weight", @@ -34,10 +34,10 @@ } ], "primaryKey": { + "autoGenerate": true, "columnNames": [ "id" - ], - "autoGenerate": true + ] }, "indices": [ { @@ -82,10 +82,10 @@ } ], "primaryKey": { + "autoGenerate": true, "columnNames": [ "id" - ], - "autoGenerate": true + ] }, "indices": [ { @@ -130,10 +130,10 @@ } ], "primaryKey": { + "autoGenerate": true, "columnNames": [ "id" - ], - "autoGenerate": true + ] }, "indices": [ { @@ -147,12 +147,86 @@ } ], "foreignKeys": [] + }, + { + "tableName": "Food", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `serving` TEXT NOT NULL, `quantity` REAL NOT NULL, `proteins` REAL NOT NULL, `carbohydrates` REAL NOT NULL, `fat` REAL NOT NULL, `energy` REAL NOT NULL, `image` TEXT, `timestamp` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serving", + "columnName": "serving", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quantity", + "columnName": "quantity", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "proteins", + "columnName": "proteins", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "carbohydrates", + "columnName": "carbohydrates", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "fat", + "columnName": "fat", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "energy", + "columnName": "energy", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] } ], "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '2acd5897bbf15393886259605a1df934')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '414712cc283c7f1d14cde8e00da277fb')" ] } } \ No newline at end of file diff --git a/app/schemas/com.dzeio.openhealth.data.AppDatabase/2.json b/app/schemas/com.dzeio.openhealth.data.AppDatabase/2.json new file mode 100644 index 0000000..f34a50e --- /dev/null +++ b/app/schemas/com.dzeio.openhealth.data.AppDatabase/2.json @@ -0,0 +1,274 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "68d8da27dde7a13bc063b8c3d8d34e5b", + "entities": [ + { + "tableName": "Weight", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `weight` REAL NOT NULL, `timestamp` INTEGER NOT NULL, `source` TEXT NOT NULL, `bmi` REAL, `totalBodyWater` REAL, `muscles` REAL, `leanBodyMass` REAL, `bodyFat` REAL, `boneMass` REAL, `visceralFat` REAL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "weight", + "columnName": "weight", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bmi", + "columnName": "bmi", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "totalBodyWater", + "columnName": "totalBodyWater", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "muscles", + "columnName": "muscles", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "leanBodyMass", + "columnName": "leanBodyMass", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "bodyFat", + "columnName": "bodyFat", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "boneMass", + "columnName": "boneMass", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "visceralFat", + "columnName": "visceralFat", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Weight_timestamp", + "unique": false, + "columnNames": [ + "timestamp" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Weight_timestamp` ON `${TABLE_NAME}` (`timestamp`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "Water", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `value` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `source` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Water_timestamp", + "unique": false, + "columnNames": [ + "timestamp" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Water_timestamp` ON `${TABLE_NAME}` (`timestamp`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "Step", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `value` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `source` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Step_timestamp", + "unique": false, + "columnNames": [ + "timestamp" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Step_timestamp` ON `${TABLE_NAME}` (`timestamp`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "Food", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `serving` TEXT NOT NULL, `quantity` REAL NOT NULL, `proteins` REAL NOT NULL, `carbohydrates` REAL NOT NULL, `fat` REAL NOT NULL, `energy` REAL NOT NULL, `image` TEXT, `timestamp` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serving", + "columnName": "serving", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quantity", + "columnName": "quantity", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "proteins", + "columnName": "proteins", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "carbohydrates", + "columnName": "carbohydrates", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "fat", + "columnName": "fat", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "energy", + "columnName": "energy", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '68d8da27dde7a13bc063b8c3d8d34e5b')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/dzeio/openhealth/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/dzeio/openhealth/ExampleInstrumentedTest.kt index d4bfffb..cc0234f 100644 --- a/app/src/androidTest/java/com/dzeio/openhealth/ExampleInstrumentedTest.kt +++ b/app/src/androidTest/java/com/dzeio/openhealth/ExampleInstrumentedTest.kt @@ -1,13 +1,11 @@ package com.dzeio.openhealth -import androidx.test.platform.app.InstrumentationRegistry import androidx.test.ext.junit.runners.AndroidJUnit4 - +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals import org.junit.Test import org.junit.runner.RunWith -import org.junit.Assert.* - /** * Instrumented test, which will execute on an Android device. * @@ -21,4 +19,4 @@ class ExampleInstrumentedTest { val appContext = InstrumentationRegistry.getInstrumentation().targetContext assertEquals("com.dzeio.openhealth", appContext.packageName) } -} \ No newline at end of file +} diff --git a/app/src/debug/res/drawable/baseline_chevron_left_24.xml b/app/src/debug/res/drawable/baseline_chevron_left_24.xml new file mode 100644 index 0000000..25a728b --- /dev/null +++ b/app/src/debug/res/drawable/baseline_chevron_left_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/debug/res/drawable/baseline_chevron_right_24.xml b/app/src/debug/res/drawable/baseline_chevron_right_24.xml new file mode 100644 index 0000000..e7cf886 --- /dev/null +++ b/app/src/debug/res/drawable/baseline_chevron_right_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 22b0177..0d268e5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,22 +1,21 @@ - + + + - + - + + + + + + + - - - - - - - - - - - - - - + + + @@ -60,26 +52,12 @@ android:name="com.google.android.gms.oss.licenses.OssLicensesActivity" android:theme="@style/Theme.OpenHealth" /> - - - - - - - - - - - + + - - - - - - diff --git a/app/src/main/java/com/dzeio/openhealth/Application.kt b/app/src/main/java/com/dzeio/openhealth/Application.kt index 491b442..5a754ae 100644 --- a/app/src/main/java/com/dzeio/openhealth/Application.kt +++ b/app/src/main/java/com/dzeio/openhealth/Application.kt @@ -16,24 +16,36 @@ class Application : Application() { } override fun onCreate() { - val prefs = PreferenceManager.getDefaultSharedPreferences(this) + // setup the CrashHandler CrashHandler.Builder() .withActivity(ErrorActivity::class.java) .withPrefs(prefs) .witheErrorReporterCrashKey(R.string.error_reporter_crash) .withPrefsKey(Settings.CRASH_LAST_TIME) - .withPrefix("${BuildConfig.APPLICATION_ID} v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})") + .withPrefix( + """ + ${BuildConfig.APPLICATION_ID} v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE}) + + Build informations: + Commit: ${BuildConfig.COMMIT} + Branch: ${BuildConfig.BRANCH} + Tag: ${BuildConfig.TAG} + """.trimIndent() + ) .build() .setup(this) - // Android Dynamics Colors + // setup for Android Dynamics Colors DynamicColors.applyToActivitiesIfAvailable(this) super.onCreate() } + /** + * Change the language of the application if said in the settings + */ override fun attachBaseContext(base: Context) { super.attachBaseContext(LocaleUtils.onAttach(base)) } diff --git a/app/src/main/java/com/dzeio/openhealth/Settings.kt b/app/src/main/java/com/dzeio/openhealth/Settings.kt index ec50814..25aecbc 100644 --- a/app/src/main/java/com/dzeio/openhealth/Settings.kt +++ b/app/src/main/java/com/dzeio/openhealth/Settings.kt @@ -1,7 +1,8 @@ package com.dzeio.openhealth -import com.dzeio.openhealth.extensions.Extension - +/** + * Object containing every keys for the different settings of the application + */ object Settings { /** @@ -29,8 +30,8 @@ object Settings { */ const val MASS_UNIT = "com.dzeio.open-health.unit.mass" - fun extensionEnabled(extension: Extension): String { - return "com.dzeio.open-health.extension.${extension.id}.enabled" - } - + /** + * Goal number of steps each days + */ + const val STEPS_GOAL = "com.dzeio.open-health.steps.goal-daily" } diff --git a/app/src/main/java/com/dzeio/openhealth/adapters/ExtensionAdapter.kt b/app/src/main/java/com/dzeio/openhealth/adapters/ExtensionAdapter.kt deleted file mode 100644 index 14a6afb..0000000 --- a/app/src/main/java/com/dzeio/openhealth/adapters/ExtensionAdapter.kt +++ /dev/null @@ -1,38 +0,0 @@ -package com.dzeio.openhealth.adapters - -import android.view.LayoutInflater -import android.view.ViewGroup -import com.dzeio.openhealth.Settings -import com.dzeio.openhealth.core.BaseAdapter -import com.dzeio.openhealth.core.BaseViewHolder -import com.dzeio.openhealth.databinding.LayoutExtensionItemBinding -import com.dzeio.openhealth.extensions.Extension -import com.dzeio.openhealth.utils.Configuration - - -class ExtensionAdapter( - private val config: Configuration -) : BaseAdapter() { - - override val bindingInflater: (LayoutInflater, ViewGroup?, Boolean) -> - LayoutExtensionItemBinding = LayoutExtensionItemBinding::inflate - - var onItemClick: ((weight: Extension) -> Unit)? = null - - override fun onBindData( - holder: BaseViewHolder, - item: Extension, - position: Int - ) { - val isEnabled = config.getBoolean(Settings.extensionEnabled(item)).value ?: false - holder.binding.name.text = item.name - holder.binding.card.isClickable = item.isAvailable() - holder.binding.card.isEnabled = item.isAvailable() - holder.binding.status.text = "enabled = $isEnabled" - if (item.isAvailable()) { - holder.binding.card.setOnClickListener { - onItemClick?.invoke(item) - } - } - } -} diff --git a/app/src/main/java/com/dzeio/openhealth/adapters/FoodAdapter.kt b/app/src/main/java/com/dzeio/openhealth/adapters/FoodAdapter.kt new file mode 100644 index 0000000..f2689d2 --- /dev/null +++ b/app/src/main/java/com/dzeio/openhealth/adapters/FoodAdapter.kt @@ -0,0 +1,45 @@ +package com.dzeio.openhealth.adapters + +import android.view.LayoutInflater +import android.view.ViewGroup +import com.dzeio.openhealth.R +import com.dzeio.openhealth.core.BaseAdapter +import com.dzeio.openhealth.core.BaseViewHolder +import com.dzeio.openhealth.data.food.Food +import com.dzeio.openhealth.databinding.ItemFoodBinding +import com.dzeio.openhealth.utils.NetworkUtils +import kotlin.math.roundToInt + +class FoodAdapter : BaseAdapter() { + + override val bindingInflater: (LayoutInflater, ViewGroup?, Boolean) -> ItemFoodBinding + get() = ItemFoodBinding::inflate + + var onItemClick: ((weight: Food) -> Unit)? = null + + override fun onBindData( + holder: BaseViewHolder, + item: Food, + position: Int + ) { + // Download remote picture + if (item.image != null) { + NetworkUtils.getImageInBackground(holder.binding.productImage, item.image!!) + } + + // set the food name + holder.binding.foodName.text = item.name + " ${item.id}" + + // set the food description + holder.binding.foodDescription.text = holder.itemView.context.getString( + R.string.food_description, + item.quantity.roundToInt().toString() + item.serving.replace(Regex("\\d+"), ""), + (item.energy / 100 * item.quantity) + ) + + // set the callback + holder.binding.edit.setOnClickListener { + onItemClick?.invoke(item) + } + } +} diff --git a/app/src/main/java/com/dzeio/openhealth/adapters/ItemAdapter.kt b/app/src/main/java/com/dzeio/openhealth/adapters/ItemAdapter.kt new file mode 100644 index 0000000..8fc236d --- /dev/null +++ b/app/src/main/java/com/dzeio/openhealth/adapters/ItemAdapter.kt @@ -0,0 +1,55 @@ +package com.dzeio.openhealth.adapters + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.DrawableRes +import com.dzeio.openhealth.core.BaseAdapter +import com.dzeio.openhealth.core.BaseViewHolder +import com.dzeio.openhealth.databinding.ItemListBinding +import com.dzeio.openhealth.utils.NetworkUtils + +class ItemAdapter : BaseAdapter, ItemListBinding>() { + + override val bindingInflater: (LayoutInflater, ViewGroup?, Boolean) -> ItemListBinding + get() = ItemListBinding::inflate + + var onItemClick: ((weight: Item) -> Unit)? = null + + override fun onBindData( + holder: BaseViewHolder, + item: Item, + position: Int + ) { + val binding = holder.binding + + binding.title.text = item.title + binding.subValue.text = item.subtitle + + if (item.image != null) { + NetworkUtils.getImageInBackground(binding.image, item.image) + } else { + binding.image.visibility = View.GONE + } + + if (item.icon != null) { + binding.iconRight.setImageResource(item.icon) + } else { + binding.iconRight.visibility = View.GONE + } + + // set the callback + binding.card.setOnClickListener { + onItemClick?.invoke(item) + } + } + + data class Item( + val value: T, + val title: String? = null, + val subtitle: String? = null, + val image: String? = null, + @DrawableRes + val icon: Int? = null + ) +} diff --git a/app/src/main/java/com/dzeio/openhealth/adapters/StepsAdapter.kt b/app/src/main/java/com/dzeio/openhealth/adapters/StepsAdapter.kt index 1560fd3..71625fc 100644 --- a/app/src/main/java/com/dzeio/openhealth/adapters/StepsAdapter.kt +++ b/app/src/main/java/com/dzeio/openhealth/adapters/StepsAdapter.kt @@ -1,7 +1,9 @@ package com.dzeio.openhealth.adapters import android.view.LayoutInflater +import android.view.View import android.view.ViewGroup +import com.dzeio.openhealth.R import com.dzeio.openhealth.core.BaseAdapter import com.dzeio.openhealth.core.BaseViewHolder import com.dzeio.openhealth.data.step.Step @@ -14,13 +16,29 @@ class StepsAdapter() : BaseAdapter() { var onItemClick: ((weight: Step) -> Unit)? = null + var isDay = false + override fun onBindData( holder: BaseViewHolder, item: Step, position: Int ) { - holder.binding.value.text = "${item.value}steps" - holder.binding.datetime.text = item.formatTimestamp() + // set the number of steps taken + holder.binding.value.text = holder.itemView.context.getString( + R.string.steps_count, + item.value + ) + + // set the datetime + holder.binding.datetime.text = item.formatTimestamp(!isDay) + + if (isDay) { + holder.binding.iconRight.visibility = View.GONE + } else { + holder.binding.iconRight.setImageResource(R.drawable.ic_zoom_out_map) + } + + // set the callback holder.binding.edit.setOnClickListener { onItemClick?.invoke(item) } diff --git a/app/src/main/java/com/dzeio/openhealth/adapters/WaterAdapter.kt b/app/src/main/java/com/dzeio/openhealth/adapters/WaterAdapter.kt index e9a1ba8..ff869e1 100644 --- a/app/src/main/java/com/dzeio/openhealth/adapters/WaterAdapter.kt +++ b/app/src/main/java/com/dzeio/openhealth/adapters/WaterAdapter.kt @@ -6,8 +6,14 @@ import com.dzeio.openhealth.core.BaseAdapter import com.dzeio.openhealth.core.BaseViewHolder import com.dzeio.openhealth.data.water.Water import com.dzeio.openhealth.databinding.LayoutItemListBinding +import com.dzeio.openhealth.units.Units -class WaterAdapter() : BaseAdapter() { +class WaterAdapter : BaseAdapter() { + + /** + * The unit the adapter will be using + */ + var unit: Units.Volume = Units.Volume.MILLILITER override val bindingInflater: (LayoutInflater, ViewGroup?, Boolean) -> LayoutItemListBinding get() = LayoutItemListBinding::inflate @@ -19,8 +25,14 @@ class WaterAdapter() : BaseAdapter() { item: Water, position: Int ) { - holder.binding.value.text = "${item.value}ml" - holder.binding.datetime.text = "${item.formatTimestamp()}" + // set the wate intake text + holder.binding.value.text = + holder.itemView.context.getString(unit.unit, unit.formatToString(item.value)) + + // set the datetime + holder.binding.datetime.text = item.formatTimestamp() + + // set the callback holder.binding.edit.setOnClickListener { onItemClick?.invoke(item) } diff --git a/app/src/main/java/com/dzeio/openhealth/adapters/WeightAdapter.kt b/app/src/main/java/com/dzeio/openhealth/adapters/WeightAdapter.kt index b4f2da7..49fefa7 100644 --- a/app/src/main/java/com/dzeio/openhealth/adapters/WeightAdapter.kt +++ b/app/src/main/java/com/dzeio/openhealth/adapters/WeightAdapter.kt @@ -10,6 +10,9 @@ import com.dzeio.openhealth.units.Units class WeightAdapter : BaseAdapter() { + /** + * The unit the adapter will be using + */ var unit: Units.Mass = Units.Mass.KILOGRAM override val bindingInflater: (LayoutInflater, ViewGroup?, Boolean) -> LayoutItemListBinding @@ -22,12 +25,14 @@ class WeightAdapter : BaseAdapter() { item: Weight, position: Int ) { - - - + // set the weight text holder.binding.value.text = holder.itemView.context.getString(unit.unit, unit.formatToString(item.weight)) + + // set the datetime holder.binding.datetime.text = item.formatTimestamp() + + // set the callback holder.binding.edit.setOnClickListener { onItemClick?.invoke(item) } diff --git a/app/src/main/java/com/dzeio/openhealth/core/BaseActivity.kt b/app/src/main/java/com/dzeio/openhealth/core/BaseActivity.kt index e92cbb8..00cacdc 100644 --- a/app/src/main/java/com/dzeio/openhealth/core/BaseActivity.kt +++ b/app/src/main/java/com/dzeio/openhealth/core/BaseActivity.kt @@ -5,9 +5,11 @@ import android.view.LayoutInflater import androidx.appcompat.app.AppCompatActivity import androidx.viewbinding.ViewBinding +/** + * Base around the Activity class to simplify usage + */ abstract class BaseActivity() : AppCompatActivity() { - /** * Function to inflate the Fragment Bindings * @@ -28,4 +30,4 @@ abstract class BaseActivity() : AppCompatActivity() { } protected open fun onCreated(savedInstanceState: Bundle?) {} -} \ No newline at end of file +} diff --git a/app/src/main/java/com/dzeio/openhealth/core/BaseAdapter.kt b/app/src/main/java/com/dzeio/openhealth/core/BaseAdapter.kt index 1e5e2f8..d60562e 100644 --- a/app/src/main/java/com/dzeio/openhealth/core/BaseAdapter.kt +++ b/app/src/main/java/com/dzeio/openhealth/core/BaseAdapter.kt @@ -6,9 +6,11 @@ import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView import androidx.viewbinding.ViewBinding +/** + * Base around the adapter to simplify usage + */ abstract class BaseAdapter : RecyclerView.Adapter>() { private var items = mutableListOf() - // private var lastPosition = -1 @SuppressLint("NotifyDataSetChanged") fun set(items: List) { @@ -22,6 +24,12 @@ abstract class BaseAdapter : RecyclerView.Adapter : RecyclerView.Adapter VB + /** + * function run when an item is displayed + */ abstract fun onBindData(holder: BaseViewHolder, item: T, position: Int) override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BaseViewHolder { @@ -42,5 +53,4 @@ abstract class BaseAdapter : RecyclerView.Adapter { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(vararg obj: T): List @@ -13,9 +15,12 @@ interface BaseDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(obj: T): Long + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(vararg obj: T) + @Update suspend fun update(vararg obj: T) @Delete suspend fun delete(vararg obj: T) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/dzeio/openhealth/core/BaseDialog.kt b/app/src/main/java/com/dzeio/openhealth/core/BaseDialog.kt index e72ee87..dd9a908 100644 --- a/app/src/main/java/com/dzeio/openhealth/core/BaseDialog.kt +++ b/app/src/main/java/com/dzeio/openhealth/core/BaseDialog.kt @@ -8,7 +8,9 @@ import androidx.viewbinding.ViewBinding * * note: Dialog crash app with viewmodel error? add @AndroidEntryPoint */ -abstract class BaseDialog(private val viewModelClass: Class) : +abstract class BaseDialog( + private val viewModelClass: Class +) : BaseSimpleDialog() { val viewModel by lazy { diff --git a/app/src/main/java/com/dzeio/openhealth/core/BaseFragment.kt b/app/src/main/java/com/dzeio/openhealth/core/BaseFragment.kt index e69a93a..73370c6 100644 --- a/app/src/main/java/com/dzeio/openhealth/core/BaseFragment.kt +++ b/app/src/main/java/com/dzeio/openhealth/core/BaseFragment.kt @@ -3,6 +3,9 @@ package com.dzeio.openhealth.core import androidx.lifecycle.ViewModelProvider import androidx.viewbinding.ViewBinding +/** + * Base around the Fragment class to simplify usage + */ abstract class BaseFragment( private val viewModelClass: Class ) : diff --git a/app/src/main/java/com/dzeio/openhealth/core/BaseFullscreenDialog.kt b/app/src/main/java/com/dzeio/openhealth/core/BaseFullscreenDialog.kt index e613597..223292c 100644 --- a/app/src/main/java/com/dzeio/openhealth/core/BaseFullscreenDialog.kt +++ b/app/src/main/java/com/dzeio/openhealth/core/BaseFullscreenDialog.kt @@ -13,8 +13,16 @@ import androidx.viewbinding.ViewBinding import com.dzeio.openhealth.R import com.google.android.material.dialog.MaterialAlertDialogBuilder -abstract class BaseFullscreenDialog(private val viewModelClass: Class) : DialogFragment() { +/** + * Base around the DialogFragment class to simplify usage + */ +abstract class BaseFullscreenDialog( + private val viewModelClass: Class +) : DialogFragment() { + /** + * Lazyload the viewModel + */ val viewModel by lazy { ViewModelProvider(this)[viewModelClass] } @@ -22,14 +30,14 @@ abstract class BaseFullscreenDialog(privat private var _binding: VB? = null val binding get() = _binding!! - /** * Function to inflate the Fragment Bindings */ abstract val bindingInflater: (LayoutInflater) -> VB - abstract val isFullscreenLayout: Boolean - + /** + * Function run when the dialog was created + */ open fun onCreated(savedInstanceState: Bundle?) {} override fun onCreateView( @@ -37,7 +45,6 @@ abstract class BaseFullscreenDialog(privat container: ViewGroup?, savedInstanceState: Bundle? ): View? { - _binding = bindingInflater(inflater) setHasOptionsMenu(true) @@ -59,14 +66,18 @@ abstract class BaseFullscreenDialog(privat onDialogInit(dialog) -// onCreated() - dialog } ?: throw IllegalStateException("Activity cannot be null") } - open fun onDialogInit(dialog: Dialog): Unit {} + /** + * Function to modify the Dialog + */ + open fun onDialogInit(dialog: Dialog) {} + /** + * FIXME: Remove it from the Base and put it in the implementations + */ override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { super.onCreateOptionsMenu(menu, inflater) @@ -75,10 +86,6 @@ abstract class BaseFullscreenDialog(privat super.onCreateOptionsMenu(menu, inflater) } - override fun onDestroy() { - super.onDestroy() - } - /** * Destroy binding */ @@ -86,4 +93,4 @@ abstract class BaseFullscreenDialog(privat super.onDestroyView() _binding = null } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/dzeio/openhealth/core/BaseSimpleDialog.kt b/app/src/main/java/com/dzeio/openhealth/core/BaseSimpleDialog.kt index 9adf943..e13e2a8 100644 --- a/app/src/main/java/com/dzeio/openhealth/core/BaseSimpleDialog.kt +++ b/app/src/main/java/com/dzeio/openhealth/core/BaseSimpleDialog.kt @@ -8,8 +8,16 @@ import androidx.fragment.app.DialogFragment import androidx.viewbinding.ViewBinding import com.google.android.material.dialog.MaterialAlertDialogBuilder +/** + * Base around the DialogFragment class to simplify usage + */ abstract class BaseSimpleDialog : DialogFragment() { + /** + * Function to inflate the Fragment Bindings + */ + abstract val bindingInflater: (LayoutInflater) -> VB + private var _binding: VB? = null val binding get() = _binding!! @@ -36,16 +44,20 @@ abstract class BaseSimpleDialog : DialogFragment() { } ?: throw IllegalStateException("Activity cannot be null") } + /** + * Function to add more customization to the AlertDialogBuilder + */ open fun onBuilderInit(builder: MaterialAlertDialogBuilder) {} + /** + * Function that allow to modificate some elements of the final dialog + */ open fun onDialogInit(dialog: AlertDialog) {} - open fun onCreated() {} - /** - * Function to inflate the Fragment Bindings + * Function run when the dialog is created */ - abstract val bindingInflater: (LayoutInflater) -> VB + open fun onCreated() {} /** * Destroy binding diff --git a/app/src/main/java/com/dzeio/openhealth/core/BaseStaticFragment.kt b/app/src/main/java/com/dzeio/openhealth/core/BaseStaticFragment.kt index 0e2e17d..b94b918 100644 --- a/app/src/main/java/com/dzeio/openhealth/core/BaseStaticFragment.kt +++ b/app/src/main/java/com/dzeio/openhealth/core/BaseStaticFragment.kt @@ -1,6 +1,5 @@ package com.dzeio.openhealth.core -import android.content.Context import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -8,11 +7,23 @@ import android.view.ViewGroup import androidx.fragment.app.Fragment import androidx.viewbinding.ViewBinding +/** + * Base around the Fragment class to simplify usage + * + * Without ViewModel support (use `BaseFragment` instead) + */ abstract class BaseStaticFragment : Fragment() { private var _binding: VB? = null val binding get() = _binding!! + /** + * Function to inflate the Fragment Bindings + * + * use like this: `ViewBinding::inflater` + */ + abstract val bindingInflater: (LayoutInflater, ViewGroup?, Boolean) -> VB + /** * Setup everything! */ @@ -28,13 +39,6 @@ abstract class BaseStaticFragment : Fragment() { return binding.root } - /** - * Function to inflate the Fragment Bindings - * - * use like this: `ViewBinding::inflater` - */ - abstract val bindingInflater: (LayoutInflater, ViewGroup?, Boolean) -> VB - /** * Destroy binding */ diff --git a/app/src/main/java/com/dzeio/openhealth/core/BaseViewHolder.kt b/app/src/main/java/com/dzeio/openhealth/core/BaseViewHolder.kt index fcadf2c..d3d54f9 100644 --- a/app/src/main/java/com/dzeio/openhealth/core/BaseViewHolder.kt +++ b/app/src/main/java/com/dzeio/openhealth/core/BaseViewHolder.kt @@ -3,7 +3,9 @@ package com.dzeio.openhealth.core import androidx.recyclerview.widget.RecyclerView import androidx.viewbinding.ViewBinding +/** + * Simple implementation of RecyclerView.ViewHolder to limitate usage + */ class BaseViewHolder( - val binding : VB -) : RecyclerView.ViewHolder(binding.root) { -} \ No newline at end of file + val binding: VB +) : RecyclerView.ViewHolder(binding.root) diff --git a/app/src/main/java/com/dzeio/openhealth/core/BaseViewModel.kt b/app/src/main/java/com/dzeio/openhealth/core/BaseViewModel.kt index fe5c162..aa6c1fc 100644 --- a/app/src/main/java/com/dzeio/openhealth/core/BaseViewModel.kt +++ b/app/src/main/java/com/dzeio/openhealth/core/BaseViewModel.kt @@ -2,4 +2,7 @@ package com.dzeio.openhealth.core import androidx.lifecycle.ViewModel -abstract class BaseViewModel : ViewModel() \ No newline at end of file +/** + * Simple Extension of the base ViewModel + */ +abstract class BaseViewModel : ViewModel() diff --git a/app/src/main/java/com/dzeio/openhealth/core/BaseWorker.kt b/app/src/main/java/com/dzeio/openhealth/core/BaseWorker.kt index 60ffde0..3d3f2bd 100644 --- a/app/src/main/java/com/dzeio/openhealth/core/BaseWorker.kt +++ b/app/src/main/java/com/dzeio/openhealth/core/BaseWorker.kt @@ -9,6 +9,9 @@ import androidx.work.Worker import androidx.work.WorkerParameters import com.dzeio.openhealth.Application +/** + * Worker Wrapper to simplify work and usage + */ abstract class BaseWorker(context: Context, params: WorkerParameters) : Worker(context, params) { companion object { @@ -19,4 +22,4 @@ abstract class BaseWorker(context: Context, params: WorkerParameters) : Worker(c .enqueueUniquePeriodicWork(tag, ExistingPeriodicWorkPolicy.KEEP, request) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/dzeio/openhealth/core/Observable.kt b/app/src/main/java/com/dzeio/openhealth/core/Observable.kt index 5b1f872..64e2683 100644 --- a/app/src/main/java/com/dzeio/openhealth/core/Observable.kt +++ b/app/src/main/java/com/dzeio/openhealth/core/Observable.kt @@ -2,12 +2,16 @@ package com.dzeio.openhealth.core import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.callbackFlow +/** + * Simple Observable implementation + */ open class Observable(baseValue: T) { private val functionObservers: ArrayList<(T) -> Unit> = ArrayList() - fun addObserver(fn: (T) -> Unit) { if (!functionObservers.contains(fn)) { functionObservers.add(fn) @@ -35,7 +39,6 @@ open class Observable(baseValue: T) { } fun notifyObservers() { - // Notify Functions for (fn in functionObservers) { notifyObserver(fn) @@ -53,4 +56,19 @@ open class Observable(baseValue: T) { } return ld } + + /** + * Transform the observable to a Kotlin Channel + */ + fun toChannel(): Channel = Channel(Channel.RENDEZVOUS).apply { + addObserver { + trySend(it) + } + } + + fun toFlow() = callbackFlow { + addObserver { + trySend(it) + } + } } diff --git a/app/src/main/java/com/dzeio/openhealth/data/AppDatabase.kt b/app/src/main/java/com/dzeio/openhealth/data/AppDatabase.kt index 8d3adf0..38779cd 100644 --- a/app/src/main/java/com/dzeio/openhealth/data/AppDatabase.kt +++ b/app/src/main/java/com/dzeio/openhealth/data/AppDatabase.kt @@ -1,9 +1,12 @@ package com.dzeio.openhealth.data import android.content.Context +import androidx.room.AutoMigration import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase +import com.dzeio.openhealth.data.food.Food +import com.dzeio.openhealth.data.food.FoodDao import com.dzeio.openhealth.data.step.Step import com.dzeio.openhealth.data.step.StepDao import com.dzeio.openhealth.data.water.Water @@ -11,50 +14,55 @@ import com.dzeio.openhealth.data.water.WaterDao import com.dzeio.openhealth.data.weight.Weight import com.dzeio.openhealth.data.weight.WeightDao +/** + * ROOM SQLite database for the application + * + * It may be replaced if I want to fully encrypt the database + */ @Database( entities = [ Weight::class, Water::class, - Step::class + Step::class, + Food::class + ], + version = 2, + autoMigrations = [ + AutoMigration(1, 2) ], - version = 1, exportSchema = true ) abstract class AppDatabase : RoomDatabase() { -// private val PREPOPULATE_DATA = listOf(Thing("1", "val"), Thing("2", "val 2")) - abstract fun weightDao(): WeightDao abstract fun waterDao(): WaterDao abstract fun stepDao(): StepDao + abstract fun foodDao(): FoodDao + companion object { + /** + * database name duh + */ private const val DATABASE_NAME = "open_health" // For Singleton instantiation @Volatile private var instance: AppDatabase? = null + // get the Database instance fun getInstance(context: Context): AppDatabase { return instance ?: synchronized(this) { instance ?: buildDatabase(context).also { instance = it } } } - // Create and pre-populate the database. See this article for more details: - // https://medium.com/google-developers/7-pro-tips-for-room-fbadea4bfbd1#4785 + /** + * build teh database + */ private fun buildDatabase(context: Context): AppDatabase { return Room.databaseBuilder(context, AppDatabase::class.java, DATABASE_NAME) -// .addCallback(object : Callback() { -// override fun onCreate(db: SupportSQLiteDatabase) { -// super.onCreate(db) -// // moving to a new thread -// Executors.newSingleThreadExecutor().execute { -// getInstance(context).thingDao() -// .insert(PREPOPULATE_DATA) -// } -// } -// }) +// .addMigrations(MIGRATION_2_3) .build() } } diff --git a/charts/consumer-rules.pro b/app/src/main/java/com/dzeio/openhealth/data/converters/.gitkeep similarity index 100% rename from charts/consumer-rules.pro rename to app/src/main/java/com/dzeio/openhealth/data/converters/.gitkeep diff --git a/app/src/main/java/com/dzeio/openhealth/data/converters/OffsetDateTimeConverter.kt b/app/src/main/java/com/dzeio/openhealth/data/converters/OffsetDateTimeConverter.kt deleted file mode 100644 index ca107c2..0000000 --- a/app/src/main/java/com/dzeio/openhealth/data/converters/OffsetDateTimeConverter.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.dzeio.openhealth.data.converters - -import androidx.room.TypeConverter -import java.time.OffsetDateTime -import java.time.format.DateTimeFormatter - -object TiviTypeConverters { - private val formatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME - - @TypeConverter - @JvmStatic - fun toOffsetDateTime(value: String?): OffsetDateTime? { - return value?.let { - return formatter.parse(value, OffsetDateTime::from) - } - } - - @TypeConverter - @JvmStatic - fun fromOffsetDateTime(date: OffsetDateTime?): String? { - return date?.format(formatter) - } -} diff --git a/app/src/main/java/com/dzeio/openhealth/data/food/Food.kt b/app/src/main/java/com/dzeio/openhealth/data/food/Food.kt new file mode 100644 index 0000000..c91cba2 --- /dev/null +++ b/app/src/main/java/com/dzeio/openhealth/data/food/Food.kt @@ -0,0 +1,107 @@ +package com.dzeio.openhealth.data.food + +import android.util.Log +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.dzeio.openhealth.data.openfoodfact.OFFProduct +import java.util.Calendar +import java.util.TimeZone + +@Entity +data class Food( + @PrimaryKey(autoGenerate = true) + var id: Long = 0, + + /** + * The product name + */ + var name: String, + + /** + * The product serving text + * + * ex: `250ml`, `520g`, etc + */ + var serving: String, + + /** + * the quantity taken by the user + */ + var quantity: Float, + + /** + * the quantity of proteins there is for 100 quantity + */ + var proteins: Float, + + /** + * the quantity of carbohydrates there is for 100 quantity + */ + var carbohydrates: Float, + + /** + * the quantity of fat there is for 100 quantity + */ + var fat: Float, + + /** + * the quantity of energy there is for 100 quantity + */ + var energy: Float, + + /** + * the url of the image of the product + */ + var image: String?, + + /** + * When the entry was added to our Database + */ + var timestamp: Long = Calendar.getInstance(TimeZone.getTimeZone("UTC")).timeInMillis +) { + companion object { + + /** + * Transform an OpenFoodFact product to use for our Database + */ + fun fromOpenFoodFact(food: OFFProduct, quantity: Float? = null): Food? { + // filter out foods that we can't use in the app + if ( + food.nutriments == null || + food.name == null || + ((food.servingSize == null || food.servingSize == "") && (food.quantity == null || food.quantity == "") && food.servingQuantity == null && food.productQuantity == null) + ) { + return null + } + + // try to know how much was eaten by the user if not said + var eaten = quantity ?: food.servingQuantity ?: food.productQuantity ?: 0f + if (eaten == 0f) { + if (food.servingQuantity != null && food.servingQuantity != 0f) { + eaten = food.servingQuantity!! + } else if (food.productQuantity != null && food.productQuantity != 0f) { + eaten = food.productQuantity!! + } else if (food.servingSize != null || food.quantity != null) { + eaten = (food.servingSize ?: food.quantity)!!.trim().replace( + Regex(" +\\w+$"), + "" + ).toInt().toFloat() + } + } + Log.d("Food", "$food") + return Food( + name = food.name!!, + // do some slight edit on the serving to remove strange entries like `100 g` + serving = (food.servingSize ?: food.quantity ?: "unknown").replace(Regex(" +"), ""), + quantity = eaten, + proteins = food.nutriments!!.proteins, + carbohydrates = food.nutriments!!.carbohydrates, + fat = food.nutriments!!.fat, + // handle case where the energy is not given in kcal but only in kj + energy = food.nutriments!!.energy + ?: (food.nutriments!!.energyKJ * 0.2390057361).toFloat(), + image = food.image + ) + } + } +} diff --git a/app/src/main/java/com/dzeio/openhealth/data/food/FoodDao.kt b/app/src/main/java/com/dzeio/openhealth/data/food/FoodDao.kt new file mode 100644 index 0000000..cfd0759 --- /dev/null +++ b/app/src/main/java/com/dzeio/openhealth/data/food/FoodDao.kt @@ -0,0 +1,21 @@ +package com.dzeio.openhealth.data.food + +import androidx.room.Dao +import androidx.room.Query +import com.dzeio.openhealth.core.BaseDao +import kotlinx.coroutines.flow.Flow + +@Dao +interface FoodDao : BaseDao { + @Query("SELECT * FROM Food ORDER BY timestamp DESC") + fun getAll(): Flow> + + @Query("SELECT * FROM Food where id = :weightId") + fun getOne(weightId: Long): Flow + + @Query("Select count(*) from Food") + fun getCount(): Flow + + @Query("Select * FROM Food ORDER BY timestamp DESC LIMIT 1") + fun last(): Flow +} diff --git a/app/src/main/java/com/dzeio/openhealth/data/food/FoodRepository.kt b/app/src/main/java/com/dzeio/openhealth/data/food/FoodRepository.kt new file mode 100644 index 0000000..d0e460a --- /dev/null +++ b/app/src/main/java/com/dzeio/openhealth/data/food/FoodRepository.kt @@ -0,0 +1,84 @@ +package com.dzeio.openhealth.data.food + +import com.dzeio.openhealth.data.openfoodfact.OpenFoodFactService +import com.dzeio.openhealth.utils.NetworkResult +import java.io.IOException +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +class FoodRepository @Inject constructor( + private val dao: FoodDao, + private val offSource: OpenFoodFactService +) { + + suspend fun searchFood(name: String): Flow>> = channelFlow { + val result = NetworkResult>() + val items = arrayListOf() + var otherFinished = false + + launch { // Search OFF + try { + val request = offSource.searchProducts(name) + if (!request.isSuccessful) { + if (otherFinished) { + result.status = NetworkResult.NetworkStatus.ERRORED + } else { + otherFinished = true + } + send(result) + return@launch + } + val offProducts = + offSource.searchProducts(name) + .body()?.products?.map { Food.fromOpenFoodFact(it) } + + if (offProducts != null) { + items.addAll(offProducts.filterNotNull()) + result.data = items + if (otherFinished) { + result.status = NetworkResult.NetworkStatus.FINISHED + } else { + otherFinished = true + } + send(result) + } + } catch (e: IOException) { + if (otherFinished) { + result.status = NetworkResult.NetworkStatus.ERRORED + } else { + otherFinished = true + } + send(result) + } + } + + launch { // search local DB + getAll().collectLatest { list -> + val filtered = list.filter { it.name.contains(name, true) } + items.removeAll { it.id > 0 } + items.addAll(0, filtered) + + result.data = items + if (otherFinished) { + result.status = NetworkResult.NetworkStatus.FINISHED + } else { + otherFinished = true + } + send(result) + } + } + } + + fun getAll() = dao.getAll() + + suspend fun add(food: Food) = dao.insert(food) + + fun getById(id: Long) = dao.getOne(id) + + suspend fun delete(food: Food) = dao.delete(food) + + suspend fun update(food: Food) = dao.update(food) +} diff --git a/app/src/main/java/com/dzeio/openhealth/data/openfoodfact/OFFNutriments.kt b/app/src/main/java/com/dzeio/openhealth/data/openfoodfact/OFFNutriments.kt new file mode 100644 index 0000000..9538c36 --- /dev/null +++ b/app/src/main/java/com/dzeio/openhealth/data/openfoodfact/OFFNutriments.kt @@ -0,0 +1,35 @@ +package com.dzeio.openhealth.data.openfoodfact + +import com.google.gson.annotations.SerializedName + +data class OFFNutriments( + /** + * the quantity of carbohydrates in a 100g serving + */ + @SerializedName("carbohydrates_100g") + var carbohydrates: Float, + + /** + * the energy in kcal in a 100g serving + */ + @SerializedName("energy-kcal_100g") + var energy: Float?, + + /** + * the energy KL in a 100g serving + */ + @SerializedName("energy-kj_100g") + var energyKJ: Float, + + /** + * the quantity of fat in a 100g serving + */ + @SerializedName("fat_100g") + var fat: Float, + + /** + * the quantity of proteins in a 100g serving + */ + @SerializedName("proteins_100g") + var proteins: Float +) diff --git a/app/src/main/java/com/dzeio/openhealth/data/openfoodfact/OFFProduct.kt b/app/src/main/java/com/dzeio/openhealth/data/openfoodfact/OFFProduct.kt new file mode 100644 index 0000000..14c1370 --- /dev/null +++ b/app/src/main/java/com/dzeio/openhealth/data/openfoodfact/OFFProduct.kt @@ -0,0 +1,53 @@ +package com.dzeio.openhealth.data.openfoodfact + +import com.google.gson.annotations.SerializedName + +data class OFFProduct( + /** + * the OFF product id + */ + @SerializedName("_id") + var id: String, + + /** + * the product name + */ + @SerializedName("product_name") + var name: String?, + + /** + * the size of a serving + */ + @SerializedName("serving_size") + var servingSize: String?, + + /** + * the size of a serving without the `g`, `ml`, etc + */ + @SerializedName("serving_quantity") + var servingQuantity: Float?, + + /** + * the size of a serving + */ + @SerializedName("quantity") + var quantity: String?, + + /** + * the size of a serving without the `g`, `ml`, etc + */ + @SerializedName("product_quantity") + var productQuantity: Float?, + + /** + * the product nutriments + */ + @SerializedName("nutriments") + var nutriments: OFFNutriments?, + + /** + * the product image + */ + @SerializedName("image_url") + var image: String? +) diff --git a/app/src/main/java/com/dzeio/openhealth/data/openfoodfact/OFFResult.kt b/app/src/main/java/com/dzeio/openhealth/data/openfoodfact/OFFResult.kt new file mode 100644 index 0000000..3ceb26d --- /dev/null +++ b/app/src/main/java/com/dzeio/openhealth/data/openfoodfact/OFFResult.kt @@ -0,0 +1,11 @@ +package com.dzeio.openhealth.data.openfoodfact + +import com.google.gson.annotations.SerializedName + +data class OFFResult( + /** + * the list of products + */ + @SerializedName("products") + var products: List +) diff --git a/app/src/main/java/com/dzeio/openhealth/data/openfoodfact/OpenFoodFactService.kt b/app/src/main/java/com/dzeio/openhealth/data/openfoodfact/OpenFoodFactService.kt new file mode 100644 index 0000000..bcbe0cb --- /dev/null +++ b/app/src/main/java/com/dzeio/openhealth/data/openfoodfact/OpenFoodFactService.kt @@ -0,0 +1,53 @@ +package com.dzeio.openhealth.data.openfoodfact + +import com.dzeio.openhealth.BuildConfig +import com.google.gson.GsonBuilder +import retrofit2.Response +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import retrofit2.http.GET +import retrofit2.http.Headers +import retrofit2.http.Query + +interface OpenFoodFactService { + + companion object { + fun getService(): OpenFoodFactService { +// val interceptor = HttpLoggingInterceptor() +// interceptor.setLevel(HttpLoggingInterceptor.Level.BODY) +// val client = OkHttpClient.Builder() +// .addInterceptor(interceptor) +// .build() + val gson = GsonBuilder() + .setLenient() + .create() + val retrofit = Retrofit.Builder() + .baseUrl("https://world.openfoodfacts.org/") + .addConverterFactory(GsonConverterFactory.create(gson)) +// .client(client) + .build() + + return retrofit.create(OpenFoodFactService::class.java) + } + } + + /** + * Search a product by it's name + */ + @Headers( + "User-Agent: OpenHealth - Android - Version ${BuildConfig.VERSION_NAME} - https://github.com/dzeiocom/OpenHealth" + ) + @GET( + "/cgi/search.pl?json=true&fields=_id,nutriments,product_name,serving_quantity,serving_size,quantity,product_quantity,image_url&action=process" + ) + suspend fun searchProducts(@Query("search_terms2") name: String): Response + + /** + * Search a product by it's barcode + */ + @Headers( + "User-Agent: OpenHealth - Android - Version ${BuildConfig.VERSION_NAME} - https://github.com/dzeiocom/OpenHealth" + ) + @GET("/api/v2/search?fields=_id,nutriments,product_name,serving_quantity") + suspend fun findByCode(@Query("code") code: String): Response +} diff --git a/app/src/main/java/com/dzeio/openhealth/data/step/Step.kt b/app/src/main/java/com/dzeio/openhealth/data/step/Step.kt index 1c4f78c..115fc4d 100644 --- a/app/src/main/java/com/dzeio/openhealth/data/step/Step.kt +++ b/app/src/main/java/com/dzeio/openhealth/data/step/Step.kt @@ -12,6 +12,10 @@ import java.util.TimeZone @Entity() data class Step( @PrimaryKey(autoGenerate = true) var id: Long = 0, + + /** + * the raw number of step + */ var value: Int = 0, /** * Timestamp down to an hour @@ -20,6 +24,12 @@ data class Step( */ @ColumnInfo(index = true) var timestamp: Long = 0, + + /** + * the source for the Entry + * + * note: Unused currently but kept for future usage + */ var source: String = "OpenHealth" ) { @@ -34,19 +44,24 @@ data class Step( } } - fun formatTimestamp(): String { - val formatter = DateFormat.getDateTimeInstance( - DateFormat.SHORT, - DateFormat.SHORT, - Locale.getDefault() - ) + fun formatTimestamp(removeTime: Boolean = false): String { + val formatter = if (removeTime) { + DateFormat.getDateInstance( + DateFormat.SHORT, + Locale.getDefault() + ) + } else { + DateFormat.getDateTimeInstance( + DateFormat.SHORT, + DateFormat.SHORT, + Locale.getDefault() + ) + } return formatter.format(Date(this.timestamp)) } fun isToday(): Boolean { - val it = Calendar.getInstance(TimeZone.getTimeZone("UTC")) - it.timeInMillis = timestamp - it.set(Calendar.HOUR, 0) + val it = getDay() val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC")) @@ -54,7 +69,17 @@ data class Step( cal.set(Calendar.MINUTE, 0) cal.set(Calendar.SECOND, 0) cal.set(Calendar.MILLISECOND, 0) - return it.timeInMillis == cal.timeInMillis + return it == cal.timeInMillis + } + + fun getDay(): Long { + val it = Calendar.getInstance(TimeZone.getTimeZone("UTC")).apply { + timeInMillis = timestamp + set(Calendar.HOUR, 0) + + set(Calendar.AM_PM, Calendar.AM) + } + return it.timeInMillis } fun isCurrent(): Boolean { diff --git a/app/src/main/java/com/dzeio/openhealth/data/step/StepDao.kt b/app/src/main/java/com/dzeio/openhealth/data/step/StepDao.kt index ecdab8d..467f8a9 100644 --- a/app/src/main/java/com/dzeio/openhealth/data/step/StepDao.kt +++ b/app/src/main/java/com/dzeio/openhealth/data/step/StepDao.kt @@ -11,13 +11,16 @@ interface StepDao : BaseDao { @Query("SELECT * FROM Step ORDER BY timestamp DESC") fun getAll(): Flow> - @Query("SELECT * FROM Step where id = :weightId") + @Query("SELECT * FROM Step WHERE timestamp >= :time") + fun getAfter(time: Long): Flow> + + @Query("SELECT * FROM Step WHERE id = :weightId") fun getOne(weightId: Long): Flow - @Query("Select count(*) from Step") + @Query("SELECT count(*) FROM Step") fun getCount(): Flow - @Query("Select * FROM Step ORDER BY timestamp DESC LIMIT 1") + @Query("SELECT * FROM Step ORDER BY timestamp DESC LIMIT 1") fun last(): Flow @Query("DELETE FROM Step where source = :source") diff --git a/app/src/main/java/com/dzeio/openhealth/data/step/StepRepository.kt b/app/src/main/java/com/dzeio/openhealth/data/step/StepRepository.kt index aca13bd..ff9fd0c 100644 --- a/app/src/main/java/com/dzeio/openhealth/data/step/StepRepository.kt +++ b/app/src/main/java/com/dzeio/openhealth/data/step/StepRepository.kt @@ -1,10 +1,11 @@ package com.dzeio.openhealth.data.step -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.withTimeoutOrNull +import java.util.Calendar +import java.util.TimeZone import javax.inject.Inject import javax.inject.Singleton +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.firstOrNull @Singleton class StepRepository @Inject constructor( @@ -23,7 +24,13 @@ class StepRepository @Inject constructor( suspend fun deleteFromSource(value: String) = stepDao.deleteFromSource(value) suspend fun todaySteps(): Int { - val steps = getSteps().firstOrNull() + val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC")) + + cal.set(Calendar.HOUR, 0) + cal.set(Calendar.MINUTE, 0) + cal.set(Calendar.SECOND, 0) + cal.set(Calendar.MILLISECOND, 0) + val steps = stepDao.getAfter(cal.timeInMillis).firstOrNull() if (steps == null) { return 0 } @@ -39,5 +46,4 @@ class StepRepository @Inject constructor( fun currentStep() = lastStep().filter { return@filter it != null && it.isCurrent() } - } diff --git a/app/src/main/java/com/dzeio/openhealth/data/step/StepSource.kt b/app/src/main/java/com/dzeio/openhealth/data/step/StepSource.kt index 5812741..d407ae5 100644 --- a/app/src/main/java/com/dzeio/openhealth/data/step/StepSource.kt +++ b/app/src/main/java/com/dzeio/openhealth/data/step/StepSource.kt @@ -7,15 +7,21 @@ import android.hardware.SensorEventListener import android.hardware.SensorManager import android.os.SystemClock import android.util.Log +import androidx.core.content.edit import androidx.preference.PreferenceManager import com.dzeio.openhealth.Application import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.runBlocking +/** + * Class that allows us to get Sensor datas for the internal step counter + * + * TODO: rewrite to use the new libs + */ class StepSource( - private val context: Context, + context: Context, private val callback: ((Float) -> Unit)? = null -): SensorEventListener { +) : SensorEventListener { companion object { const val TAG = "${Application.TAG}/StepSource" @@ -28,27 +34,31 @@ class StepSource( return prefs.getLong("steps_time_since_last_record", Long.MAX_VALUE) } set(value) { - val editor = prefs.edit() - editor.putLong("steps_time_since_last_record", value) - editor.commit() + prefs.edit { + putLong("steps_time_since_last_record", value) + } } private var stepsAsOfLastRecord: Float get() { return prefs.getFloat("steps_as_of_last_record", 0f) } set(value) { - val editor = prefs.edit() - editor.putFloat("steps_as_of_last_record", value) - editor.commit() + prefs.edit { + putFloat("steps_as_of_last_record", value) + } } - init { Log.d(TAG, "Setting up") val sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager val stepCountSensor = sensorManager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER) stepCountSensor.let { - sensorManager.registerListener(this, it, SensorManager.SENSOR_DELAY_NORMAL, SensorManager.SENSOR_DELAY_NORMAL) + sensorManager.registerListener( + this, + it, + SensorManager.SENSOR_DELAY_NORMAL, + SensorManager.SENSOR_DELAY_NORMAL + ) Log.d(TAG, "should be setup :D") } } @@ -68,7 +78,10 @@ class StepSource( // don't send changes since it wasn't made when the app was running if (timeSinceLastBoot < timeSinceLastRecord) { - Log.d(TAG, "Skipping since we don't know when many steps are taken since last boot ($timeSinceLastRecord, $timeSinceLastBoot)") + Log.d( + TAG, + "Skipping since we don't know when many steps are taken since last boot ($timeSinceLastRecord, $timeSinceLastBoot)" + ) timeSinceLastRecord = timeSinceLastBoot return@let } diff --git a/app/src/main/java/com/dzeio/openhealth/data/water/Water.kt b/app/src/main/java/com/dzeio/openhealth/data/water/Water.kt index 2cbf4ae..02d6f57 100644 --- a/app/src/main/java/com/dzeio/openhealth/data/water/Water.kt +++ b/app/src/main/java/com/dzeio/openhealth/data/water/Water.kt @@ -11,9 +11,23 @@ import java.util.TimeZone @Entity() data class Water( @PrimaryKey(autoGenerate = true) var id: Long = 0, + + /** + * the quantity of water in ML drank by the user + */ var value: Int = 0, + + /** + * when the water was drank precise to the day + */ @ColumnInfo(index = true) var timestamp: Long = 0, + + /** + * the source for the Entry + * + * note: Unused currently but kept for future usage + */ var source: String = "OpenHealth" ) { init { diff --git a/app/src/main/java/com/dzeio/openhealth/data/water/WaterRepository.kt b/app/src/main/java/com/dzeio/openhealth/data/water/WaterRepository.kt index 33a7f77..6265d81 100644 --- a/app/src/main/java/com/dzeio/openhealth/data/water/WaterRepository.kt +++ b/app/src/main/java/com/dzeio/openhealth/data/water/WaterRepository.kt @@ -1,10 +1,8 @@ package com.dzeio.openhealth.data.water -import android.util.Log -import kotlinx.coroutines.flow.* -import java.util.* import javax.inject.Inject import javax.inject.Singleton +import kotlinx.coroutines.flow.filter @Singleton class WaterRepository @Inject constructor( @@ -23,4 +21,4 @@ class WaterRepository @Inject constructor( fun todayWater() = lastWater().filter { return@filter it != null && it.isToday() } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/dzeio/openhealth/data/weight/Weight.kt b/app/src/main/java/com/dzeio/openhealth/data/weight/Weight.kt index ed4c280..b00c9a7 100644 --- a/app/src/main/java/com/dzeio/openhealth/data/weight/Weight.kt +++ b/app/src/main/java/com/dzeio/openhealth/data/weight/Weight.kt @@ -14,9 +14,77 @@ data class Weight( * Store the weight in kilograms */ var weight: Float = 0f, + + /** + * when the weight was taken precise to the millisecond + */ @ColumnInfo(index = true) var timestamp: Long = System.currentTimeMillis(), - var source: String = "" + + /** + * the source for the Entry + * + * note: Unused currently but kept for future usage + */ + var source: String = "OpenHealth", + + /** + * The BMI + * + * calculated from the height and Weight of the user + * + * ex: weight / (height(cm)/100)² + */ + var bmi: Float? = null, + + /** + * the total body water (tbw) in percents + * + * can be estimated + * https://www.mdapp.co/total-body-water-tbw-calculator-448/ + */ + var totalBodyWater: Float? = null, + + /** + * the Muscle weight in percents + */ + var muscles: Float? = null, + + /** + * the lean Body Mass in percents + */ + var leanBodyMass: Float? = null, + + /** + * the Body Fat in percents + */ + var bodyFat: Float? = null, + + /** + * the bone Mass in Percents + */ + var boneMass: Float? = null, + + /** + * visceral fat in it's own unit? + */ + var visceralFat: Float? = null ) { - fun formatTimestamp(): String = getDateInstance().format(Date(timestamp)); + fun formatTimestamp(): String = getDateInstance().format(Date(timestamp)) + + override fun equals(other: Any?): Boolean { + if (!(other is Weight)) { + return super.equals(other) + } + + return weight == other.weight && + timestamp == other.timestamp && + bmi == other.bmi && + totalBodyWater == other.totalBodyWater && + muscles == other.muscles && + leanBodyMass == other.leanBodyMass && + bodyFat == other.bodyFat && + boneMass == other.boneMass && + visceralFat == other.visceralFat + } } diff --git a/app/src/main/java/com/dzeio/openhealth/data/weight/WeightDao.kt b/app/src/main/java/com/dzeio/openhealth/data/weight/WeightDao.kt index 9423bc9..c8fcea4 100644 --- a/app/src/main/java/com/dzeio/openhealth/data/weight/WeightDao.kt +++ b/app/src/main/java/com/dzeio/openhealth/data/weight/WeightDao.kt @@ -22,4 +22,4 @@ interface WeightDao : BaseDao { @Query("DELETE FROM Weight where source = :source") suspend fun deleteFromSource(source: String) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/dzeio/openhealth/data/weight/WeightRepository.kt b/app/src/main/java/com/dzeio/openhealth/data/weight/WeightRepository.kt index f17a720..ae60eb2 100644 --- a/app/src/main/java/com/dzeio/openhealth/data/weight/WeightRepository.kt +++ b/app/src/main/java/com/dzeio/openhealth/data/weight/WeightRepository.kt @@ -14,6 +14,7 @@ class WeightRepository @Inject constructor( fun getWeight(id: Long) = weightDao.getOne(id) suspend fun addWeight(weight: Weight) = weightDao.insert(weight) - suspend fun deleteWeight(weight: Weight) = weightDao.delete(weight) + suspend fun addAll(vararg weight: Weight) = weightDao.insertAll(*weight) + suspend fun deleteWeight(vararg weight: Weight) = weightDao.delete(*weight) suspend fun deleteFromSource(source: String) = weightDao.deleteFromSource(source) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/dzeio/openhealth/devices/BluetoothLeGattDevice.kt b/app/src/main/java/com/dzeio/openhealth/devices/BluetoothLeGattDevice.kt new file mode 100644 index 0000000..96288e8 --- /dev/null +++ b/app/src/main/java/com/dzeio/openhealth/devices/BluetoothLeGattDevice.kt @@ -0,0 +1,292 @@ +package com.dzeio.openhealth.devices + +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGattCallback +import android.bluetooth.BluetoothGattCharacteristic +import android.bluetooth.BluetoothGattDescriptor +import android.bluetooth.BluetoothGattService +import android.bluetooth.BluetoothProfile +import android.util.Log +import androidx.annotation.RequiresPermission +import com.dzeio.openhealth.core.Observable +import com.dzeio.openhealth.utils.Bluetooth +import com.dzeio.openhealth.utils.Configuration +import com.dzeio.openhealth.utils.polyfills.writeCharacteristicPoly +import com.dzeio.openhealth.utils.polyfills.writeDescriptorPoly +import java.util.UUID + +abstract class BluetoothLeGattDevice( + var bluetooth: Bluetooth, + config: Configuration +) : Device(config) { + + private val status = Observable(ConnectionStatus.DISCONNECTED) + + companion object { + + @RequiresPermission(value = "android.permission.BLUETOOTH_SCAN") + fun findDevices( + bluetooth: Bluetooth, + config: Configuration + ): Observable?> { + val list = arrayListOf() + val obs = Observable?>(null) + val devices = DeviceFactory.getBluetoothLEDevices(bluetooth, config) + + bluetooth.scanLeDevices { + if (list.find { item -> item.item?.address == it.address } != null) { + return@scanLeDevices false + } + for (device in devices) { + if (device.isOfType(it)) { + device.item = it + list.add(device) + obs.value = list + } + } + return@scanLeDevices false + } + return obs + } + } + + protected val services = arrayListOf() + protected lateinit var gatt: BluetoothGatt + + private val gattCallback = ConnectionCallback() + + override fun search(): Observable { + TODO("Not yet implemented") + } + + override fun connect(): Observable { + if (item == null) { + status.value = ConnectionStatus.ERROR + return status + } + status.value = ConnectionStatus.CONNECTING + + bluetooth.connectGatt(item!!, true, gattCallback) + return status + } + + @RequiresPermission(value = "android.permission.BLUETOOTH_CONNECT") + fun close() { + gatt.close() + } + + @RequiresPermission(value = "android.permission.BLUETOOTH_CONNECT") + protected fun writeBytes(service: UUID, characteristic: UUID, value: ByteArray) { + val t = findCharacteristic(service, characteristic) + if (t == null) { + Log.e("BluetoothLeDevice", "Could not write characteristic") + return + } + + gatt.writeCharacteristicPoly( + t, + value, + BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT + ) + } + + @RequiresPermission(value = "android.permission.BLUETOOTH_CONNECT") + protected fun setNotification(service: UUID, characteristic: UUID, value: Boolean): Boolean { + val char = findCharacteristic(service, characteristic) + if (char == null) { + Log.e("BluetoothLeDevice", "Could not set notification on characteristic") + return false + } + gatt.setCharacteristicNotification( + char, + value + ) + gatt.writeDescriptorPoly( + char.getDescriptor( + BluetoothLeGattUuid.DESCRIPTOR_CLIENT_CHARACTERISTIC_CONFIGURATION + ), + BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE + ) + return true + } + + private fun findCharacteristic( + service: UUID, + characteristic: UUID + ): BluetoothGattCharacteristic? { + return gatt.services.find { it.uuid == service }?.getCharacteristic(characteristic) + } + + open fun onPhyUpdate(gatt: BluetoothGatt?, txPhy: Int, rxPhy: Int, status: Int) {} + + open fun onPhyRead(gatt: BluetoothGatt?, txPhy: Int, rxPhy: Int, status: Int) {} + + open fun onConnectionStateChange(gatt: BluetoothGatt?, status: Int, newState: Int) {} + + open fun onServicesDiscovered(gatt: BluetoothGatt?, status: Int) {} + + open fun onCharacteristicRead( + gatt: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + value: ByteArray, + status: Int + ) { + } + + open fun onCharacteristicWrite( + gatt: BluetoothGatt?, + characteristic: BluetoothGattCharacteristic?, + status: Int + ) { + } + + open fun onCharacteristicChanged( + gatt: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + value: ByteArray + ) { + } + + open fun onDescriptorRead( + gatt: BluetoothGatt, + descriptor: BluetoothGattDescriptor, + status: Int, + value: ByteArray + ) { + } + + open fun onDescriptorWrite( + gatt: BluetoothGatt?, + descriptor: BluetoothGattDescriptor?, + status: Int + ) { + } + + open fun onReliableWriteCompleted(gatt: BluetoothGatt?, status: Int) {} + + open fun onReadRemoteRssi(gatt: BluetoothGatt?, rssi: Int, status: Int) {} + + open fun onMtuChanged(gatt: BluetoothGatt?, mtu: Int, status: Int) {} + + open fun onServiceChanged(gatt: BluetoothGatt) {} + + private inner class ConnectionCallback : BluetoothGattCallback() { + override fun onPhyUpdate( + gatt: BluetoothGatt?, + txPhy: Int, + rxPhy: Int, + status: Int + ) { + Log.d("onPhyUpdate", "$gatt, $txPhy, $rxPhy, $status") + this@BluetoothLeGattDevice.onPhyUpdate(gatt, txPhy, rxPhy, status) + } + + override fun onPhyRead( + gatt: BluetoothGatt?, + txPhy: Int, + rxPhy: Int, + status: Int + ) { + Log.d("onPhyRead", "$gatt, $txPhy, $rxPhy, $status") + this@BluetoothLeGattDevice.onPhyRead(gatt, txPhy, rxPhy, status) + } + + @RequiresPermission("android.permission.BLUETOOTH_CONNECT") + override fun onConnectionStateChange( + gatt: BluetoothGatt?, + status: Int, + newState: Int + ) { + Log.d("onConnectionStateChange", "$gatt, $status, $newState") + if (gatt != null && newState == BluetoothProfile.STATE_CONNECTED) { + this@BluetoothLeGattDevice.gatt = gatt + gatt.discoverServices() + } + this@BluetoothLeGattDevice.onConnectionStateChange(gatt, status, newState) + } + + override fun onServicesDiscovered(gatt: BluetoothGatt?, status: Int) { + Log.d("onServicesDiscovered", "$gatt, $status") + if (gatt != null) { + this@BluetoothLeGattDevice.status.value = ConnectionStatus.CONNECTED + services.addAll(gatt.services) + } + this@BluetoothLeGattDevice.onServicesDiscovered(gatt, status) + } + + override fun onCharacteristicRead( + gatt: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + value: ByteArray, + status: Int + ) { + Log.d("onCharacteristicRead", "$gatt, $characteristic, $status, $value") + this@BluetoothLeGattDevice.onCharacteristicRead(gatt, characteristic, value, status) + } + + override fun onCharacteristicWrite( + gatt: BluetoothGatt?, + characteristic: BluetoothGattCharacteristic?, + status: Int + ) { + Log.d("onCharacteristicWrite", "$gatt, $characteristic, $status") + this@BluetoothLeGattDevice.onCharacteristicWrite(gatt, characteristic, status) + } + + override fun onCharacteristicChanged( + gatt: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + value: ByteArray + ) { + Log.d("onCharacteristicChanged", "$gatt, $characteristic, ${byteArrayToHex(value)}") + this@BluetoothLeGattDevice.onCharacteristicChanged(gatt, characteristic, value) + } + + override fun onDescriptorRead( + gatt: BluetoothGatt, + descriptor: BluetoothGattDescriptor, + status: Int, + value: ByteArray + ) { + Log.d("onDescriptorRead", "$gatt, $descriptor, $status, $value") + this@BluetoothLeGattDevice.onDescriptorRead(gatt, descriptor, status, value) + } + + override fun onDescriptorWrite( + gatt: BluetoothGatt?, + descriptor: BluetoothGattDescriptor?, + status: Int + ) { + Log.d("onDescriptorWrite", "$gatt, $descriptor, $status") + this@BluetoothLeGattDevice.onDescriptorWrite(gatt, descriptor, status) + } + + override fun onReliableWriteCompleted(gatt: BluetoothGatt?, status: Int) { + Log.d("onReliableWriteComplete", "$gatt, $status") + this@BluetoothLeGattDevice.onReliableWriteCompleted(gatt, status) + } + + override fun onReadRemoteRssi( + gatt: BluetoothGatt?, + rssi: Int, + status: Int + ) { + Log.d("onReadRemoteRssi", "$gatt, $rssi, $status") + this@BluetoothLeGattDevice.onReadRemoteRssi(gatt, rssi, status) + } + + override fun onMtuChanged(gatt: BluetoothGatt?, mtu: Int, status: Int) { + Log.d("onMtuChanged", "$gatt, $mtu, $status") + this@BluetoothLeGattDevice.onMtuChanged(gatt, mtu, status) + } + + override fun onServiceChanged(gatt: BluetoothGatt) { + Log.d("onServiceChanged", "$gatt") + this@BluetoothLeGattDevice.onServiceChanged(gatt) + } + } + + private fun byteArrayToHex(arr: ByteArray): String = + arr.joinToString(" ") { String.format("%02X", it) } +} diff --git a/app/src/main/java/com/dzeio/openhealth/devices/BluetoothLeGattUuid.kt b/app/src/main/java/com/dzeio/openhealth/devices/BluetoothLeGattUuid.kt new file mode 100644 index 0000000..599057d --- /dev/null +++ b/app/src/main/java/com/dzeio/openhealth/devices/BluetoothLeGattUuid.kt @@ -0,0 +1,57 @@ +package com.dzeio.openhealth.devices + +import java.util.UUID + +object BluetoothLeGattUuid { + private const val STANDARD_SUFFIX = "-0000-1000-8000-00805f9b34fb" + fun fromShortCode(code: Long): UUID { + return UUID.fromString(String.format("%08x%s", code, STANDARD_SUFFIX)) + } + + // https://www.bluetooth.com/specifications/gatt/services + val SERVICE_GENERIC_ACCESS = fromShortCode(0x1800) // 1800 + val SERVICE_GENERIC_ATTRIBUTE = fromShortCode(0x1801) // 1801 + + val SERVICE_CURRENT_TIME = fromShortCode(0x1805) + val SERVICE_DEVICE_INFORMATION = fromShortCode(0x180A) + val SERVICE_BATTERY_LEVEL = fromShortCode(0x180F) + val SERVICE_BODY_COMPOSITION = fromShortCode(0x181B) // 181B + val SERVICE_USER_DATA = fromShortCode(0x181C) + val SERVICE_WEIGHT_SCALE = fromShortCode(0x181D) + + // https://www.bluetooth.com/specifications/gatt/characteristics + val CHARACTERISTIC_DEVICE_NAME = fromShortCode(0x2A00) + val CHARACTERISTIC_APPEARANCE = fromShortCode(0x2A01) + val CHARACTERISTIC_PERIPHERAL_PRIVACY_FLAG = fromShortCode(0x2A02) + val CHARACTERISTIC_RECONNECTION_ADDRESS = fromShortCode(0x2A03) + val CHARACTERISTIC_PERIPHERAL_PREFERRED_CONNECTION_PARAMETERS = fromShortCode(0x2A04) + val CHARACTERISTIC_SERVICE_CHANGED = fromShortCode(0x2A05) + + val CHARACTERISTIC_BATTERY_LEVEL = fromShortCode(0x2A19) + + val CHARACTERISTIC_SYSTEM_ID = fromShortCode(0x2A23) + val CHARACTERISTIC_MODEL_NUMBER_STRING = fromShortCode(0x2A24) + val CHARACTERISTIC_SERIAL_NUMBER_STRING = fromShortCode(0x2A25) + val CHARACTERISTIC_FIRMWARE_REVISION_STRING = fromShortCode(0x2A26) + val CHARACTERISTIC_HARDWARE_REVISION_STRING = fromShortCode(0x2A27) + val CHARACTERISTIC_SOFTWARE_REVISION_STRING = fromShortCode(0x2A28) + val CHARACTERISTIC_MANUFACTURER_NAME_STRING = fromShortCode(0x2A29) + val CHARACTERISTIC_IEEE_11073_20601_REGULATORY_CERTIFICATION_DATA_LIST = fromShortCode(0x2A2A) + val CHARACTERISTIC_CURRENT_TIME = fromShortCode(0x2A2B) + + val CHARACTERISTIC_PNP_ID = fromShortCode(0x2A50) + + val CHARACTERISTIC_USER_AGE = fromShortCode(0x2A80) + val CHARACTERISTIC_USER_DATE_OF_BIRTH = fromShortCode(0x2A85) + val CHARACTERISTIC_USER_GENDER = fromShortCode(0x2A8C) + val CHARACTERISTIC_USER_HEIGHT = fromShortCode(0x2A8E) + + val CHARACTERISTIC_CHANGE_INCREMENT = fromShortCode(0x2A99) + val CHARACTERISTIC_BODY_COMPOSITION_MEASUREMENT = fromShortCode(0x2A9C) + val CHARACTERISTIC_WEIGHT_MEASUREMENT = fromShortCode(0x2A9D) + val CHARACTERISTIC_USER_CONTROL_POINT = fromShortCode(0x2A9F) + + // https://www.bluetooth.com/specifications/gatt/descriptors + val DESCRIPTOR_CLIENT_CHARACTERISTIC_CONFIGURATION = fromShortCode(0x2902) + val DESCRIPTOR_CHARACTERISTIC_USER_DESCRIPTION = fromShortCode(0x2901) +} diff --git a/app/src/main/java/com/dzeio/openhealth/devices/Device.kt b/app/src/main/java/com/dzeio/openhealth/devices/Device.kt new file mode 100644 index 0000000..6a4f8e0 --- /dev/null +++ b/app/src/main/java/com/dzeio/openhealth/devices/Device.kt @@ -0,0 +1,38 @@ +package com.dzeio.openhealth.devices + +import com.dzeio.openhealth.core.Observable +import com.dzeio.openhealth.data.weight.Weight +import com.dzeio.openhealth.utils.Configuration + +abstract class Device( + val config: Configuration +) { + + abstract val name: String + var item: T? = null + + enum class ConnectionStatus { + DISCONNECTED, + CONNECTING, + CONNECTED, + ERROR + } + + data class FetchStatus( + var progress: Int, + var progressMax: Int, + val data: ArrayList = arrayListOf() + ) + +// enum class ActionStatus {} + + abstract fun isOfType(item: T): Boolean + + abstract fun search(): Observable + + abstract fun connect(): Observable + + abstract fun fetchWeights(): Observable + + abstract fun reset() +} diff --git a/app/src/main/java/com/dzeio/openhealth/devices/DeviceFactory.kt b/app/src/main/java/com/dzeio/openhealth/devices/DeviceFactory.kt new file mode 100644 index 0000000..13ed8b1 --- /dev/null +++ b/app/src/main/java/com/dzeio/openhealth/devices/DeviceFactory.kt @@ -0,0 +1,16 @@ +package com.dzeio.openhealth.devices + +import com.dzeio.openhealth.utils.Bluetooth +import com.dzeio.openhealth.utils.Configuration + +object DeviceFactory { + + fun getBluetoothLEDevices( + bluetooth: Bluetooth, + configuration: Configuration + ): ArrayList { + return arrayListOf( + DeviceMiSmartScale2(bluetooth, configuration) + ) + } +} diff --git a/app/src/main/java/com/dzeio/openhealth/devices/DeviceMiSmartScale2.kt b/app/src/main/java/com/dzeio/openhealth/devices/DeviceMiSmartScale2.kt new file mode 100644 index 0000000..5298f3c --- /dev/null +++ b/app/src/main/java/com/dzeio/openhealth/devices/DeviceMiSmartScale2.kt @@ -0,0 +1,297 @@ +package com.dzeio.openhealth.devices + +import android.Manifest +import android.annotation.SuppressLint +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGattCharacteristic +import android.bluetooth.BluetoothGattDescriptor +import android.util.Log +import androidx.annotation.RequiresPermission +import com.dzeio.openhealth.core.Observable +import com.dzeio.openhealth.data.weight.Weight +import com.dzeio.openhealth.devices.libs.MiScaleLib +import com.dzeio.openhealth.utils.Bluetooth +import com.dzeio.openhealth.utils.Configuration +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Random +import java.util.UUID + +class DeviceMiSmartScale2( + bluetooth: Bluetooth, + config: Configuration +) : BluetoothLeGattDevice(bluetooth, config) { + + companion object { + val TAG = this::class.java.name + } + + override val name = "Mi Smart Scale 2" + + private val WEIGHT_MEASUREMENT_HISTORY_CHARACTERISTIC = + UUID.fromString("00002a2f-0000-3512-2118-0009af100700") + private val WEIGHT_CUSTOM_SERVICE = UUID.fromString("00001530-0000-3512-2118-0009af100700") + private val WEIGHT_CUSTOM_CONFIG = UUID.fromString("00001542-0000-3512-2118-0009af100700") + + private val CONFIG_USER_ID = "com.dzeio.open-health.devices.mi-scale-2.id" + + private val fetchStatus = Observable(FetchStatus(0, 5)) + + @SuppressLint("MissingPermission") + override fun isOfType(item: BluetoothDevice): Boolean = + item.name == "MIBFS" + + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + override fun fetchWeights(): Observable { + fetchStatus.value = FetchStatus(0, 5) + // step 0 + writeBytes( + WEIGHT_CUSTOM_SERVICE, + WEIGHT_CUSTOM_CONFIG, + byteArrayOf( + 0x06, + 0x04, + 0x00, + 0x00 + ) + ) + + return fetchStatus + } + + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + override fun onCharacteristicWrite( + gatt: BluetoothGatt?, + characteristic: BluetoothGattCharacteristic?, + status: Int + ) { + if (characteristic?.uuid == WEIGHT_CUSTOM_CONFIG) { + // step 1 + // set current time + val currentDateTime: Calendar = Calendar.getInstance() + val year: Int = currentDateTime.get(Calendar.YEAR) + val month = (currentDateTime.get(Calendar.MONTH) + 1) + val day = currentDateTime.get(Calendar.DAY_OF_MONTH) + val hour = currentDateTime.get(Calendar.HOUR_OF_DAY) + val min = currentDateTime.get(Calendar.MINUTE) + val sec = currentDateTime.get(Calendar.SECOND) + + val dateTimeByte = byteArrayOf( + year.toByte(), + (year shr 8).toByte(), + month.toByte(), + day.toByte(), + hour.toByte(), + min.toByte(), + sec.toByte(), + 0x03, + 0x00, + 0x00 + ) + + writeBytes( + BluetoothLeGattUuid.SERVICE_BODY_COMPOSITION, + BluetoothLeGattUuid.CHARACTERISTIC_CURRENT_TIME, + dateTimeByte + ) + fetchStatus.value.progress++ + fetchStatus.notifyObservers() + } else if (characteristic?.uuid == BluetoothLeGattUuid.CHARACTERISTIC_CURRENT_TIME) { + // step 2 + setNotification( + BluetoothLeGattUuid.SERVICE_BODY_COMPOSITION, + WEIGHT_MEASUREMENT_HISTORY_CHARACTERISTIC, + true + ) + fetchStatus.value.progress++ + fetchStatus.notifyObservers() + } else if ( + fetchStatus.value.progress < 4 && + characteristic?.uuid == WEIGHT_MEASUREMENT_HISTORY_CHARACTERISTIC + ) { + // step 4 + writeBytes( + BluetoothLeGattUuid.SERVICE_BODY_COMPOSITION, + WEIGHT_MEASUREMENT_HISTORY_CHARACTERISTIC, + byteArrayOf( + 0x02 + ) + ) + fetchStatus.value.progress++ + fetchStatus.notifyObservers() + } + } + + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + override fun onDescriptorWrite( + gatt: BluetoothGatt?, + descriptor: BluetoothGattDescriptor?, + status: Int + ) { + // step 3 + super.onDescriptorWrite(gatt, descriptor, status) + val id = getID() + writeBytes( + BluetoothLeGattUuid.SERVICE_BODY_COMPOSITION, + WEIGHT_MEASUREMENT_HISTORY_CHARACTERISTIC, + byteArrayOf( + 0x01.toByte(), + 0xFF.toByte(), + 0xFF.toByte(), + (id and 0xFF00 shl 8).toByte(), + (id and 0x00FF shl 0).toByte() + ) + ) + fetchStatus.value.progress++ + fetchStatus.notifyObservers() + } + + @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) + override fun onCharacteristicChanged( + gatt: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + value: ByteArray + ) { + super.onCharacteristicChanged(gatt, characteristic, value) + + if (value.isNotEmpty()) { + // step 5+x + if (value[0] == 0x03.toByte()) { + Log.d(TAG, "Stop signal received") + writeBytes( + BluetoothLeGattUuid.SERVICE_BODY_COMPOSITION, + WEIGHT_MEASUREMENT_HISTORY_CHARACTERISTIC, + byteArrayOf(0x03) + ) + + val id = getID() + + val userIdentifier = byteArrayOf( + 0x04.toByte(), + 0xFF.toByte(), + 0xFF.toByte(), + (id and 0xFF00 shr 8).toByte(), + (id and 0xFF shr 0).toByte() + ) + writeBytes( + BluetoothLeGattUuid.SERVICE_BODY_COMPOSITION, + WEIGHT_MEASUREMENT_HISTORY_CHARACTERISTIC, + userIdentifier + ) + fetchStatus.value.progress++ + fetchStatus.notifyObservers() + } else if (value.size == 13) { + // 4+x + Log.d(TAG, "Measurement received") + val weight = decodeEntry(value) + if (weight == null) { + return + } + + fetchStatus.value.data.add(weight) + fetchStatus.value.progress++ + fetchStatus.value.progressMax++ + fetchStatus.notifyObservers() + } + } + } + + override fun reset() { + config.getInt(CONFIG_USER_ID).value = null + } + + private fun getID(): Int { + val id = config.getInt(CONFIG_USER_ID) + if (id.value == null) { + id.value = (Random().nextInt(65535 - 100 + 1) + 100) + } + return id.value!! + 1 + } + + /** + * Decode the 13 bytes entry into a [Weight] object + * + * control bytes + * XX XX 00 00 00 00 00 00 00 00 00 00 00 + * + * Datetime bytes + * 00 00 XX XX XX XX XX XX XX 00 00 00 00 + * + * Impedance Bytes + * 00 00 00 00 00 00 00 00 00 XX XX 00 00 + * + * Weight Bytes + * 00 00 00 00 00 00 00 00 00 00 00 XX XX + */ + @SuppressLint("SimpleDateFormat") + private fun decodeEntry(entry: ByteArray): Weight? { + // byte 0 + val ctrlByte0 = entry[0] + // byte 1 + val ctrlByte1 = entry[1] + + val isLBSUnit = ctrlByte0.isBitSet(0) + val isImpedance = ctrlByte1.isBitSet(1) + val isStablilized = ctrlByte1.isBitSet(5) + val isCattyUnit = ctrlByte1.isBitSet(6) + val isWeightRemoved = ctrlByte1.isBitSet(7) + + if (isWeightRemoved || !isStablilized) { + return null + } + + // byte 2 to 8 represent the datetime + val year = ((entry[3].toInt() and 0xFF) shl 8) or (entry[2].toInt() and 0xFF) + val month = entry[4].toInt() + val day = entry[5].toInt() + val hours = entry[6].toInt() + val min = entry[7].toInt() + val sec = entry[8].toInt() + + val weightTmp = ((entry[12].toInt() and 0xFF) shl 8) or (entry[11].toInt() and 0xFF) + val weightFloat = if (isLBSUnit || isCattyUnit) weightTmp / 100f else weightTmp / 200f + + val weight = Weight( + weight = weightFloat + ) + + var impedance: Float? = null + if (isImpedance) { + impedance = + (((entry[10].toInt() and 0xFF) shl 8) or (entry[9].toInt() and 0xFF)).toFloat() + } + + val dateStr = "$year/$month/$day/$hours/$min/$sec" + val date = SimpleDateFormat("yyyy/MM/dd/HH/mm/ss").parse(dateStr)?.time + + if (date == null) { + return null + } + + weight.timestamp = date + + if (impedance == null) { + return weight + } + + val miScaleLib = MiScaleLib(1, 24, 179f) + + weight.apply { + bmi = miScaleLib.getBMI(weightFloat) + totalBodyWater = miScaleLib.getWater(weightFloat, impedance) + visceralFat = miScaleLib.getVisceralFat(weightFloat) + bodyFat = miScaleLib.getBodyFat(weightFloat, impedance) + muscles = miScaleLib.getMuscle(weightFloat, impedance) / weightFloat * 100 + leanBodyMass = miScaleLib.getLBM(weightFloat, impedance) / weightFloat * 100 + boneMass = miScaleLib.getBoneMass(weightFloat, impedance) + } + + return weight + } + + private fun Byte.isBitSet(pos: Int): Boolean { + val nn = this.toInt() shr pos + return (nn and 1) == 1 + } +} diff --git a/app/src/main/java/com/dzeio/openhealth/devices/README.md b/app/src/main/java/com/dzeio/openhealth/devices/README.md new file mode 100644 index 0000000..eb19b76 --- /dev/null +++ b/app/src/main/java/com/dzeio/openhealth/devices/README.md @@ -0,0 +1 @@ +Most code from `./BluetoothGattUuid.kt`, `./DeviceMiSmartScale2.kt` and `./libs/MiScaleLib.java` was taken from [OpenScale](https://github.com/oliexdev/openScale) diff --git a/app/src/main/java/com/dzeio/openhealth/devices/libs/MiScaleLib.java b/app/src/main/java/com/dzeio/openhealth/devices/libs/MiScaleLib.java new file mode 100644 index 0000000..23a3047 --- /dev/null +++ b/app/src/main/java/com/dzeio/openhealth/devices/libs/MiScaleLib.java @@ -0,0 +1,175 @@ +/* Copyright (C) 2019 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + */ +package com.dzeio.openhealth.devices.libs; + + + + +/** + * based on ... by prototux + *

+ * TODO: Carefully transform it into a Kotlin Class + */ +public class MiScaleLib { + private final int sex; // male = 1; female = 0 + private final int age; + private final float height; + + public MiScaleLib(int sex, int age, float height) { + this.sex = sex; + this.age = age; + this.height = height; + } + + private float getLBMCoefficient(float weight, float impedance) { + float lbm = (height * 9.058f / 100.0f) * (height / 100.0f); + lbm += weight * 0.32f + 12.226f; + lbm -= impedance * 0.0068f; + lbm -= age * 0.0542f; + + return lbm; + } + + public float getBMI(float weight) { + return weight / (((height * height) / 100.0f) / 100.0f); + } + + public float getLBM(float weight, float impedance) { + float leanBodyMass = weight - ((getBodyFat(weight, impedance) * 0.01f) * weight) - getBoneMass(weight, impedance); + + if (sex == 0 && leanBodyMass >= 84.0f) { + leanBodyMass = 120.0f; + } + else if (sex == 1 && leanBodyMass >= 93.5f) { + leanBodyMass = 120.0f; + } + + return leanBodyMass; + } + + public float getMuscle(float weight, float impedance) { + return this.getLBM(weight,impedance); // this is wrong but coherent with MiFit app behaviour + } + + public float getWater(float weight, float impedance) { + float coeff; + float water = (100.0f - getBodyFat(weight, impedance)) * 0.7f; + + if (water < 50) { + coeff = 1.02f; + } else { + coeff = 0.98f; + } + + return coeff * water; + } + + public float getBoneMass(float weight, float impedance) { + float boneMass; + float base; + + if (sex == 0) { + base = 0.245691014f; + } + else { + base = 0.18016894f; + } + + boneMass = (base - (getLBMCoefficient(weight, impedance) * 0.05158f)) * -1.0f; + + if (boneMass > 2.2f) { + boneMass += 0.1f; + } + else { + boneMass -= 0.1f; + } + + if (sex == 0 && boneMass > 5.1f) { + boneMass = 8.0f; + } + else if (sex == 1 && boneMass > 5.2f) { + boneMass = 8.0f; + } + + return boneMass; + } + + public float getVisceralFat(float weight) { + float visceralFat = 0.0f; + if (sex == 0) { + if (weight > (13.0f - (height * 0.5f)) * -1.0f) { + float subsubcalc = ((height * 1.45f) + (height * 0.1158f) * height) - 120.0f; + float subcalc = weight * 500.0f / subsubcalc; + visceralFat = (subcalc - 6.0f) + (age * 0.07f); + } + else { + float subcalc = 0.691f + (height * -0.0024f) + (height * -0.0024f); + visceralFat = (((height * 0.027f) - (subcalc * weight)) * -1.0f) + (age * 0.07f) - age; + } + } + else { + if (height < weight * 1.6f) { + float subcalc = ((height * 0.4f) - (height * (height * 0.0826f))) * -1.0f; + visceralFat = ((weight * 305.0f) / (subcalc + 48.0f)) - 2.9f + (age * 0.15f); + } + else { + float subcalc = 0.765f + height * -0.0015f; + visceralFat = (((height * 0.143f) - (weight * subcalc)) * -1.0f) + (age * 0.15f) - 5.0f; + } + } + + return visceralFat; + } + + public float getBodyFat(float weight, float impedance) { + float bodyFat = 0.0f; + float lbmSub = 0.8f; + + if (sex == 0 && age <= 49) { + lbmSub = 9.25f; + } else if (sex == 0) { + lbmSub = 7.25f; + } + + float lbmCoeff = getLBMCoefficient(weight, impedance); + float coeff = 1.0f; + + if (sex == 1 && weight < 61.0f) { + coeff = 0.98f; + } + else if (sex == 0 && weight > 60.0f) { + coeff = 0.96f; + + if (height > 160.0f) { + coeff *= 1.03f; + } + } else if (sex == 0 && weight < 50.0f) { + coeff = 1.02f; + + if (height > 160.0f) { + coeff *= 1.03f; + } + } + + bodyFat = (1.0f - (((lbmCoeff - lbmSub) * coeff) / weight)) * 100.0f; + + if (bodyFat > 63.0f) { + bodyFat = 75.0f; + } + + return bodyFat; + } +} diff --git a/app/src/main/java/com/dzeio/openhealth/di/DatabaseModule.kt b/app/src/main/java/com/dzeio/openhealth/di/DatabaseModule.kt index 10841ac..e79b83f 100644 --- a/app/src/main/java/com/dzeio/openhealth/di/DatabaseModule.kt +++ b/app/src/main/java/com/dzeio/openhealth/di/DatabaseModule.kt @@ -2,6 +2,8 @@ package com.dzeio.openhealth.di import android.content.Context import com.dzeio.openhealth.data.AppDatabase +import com.dzeio.openhealth.data.food.FoodDao +import com.dzeio.openhealth.data.openfoodfact.OpenFoodFactService import com.dzeio.openhealth.data.step.StepDao import com.dzeio.openhealth.data.water.WaterDao import com.dzeio.openhealth.data.weight.WeightDao @@ -12,6 +14,9 @@ import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import javax.inject.Singleton +/** + * Provide to the application the Database/Daos and external services + */ @InstallIn(SingletonComponent::class) @Module class DatabaseModule { @@ -36,4 +41,15 @@ class DatabaseModule { fun provideStepsDao(appDatabase: AppDatabase): StepDao { return appDatabase.stepDao() } -} \ No newline at end of file + + @Provides + fun provideFoodDao(appDatabase: AppDatabase): FoodDao { + return appDatabase.foodDao() + } + + @Singleton + @Provides + fun provideOpenFoodFactService(): OpenFoodFactService { + return OpenFoodFactService.getService() + } +} diff --git a/app/src/main/java/com/dzeio/openhealth/di/SystemModule.kt b/app/src/main/java/com/dzeio/openhealth/di/SystemModule.kt index 2315607..79f4983 100644 --- a/app/src/main/java/com/dzeio/openhealth/di/SystemModule.kt +++ b/app/src/main/java/com/dzeio/openhealth/di/SystemModule.kt @@ -3,6 +3,7 @@ package com.dzeio.openhealth.di import android.content.Context import android.content.SharedPreferences import androidx.preference.PreferenceManager +import com.dzeio.openhealth.utils.Bluetooth import com.dzeio.openhealth.utils.Configuration import dagger.Module import dagger.Provides @@ -11,6 +12,9 @@ import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import javax.inject.Singleton +/** + * Provide to the application System elements + */ @InstallIn(SingletonComponent::class) @Module class SystemModule { @@ -26,4 +30,8 @@ class SystemModule { fun provideConfig(sharedPreferences: SharedPreferences): Configuration { return Configuration(sharedPreferences) } + + @Singleton + @Provides + fun provideBluetooth(@ApplicationContext context: Context): Bluetooth = Bluetooth(context) } diff --git a/app/src/main/java/com/dzeio/openhealth/extensions/Extension.kt b/app/src/main/java/com/dzeio/openhealth/extensions/Extension.kt deleted file mode 100644 index fd45fec..0000000 --- a/app/src/main/java/com/dzeio/openhealth/extensions/Extension.kt +++ /dev/null @@ -1,143 +0,0 @@ -package com.dzeio.openhealth.extensions - -import androidx.activity.result.ActivityResultCallback -import androidx.activity.result.contract.ActivityResultContract -import androidx.fragment.app.Fragment -import com.dzeio.openhealth.data.weight.Weight -import kotlinx.coroutines.flow.Flow - -/** - * Extension Schema - * - * Version: 0.2.0 - */ -interface Extension : ActivityResultCallback { - - data class TaskProgress( - /** - * value indicating the current status of the task - */ - val state: TaskState = TaskState.INITIALIZATING, - - /** - * value between 0 and 100 indicating the progress for the task - */ - val progress: Float? = null, - - /** - * Additionnal message that will be displayed when the task has ended in a [TaskState.CANCELLED] or [TaskState.ERROR] state - */ - val statusMessage: String? = null, - - /** - * Additional information - */ - val additionalData: T? = null - ) - - enum class TaskState { - /** - * define the task as being preped - */ - INITIALIZATING, - - /** - * Define the task a bein worked on - */ - WORK_IN_PROGRESS, - - /** - * define the task as being done - */ - DONE, - - /** - * Define the task as being cancelled - */ - CANCELLED, - - /** - * define the task as being ended with an error - */ - ERROR - } - - enum class Data { - /** - * Special case to handle basic errors from other activities - */ - NOTHING, - WEIGHT, - STEPS - - /** - * Google Fit: - * - * STEP_COUNT_CUMULATIVE - * ACTIVITY_SEGMENT - * SLEEP_SEGMENT - * CALORIES_EXPENDED - * BASAL_METABOLIC_RATE - * POWER_SAMPLE - * HEART_RATE_BPM - * LOCATION_SAMPLE - */ - } - - /** - * the permissions necessary for the extension to works - */ - val permissions: Array - - /** - * the Source ID - * - * DO NOT CHANGE IT AFTER THE EXTENSION IS IN PRODUCTION - */ - val id: String - - /** - * The Extension Display Name - */ - val name: String - - /** - * the different types of data handled by the extension - */ - val data: Array - - /** - * Enable the extension, no code is gonna be run before - */ - fun enable(fragment: Fragment): Boolean - - /** - * return if the extension is already connected to remote of not - */ - suspend fun isConnected(): Boolean - - /** - * Return if the extension is runnable on the device - */ - fun isAvailable(): Boolean - - /** - * try to connect to remote - */ - suspend fun connect(): Boolean - - val contract: ActivityResultContract<*, *>? - val requestInput: Any? - suspend fun importWeight(): Flow>> - - /** - * function run when outgoing sync is enabled and new value is added - * or manual export is launched - */ - suspend fun exportWeights(weight: Array): Flow> - -// fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) = Unit - - - suspend fun permissionsGranted(): Boolean -} diff --git a/app/src/main/java/com/dzeio/openhealth/extensions/ExtensionFactory.kt b/app/src/main/java/com/dzeio/openhealth/extensions/ExtensionFactory.kt deleted file mode 100644 index df4b895..0000000 --- a/app/src/main/java/com/dzeio/openhealth/extensions/ExtensionFactory.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.dzeio.openhealth.extensions - -import android.os.Build - -class ExtensionFactory { - companion object { - fun getExtension(extension: String): Extension? { - return when (extension) { - "GoogleFit" -> { - GoogleFitExtension() - } - "HealthConnect" -> { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - HealthConnectExtension() - } else { - TODO("VERSION.SDK_INT < P") - } - } - else -> { - null - } - } - } - - fun getAll(): ArrayList { - val extensions: ArrayList = arrayListOf( - GoogleFitExtension() - ) - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - extensions.add(HealthConnectExtension()) - } - - return extensions - } - } -} diff --git a/app/src/main/java/com/dzeio/openhealth/extensions/FileSystemExtension.kt.old b/app/src/main/java/com/dzeio/openhealth/extensions/FileSystemExtension.kt.old deleted file mode 100644 index c5e6ec0..0000000 --- a/app/src/main/java/com/dzeio/openhealth/extensions/FileSystemExtension.kt.old +++ /dev/null @@ -1,68 +0,0 @@ -package com.dzeio.openhealth.extensions - -import android.Manifest -import android.app.Activity -import android.content.Intent -import android.util.Log -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import com.dzeio.openhealth.data.weight.Weight - -class FileSystemExtension : Extension { - companion object { - const val TAG = "FSExtension" - } - - private lateinit var activity: Activity - - override val id = "FileSystem" - override val name = "File System" - - override val permissions = arrayOf( - Manifest.permission.WRITE_EXTERNAL_STORAGE - ) - - override val permissionsText: String = "Please" - - override fun init(activity: Activity): Array { - this.activity = activity - return Extension.Data.values() - } - - override fun getStatus(): String { - return "" - } - - override fun isAvailable(): Boolean { - return true - } - - override fun isConnected(): Boolean = true - - private val connectLiveData: MutableLiveData = MutableLiveData(Extension.States.DONE) - - override fun connect(): LiveData = connectLiveData - - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - Log.d(this.name, "[$requestCode] -> [$resultCode]: $data") - if (requestCode == 0) { - return - } - - if (resultCode == Activity.RESULT_OK) connectLiveData.value = Extension.States.DONE - // signIn(Data.values()[requestCode]) - } - - override fun importWeight(): LiveData> { - - weightLiveData = MutableLiveData( - Extension.ImportState( - Extension.States.WIP - ) - ) - - startImport(Extension.Data.WEIGHT) - - return weightLiveData - } -} diff --git a/app/src/main/java/com/dzeio/openhealth/extensions/GoogleFit.kt.old b/app/src/main/java/com/dzeio/openhealth/extensions/GoogleFit.kt.old deleted file mode 100644 index e5b5d40..0000000 --- a/app/src/main/java/com/dzeio/openhealth/extensions/GoogleFit.kt.old +++ /dev/null @@ -1,210 +0,0 @@ -package com.dzeio.openhealth.extensions - -import android.Manifest -import android.app.Activity -import android.content.Intent -import android.util.Log -import androidx.core.app.ActivityCompat -import androidx.fragment.app.Fragment -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import com.dzeio.openhealth.data.weight.Weight -import com.google.android.gms.auth.api.signin.GoogleSignIn -import com.google.android.gms.fitness.Fitness -import com.google.android.gms.fitness.FitnessOptions -import com.google.android.gms.fitness.data.DataType -import com.google.android.gms.fitness.request.DataReadRequest -import java.text.DateFormat -import java.util.Calendar -import java.util.Date -import java.util.TimeZone -import java.util.concurrent.TimeUnit - -class GoogleFit: Extension { - companion object { - const val TAG = "GoogleFitConnector" - } - - private lateinit var activity: Activity - - override val id = "GoogleFit" - override val name = "Google Fit" - - override val permissions = arrayOf( - Manifest.permission.ACCESS_FINE_LOCATION - ) - - override val permissionsText: String = "Please" - - override fun init(activity: Fragment): Array { - this.activity = activity.register - return arrayOf( - Extension.Data.WEIGHT - ) - } - - override fun getStatus(): String { - return if (isConnected()) "Connected" else "Not Connected" - } - - override fun isAvailable(): Boolean { - return true - } - - override fun isConnected(): Boolean = - GoogleSignIn.hasPermissions(getGoogleAccount(), fitnessOptions) - - private val fitnessOptions = FitnessOptions.builder() - .addDataType(DataType.TYPE_WEIGHT) -// .addDataType(DataType.TYPE_STEP_COUNT_CUMULATIVE) -// .addDataType(DataType.TYPE_CALORIES_EXPENDED) - .build() - - private val connectLiveData: MutableLiveData = MutableLiveData(Extension.States.WIP) - - override fun connect(): LiveData { - - if (isConnected()) { - connectLiveData.value = Extension.States.DONE - } else { - Log.d(this.name, "Signing In") - GoogleSignIn.requestPermissions( - activity, - 124887, - getGoogleAccount(), fitnessOptions - ) - } - return connectLiveData - } - - private fun getGoogleAccount() = GoogleSignIn.getAccountForExtension(activity, fitnessOptions) - - private val timeRange by lazy { - val calendar = Calendar.getInstance(TimeZone.getTimeZone("UTC")) - calendar.time = Date() - val endTime = calendar.timeInMillis - - // Set year to 2013 to be sure to get data from when Google Fit Started to today - calendar.set(Calendar.YEAR, 2013) - val startTime = calendar.timeInMillis - return@lazy arrayOf(startTime, endTime) - } - - private fun startImport(data: Extension.Data) { - Log.d("GoogleFitImporter", "Importing for ${data.name}") - - val dateFormat = DateFormat.getDateInstance() - Log.i(TAG, "Range Start: ${dateFormat.format(timeRange[0])}") - Log.i(TAG, "Range End: ${dateFormat.format(timeRange[1])}") - - var type = DataType.TYPE_WEIGHT - var timeUnit = TimeUnit.MILLISECONDS - - when (data) { - Extension.Data.STEPS -> { - type = DataType.TYPE_STEP_COUNT_CUMULATIVE - } - else -> {} - } - - runRequest( - DataReadRequest.Builder() - .read(type) - .setTimeRange(timeRange[0], timeRange[1], timeUnit) - .build(), - data - ) - } - - private fun runRequest(request: DataReadRequest, data: Extension.Data) { - Fitness.getHistoryClient( - activity, - GoogleSignIn.getAccountForExtension(activity, fitnessOptions) - ) - .readData(request) - .addOnSuccessListener { response -> - Log.d( - TAG, - "Received response! ${response.dataSets.size} ${response.buckets.size} ${response.status}" - ) - for (dataSet in response.dataSets) { - Log.i( - TAG, - "Data returned for Data type: ${dataSet.dataType.name} ${dataSet.dataPoints.size} ${dataSet.dataSource}" - ) - dataSet.dataPoints.forEach { dp -> - - // Global - Log.i(TAG, "Importing Data point:") - Log.i(TAG, "\tType: ${dp.dataType.name}") - Log.i( - TAG, - "\tStart: ${Date(dp.getStartTime(TimeUnit.SECONDS) * 1000L).toLocaleString()}" - ) - Log.i( - TAG, - "\tEnd: ${Date(dp.getEndTime(TimeUnit.SECONDS) * 1000L).toLocaleString()}" - ) - - // Field Specifics - for (field in dp.dataType.fields) { - Log.i(TAG, "\tField: ${field.name} Value: ${dp.getValue(field)}") - when (data) { - Extension.Data.WEIGHT -> { - val weight = Weight() - weight.timestamp = dp.getStartTime(TimeUnit.MILLISECONDS) - weight.weight = dp.getValue(field).asFloat() - val list = weightLiveData.value?.list?.toMutableList() - ?: ArrayList() - list.add(weight) - weightLiveData.value = - - Extension.ImportState(Extension.States.WIP, list) - } - else -> {} - } - } - } - when (data) { - Extension.Data.WEIGHT -> { - weightLiveData.value = - Extension.ImportState( - Extension.States.DONE, - weightLiveData.value?.list - ?: ArrayList() - ) - } - else -> {} - } - } - } - .addOnFailureListener { e -> - Log.e(TAG, "There was an error reading data from Google Fit", e) - } - } - - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - Log.d(this.name, "[$requestCode] -> [$resultCode]: $data") - if (requestCode == 0) { - return - } - - if (resultCode == Activity.RESULT_OK) connectLiveData.value = Extension.States.DONE - // signIn(Data.values()[requestCode]) - } - - private lateinit var weightLiveData: MutableLiveData> - - override fun importWeight(): LiveData> { - - weightLiveData = MutableLiveData( - Extension.ImportState( - Extension.States.WIP - ) - ) - - startImport(Extension.Data.WEIGHT) - - return weightLiveData - } -} diff --git a/app/src/main/java/com/dzeio/openhealth/extensions/GoogleFitExtension.kt b/app/src/main/java/com/dzeio/openhealth/extensions/GoogleFitExtension.kt deleted file mode 100644 index a347645..0000000 --- a/app/src/main/java/com/dzeio/openhealth/extensions/GoogleFitExtension.kt +++ /dev/null @@ -1,187 +0,0 @@ -package com.dzeio.openhealth.extensions - -import android.Manifest -import android.util.Log -import androidx.activity.result.contract.ActivityResultContract -import androidx.activity.result.contract.ActivityResultContracts -import androidx.fragment.app.Fragment -import com.dzeio.openhealth.core.Observable -import com.dzeio.openhealth.data.weight.Weight -import com.dzeio.openhealth.utils.PermissionsManager -import com.google.android.gms.auth.api.signin.GoogleSignIn -import com.google.android.gms.fitness.Fitness -import com.google.android.gms.fitness.FitnessOptions -import com.google.android.gms.fitness.data.DataType -import com.google.android.gms.fitness.request.DataReadRequest -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.channelFlow -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.suspendCancellableCoroutine -import java.util.Calendar -import java.util.Date -import java.util.TimeZone -import java.util.concurrent.TimeUnit - -class GoogleFitExtension : Extension { - companion object { - const val TAG = "GoogleFitConnector" - } - - override val id = "GoogleFit" - override val name = "Google Fit" - - override val permissions = arrayOf( - Manifest.permission.ACCESS_FINE_LOCATION - ) - - override val data: Array = arrayOf( - Extension.Data.WEIGHT - ) - - override suspend fun isConnected(): Boolean = - GoogleSignIn.hasPermissions(getGoogleAccount(), fitnessOptions) - - - private val fitnessOptions = FitnessOptions.builder() - .addDataType(DataType.TYPE_WEIGHT) -// .addDataType(DataType.TYPE_STEP_COUNT_CUMULATIVE) -// .addDataType(DataType.TYPE_CALORIES_EXPENDED) - .build() - - private val connectionStatus = Observable(false) - - private lateinit var fragment: Fragment - - override fun isAvailable(): Boolean = true - - override fun enable(fragment: Fragment): Boolean { - this.fragment = fragment - return true - } - - @OptIn(ExperimentalCoroutinesApi::class) - override suspend fun connect(): Boolean { - - if (isConnected()) { - return true - } - - return suspendCancellableCoroutine { cancellableContinuation -> - Log.d(this.name, "Signing In") - GoogleSignIn.requestPermissions( - fragment, - 124887, - getGoogleAccount(), fitnessOptions - ) - connectionStatus.addOneTimeObserver { it: Boolean -> - cancellableContinuation.resume(it) { - - } - } - } - } - - override val contract: ActivityResultContract<*, Map>? = ActivityResultContracts.RequestMultiplePermissions() - override val requestInput = permissions - - private fun getGoogleAccount() = GoogleSignIn.getAccountForExtension(fragment.requireContext(), fitnessOptions) - - private val timeRange by lazy { - val calendar = Calendar.getInstance(TimeZone.getTimeZone("UTC")) - calendar.time = Date() - val endTime = calendar.timeInMillis - - // Set year to 2013 to be sure to get data from when Google Fit Started to today - calendar.set(Calendar.YEAR, 2013) - val startTime = calendar.timeInMillis - return@lazy arrayOf(startTime, endTime) - } - - override suspend fun importWeight(): Flow>> = - channelFlow { - send( - Extension.TaskProgress( - Extension.TaskState.INITIALIZATING - ) - ) - - val type = DataType.TYPE_WEIGHT - val timeUnit = TimeUnit.MILLISECONDS - - val request = DataReadRequest.Builder() - .read(type) - .setTimeRange(timeRange[0], timeRange[1], timeUnit) - .build() - - Fitness.getHistoryClient( - fragment.requireContext(), - GoogleSignIn.getAccountForExtension(fragment.requireContext(), fitnessOptions) - ) - .readData(request) - .addOnSuccessListener { response -> - val weights: ArrayList = ArrayList() - var index = 0 - var total = response.dataSets.size - for (dataset in response.dataSets) { - total += dataset.dataPoints.size - 1 - for (dataPoint in dataset.dataPoints) { - total += dataPoint.dataType.fields.size - 1 - for (field in dataPoint.dataType.fields) { - val weight = Weight().apply { - timestamp = dataPoint.getStartTime(TimeUnit.MILLISECONDS) - weight = dataPoint.getValue(field).asFloat() - source = this@GoogleFitExtension.id - } - weights.add(weight) - runBlocking { - send( - Extension.TaskProgress( - Extension.TaskState.WORK_IN_PROGRESS, - progress = index++ / total.toFloat() - ) - ) - } - } - } - } - runBlocking { - send( - Extension.TaskProgress( - Extension.TaskState.DONE, - additionalData = weights - ) - ) - } - } - .addOnFailureListener { - runBlocking { - send( - Extension.TaskProgress( - Extension.TaskState.ERROR, - statusMessage = it.localizedMessage ?: it.message ?: "Unknown error" - ) - ) - } - } - - - } - - override suspend fun exportWeights(weight: Array): Flow> { - TODO("Not yet implemented") - } - - override suspend fun permissionsGranted(): Boolean { - return PermissionsManager.hasPermission(this.fragment.requireContext(), permissions) - } - - override fun onActivityResult(result: Any) { - if ((result as Map<*, *>).containsValue(false)) { - return - } - - connectionStatus.value = true - } - -} diff --git a/app/src/main/java/com/dzeio/openhealth/extensions/HealthConnectExtension.kt b/app/src/main/java/com/dzeio/openhealth/extensions/HealthConnectExtension.kt deleted file mode 100644 index 808764e..0000000 --- a/app/src/main/java/com/dzeio/openhealth/extensions/HealthConnectExtension.kt +++ /dev/null @@ -1,137 +0,0 @@ -package com.dzeio.openhealth.extensions - -import android.Manifest -import android.os.Build -import androidx.activity.result.contract.ActivityResultContract -import androidx.annotation.RequiresApi -import androidx.fragment.app.Fragment -import androidx.health.connect.client.HealthConnectClient -import androidx.health.connect.client.PermissionController -import androidx.health.connect.client.permission.HealthPermission -import androidx.health.connect.client.records.HeartRateRecord -import androidx.health.connect.client.records.StepsRecord -import androidx.health.connect.client.records.WeightRecord -import androidx.health.connect.client.request.ReadRecordsRequest -import androidx.health.connect.client.time.TimeRangeFilter -import com.dzeio.openhealth.core.Observable -import com.dzeio.openhealth.data.weight.Weight -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.channelFlow -import kotlinx.coroutines.runBlocking -import java.time.Instant - -@RequiresApi(Build.VERSION_CODES.P) -class HealthConnectExtension : Extension { - companion object { - const val TAG = "HealthConnectExtension" - } - - // build a set of permissions for required data types - val PERMISSIONS = - setOf( - HealthPermission.createReadPermission(HeartRateRecord::class), - HealthPermission.createWritePermission(HeartRateRecord::class), - HealthPermission.createReadPermission(StepsRecord::class), - HealthPermission.createWritePermission(StepsRecord::class) - ) - - - override val id = "HealthConnect" - override val name = "Health Connect" - - override val permissions = arrayOf( - Manifest.permission.ACCESS_FINE_LOCATION - ) - - override val requestInput = PERMISSIONS - - override val data: Array = arrayOf( - Extension.Data.WEIGHT - ) - - override suspend fun isConnected(): Boolean = true - - - - private val connectionStatus = Observable(false) - - private lateinit var fragment: Fragment - private lateinit var client: HealthConnectClient - - override fun isAvailable(): Boolean { - return HealthConnectClient.isAvailable(fragment.requireContext()) - } - - override fun enable(fragment: Fragment): Boolean { - this.fragment = fragment - if (!isAvailable()) { - return false - } - - this.client = HealthConnectClient.getOrCreate(fragment.requireContext()) - - return true - } - - override suspend fun connect(): Boolean = true - - override suspend fun importWeight(): Flow>> = - channelFlow { - send( - Extension.TaskProgress( - Extension.TaskState.INITIALIZATING - ) - ) - - val response = client.readRecords( - ReadRecordsRequest( - WeightRecord::class, - timeRangeFilter = TimeRangeFilter.before(Instant.now()) - ) - ) - - val weights: ArrayList = ArrayList() - var index = 0 - for (record in response.records) { - val weight = Weight().apply { - timestamp = record.time.toEpochMilli() - weight = record.weight.inKilograms.toFloat() - source = this@HealthConnectExtension.id - } - weights.add(weight) - runBlocking { - send( - Extension.TaskProgress( - Extension.TaskState.WORK_IN_PROGRESS, - progress = index++ / response.records.size.toFloat() - ) - ) - } - } - runBlocking { - send( - Extension.TaskProgress( - Extension.TaskState.DONE, - additionalData = weights - ) - ) - } - - } - - override suspend fun exportWeights(weight: Array): Flow> { - TODO("Not yet implemented") - } - - override fun onActivityResult(result: Any) { - if ((result as Set<*>).containsAll(this.PERMISSIONS)) connectionStatus.value = true - // signIn(Data.values()[requestCode]) - } - - override val contract: ActivityResultContract, Set> - get() = PermissionController.createRequestPermissionResultContract() - - override suspend fun permissionsGranted(): Boolean { - return this.client.permissionController.getGrantedPermissions(this.PERMISSIONS).containsAll(this.PERMISSIONS) - } -} diff --git a/app/src/main/java/com/dzeio/openhealth/extensions/samsunghealth/SamsungHealth.kt b/app/src/main/java/com/dzeio/openhealth/extensions/samsunghealth/SamsungHealth.kt deleted file mode 100644 index e301f7c..0000000 --- a/app/src/main/java/com/dzeio/openhealth/extensions/samsunghealth/SamsungHealth.kt +++ /dev/null @@ -1,112 +0,0 @@ -package com.dzeio.openhealth.extensions.samsunghealth - -import android.app.Activity -import android.content.Intent -import android.os.Handler -import android.os.Looper -import android.util.Log -import com.dzeio.openhealth.data.weight.Weight -import com.samsung.android.sdk.healthdata.HealthConnectionErrorResult -import com.samsung.android.sdk.healthdata.HealthConstants.StepCount -import com.samsung.android.sdk.healthdata.HealthDataStore -import com.samsung.android.sdk.healthdata.HealthDataStore.ConnectionListener -import com.samsung.android.sdk.healthdata.HealthPermissionManager -import com.samsung.android.sdk.healthdata.HealthPermissionManager.* - - -/** - * Does not FUCKING work - */ -class SamsungHealth( - private val context: Activity -) { - - companion object { - const val TAG = "SamsungHealthConnector" - } - - private val listener = object : ConnectionListener { - override fun onConnected() { - Log.d(TAG, "Connected!") - if (isPermissionAcquired()) { - reporter.start() - } else { - requestPermission() - } - } - - override fun onConnectionFailed(p0: HealthConnectionErrorResult?) { - Log.d(TAG, "Health data service is not available.") - } - - override fun onDisconnected() { - Log.d(TAG, "Health data service is disconnected.") - } - - } - - private val store: HealthDataStore = HealthDataStore(context, listener) - - private fun isPermissionAcquired(): Boolean { - val permKey = PermissionKey(StepCount.HEALTH_DATA_TYPE, PermissionType.READ) - val pmsManager = HealthPermissionManager(store) - try { - // Check whether the permissions that this application needs are acquired - val resultMap = pmsManager.isPermissionAcquired(setOf(permKey)) - return !resultMap.containsValue(java.lang.Boolean.FALSE) - } catch (e: java.lang.Exception) { - Log.e(TAG, "Permission request fails.", e) - } - return false - } - - private fun requestPermission() { - val permKey = PermissionKey(StepCount.HEALTH_DATA_TYPE, PermissionType.READ) - val pmsManager = HealthPermissionManager(store) - try { - // Show user permission UI for allowing user to change options - pmsManager.requestPermissions(setOf(permKey), context) - .setResultListener { result: PermissionResult -> - Log.d(TAG, "Permission callback is received.") - val resultMap = - result.resultMap - if (resultMap.containsValue(java.lang.Boolean.FALSE)) { - Log.d(TAG, "No Data???") - } else { - // Get the current step count and display it - reporter.start() - } - } - } catch (e: Exception) { - Log.e(TAG, "Permission setting fails.", e) - } - } - - private val stepCountObserver = object : StepCountReporter.StepCountObserver { - override fun onChanged(count: Int) { - Log.d(TAG, "Step reported : $count") - } - } - - private val reporter = - StepCountReporter(store, stepCountObserver, Handler(Looper.getMainLooper())) - - /** - * Connector - */ - - val sourceID: String = "SamsungHealth" - - fun onRequestPermissionResult( - requestCode: Int, - permission: Array, - grantResult: IntArray - ) { - } - - fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {} - - fun importWeight(callback: (weight: Weight, end: Boolean) -> Unit) { - store.connectService() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/dzeio/openhealth/extensions/samsunghealth/StepCountReporter.kt b/app/src/main/java/com/dzeio/openhealth/extensions/samsunghealth/StepCountReporter.kt deleted file mode 100644 index 83a1731..0000000 --- a/app/src/main/java/com/dzeio/openhealth/extensions/samsunghealth/StepCountReporter.kt +++ /dev/null @@ -1,88 +0,0 @@ -package com.dzeio.openhealth.extensions.samsunghealth - -import android.os.Handler -import android.util.Log -import com.samsung.android.sdk.healthdata.HealthConstants.StepCount -import com.samsung.android.sdk.healthdata.HealthData -import com.samsung.android.sdk.healthdata.HealthDataObserver -import com.samsung.android.sdk.healthdata.HealthDataResolver -import com.samsung.android.sdk.healthdata.HealthDataResolver.AggregateRequest -import com.samsung.android.sdk.healthdata.HealthDataResolver.AggregateRequest.AggregateFunction -import com.samsung.android.sdk.healthdata.HealthDataResolver.AggregateResult -import com.samsung.android.sdk.healthdata.HealthDataStore -import java.util.* -import java.util.concurrent.TimeUnit - -class StepCountReporter( - private val mStore: HealthDataStore, private val mStepCountObserver: StepCountObserver, - resultHandler: Handler? -) { - private val mHealthDataResolver: HealthDataResolver - private val mHealthDataObserver: HealthDataObserver - fun start() { - // Register an observer to listen changes of step count and get today step count - HealthDataObserver.addObserver(mStore, StepCount.HEALTH_DATA_TYPE, mHealthDataObserver) - readTodayStepCount() - } - - fun stop() { - HealthDataObserver.removeObserver(mStore, mHealthDataObserver) - } - - // Read the today's step count on demand - private fun readTodayStepCount() { - // Set time range from start time of today to the current time - val startTime = getUtcStartOfDay(System.currentTimeMillis(), TimeZone.getDefault()) - val endTime = startTime + TimeUnit.DAYS.toMillis(1) - val request = AggregateRequest.Builder() - .setDataType(StepCount.HEALTH_DATA_TYPE) - .addFunction(AggregateFunction.SUM, StepCount.COUNT, "total_step") - .setLocalTimeRange(StepCount.START_TIME, StepCount.TIME_OFFSET, startTime, endTime) - .build() - try { - mHealthDataResolver.aggregate(request) - .setResultListener { aggregateResult: AggregateResult -> - aggregateResult.use { result -> - val iterator: Iterator = result.iterator() - if (iterator.hasNext()) { - mStepCountObserver.onChanged(iterator.next().getInt("total_step")) - } - } - } - } catch (e: Exception) { - Log.e("APP_TAG", "Getting step count fails.", e) - } - } - - private fun getUtcStartOfDay(time: Long, tz: TimeZone): Long { - val cal = Calendar.getInstance(tz) - cal.timeInMillis = time - val year = cal[Calendar.YEAR] - val month = cal[Calendar.MONTH] - val date = cal[Calendar.DATE] - cal.timeZone = TimeZone.getTimeZone("UTC") - cal[Calendar.YEAR] = year - cal[Calendar.MONTH] = month - cal[Calendar.DATE] = date - cal[Calendar.HOUR_OF_DAY] = 0 - cal[Calendar.MINUTE] = 0 - cal[Calendar.SECOND] = 0 - cal[Calendar.MILLISECOND] = 0 - return cal.timeInMillis - } - - interface StepCountObserver { - fun onChanged(count: Int) - } - - init { - mHealthDataResolver = HealthDataResolver(mStore, resultHandler) - mHealthDataObserver = object : HealthDataObserver(resultHandler) { - // Update the step count when a change event is received - override fun onChange(dataTypeName: String) { - Log.d("APP_TAG", "Observer receives a data changed event") - readTodayStepCount() - } - } - } -} diff --git a/app/src/main/java/com/dzeio/openhealth/graphs/WeightChart.kt b/app/src/main/java/com/dzeio/openhealth/graphs/WeightChart.kt deleted file mode 100644 index 70fd8e8..0000000 --- a/app/src/main/java/com/dzeio/openhealth/graphs/WeightChart.kt +++ /dev/null @@ -1,145 +0,0 @@ -package com.dzeio.openhealth.graphs - -import android.graphics.Color -import android.view.View -import com.dzeio.openhealth.data.weight.Weight -import com.dzeio.openhealth.units.Units -import com.dzeio.openhealth.utils.GraphUtils -import com.github.mikephil.charting.charts.LineChart -import com.github.mikephil.charting.components.LimitLine -import com.github.mikephil.charting.components.YAxis -import com.github.mikephil.charting.data.Entry -import com.github.mikephil.charting.data.LineData -import com.github.mikephil.charting.data.LineDataSet -import com.google.android.material.color.MaterialColors -import kotlin.math.max -import kotlin.math.min - -object WeightChart { - fun setup( - chart: LineChart, - view: View, - data: List, - modifier: Units.Mass, - goal: Float?, - limit: Boolean = true - ) { - GraphUtils.lineChartSetup( - chart, - MaterialColors.getColor( - view, - com.google.android.material.R.attr.colorPrimary - ), - MaterialColors.getColor( - view, - com.google.android.material.R.attr.colorOnBackground - ) - ) - - if (data.isEmpty()) { - return - } - - // Axis Max/Min - var axisMin = max(data.minOf { it.weight } - 10, 0f) - var axisMax = data.maxOf { it.weight } + 10 - - if (goal != null) { - axisMax = max(axisMax, goal) - axisMin = min(axisMin, goal) - } - - // Average calculation - val averageCalculation = min(30, max(3, data.size / 2)) - val isEven = averageCalculation % 2 == 1 - val midValue = averageCalculation / 2 - - val averageYs = data.mapIndexed { index, entry -> - var minItem = index - midValue - var maxItem = index + if (!isEven) midValue + 1 else midValue - val lastEntry = data.size - 1 - if (minItem < 0) { - maxItem += kotlin.math.abs(minItem) - minItem = 0 - } - if (maxItem >= lastEntry) { - val diff = maxItem - lastEntry - minItem = max(0, minItem - diff) - maxItem -= diff - } - - var average = 0f - for (i in minItem..maxItem) { - average += data[i].weight - } - - return@mapIndexed Entry( - entry.timestamp.toFloat(), - (average / (maxItem - minItem + 1)) * modifier.modifier - ) - } - - val rawData = GraphUtils.lineDataSet( - LineDataSet( - data.mapIndexed { _, weight -> - return@mapIndexed Entry( - weight.timestamp.toFloat(), - weight.weight * modifier.modifier - ) - }, - "Weight" - ) - ).apply { - axisDependency = YAxis.AxisDependency.RIGHT - } - - val averageData = GraphUtils.lineDataSet(LineDataSet(averageYs, "Average")).apply { - axisDependency = YAxis.AxisDependency.RIGHT - color = Color.GREEN - } - - val entries = ArrayList() - for (item in data) { - entries.add( - Entry( - item.timestamp.toFloat(), - item.weight * modifier.modifier - ) - ) - } - - chart.apply { - - this.data = LineData(rawData, averageData) - - val twoWeeks = (data[data.size - 1].timestamp - data[0].timestamp) > 1290000000f - - if (twoWeeks && limit) { - // idk what I did but it works lol - setVisibleXRange( - 0f, data[data.size - 1].timestamp / 1000f - ) - - axisRight.axisMinimum = axisMin * modifier.modifier - axisRight.axisMaximum = axisMax * modifier.modifier - - // BIS... :( - // Also it invalidate the view so I don't have to call invalidate - moveViewToX(data[data.size - 1].timestamp - 1290000000f) - } - - if (goal != null) { - val limit = LimitLine(goal * modifier.modifier) - limit.lineColor = Color.RED - val dash = 30f - limit.enableDashedLine(dash, dash, 1f) - limit.lineWidth = 1f - limit.textColor = Color.BLACK - - axisRight.removeAllLimitLines() - axisRight.addLimitLine(limit) - } - invalidate() - } - } -} diff --git a/app/src/main/java/com/dzeio/openhealth/interfaces/NotificationChannels.kt b/app/src/main/java/com/dzeio/openhealth/interfaces/NotificationChannels.kt index 1ed7fb1..a0f4344 100644 --- a/app/src/main/java/com/dzeio/openhealth/interfaces/NotificationChannels.kt +++ b/app/src/main/java/com/dzeio/openhealth/interfaces/NotificationChannels.kt @@ -2,6 +2,9 @@ package com.dzeio.openhealth.interfaces import android.app.NotificationManager +/** + * The different notification channels the applicaiton is using + */ enum class NotificationChannels( val id: String, val channelName: String, diff --git a/app/src/main/java/com/dzeio/openhealth/interfaces/NotificationIds.kt b/app/src/main/java/com/dzeio/openhealth/interfaces/NotificationIds.kt index 8668f81..d1e72b1 100644 --- a/app/src/main/java/com/dzeio/openhealth/interfaces/NotificationIds.kt +++ b/app/src/main/java/com/dzeio/openhealth/interfaces/NotificationIds.kt @@ -1,5 +1,8 @@ package com.dzeio.openhealth.interfaces +/** + * The different notifications the application can send to the user + */ enum class NotificationIds { WaterIntake, @@ -7,4 +10,4 @@ enum class NotificationIds { * Open Health Main Service Notification ID */ Service -} \ No newline at end of file +} diff --git a/app/src/main/java/com/dzeio/openhealth/services/OpenHealthService.kt b/app/src/main/java/com/dzeio/openhealth/services/OpenHealthService.kt index 461da37..e40226f 100644 --- a/app/src/main/java/com/dzeio/openhealth/services/OpenHealthService.kt +++ b/app/src/main/java/com/dzeio/openhealth/services/OpenHealthService.kt @@ -20,6 +20,8 @@ import com.dzeio.openhealth.data.step.StepRepository_Factory import com.dzeio.openhealth.data.step.StepSource import com.dzeio.openhealth.interfaces.NotificationChannels import com.dzeio.openhealth.interfaces.NotificationIds +import com.dzeio.openhealth.utils.polyfills.NotificationBehavior +import com.dzeio.openhealth.utils.polyfills.stopForegroundPoly import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -29,12 +31,18 @@ import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull +/** + * The Service that allow the application to run in the background + */ class OpenHealthService : Service() { companion object { private const val TAG = "${Application.TAG}/Service" } + /** + * Get the StepRepository without DI because it is unavailable here + */ private val stepRepository: StepRepository get() = StepRepository_Factory.newInstance( AppDatabase.getInstance(applicationContext).stepDao() @@ -64,42 +72,56 @@ class OpenHealthService : Service() { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { scope.launch { + // start the StepSource val source = StepSource(this@OpenHealthService) - source.events.receiveAsFlow().collectLatest { - Log.d(TAG, "Received value: $it") + // receive each updates as to the number of steps taken + source.events.receiveAsFlow().collectLatest { +// Log.d(TAG, "Received value: $it") + + // handle case where no new steps were taken if (it <= 0f) { Log.d(TAG, "No new steps registered ($it)") return@collectLatest } - Log.d(TAG, "New steps registered: $it") + + // update internal variables to keep track of the number of steps taken +// Log.d(TAG, "New steps registered: $it") stepsTaken += it.toUInt() stepsBuffer += it.toInt() + + // show the notification showNotification() + + // try to get the current number of steps for the hour from the DB val step = withTimeoutOrNull(1000) { return@withTimeoutOrNull stepRepository.currentStep().firstOrNull() } - Log.d(TAG, "stepRepository: $step") + +// Log.d(TAG, "stepRepository: $step") + // if steps registered, add them and send them back if (step != null) { step.value += stepsBuffer stepRepository.updateStep(step) + // create a new steps object and send it } else { stepRepository.addStep(Step(value = stepsBuffer)) } + + // reset the internal buffer stepsBuffer = 0 - Log.d(TAG, "Added step!") +// Log.d(TAG, "Added step!") } } // Display a notification about us starting. We put an icon in the status bar. - startForeground(NotificationIds.Service.ordinal, showNotification()) return START_STICKY } override fun onDestroy() { - stopForeground(true) + stopForegroundPoly(NotificationBehavior.REMOVE) // Tell the user we stopped. Toast.makeText(this, "Service stopped", Toast.LENGTH_SHORT).show() diff --git a/app/src/main/java/com/dzeio/openhealth/ui/ErrorActivity.kt b/app/src/main/java/com/dzeio/openhealth/ui/ErrorActivity.kt index 77dcfd6..43de522 100644 --- a/app/src/main/java/com/dzeio/openhealth/ui/ErrorActivity.kt +++ b/app/src/main/java/com/dzeio/openhealth/ui/ErrorActivity.kt @@ -3,13 +3,11 @@ package com.dzeio.openhealth.ui import android.content.ActivityNotFoundException import android.content.Intent import android.net.Uri -import android.os.Build import android.os.Bundle import android.os.Process import android.view.LayoutInflater import android.widget.Toast import com.dzeio.openhealth.Application -import com.dzeio.openhealth.BuildConfig import com.dzeio.openhealth.core.BaseActivity import com.dzeio.openhealth.databinding.ActivityErrorBinding import kotlin.system.exitProcess @@ -28,20 +26,8 @@ class ErrorActivity : BaseActivity() { val data = intent.getStringExtra("error") - // Get Application datas - val deviceToReport = if (Build.DEVICE.contains(Build.MANUFACTURER)) Build.DEVICE else "${Build.MANUFACTURER} ${Build.DEVICE}" - - val reportText = """ - Crash Report (Thread: ${intent?.getLongExtra("threadId", -1) ?: "unknown"}) - ${BuildConfig.APPLICATION_ID} v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE}) - on $deviceToReport (${Build.MODEL}) running Android ${Build.VERSION.RELEASE} (${Build.VERSION.SDK_INT}) - - backtrace: - - """.trimIndent() + data - // put it in the textView - binding.errorText.text = reportText + binding.errorText.text = data // Handle the Quit button binding.errorQuit.setOnClickListener { @@ -51,14 +37,15 @@ class ErrorActivity : BaseActivity() { // Handle the Email Button binding.errorSubmitEmail.setOnClickListener { - // Create Intent - val intent = Intent(Intent.ACTION_SEND) - intent.data = Uri.parse("mailto:") - intent.type = "text/plain" + val intent = Intent(Intent.ACTION_VIEW) + intent.setDataAndType( + Uri.parse("mailto:"), + "text/plain" + ) intent.putExtra(Intent.EXTRA_EMAIL, arrayOf("report.openhealth@dzeio.com")) intent.putExtra(Intent.EXTRA_SUBJECT, "Error report for application crash") - intent.putExtra(Intent.EXTRA_TEXT, "Send Report Email\n$reportText") + intent.putExtra(Intent.EXTRA_TEXT, "Send Report Email\n$data") try { startActivity(Intent.createChooser(intent, "Send Report Email...")) @@ -69,14 +56,17 @@ class ErrorActivity : BaseActivity() { // Handle the GitHub Button binding.errorSubmitGithub.setOnClickListener { - // Build URL - val url = "https://github.com/dzeiocom/OpenHealth/issues/new?title=Application Error&body=$reportText" + val url = + "https://github.com/dzeiocom/OpenHealth/issues/new?" + + "title=Application Error&" + + "body=${data?.replace("\n", "\\n")}" try { - startActivity( - Intent(Intent.ACTION_VIEW, Uri.parse(url)) - ) + val intent = Intent(Intent.ACTION_VIEW).apply { + setData(Uri.parse(url)) + } + startActivity(intent) } catch (e: ActivityNotFoundException) { Toast.makeText(this, "No Web Browser found :(", Toast.LENGTH_LONG).show() } diff --git a/app/src/main/java/com/dzeio/openhealth/ui/MainActivity.kt b/app/src/main/java/com/dzeio/openhealth/ui/MainActivity.kt index 20d198c..0a46760 100644 --- a/app/src/main/java/com/dzeio/openhealth/ui/MainActivity.kt +++ b/app/src/main/java/com/dzeio/openhealth/ui/MainActivity.kt @@ -3,7 +3,6 @@ package com.dzeio.openhealth.ui import android.app.NotificationChannel import android.app.NotificationManager import android.content.Context -import android.content.Intent import android.content.res.Configuration import android.os.Build import android.os.Bundle @@ -11,6 +10,7 @@ import android.util.Log import android.view.LayoutInflater import android.view.Menu import android.view.MenuItem +import android.view.WindowInsets import android.view.WindowManager import androidx.core.view.WindowCompat import androidx.core.view.updatePadding @@ -21,7 +21,6 @@ import androidx.navigation.ui.NavigationUI import androidx.navigation.ui.navigateUp import androidx.navigation.ui.setupActionBarWithNavController import androidx.navigation.ui.setupWithNavController -import com.dzeio.openhealth.Application import com.dzeio.openhealth.R import com.dzeio.openhealth.core.BaseActivity import com.dzeio.openhealth.databinding.ActivityMainBinding @@ -35,7 +34,7 @@ import dagger.hilt.android.AndroidEntryPoint class MainActivity : BaseActivity() { companion object { - const val TAG = "${Application.TAG}/MainActivity" + val TAG: String = this::class.java.simpleName } private lateinit var appBarConfiguration: AppBarConfiguration @@ -48,7 +47,6 @@ class MainActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { setTheme(R.style.Theme_OpenHealth_NoActionBar) super.onCreate(savedInstanceState) - } override fun onCreated(savedInstanceState: Bundle?) { @@ -58,7 +56,6 @@ class MainActivity : BaseActivity() { // Comportement chelou API 28- // Comportement normal 31+ - // do not do the cool status/navigation bars for API 29 & 30 if (Build.VERSION.SDK_INT != Build.VERSION_CODES.R && Build.VERSION.SDK_INT != Build.VERSION_CODES.Q) { // allow to put the content behind the status bar & Navigation bar (one of them at least lul) @@ -66,25 +63,23 @@ class MainActivity : BaseActivity() { window.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION) window.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS) - // Make the color of the navigation bar semi-transparent -// window.navigationBarColor = Color.TRANSPARENT - // Make the color of the status bar transparent -// window.statusBarColor = Color.TRANSPARENT - // Apply the previous changes -// window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS) - // Update toolbar height with the statusbar size included // ALSO: make both the status/navigation bars transparent (WHYYYYYYY) val toolbarHeight = binding.toolbar.layoutParams.height window.decorView.setOnApplyWindowInsetsListener { _, insets -> - val statusBarSize = insets.systemWindowInsetTop + // Use getInsets(int) with WindowInsets.Type.systemBars() instead. + val statusBarSize = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + insets.getInsets(WindowInsets.Type.systemBars()).top + } else { + insets.systemWindowInsetTop + } // Add padding to the toolbar (YaY I know how something works) binding.toolbar.updatePadding(top = statusBarSize) binding.toolbar.layoutParams.height = toolbarHeight + statusBarSize return@setOnApplyWindowInsetsListener insets } - // normally makes sure icons are at the correct color but idk if it works + // normally makes sure icons are at the correct color when (this.resources.configuration.uiMode.and(Configuration.UI_MODE_NIGHT_MASK)) { Configuration.UI_MODE_NIGHT_YES -> { WindowCompat.getInsetsController(window, window.decorView).apply { @@ -121,25 +116,17 @@ class MainActivity : BaseActivity() { binding.bottomNav.setupWithNavController(navController) -// registerForActivityResult(ActivityResultContracts.RequestPermission()) { -// -// } -// .launch(Manifest.permission.ACTIVITY_RECOGNITION) - createNotificationChannel() // Services WaterReminderWorker.setup(this) -// StepCountService.setup(this) ServiceUtils.startService(this, OpenHealthService::class.java) } - override fun onCreateOptionsMenu(menu: Menu): Boolean { - super.onCreateOptionsMenu(menu) menuInflater.inflate(R.menu.main, menu) - return true + return super.onCreateOptionsMenu(menu) } override fun onSupportNavigateUp(): Boolean = @@ -149,22 +136,13 @@ class MainActivity : BaseActivity() { NavigationUI.onNavDestinationSelected(item, navController) || super.onOptionsItemSelected(item) - @Deprecated("Deprecated in Java") - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - super.onActivityResult(requestCode, resultCode, data) - Log.d("MainActivity", "onActivityResult $requestCode $resultCode") - for (fragment in supportFragmentManager.primaryNavigationFragment!!.childFragmentManager.fragments) { - fragment.onActivityResult(requestCode, resultCode, data) - } - } - private fun createNotificationChannel() { val notificationManager: NotificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { for (channel in NotificationChannels.values()) { - Log.d("MainActivity", channel.channelName) + Log.d(TAG, channel.channelName) try { notificationManager.createNotificationChannel( NotificationChannel( @@ -174,7 +152,7 @@ class MainActivity : BaseActivity() { ) ) } catch (e: Exception) { - Log.e("MainActivity", "Error Creating Notification Channel", e) + Log.e(TAG, "Error Creating Notification Channel", e) } } } diff --git a/app/src/main/java/com/dzeio/openhealth/ui/about/AboutFragment.kt b/app/src/main/java/com/dzeio/openhealth/ui/about/AboutFragment.kt index 63f67b6..8e9d2b9 100644 --- a/app/src/main/java/com/dzeio/openhealth/ui/about/AboutFragment.kt +++ b/app/src/main/java/com/dzeio/openhealth/ui/about/AboutFragment.kt @@ -15,6 +15,9 @@ import com.dzeio.openhealth.core.BaseStaticFragment import com.dzeio.openhealth.databinding.FragmentAboutBinding import com.google.android.gms.oss.licenses.OssLicensesMenuActivity +/** + * Fragment for the About page + */ class AboutFragment : BaseStaticFragment() { override val bindingInflater: (LayoutInflater, ViewGroup?, Boolean) -> FragmentAboutBinding get() = FragmentAboutBinding::inflate @@ -23,22 +26,29 @@ class AboutFragment : BaseStaticFragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + // set the version number binding.version.text = resources.getString(R.string.version_number, BuildConfig.VERSION_NAME) + // handle contact US button binding.contactUs.setOnClickListener { - openLink("mailto:context.openhealth@dze.io") + openLink("mailto:contact.openhealth@dze.io") } + // handle Github button binding.github.setOnClickListener { openLink("https://github.com/dzeiocom/OpenHealth") } + // send the user to the Google OSS licenses page when clicked binding.licenses.setOnClickListener { startActivity(Intent(requireContext(), OssLicensesMenuActivity::class.java)) } } + /** + * simple function that try to open a link. + */ private fun openLink(url: String) { try { val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) diff --git a/app/src/main/java/com/dzeio/openhealth/ui/activity/ActivityFragment.kt b/app/src/main/java/com/dzeio/openhealth/ui/activity/ActivityFragment.kt index 5b20b54..c50a589 100644 --- a/app/src/main/java/com/dzeio/openhealth/ui/activity/ActivityFragment.kt +++ b/app/src/main/java/com/dzeio/openhealth/ui/activity/ActivityFragment.kt @@ -10,6 +10,9 @@ import com.dzeio.openhealth.core.BaseFragment import com.dzeio.openhealth.databinding.FragmentActivityBinding import dagger.hilt.android.AndroidEntryPoint +/** + * Fragment for the Activity page + */ @AndroidEntryPoint class ActivityFragment : BaseFragment(ActivityViewModel::class.java) { diff --git a/app/src/main/java/com/dzeio/openhealth/ui/browse/BrowseFragment.kt b/app/src/main/java/com/dzeio/openhealth/ui/browse/BrowseFragment.kt index 998f765..98d3b42 100644 --- a/app/src/main/java/com/dzeio/openhealth/ui/browse/BrowseFragment.kt +++ b/app/src/main/java/com/dzeio/openhealth/ui/browse/BrowseFragment.kt @@ -1,7 +1,6 @@ package com.dzeio.openhealth.ui.browse import android.Manifest -import android.content.SharedPreferences import android.os.Build import android.os.Bundle import android.view.LayoutInflater @@ -10,7 +9,6 @@ import android.view.ViewGroup import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.navigation.fragment.findNavController -import androidx.preference.PreferenceManager import com.dzeio.openhealth.R import com.dzeio.openhealth.core.BaseFragment import com.dzeio.openhealth.databinding.FragmentBrowseBinding @@ -25,10 +23,6 @@ class BrowseFragment : override val bindingInflater: (LayoutInflater, ViewGroup?, Boolean) -> FragmentBrowseBinding get() = FragmentBrowseBinding::inflate - private val settings: SharedPreferences by lazy { - PreferenceManager.getDefaultSharedPreferences(requireContext()) - } - private lateinit var button: MaterialCardView private val activityResult = registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions() @@ -45,21 +39,32 @@ class BrowseFragment : override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + // handle clicking on the weight card binding.weight.setOnClickListener { findNavController().navigate(BrowseFragmentDirections.actionNavBrowseToNavListWeight()) } + // handle clicking on the water intake card binding.waterIntake.setOnClickListener { findNavController().navigate(BrowseFragmentDirections.actionNavBrowseToNavWaterHome()) } + // handle clicking on the food calories card + binding.foodCalories.setOnClickListener { + findNavController().navigate(BrowseFragmentDirections.actionNavBrowseToFoodHomeFragment()) + } + + // handle clicking on the steps card binding.steps.setOnClickListener { + // since Android Q We need additionnal permissions if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + // check for activity permission val activityPermission = PermissionsManager.hasPermission( requireContext(), Manifest.permission.ACTIVITY_RECOGNITION ) + // check for notification permission val notificationPermission = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && PermissionsManager.hasPermission( requireContext(), @@ -68,38 +73,56 @@ class BrowseFragment : val permissionsToAsk = arrayListOf() + // add missing permission to list if (!activityPermission) { permissionsToAsk.add(Manifest.permission.ACTIVITY_RECOGNITION) } + // add missing permission to list only if necessary if (!notificationPermission && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { permissionsToAsk.add(Manifest.permission.POST_NOTIFICATIONS) } + // ask for permissions if (permissionsToAsk.isNotEmpty()) { button = binding.steps activityResult.launch(permissionsToAsk.toTypedArray()) return@setOnClickListener } } + + // navigate user to the Steps home fragment findNavController().navigate( BrowseFragmentDirections.actionNavBrowseToStepsHomeFragment() ) } + // display the number of steps the user made today viewModel.steps.observe(viewLifecycleOwner) { - binding.stepsText.setText("$it of xxx steps") + updateStepsText(it, viewModel.stepsGoal.value) } + // display the number of steps the user should do today + viewModel.stepsGoal.observe(viewLifecycleOwner) { + updateStepsText(viewModel.steps.value, it) + } + + // display the current user's weight viewModel.weight.observe(viewLifecycleOwner) { binding.weightText.setText( String.format( resources.getString(R.string.weight_current), - it, - resources.getString(R.string.unit_mass_kilogram_unit) + String.format(resources.getString(R.string.unit_mass_kilogram_unit), it) ) ) } + } + private fun updateStepsText(numberOfSteps: Int?, goal: Int?) { + var text = "${numberOfSteps ?: 0} steps" + if (goal != null) { + text = "${numberOfSteps ?: 0} of $goal steps" + } + binding.stepsText.setText(text) } } diff --git a/app/src/main/java/com/dzeio/openhealth/ui/browse/BrowseViewModel.kt b/app/src/main/java/com/dzeio/openhealth/ui/browse/BrowseViewModel.kt index 9a3d215..5335aae 100644 --- a/app/src/main/java/com/dzeio/openhealth/ui/browse/BrowseViewModel.kt +++ b/app/src/main/java/com/dzeio/openhealth/ui/browse/BrowseViewModel.kt @@ -3,23 +3,28 @@ package com.dzeio.openhealth.ui.browse import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope +import com.dzeio.openhealth.Settings import com.dzeio.openhealth.core.BaseViewModel import com.dzeio.openhealth.data.step.StepRepository import com.dzeio.openhealth.data.weight.WeightRepository +import com.dzeio.openhealth.utils.Configuration import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch -import javax.inject.Inject @HiltViewModel class BrowseViewModel @Inject internal constructor( stepRepository: StepRepository, - weightRepository: WeightRepository + weightRepository: WeightRepository, + config: Configuration ) : BaseViewModel() { private val _steps = MutableLiveData(0) val steps: LiveData = _steps + val stepsGoal = config.getInt(Settings.STEPS_GOAL).toLiveData() + private val _weight = MutableLiveData(0f) val weight: LiveData = _weight diff --git a/app/src/main/java/com/dzeio/openhealth/ui/extension/ExtensionFragment.kt b/app/src/main/java/com/dzeio/openhealth/ui/extension/ExtensionFragment.kt deleted file mode 100644 index c383d8d..0000000 --- a/app/src/main/java/com/dzeio/openhealth/ui/extension/ExtensionFragment.kt +++ /dev/null @@ -1,90 +0,0 @@ -package com.dzeio.openhealth.ui.extension - -import android.app.ProgressDialog -import android.content.Context -import android.os.Bundle -import android.util.Log -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.activity.result.ActivityResultLauncher -import androidx.activity.result.contract.ActivityResultContract -import androidx.lifecycle.lifecycleScope -import androidx.navigation.fragment.navArgs -import com.dzeio.openhealth.core.BaseFragment -import com.dzeio.openhealth.databinding.FragmentExtensionBinding -import com.dzeio.openhealth.extensions.Extension -import com.dzeio.openhealth.extensions.ExtensionFactory -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch - -@AndroidEntryPoint -class ExtensionFragment : - BaseFragment(ExtensionViewModel::class.java) { - - override val bindingInflater: (LayoutInflater, ViewGroup?, Boolean) -> FragmentExtensionBinding = - FragmentExtensionBinding::inflate - - private val args: ExtensionFragmentArgs by navArgs() - - private val extension by lazy { - ExtensionFactory.getExtension(args.extension) - ?: throw Exception("No Extension found!") - } - - private var request: ActivityResultLauncher? = null - - override fun onAttach(context: Context) { - if (this.extension.contract != null) { - this.request = - registerForActivityResult(this.extension.contract!! as ActivityResultContract) { - this.extension.onActivityResult(it as Any) - } - } - super.onAttach(context) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - val enabled = extension.enable(this) - - if (!enabled) { - throw Exception("Extension can't be enabled (${extension.id})") - } - - requireActivity().actionBar?.title = extension.name - -// extension.init(requireActivity()) - - binding.importButton.setOnClickListener { - val dialog = ProgressDialog(requireContext()) - dialog.setTitle("Importing...") - dialog.setMessage("Imported 0 values") - dialog.show() - lifecycleScope.launch { - extension.importWeight().collectLatest { state -> - Log.d("ExtensionFragment", state.state.name) - dialog.setMessage(state.statusMessage ?: "progress ${state.progress}%") - if (state.state == Extension.TaskState.DONE) { - dialog.setMessage("Finishing Import...") - lifecycleScope.launchWhenStarted { - state.additionalData!!.forEach { - it.source = extension.id - viewModel.importWeight(it) - } - dialog.dismiss() - } - } - } - } - } - - lifecycleScope.launch { - if (!extension.permissionsGranted() && request != null) { - request!!.launch(extension.requestInput) - } - } - } -} diff --git a/app/src/main/java/com/dzeio/openhealth/ui/extension/ExtensionViewModel.kt b/app/src/main/java/com/dzeio/openhealth/ui/extension/ExtensionViewModel.kt deleted file mode 100644 index 74597fc..0000000 --- a/app/src/main/java/com/dzeio/openhealth/ui/extension/ExtensionViewModel.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.dzeio.openhealth.ui.extension - -import androidx.lifecycle.MutableLiveData -import com.dzeio.openhealth.core.BaseViewModel -import com.dzeio.openhealth.data.weight.Weight -import com.dzeio.openhealth.data.weight.WeightRepository -import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject - -@HiltViewModel -class ExtensionViewModel @Inject internal constructor( - private val weightRepository: WeightRepository -) : BaseViewModel() { - - val text = MutableLiveData().apply { - value = "This is slideshow Fragment" - } - val importProgress = MutableLiveData().apply { - value = 0 - } - // If -1 progress is undetermined - // If 0 no progress bar - // Else progress bar - val importProgressTotal = MutableLiveData().apply { - value = 0 - } - - suspend fun importWeight(weight: Weight) = weightRepository.addWeight(weight) - suspend fun deleteFromSource(source: String) = weightRepository.deleteFromSource(source) -} \ No newline at end of file diff --git a/app/src/main/java/com/dzeio/openhealth/ui/extensions/ExtensionsFragment.kt b/app/src/main/java/com/dzeio/openhealth/ui/extensions/ExtensionsFragment.kt deleted file mode 100644 index 8186e99..0000000 --- a/app/src/main/java/com/dzeio/openhealth/ui/extensions/ExtensionsFragment.kt +++ /dev/null @@ -1,78 +0,0 @@ -package com.dzeio.openhealth.ui.extensions - -import android.os.Bundle -import android.util.Log -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.lifecycle.lifecycleScope -import androidx.navigation.fragment.findNavController -import androidx.recyclerview.widget.LinearLayoutManager -import com.dzeio.openhealth.adapters.ExtensionAdapter -import com.dzeio.openhealth.core.BaseFragment -import com.dzeio.openhealth.databinding.FragmentExtensionsBinding -import com.dzeio.openhealth.extensions.Extension -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.launch - -@AndroidEntryPoint -class ExtensionsFragment : - BaseFragment(ExtensionsViewModel::class.java) { - - companion object { - const val TAG = "ExtensionsFragment" - } - - override val bindingInflater: (LayoutInflater, ViewGroup?, Boolean) -> FragmentExtensionsBinding = - FragmentExtensionsBinding::inflate - - private lateinit var activeExtension: Extension - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - val recycler = binding.list - - val manager = LinearLayoutManager(requireContext()) - recycler.layoutManager = manager - - val adapter = ExtensionAdapter(viewModel.config) - adapter.onItemClick = { - activeExtension = it - activeExtension.enable(this) - Log.d(TAG, "${it.id}: ${it.name}") - - lifecycleScope.launch { - extensionIsConnected(it) - } - } - recycler.adapter = adapter - - val list = viewModel.extensions - list.forEach { - it.enable(this) - } - - adapter.set(list) - } - - private suspend fun extensionIsConnected(it: Extension) { - // check if it is connected - if (it.isConnected()) { - gotoExtension(it) - return - } - - val ld = it.connect() - if (ld) { - gotoExtension(it) - } - // handle if extension can't be connected - } - - private fun gotoExtension(it: Extension) { - findNavController().navigate( - ExtensionsFragmentDirections.actionNavExtensionsToNavExtension(it.id) - ) - } -} diff --git a/app/src/main/java/com/dzeio/openhealth/ui/extensions/ExtensionsViewModel.kt b/app/src/main/java/com/dzeio/openhealth/ui/extensions/ExtensionsViewModel.kt deleted file mode 100644 index 646e69e..0000000 --- a/app/src/main/java/com/dzeio/openhealth/ui/extensions/ExtensionsViewModel.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.dzeio.openhealth.ui.extensions - -import com.dzeio.openhealth.core.BaseViewModel -import com.dzeio.openhealth.extensions.ExtensionFactory -import com.dzeio.openhealth.utils.Configuration -import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject - -@HiltViewModel -class ExtensionsViewModel @Inject internal constructor( - val config: Configuration -) : BaseViewModel() { - - val extensions = ExtensionFactory.getAll() - -} diff --git a/app/src/main/java/com/dzeio/openhealth/ui/food/FoodDialog.kt b/app/src/main/java/com/dzeio/openhealth/ui/food/FoodDialog.kt new file mode 100644 index 0000000..4f33981 --- /dev/null +++ b/app/src/main/java/com/dzeio/openhealth/ui/food/FoodDialog.kt @@ -0,0 +1,94 @@ +package com.dzeio.openhealth.ui.food + +import android.util.Log +import android.view.LayoutInflater +import androidx.core.widget.addTextChangedListener +import androidx.navigation.fragment.navArgs +import com.dzeio.openhealth.R +import com.dzeio.openhealth.core.BaseDialog +import com.dzeio.openhealth.databinding.DialogFoodProductBinding +import com.dzeio.openhealth.utils.NetworkUtils +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import dagger.hilt.android.AndroidEntryPoint + +/** + * Dialog that display to the user a spcific product and it's consumption + */ +@AndroidEntryPoint +class FoodDialog : + BaseDialog(FoodDialogViewModel::class.java) { + + private val args: FoodDialogArgs by navArgs() + + private var quantity: Float? = null + + override val bindingInflater: (LayoutInflater) -> DialogFoodProductBinding = + DialogFoodProductBinding::inflate + + override fun onBuilderInit(builder: MaterialAlertDialogBuilder) { + super.onBuilderInit(builder) + + builder.apply { + setTitle("Product") + setIcon(R.drawable.ic_outline_fastfood_24) + setPositiveButton(R.string.validate) { dialog, _ -> + if (quantity != null) { + viewModel.saveNewQuantity(quantity!!) + } + dialog.dismiss() + } + setNegativeButton(R.string.cancel) { dialog, _ -> + if (args.deleteOnCancel) { + viewModel.delete() + } + dialog.cancel() + } + setNeutralButton(R.string.delete) { _, _ -> + viewModel.delete() + } + } + } + + override fun onCreated() { + super.onCreated() + Log.d("FoodDialog", args.id.toString()) + viewModel.init(args.id) + + viewModel.items.observe(this) { + Log.d("FoodDialog", it.toString()) + updateGraphs(null) + + binding.serving.text = "Serving: ${it.serving}" + if (it.image != null) { + NetworkUtils.getImageInBackground( + binding.image, + it.image!! + ) + } + binding.quantity.setText(it.quantity.toString()) + } + + binding.quantity.addTextChangedListener { + updateGraphs(binding.quantity.text.toString().toFloatOrNull()) + } + } + + private fun updateGraphs(newQuantity: Float?) { + quantity = newQuantity + viewModel.items.value?.let { + val transformer = newQuantity ?: it.quantity + val energy = it.energy / 100 * transformer + binding.energyTxt.text = "${energy.toInt()} / 2594kcal" + binding.energyBar.progress = (100 * energy / 2594).toInt() + val proteins = it.proteins / 100 * transformer + binding.proteinsTxt.text = "${proteins.toInt()} / 130g" + binding.proteinsBar.progress = (100 * proteins / 130).toInt() + val carbohydrates = it.carbohydrates / 100 * transformer + binding.carbsTxt.text = "${carbohydrates.toInt()} / 324g" + binding.carbsBar.progress = (100 * carbohydrates / 324).toInt() + val fat = it.fat / 100 * transformer + binding.fatTxt.text = "${fat.toInt()} / 87g" + binding.fatBar.progress = (100 * fat / 87).toInt() + } + } +} diff --git a/app/src/main/java/com/dzeio/openhealth/ui/food/FoodDialogViewModel.kt b/app/src/main/java/com/dzeio/openhealth/ui/food/FoodDialogViewModel.kt new file mode 100644 index 0000000..59b9f27 --- /dev/null +++ b/app/src/main/java/com/dzeio/openhealth/ui/food/FoodDialogViewModel.kt @@ -0,0 +1,55 @@ +package com.dzeio.openhealth.ui.food + +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import com.dzeio.openhealth.core.BaseViewModel +import com.dzeio.openhealth.data.food.Food +import com.dzeio.openhealth.data.food.FoodRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +@HiltViewModel +class FoodDialogViewModel @Inject internal constructor( + private val foodRepository: FoodRepository +) : BaseViewModel() { + val items: MutableLiveData = MutableLiveData() + + fun init(productId: Long) { + viewModelScope.launch { + val res = foodRepository.getById(productId) + val food = res.first() + if (food != null) { + items.postValue(food!!) + } + } + } + + fun delete() { + viewModelScope.launch { + val item = items.value + if (item != null) { + foodRepository.delete(item) + } + } + } + + fun saveNewQuantity(quantity: Float) { + val it = items.value + if (it == null) { + return + } + + val transformer = quantity / it.quantity + it.energy = it.energy * transformer + it.proteins = it.proteins * transformer + it.carbohydrates = it.carbohydrates * transformer + it.fat = it.fat * transformer + it.quantity = quantity + + viewModelScope.launch { + foodRepository.update(it) + } + } +} diff --git a/app/src/main/java/com/dzeio/openhealth/ui/food/FoodHomeFragment.kt b/app/src/main/java/com/dzeio/openhealth/ui/food/FoodHomeFragment.kt new file mode 100644 index 0000000..38110c1 --- /dev/null +++ b/app/src/main/java/com/dzeio/openhealth/ui/food/FoodHomeFragment.kt @@ -0,0 +1,115 @@ +package com.dzeio.openhealth.ui.food + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import androidx.navigation.fragment.findNavController +import androidx.recyclerview.widget.LinearLayoutManager +import com.dzeio.openhealth.Application +import com.dzeio.openhealth.R +import com.dzeio.openhealth.adapters.FoodAdapter +import com.dzeio.openhealth.core.BaseFragment +import com.dzeio.openhealth.databinding.FragmentFoodHomeBinding +import com.dzeio.openhealth.ui.steps.FoodHomeViewModel +import dagger.hilt.android.AndroidEntryPoint +import java.util.Calendar + +@AndroidEntryPoint +class FoodHomeFragment : + BaseFragment(FoodHomeViewModel::class.java) { + + companion object { + const val TAG = "${Application.TAG}/SHFragment" + } + + override val bindingInflater: (LayoutInflater, ViewGroup?, Boolean) -> FragmentFoodHomeBinding = + FragmentFoodHomeBinding::inflate + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + // FIXME: deprecated + setHasOptionsMenu(true) + + viewModel.init() + + val recycler = binding.list + + val manager = LinearLayoutManager(requireContext()) + recycler.layoutManager = manager + + val adapter = FoodAdapter() + adapter.onItemClick = { + findNavController().navigate( + FoodHomeFragmentDirections.actionFoodHomeFragmentToNavDialogFoodProduct( + it.id, + false + ) + ) + } + recycler.adapter = adapter + + viewModel.items.observe(viewLifecycleOwner) { + adapter.set(it) + + var energy = 0f + var proteins = 0f + var carbohydrates = 0f + var fat = 0f + for (food in it) { + energy += food.energy / 100 * food.quantity + proteins += food.proteins / 100 * food.quantity + carbohydrates += food.carbohydrates / 100 * food.quantity + fat += food.fat / 100 * food.quantity + } + binding.energyTxt.text = "${energy.toInt()} / 2594kcal" + binding.energyBar.progress = (100 * energy / 2594).toInt() + binding.proteinsTxt.text = "${proteins.toInt()} / 130g" + binding.proteinsBar.progress = (100 * proteins / 130).toInt() + binding.carbsTxt.text = "${carbohydrates.toInt()} / 324g" + binding.carbsBar.progress = (100 * carbohydrates / 324).toInt() + binding.fatTxt.text = "${fat.toInt()} / 87g" + binding.fatBar.progress = (100 * fat / 87).toInt() + } + + binding.next.setOnClickListener { + viewModel.next() + } + + binding.previous.setOnClickListener { + viewModel.previous() + } + + viewModel.date.observe(viewLifecycleOwner) { + val date = Calendar.getInstance() + date.timeInMillis = it + binding.date.text = "${date.get(Calendar.YEAR)}-${date.get(Calendar.MONTH) + 1}-${date.get( + Calendar.DAY_OF_MONTH + )}" + } + } + + @Deprecated("Deprecated in Java") + override fun onPrepareOptionsMenu(menu: Menu) { + menu.findItem(R.id.action_add).isVisible = true + + super.onPrepareOptionsMenu(menu) + } + + @Deprecated("Deprecated in Java") + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + R.id.action_add -> { + findNavController().navigate( + FoodHomeFragmentDirections.actionFoodHomeFragmentToNavDialogFoodSearch() + ) + true + } + + else -> super.onOptionsItemSelected(item) + } + } +} diff --git a/app/src/main/java/com/dzeio/openhealth/ui/food/FoodHomeViewModel.kt b/app/src/main/java/com/dzeio/openhealth/ui/food/FoodHomeViewModel.kt new file mode 100644 index 0000000..a47fee9 --- /dev/null +++ b/app/src/main/java/com/dzeio/openhealth/ui/food/FoodHomeViewModel.kt @@ -0,0 +1,69 @@ +package com.dzeio.openhealth.ui.steps + +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import com.dzeio.openhealth.core.BaseViewModel +import com.dzeio.openhealth.data.food.Food +import com.dzeio.openhealth.data.food.FoodRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import java.util.Calendar +import javax.inject.Inject +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +@HiltViewModel +class FoodHomeViewModel @Inject internal constructor( + private val foodRepository: FoodRepository +) : BaseViewModel() { + val items: MutableLiveData> = MutableLiveData() + private val list: MutableLiveData> = MutableLiveData(arrayListOf()) + val date: MutableLiveData = MutableLiveData(Calendar.getInstance().timeInMillis) + + fun init() { + val now = Calendar.getInstance() + now.set(Calendar.HOUR, 0) + now.set(Calendar.MINUTE, 0) + now.set(Calendar.SECOND, 0) + date.postValue(now.timeInMillis) + viewModelScope.launch { + foodRepository.getAll().collectLatest { + list.postValue(it) + updateList(it) + } + } + } + + fun next() { + val now = Calendar.getInstance() + now.timeInMillis = date.value!! + now.add(Calendar.DAY_OF_YEAR, 1) + + date.value = now.timeInMillis + + updateList() + } + + fun previous() { + val now = Calendar.getInstance() + now.timeInMillis = date.value!! + now.add(Calendar.DAY_OF_YEAR, -1) + + date.value = now.timeInMillis + + updateList() + } + + private fun updateList(foods: List? = null) { + val day = Calendar.getInstance() + day.timeInMillis = date.value!! + val todayInMillis = day.timeInMillis + day.add(Calendar.DAY_OF_YEAR, 1) + val tomorrow = day.timeInMillis + + items.postValue( + (foods ?: list.value!!).filter { food -> + food.timestamp in todayInMillis until tomorrow + } + ) + } +} diff --git a/app/src/main/java/com/dzeio/openhealth/ui/food/SearchFoodDialog.kt b/app/src/main/java/com/dzeio/openhealth/ui/food/SearchFoodDialog.kt new file mode 100644 index 0000000..0b6d0fe --- /dev/null +++ b/app/src/main/java/com/dzeio/openhealth/ui/food/SearchFoodDialog.kt @@ -0,0 +1,84 @@ +package com.dzeio.openhealth.ui.food + +import android.view.LayoutInflater +import android.view.View +import androidx.appcompat.app.AlertDialog +import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.findNavController +import androidx.recyclerview.widget.LinearLayoutManager +import com.dzeio.openhealth.R +import com.dzeio.openhealth.adapters.FoodAdapter +import com.dzeio.openhealth.core.BaseDialog +import com.dzeio.openhealth.databinding.DialogFoodSearchProductBinding +import com.dzeio.openhealth.utils.NetworkResult +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch + +@AndroidEntryPoint +class SearchFoodDialog : + BaseDialog( + SearchFoodDialogViewModel::class.java + ) { + + override val bindingInflater: (LayoutInflater) -> DialogFoodSearchProductBinding = + DialogFoodSearchProductBinding::inflate + + override fun onBuilderInit(builder: MaterialAlertDialogBuilder) { + super.onBuilderInit(builder) + + builder.apply { + setTitle("Add Product") + setIcon(R.drawable.ic_outline_fastfood_24) + setNegativeButton(R.string.close) { dialog, _ -> + dialog.cancel() + } + setNeutralButton("Search", null) + } + } + + override fun onDialogInit(dialog: AlertDialog) { + super.onDialogInit(dialog) + + dialog.setOnShowListener { + val btn = dialog.getButton(AlertDialog.BUTTON_NEUTRAL) + btn.setOnClickListener { + viewModel.search(binding.input.text.toString()) + binding.loading.visibility = View.VISIBLE + } + } + } + + override fun onCreated() { + super.onCreated() + + val recycler = binding.list + + val manager = LinearLayoutManager(requireContext()) + recycler.layoutManager = manager + + val adapter = FoodAdapter() + adapter.onItemClick = { + lifecycleScope.launch { + val id = viewModel.addProduct(it) + findNavController().navigate( + SearchFoodDialogDirections.actionNavDialogFoodSearchToNavDialogFoodProduct( + id, + true + ) + ) + } + } + recycler.adapter = adapter + + viewModel.items.observe(this) { + adapter.set(it.data ?: arrayListOf()) + if (it.status == NetworkResult.NetworkStatus.FINISHED) { + binding.loading.visibility = View.GONE + } else if (it.status == NetworkResult.NetworkStatus.ERRORED) { + binding.errorText.visibility = View.VISIBLE + binding.loading.visibility = View.GONE + } + } + } +} diff --git a/app/src/main/java/com/dzeio/openhealth/ui/food/SearchFoodDialogViewModel.kt b/app/src/main/java/com/dzeio/openhealth/ui/food/SearchFoodDialogViewModel.kt new file mode 100644 index 0000000..74f098c --- /dev/null +++ b/app/src/main/java/com/dzeio/openhealth/ui/food/SearchFoodDialogViewModel.kt @@ -0,0 +1,34 @@ +package com.dzeio.openhealth.ui.food + +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import com.dzeio.openhealth.core.BaseViewModel +import com.dzeio.openhealth.data.food.Food +import com.dzeio.openhealth.data.food.FoodRepository +import com.dzeio.openhealth.utils.NetworkResult +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +@HiltViewModel +class SearchFoodDialogViewModel @Inject internal constructor( + private val foodRepository: FoodRepository +) : BaseViewModel() { + val items: MutableLiveData>> = MutableLiveData() + + fun search(text: String) { + viewModelScope.launch { + foodRepository.searchFood(text).collectLatest { + items.postValue(it) + } + } + } + + suspend fun addProduct(product: Food): Long { + if (product.id > 0) { + product.id = 0 + } + return foodRepository.add(product) + } +} diff --git a/app/src/main/java/com/dzeio/openhealth/ui/home/HomeFragment.kt b/app/src/main/java/com/dzeio/openhealth/ui/home/HomeFragment.kt index 0e10999..5b378a5 100644 --- a/app/src/main/java/com/dzeio/openhealth/ui/home/HomeFragment.kt +++ b/app/src/main/java/com/dzeio/openhealth/ui/home/HomeFragment.kt @@ -4,20 +4,25 @@ import android.animation.ValueAnimator import android.content.SharedPreferences import android.graphics.Bitmap import android.graphics.Canvas +import android.graphics.Paint import android.graphics.RectF import android.view.LayoutInflater +import android.view.View import android.view.ViewGroup import androidx.navigation.fragment.findNavController import androidx.preference.PreferenceManager +import com.dzeio.charts.Entry +import com.dzeio.charts.axis.Line +import com.dzeio.charts.series.LineSerie +import com.dzeio.openhealth.BuildConfig import com.dzeio.openhealth.core.BaseFragment import com.dzeio.openhealth.data.water.Water import com.dzeio.openhealth.data.weight.Weight import com.dzeio.openhealth.databinding.FragmentHomeBinding -import com.dzeio.openhealth.graphs.WeightChart import com.dzeio.openhealth.ui.weight.WeightDialog import com.dzeio.openhealth.units.Units +import com.dzeio.openhealth.utils.ChartUtils import com.dzeio.openhealth.utils.DrawUtils -import com.dzeio.openhealth.utils.GraphUtils import com.google.android.material.color.MaterialColors import dagger.hilt.android.AndroidEntryPoint import kotlin.math.max @@ -44,6 +49,9 @@ class HomeFragment : BaseFragment(HomeViewMo ) } + /** + * Water Intake + */ binding.fragmentHomeWaterAdd.setOnClickListener { val water = viewModel.water.value if (water == null || !water.isToday()) { @@ -76,26 +84,34 @@ class HomeFragment : BaseFragment(HomeViewMo } } + // handle button to go to weight home binding.listWeight.setOnClickListener { findNavController().navigate(HomeFragmentDirections.actionNavHomeToNavListWeight()) } + // handle button to go to water intake home binding.gotoWaterHome.setOnClickListener { findNavController().navigate(HomeFragmentDirections.actionNavHomeToNavWaterHome()) } - GraphUtils.lineChartSetup( - binding.weightGraph, - MaterialColors.getColor( - requireView(), - com.google.android.material.R.attr.colorPrimary - ), - MaterialColors.getColor( - requireView(), - com.google.android.material.R.attr.colorOnBackground - ) - ) + binding.weightGraph.apply { + val serie = LineSerie(this) + ChartUtils.materielTheme(this, requireView()) + series = arrayListOf(serie) + } + if (BuildConfig.DEBUG) { + binding.gotoTests.apply { + visibility = View.VISIBLE + setOnClickListener { + findNavController().navigate( + HomeFragmentDirections.actionNavHomeToTestsFragment() + ) + } + } + } + + // Update the water intake Graph when the water intake changes viewModel.water.observe(viewLifecycleOwner) { if (it != null) { updateWater(it.value) @@ -104,63 +120,101 @@ class HomeFragment : BaseFragment(HomeViewMo } } + // Update the steps Graph when the steps count changes + viewModel.steps.observe(viewLifecycleOwner) { + binding.stepsCurrent.text = it.toString() + } + + // Update the steps Graph when the goal changes + viewModel.stepsGoal.observe(viewLifecycleOwner) { + if (it == null) { + binding.stepsTotal.text = "" + return@observe + } + binding.stepsTotal.text = it.toString() + } + + // update the graph when the weight changes viewModel.weights.observe(viewLifecycleOwner) { if (it != null) { updateGraph(it) } } + // update the graph when the goal weight change viewModel.goalWeight.observe(viewLifecycleOwner) { if (viewModel.weights.value != null) updateGraph(viewModel.weights.value!!) } + // update the graph when the weight unit change viewModel.massUnit.observe(viewLifecycleOwner) { if (viewModel.weights.value != null) updateGraph(viewModel.weights.value!!) } } + /** + * Function that update the graph for the weight + */ private fun updateGraph(list: List) { - WeightChart.setup( - binding.weightGraph, - requireView(), - list, - viewModel.massUnit.value!!, - viewModel.goalWeight.value - ) + val chart = binding.weightGraph + val serie = chart.series[0] as LineSerie -// legend.apply { -// isEnabled = true -// form = Legend.LegendForm.LINE -// -// if (goal != null) { -// val legendEntry = LegendEntry().apply { -// label = "Weight Goal" -// formColor = Color.RED -// } -// setCustom(arrayOf(legendEntry)) -// } -// } + val entries: ArrayList = arrayListOf() + + list.forEach { + entries.add( + Entry( + it.timestamp.toDouble(), + it.weight + ) + ) + } + serie.entries = entries + + if (viewModel.goalWeight.value != null) { + chart.yAxis.addLine( + viewModel.goalWeight.value!!, + Line(true, Paint(chart.yAxis.linePaint).apply { strokeWidth = 4f }) + ) + } + + if (list.isEmpty()) { + chart.xAxis.x = 0.0 + } else { + chart.xAxis.x = list[0].timestamp.toDouble() + } + + chart.refresh() } + /** + * the waterintake old value to keep for value update + */ private var oldValue = 0f + /** + * function that update the water count in the home page + */ private fun updateWater(newValue: Int) { - + // get the current Unit val waterUnit = Units.Volume.find(settings.getString("water_unit", "milliliter") ?: "Milliliter") + // Update the count binding.fragmentHomeWaterCurrent.text = String.format( resources.getString(waterUnit.unit), (newValue * waterUnit.modifier).toInt() ) + // TODO: move it elsewhere binding.fragmentHomeWaterTotal.text = String.format( resources.getString(waterUnit.unit), viewModel.dailyWaterIntake ) + // get the with/height of the ImageView var width = 1500 var height = 750 @@ -169,55 +223,41 @@ class HomeFragment : BaseFragment(HomeViewMo height = binding.background.height } - val graph = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) - - val canvas = Canvas(graph) - val rect = RectF( - 10f, - 15f, - 90f, - 85f - ) - -// DrawUtils.drawRect( -// canvas, -// RectF( -// 0f, -// 0f, -// 100f, -// 100f -// ), -// MaterialColors.getColor( -// requireView(), -// com.google.android.material.R.attr.colorOnPrimary -// ), -// 3f -// ) - - DrawUtils.drawArc( - canvas, - 100f, - rect, - MaterialColors.getColor( - requireView(), - com.google.android.material.R.attr.colorOnPrimary - ), - 3f - ) - + // Prepare the update animation val animator = ValueAnimator.ofInt( - min(this.oldValue, viewModel.dailyWaterIntake.toFloat()).toInt(), - min(newValue, viewModel.dailyWaterIntake) + this.oldValue.toInt(), + newValue ) animator.duration = 300 // ms animator.addUpdateListener { + this.oldValue = (it.animatedValue as Int).toFloat() + val value = 100 * it.animatedValue as Int / viewModel.dailyWaterIntake - this.oldValue = 100 * it.animatedValue as Int / viewModel.dailyWaterIntake.toFloat() -// Log.d("Test2", "${this.oldValue}") + val graph = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(graph) + val rect = RectF( + 10f, + 15f, + 90f, + 85f + ) + // background Arc DrawUtils.drawArc( canvas, - max(this.oldValue, 1f), + 100f, + rect, + MaterialColors.getColor( + requireView(), + com.google.android.material.R.attr.colorOnPrimary + ), + 3f + ) + + // Draw the big Arc + DrawUtils.drawArc( + canvas, + min(max(value, 0.01f), 100f), rect, MaterialColors.getColor( requireView(), @@ -225,9 +265,15 @@ class HomeFragment : BaseFragment(HomeViewMo ), 6f ) + + // save the canvas canvas.save() + + // send it binding.background.setImageBitmap(graph) } + + // start the animation animator.start() } } diff --git a/app/src/main/java/com/dzeio/openhealth/ui/home/HomeViewModel.kt b/app/src/main/java/com/dzeio/openhealth/ui/home/HomeViewModel.kt index ca919d8..dfc5928 100644 --- a/app/src/main/java/com/dzeio/openhealth/ui/home/HomeViewModel.kt +++ b/app/src/main/java/com/dzeio/openhealth/ui/home/HomeViewModel.kt @@ -1,58 +1,94 @@ package com.dzeio.openhealth.ui.home -import android.content.SharedPreferences import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import com.dzeio.openhealth.Settings import com.dzeio.openhealth.core.BaseViewModel +import com.dzeio.openhealth.data.step.StepRepository import com.dzeio.openhealth.data.water.Water import com.dzeio.openhealth.data.water.WaterRepository import com.dzeio.openhealth.data.weight.Weight import com.dzeio.openhealth.data.weight.WeightRepository -import com.dzeio.openhealth.units.UnitFactory import com.dzeio.openhealth.units.Units import com.dzeio.openhealth.utils.Configuration import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch -import javax.inject.Inject @HiltViewModel class HomeViewModel @Inject internal constructor( private val weightRepository: WeightRepository, private val waterRepository: WaterRepository, - settings: SharedPreferences, + stepRepository: StepRepository, config: Configuration ) : BaseViewModel() { + private val _steps = MutableLiveData(0) + + /** + * Steps taken today by the user + */ + val steps: LiveData = _steps + + /** + * Number of steps the use should do today + */ + val stepsGoal: LiveData = config.getInt(Settings.STEPS_GOAL).toLiveData() + private val _water = MutableLiveData(null) + + /** + * Quantity of water the user drank today + */ val water: LiveData = _water private val _weights = MutableLiveData?>(null) + + /** + * The list of weight of the user + */ val weights: LiveData?> = _weights + /** + * The size of a cup for the quick water intake add + */ var waterCupSize = config.getInt("water_cup_size").toLiveData() - var waterUnit = - UnitFactory.volume(settings.getString("water_unit", "milliliter") ?: "Milliliter") + /** + * The unit used to display the water intake of the user + */ + var waterUnit = Units.Volume.find(config.getString("water_unit").value ?: "ml") private val _massUnit = MutableLiveData(Units.Mass.KILOGRAM) + + /** + * The Mass unit used by the user + */ val massUnit: LiveData = _massUnit + /** + * the User weight goal + */ val goalWeight = config.getFloat(Settings.WEIGHT_GOAL).toLiveData() - val dailyWaterIntake: Int = - ((settings.getString("water_intake", "1200")?.toFloatOrNull() ?: 1200f) * waterUnit.modifier) - .toInt() + val dailyWaterIntake: Float = (config.getFloat("water_intake").value ?: 1200f) * waterUnit.modifier init { + // Fetch today's water intake viewModelScope.launch { waterRepository.todayWater().collectLatest { _water.postValue(it) } } + // Fetch the user weights + viewModelScope.launch { + _steps.postValue(stepRepository.todaySteps()) + } + + // fetch the user weights viewModelScope.launch { weightRepository.getWeights().collectLatest { _weights.postValue(it) @@ -70,24 +106,6 @@ class HomeViewModel @Inject internal constructor( } } - /** - * @deprecated - */ - fun fetchWeights() = weightRepository.getWeights() - - /** - * @deprecated - */ - fun lastWeight() = weightRepository.lastWeight() - - fun fetchWeight(id: Long) = weightRepository.getWeight(id) - - suspend fun deleteWeight(weight: Weight) = weightRepository.deleteWeight(weight) - - suspend fun addWeight(weight: Weight) = weightRepository.addWeight(weight) - - fun fetchTodayWater() = waterRepository.todayWater() - fun updateWater(water: Water) { viewModelScope.launch { waterRepository.addWater(water) diff --git a/app/src/main/java/com/dzeio/openhealth/ui/settings/SettingsFragment.kt b/app/src/main/java/com/dzeio/openhealth/ui/settings/SettingsFragment.kt index 856dfad..640e794 100644 --- a/app/src/main/java/com/dzeio/openhealth/ui/settings/SettingsFragment.kt +++ b/app/src/main/java/com/dzeio/openhealth/ui/settings/SettingsFragment.kt @@ -40,7 +40,6 @@ class SettingsFragment : PreferenceFragmentCompat() { // Force only numbers on Goal val weightGoal = findPreference("tmp_goal_weight") weightGoal?.apply { - setOnBindEditTextListener { it.setSelectAllOnFocus(true) it.inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL @@ -60,7 +59,6 @@ class SettingsFragment : PreferenceFragmentCompat() { return@setOnPreferenceClickListener true } - setOnPreferenceChangeListener { _, newValue -> val unit = config.getString(Settings.MASS_UNIT).value var modifier = Units.Mass.KILOGRAM.modifier diff --git a/app/src/main/java/com/dzeio/openhealth/ui/steps/StepsHomeFragment.kt b/app/src/main/java/com/dzeio/openhealth/ui/steps/StepsHomeFragment.kt index 19fcbe2..05a7b77 100644 --- a/app/src/main/java/com/dzeio/openhealth/ui/steps/StepsHomeFragment.kt +++ b/app/src/main/java/com/dzeio/openhealth/ui/steps/StepsHomeFragment.kt @@ -1,23 +1,29 @@ package com.dzeio.openhealth.ui.steps +import android.graphics.Paint import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs import androidx.recyclerview.widget.LinearLayoutManager import com.dzeio.charts.Entry +import com.dzeio.charts.axis.Line import com.dzeio.charts.series.BarSerie import com.dzeio.openhealth.Application import com.dzeio.openhealth.adapters.StepsAdapter import com.dzeio.openhealth.core.BaseFragment +import com.dzeio.openhealth.data.step.Step import com.dzeio.openhealth.databinding.FragmentStepsHomeBinding -import com.google.android.material.color.MaterialColors +import com.dzeio.openhealth.utils.ChartUtils import dagger.hilt.android.AndroidEntryPoint import java.text.DateFormat import java.util.Calendar import java.util.Date import java.util.Locale import java.util.TimeZone +import kotlin.math.roundToInt @AndroidEntryPoint class StepsHomeFragment : @@ -27,6 +33,8 @@ class StepsHomeFragment : const val TAG = "${Application.TAG}/SHFragment" } + private val args: StepsHomeFragmentArgs by navArgs() + override val bindingInflater: (LayoutInflater, ViewGroup?, Boolean) -> FragmentStepsHomeBinding = FragmentStepsHomeBinding::inflate @@ -35,102 +43,142 @@ class StepsHomeFragment : viewModel.init() + val isDay = args.day > 0L + val recycler = binding.list val manager = LinearLayoutManager(requireContext()) recycler.layoutManager = manager - val adapter = StepsAdapter() - adapter.onItemClick = { -// findNavController().navigate( -// WaterHomeFragmentDirections.actionNavWaterHomeToNavWaterEdit( -// it.id -// ) -// ) + val adapter = StepsAdapter().apply { + this.isDay = isDay + } + + if (!isDay) { + adapter.onItemClick = { + findNavController().navigate( + StepsHomeFragmentDirections.actionNavStepsHomeSelf().apply { + day = it.timestamp + title = "Steps from " + it.formatTimestamp(true) + } + ) + } } recycler.adapter = adapter val chart = binding.chart // setup serie - val serie = BarSerie(chart).apply { - barPaint.color = MaterialColors.getColor( - requireView(), - com.google.android.material.R.attr.colorPrimary - ) - textPaint.color = MaterialColors.getColor( - requireView(), - com.google.android.material.R.attr.colorOnPrimary - ) - } + val serie = BarSerie(chart) chart.apply { - series = arrayListOf(serie) -// debug = true - + ChartUtils.materielTheme(chart, requireView()) yAxis.apply { - setYMax(500f) - textLabel.color = MaterialColors.getColor( - requireView(), - com.google.android.material.R.attr.colorOnPrimaryContainer - ) - linePaint.color = MaterialColors.getColor( - requireView(), - com.google.android.material.R.attr.colorOnPrimaryContainer - ) -// - onValueFormat = { value -> "${value.toInt()}" } + setYMin(0f) } xAxis.apply { - increment = 3600000.0 -// displayCount = 168 - displayCount = 10 - textPaint.color = MaterialColors.getColor( - requireView(), - com.google.android.material.R.attr.colorOnPrimaryContainer - ) + dataWidth = if (isDay) 8.64e+7 else 6.048e+8 + scrollEnabled = !isDay textPaint.textSize = 32f onValueFormat = onValueFormat@{ - val formatter = DateFormat.getDateTimeInstance( - DateFormat.SHORT, + val formatter = if (isDay) { + DateFormat.getTimeInstance( + DateFormat.SHORT, + Locale.getDefault() + ) + } else { + DateFormat.getDateInstance( + DateFormat.SHORT, + Locale.getDefault() + ) + } + return@onValueFormat formatter.format(Date(it.toLong())) + } + } + annotator.annotationTitleFormat = { "${it.y.roundToInt()} steps" } + annotator.annotationSubTitleFormat = annotationSubTitleFormat@{ + val formatter = if (isDay) { + DateFormat.getTimeInstance( + DateFormat.SHORT, + Locale.getDefault() + ) + } else { + DateFormat.getDateInstance( DateFormat.SHORT, Locale.getDefault() ) - return@onValueFormat formatter.format(Date(it.toLong())) } + return@annotationSubTitleFormat formatter.format(Date(it.x.toLong())) + } + } + viewModel.goal.observe(viewLifecycleOwner) { + if (it != null && !isDay) { + chart.yAxis.addLine( + it.toFloat(), + Line(true, Paint(chart.yAxis.linePaint).apply { strokeWidth = 4f }) + ) + chart.refresh() } } viewModel.items.observe(viewLifecycleOwner) { list -> - adapter.set(list) - if (list.isEmpty()) { + adapter.set(arrayListOf()) return@observe } - val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC")) + val filtered = if (!isDay) { + list + } else { + list.filter { + it.getDay() == args.day + } + } + if (isDay) { + adapter.set(filtered) + serie.entries = filtered.map { + Entry( + it.timestamp.toDouble(), + it.value.toFloat() + ) + } as ArrayList + } else { + val entries: HashMap = HashMap() - cal.set(Calendar.HOUR, 0) - cal.set(Calendar.MINUTE, 0) - cal.set(Calendar.SECOND, 0) - cal.set(Calendar.MILLISECOND, 0) + list.forEach { + val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC")) + cal.timeInMillis = it.timestamp -// chart.animation.enabled = false -// chart.animation.refreshRate = 60 -// chart.animation.duration = 300 + cal.set(Calendar.HOUR, 0) + cal.set(Calendar.AM_PM, Calendar.AM) + val ts = cal.timeInMillis + if (!entries.containsKey(ts)) { + entries[ts] = Entry((ts).toDouble(), 0F, chart.yAxis.goalLinePaint.color) + } -// chart.scroller.zoomEnabled = false + entries[ts]!!.y += it.value.toFloat() -// chart.xAxis.labels.size = 32f + if (viewModel.goal.value != null) { + if (entries[ts]!!.y > viewModel.goal.value!!) { + entries[ts]!!.color = null + } + } else { + entries[ts]!!.color = null + } + } - serie.entries = list.reversed().map { - return@map Entry(it.timestamp.toDouble(), it.value.toFloat()) - } as ArrayList + adapter.set( + entries.map { Step(value = it.value.y.toInt(), timestamp = it.key) } + .sortedByDescending { it.timestamp } + ) - chart.xAxis.x = serie.entries.first().x + serie.entries = ArrayList(entries.values) + } + chart.xAxis.x = + chart.xAxis.getXMax() - chart.xAxis.dataWidth!! + chart.xAxis.dataWidth!! / (if (isDay) 24 else 7) chart.refresh() } diff --git a/app/src/main/java/com/dzeio/openhealth/ui/steps/StepsHomeViewModel.kt b/app/src/main/java/com/dzeio/openhealth/ui/steps/StepsHomeViewModel.kt index 3908ac8..4e4e189 100644 --- a/app/src/main/java/com/dzeio/openhealth/ui/steps/StepsHomeViewModel.kt +++ b/app/src/main/java/com/dzeio/openhealth/ui/steps/StepsHomeViewModel.kt @@ -1,26 +1,37 @@ package com.dzeio.openhealth.ui.steps +import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope +import com.dzeio.openhealth.Settings import com.dzeio.openhealth.core.BaseViewModel import com.dzeio.openhealth.data.step.Step import com.dzeio.openhealth.data.step.StepRepository +import com.dzeio.openhealth.utils.Configuration import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch -import javax.inject.Inject @HiltViewModel class StepsHomeViewModel@Inject internal constructor( - private val stepRepository: StepRepository + private val stepRepository: StepRepository, + private val config: Configuration ) : BaseViewModel() { val items: MutableLiveData> = MutableLiveData() + private val _goal: MutableLiveData = MutableLiveData() + val goal: LiveData = _goal + fun init() { viewModelScope.launch { stepRepository.getSteps().collectLatest { items.postValue(it) } } + + this._goal.postValue( + config.getInt(Settings.STEPS_GOAL).value + ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/dzeio/openhealth/ui/tests/TestsFragment.kt b/app/src/main/java/com/dzeio/openhealth/ui/tests/TestsFragment.kt new file mode 100644 index 0000000..e838924 --- /dev/null +++ b/app/src/main/java/com/dzeio/openhealth/ui/tests/TestsFragment.kt @@ -0,0 +1,46 @@ +package com.dzeio.openhealth.ui.tests + +import android.annotation.SuppressLint +import android.view.LayoutInflater +import android.view.ViewGroup +import com.dzeio.openhealth.core.BaseFragment +import com.dzeio.openhealth.databinding.FragmentTestsBinding +import com.dzeio.openhealth.utils.Bluetooth +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class TestsFragment : + BaseFragment(TestsViewModel::class.java) { + + override val bindingInflater: (LayoutInflater, ViewGroup?, Boolean) -> FragmentTestsBinding + get() = FragmentTestsBinding::inflate + + @SuppressLint("MissingPermission") + override fun onStart() { + super.onStart() + + // Bindings + binding.button.setOnClickListener { + Bluetooth(requireContext()).apply { +// scanDevices(cb) + +// val device = DeviceMiSmartScale2(requireContext()) +// +// device.status.addObserver { +// Log.d("Device", "New device status $it") +// +// if (it == Device.ConnectionStatus.CONNECTED) { +// device.fetchWeights().addObserver { +// Log.i( +// "FetchStatus", +// "${(it.progress.toFloat() / it.progressMax.toFloat() * 100f).roundToInt()}% ${it.progress}/${it.progressMax}" +// ) +// } +// } +// } +// +// device.connect() + } + } + } +} diff --git a/app/src/main/java/com/dzeio/openhealth/ui/tests/TestsViewModel.kt b/app/src/main/java/com/dzeio/openhealth/ui/tests/TestsViewModel.kt new file mode 100644 index 0000000..1cbcdfa --- /dev/null +++ b/app/src/main/java/com/dzeio/openhealth/ui/tests/TestsViewModel.kt @@ -0,0 +1,8 @@ +package com.dzeio.openhealth.ui.tests + +import com.dzeio.openhealth.core.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class TestsViewModel @Inject internal constructor() : BaseViewModel() diff --git a/app/src/main/java/com/dzeio/openhealth/ui/water/EditWaterDialog.kt b/app/src/main/java/com/dzeio/openhealth/ui/water/EditWaterDialog.kt index 916490a..0ba0697 100644 --- a/app/src/main/java/com/dzeio/openhealth/ui/water/EditWaterDialog.kt +++ b/app/src/main/java/com/dzeio/openhealth/ui/water/EditWaterDialog.kt @@ -20,7 +20,7 @@ import com.google.android.material.datepicker.DateValidatorPointBackward import com.google.android.material.datepicker.MaterialDatePicker import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint -import java.util.* +import java.util.Date @AndroidEntryPoint class EditWaterDialog : @@ -31,8 +31,6 @@ class EditWaterDialog : private val args: EditWaterDialogArgs by navArgs() - override val isFullscreenLayout = true - var newValue: Int = 0 override fun onDialogInit(dialog: Dialog) { @@ -50,8 +48,11 @@ class EditWaterDialog : } binding.editTextNumber.doOnTextChanged { text, start, before, count -> val value = text.toString() - newValue = if (value == "") 0 - else text.toString().toInt() + newValue = if (value == "") { + 0 + } else { + text.toString().toInt() + } } binding.date.setOnClickListener { @@ -102,6 +103,7 @@ class EditWaterDialog : findNavController().popBackStack() } + @Deprecated("Deprecated in Java") override fun onOptionsItemSelected(item: MenuItem): Boolean { return when (item.itemId) { R.id.menu_fullscreen_dialog_save -> { diff --git a/app/src/main/java/com/dzeio/openhealth/ui/water/EditWaterViewModel.kt b/app/src/main/java/com/dzeio/openhealth/ui/water/EditWaterViewModel.kt index 06630ba..e31f1cb 100644 --- a/app/src/main/java/com/dzeio/openhealth/ui/water/EditWaterViewModel.kt +++ b/app/src/main/java/com/dzeio/openhealth/ui/water/EditWaterViewModel.kt @@ -6,9 +6,9 @@ import com.dzeio.openhealth.core.BaseViewModel import com.dzeio.openhealth.data.water.Water import com.dzeio.openhealth.data.water.WaterRepository import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch -import javax.inject.Inject @HiltViewModel class EditWaterViewModel @Inject internal constructor( @@ -36,4 +36,4 @@ class EditWaterViewModel @Inject internal constructor( waterRepository.addWater(water) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/dzeio/openhealth/ui/water/WaterHomeFragment.kt b/app/src/main/java/com/dzeio/openhealth/ui/water/WaterHomeFragment.kt index f92bd92..9c74cc1 100644 --- a/app/src/main/java/com/dzeio/openhealth/ui/water/WaterHomeFragment.kt +++ b/app/src/main/java/com/dzeio/openhealth/ui/water/WaterHomeFragment.kt @@ -6,15 +6,16 @@ import android.view.View import android.view.ViewGroup import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.LinearLayoutManager +import com.dzeio.charts.Entry +import com.dzeio.charts.series.BarSerie import com.dzeio.openhealth.adapters.WaterAdapter import com.dzeio.openhealth.core.BaseFragment import com.dzeio.openhealth.databinding.FragmentMainWaterHomeBinding -import com.dzeio.openhealth.utils.GraphUtils -import com.github.mikephil.charting.data.BarData -import com.github.mikephil.charting.data.BarDataSet -import com.github.mikephil.charting.data.BarEntry -import com.google.android.material.color.MaterialColors +import com.dzeio.openhealth.utils.ChartUtils import dagger.hilt.android.AndroidEntryPoint +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale @AndroidEntryPoint class WaterHomeFragment : @@ -45,39 +46,50 @@ class WaterHomeFragment : val chart = binding.chart - GraphUtils.barChartSetup( - chart, - MaterialColors.getColor( - requireView(), - com.google.android.material.R.attr.colorPrimary - ), - MaterialColors.getColor( - requireView(), - com.google.android.material.R.attr.colorOnBackground - ) - ) + val serie = BarSerie(chart) - binding.buttonEditDefaultIntake.setOnClickListener { - findNavController().navigate(WaterHomeFragmentDirections.actionNavWaterHomeToNavWaterSizeDialog()) + chart.apply { + ChartUtils.materielTheme(chart, requireView()) + + yAxis.apply { +// onValueFormat + } + + xAxis.apply { + dataWidth = 604800000.0 + textPaint.textSize = 32f + onValueFormat = onValueFormat@{ + return@onValueFormat SimpleDateFormat( + "yyyy-MM-dd", + Locale.getDefault() + ).format(Date(it.toLong())) + } + } } - chart.xAxis.valueFormatter = GraphUtils.DateValueFormatter(1000 * 60 * 60 * 24) + binding.buttonEditDefaultIntake.setOnClickListener { + findNavController().navigate( + WaterHomeFragmentDirections.actionNavWaterHomeToNavWaterSizeDialog() + ) + } viewModel.items.observe(viewLifecycleOwner) { list -> adapter.set(list) - val dataset = BarDataSet( - list.map { - return@map BarEntry( - (it.timestamp / 1000 / 60 / 60 / 24).toFloat(), - it.value.toFloat() - ) - }, - "" - ) + if (list.isEmpty()) { + return@observe + } - chart.data = BarData(dataset) - chart.invalidate() + val dataset = list.map { + return@map Entry( + it.timestamp.toDouble(), + it.value.toFloat() + ) + } + + serie.entries = dataset as ArrayList + chart.xAxis.x = dataset[0].x + chart.refresh() } } } diff --git a/app/src/main/java/com/dzeio/openhealth/ui/water/WaterHomeViewModel.kt b/app/src/main/java/com/dzeio/openhealth/ui/water/WaterHomeViewModel.kt index 8552e40..5a06b4f 100644 --- a/app/src/main/java/com/dzeio/openhealth/ui/water/WaterHomeViewModel.kt +++ b/app/src/main/java/com/dzeio/openhealth/ui/water/WaterHomeViewModel.kt @@ -6,9 +6,9 @@ import com.dzeio.openhealth.core.BaseViewModel import com.dzeio.openhealth.data.water.Water import com.dzeio.openhealth.data.water.WaterRepository import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch -import javax.inject.Inject @HiltViewModel class WaterHomeViewModel@Inject internal constructor( @@ -23,4 +23,4 @@ class WaterHomeViewModel@Inject internal constructor( } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/dzeio/openhealth/ui/water/WaterSizeSelectorDialog.kt b/app/src/main/java/com/dzeio/openhealth/ui/water/WaterSizeSelectorDialog.kt index ca4c865..b49f762 100644 --- a/app/src/main/java/com/dzeio/openhealth/ui/water/WaterSizeSelectorDialog.kt +++ b/app/src/main/java/com/dzeio/openhealth/ui/water/WaterSizeSelectorDialog.kt @@ -1,7 +1,6 @@ package com.dzeio.openhealth.ui.water import android.view.LayoutInflater -import androidx.core.view.marginBottom import com.dzeio.openhealth.R import com.dzeio.openhealth.core.BaseDialog import com.dzeio.openhealth.databinding.DialogWaterSizeSelectorBinding diff --git a/app/src/main/java/com/dzeio/openhealth/ui/weight/EditWeightDialog.kt b/app/src/main/java/com/dzeio/openhealth/ui/weight/EditWeightDialog.kt index de59f88..0d65fed 100644 --- a/app/src/main/java/com/dzeio/openhealth/ui/weight/EditWeightDialog.kt +++ b/app/src/main/java/com/dzeio/openhealth/ui/weight/EditWeightDialog.kt @@ -15,24 +15,22 @@ import com.dzeio.openhealth.R import com.dzeio.openhealth.core.BaseFullscreenDialog import com.dzeio.openhealth.data.weight.Weight import com.dzeio.openhealth.databinding.DialogEditWeightBinding -import com.dzeio.openhealth.ui.home.HomeViewModel import com.google.android.material.datepicker.MaterialDatePicker import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.timepicker.MaterialTimePicker import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.flow.collect -import java.util.* +import java.util.Date @AndroidEntryPoint class EditWeightDialog : - BaseFullscreenDialog(HomeViewModel::class.java) { + BaseFullscreenDialog( + EditWeightDialogViewModel::class.java + ) { override val bindingInflater: (LayoutInflater) -> DialogEditWeightBinding = DialogEditWeightBinding::inflate - override val isFullscreenLayout = true - - val args: EditWeightDialogArgs by navArgs() + private val args: EditWeightDialogArgs by navArgs() lateinit var weight: Weight @@ -101,10 +99,7 @@ class EditWeightDialog : } else { TODO("VERSION.SDK_INT < N") } - - } - } private fun save() { @@ -144,6 +139,5 @@ class EditWeightDialog : } else -> super.onOptionsItemSelected(item) } - } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/dzeio/openhealth/ui/weight/EditWeightDialogViewModel.kt b/app/src/main/java/com/dzeio/openhealth/ui/weight/EditWeightDialogViewModel.kt new file mode 100644 index 0000000..e1115c6 --- /dev/null +++ b/app/src/main/java/com/dzeio/openhealth/ui/weight/EditWeightDialogViewModel.kt @@ -0,0 +1,60 @@ +package com.dzeio.openhealth.ui.weight + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import com.dzeio.openhealth.Settings +import com.dzeio.openhealth.core.BaseViewModel +import com.dzeio.openhealth.data.water.Water +import com.dzeio.openhealth.data.weight.Weight +import com.dzeio.openhealth.data.weight.WeightRepository +import com.dzeio.openhealth.units.Units +import com.dzeio.openhealth.utils.Configuration +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +@HiltViewModel +class EditWeightDialogViewModel @Inject internal constructor( + private val weightRepository: WeightRepository, + config: Configuration +) : BaseViewModel() { + + private val _water = MutableLiveData(null) + + /** + * Quantity of water the user drank today + */ + val water: LiveData = _water + + private val _weights = MutableLiveData?>(null) + + private val _massUnit = MutableLiveData(Units.Mass.KILOGRAM) + + init { + + // fetch the user weights + viewModelScope.launch { + weightRepository.getWeights().collectLatest { + _weights.postValue(it) + } + } + + config.getString(Settings.MASS_UNIT).apply { + addObserver { + if (it == null) return@addObserver + _massUnit.postValue(Units.Mass.find(it)) + } + if (value != null) { + _massUnit.postValue(Units.Mass.find(value!!)) + } + } + } + + fun fetchWeight(id: Long) = weightRepository.getWeight(id) + + suspend fun deleteWeight(weight: Weight) = weightRepository.deleteWeight(weight) + + suspend fun addWeight(weight: Weight) = weightRepository.addWeight(weight) +} diff --git a/app/src/main/java/com/dzeio/openhealth/ui/weight/ListWeightFragment.kt b/app/src/main/java/com/dzeio/openhealth/ui/weight/ListWeightFragment.kt index 5c0ed3b..dbeeed9 100644 --- a/app/src/main/java/com/dzeio/openhealth/ui/weight/ListWeightFragment.kt +++ b/app/src/main/java/com/dzeio/openhealth/ui/weight/ListWeightFragment.kt @@ -1,24 +1,36 @@ package com.dzeio.openhealth.ui.weight +import android.Manifest import android.content.SharedPreferences +import android.graphics.Paint import android.os.Bundle import android.view.LayoutInflater import android.view.Menu +import android.view.MenuInflater import android.view.MenuItem import android.view.View import android.view.ViewGroup +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.view.MenuProvider import androidx.navigation.fragment.findNavController import androidx.preference.PreferenceManager import androidx.recyclerview.widget.LinearLayoutManager +import com.dzeio.charts.Entry +import com.dzeio.charts.axis.Line +import com.dzeio.charts.series.LineSerie +import com.dzeio.openhealth.BuildConfig import com.dzeio.openhealth.R import com.dzeio.openhealth.adapters.WeightAdapter import com.dzeio.openhealth.core.BaseFragment import com.dzeio.openhealth.data.weight.Weight import com.dzeio.openhealth.databinding.FragmentListWeightBinding -import com.dzeio.openhealth.graphs.WeightChart -import com.dzeio.openhealth.utils.GraphUtils +import com.dzeio.openhealth.utils.PermissionsManager import com.google.android.material.color.MaterialColors import dagger.hilt.android.AndroidEntryPoint +import java.text.DateFormat +import java.util.Date +import java.util.Locale @AndroidEntryPoint class ListWeightFragment : @@ -27,6 +39,40 @@ class ListWeightFragment : override val bindingInflater: (LayoutInflater, ViewGroup?, Boolean) -> FragmentListWeightBinding = FragmentListWeightBinding::inflate + private lateinit var button: View + private val activityResult = registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { + if (it.containsValue(false)) { + // TODO: Show a popup with choice to change it + Toast.makeText(requireContext(), R.string.permission_declined, Toast.LENGTH_LONG).show() + return@registerForActivityResult + } + + button.callOnClick() + } + + private val menuProvider = object : MenuProvider { + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menu.findItem(R.id.action_add).isVisible = true + } + + override fun onMenuItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + R.id.action_add -> { + findNavController().navigate( + ListWeightFragmentDirections.actionNavListWeightToNavWeightDialog( + WeightDialog.DialogTypes.ADD_WEIGHT.ordinal + ) + ) + true + } + + else -> false + } + } + } + val settings: SharedPreferences by lazy { PreferenceManager.getDefaultSharedPreferences(requireContext()) } @@ -34,8 +80,8 @@ class ListWeightFragment : override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - // FIXME: deprecated - setHasOptionsMenu(true) + // Menu + requireActivity().addMenuProvider(menuProvider) if (viewModel.goalWeight.value != null) { binding.goalButton.setText(R.string.edit_goal) @@ -43,7 +89,27 @@ class ListWeightFragment : binding.goalButton.setOnClickListener { findNavController().navigate( - ListWeightFragmentDirections.actionNavListWeightToNavWeightDialog(WeightDialog.DialogTypes.EDIT_GOAL.ordinal) + ListWeightFragmentDirections.actionNavListWeightToNavWeightDialog( + WeightDialog.DialogTypes.EDIT_GOAL.ordinal + ) + ) + } + + binding.bluetoothButton.setOnClickListener { + val permissions = arrayOf( + Manifest.permission.BLUETOOTH, + Manifest.permission.BLUETOOTH_CONNECT, + Manifest.permission.BLUETOOTH_SCAN, + Manifest.permission.ACCESS_FINE_LOCATION + ) + val hasPermission = PermissionsManager.hasPermission(requireContext(), permissions) + if (!hasPermission) { + button = binding.bluetoothButton + activityResult.launch(permissions) + return@setOnClickListener + } + findNavController().navigate( + ListWeightFragmentDirections.actionNavListWeightToScanScalesDialog() ) } @@ -57,7 +123,7 @@ class ListWeightFragment : } } - val recycler = binding.list.apply { + binding.list.apply { val manager = LinearLayoutManager(requireContext()) layoutManager = manager this.adapter = adapter @@ -65,7 +131,6 @@ class ListWeightFragment : viewModel.massUnit.observe(viewLifecycleOwner) { adapter.unit = it -// adapter.notifyDataSetChanged() } viewModel.weights.observe(viewLifecycleOwner) { @@ -77,50 +142,100 @@ class ListWeightFragment : } } - GraphUtils.lineChartSetup( - binding.chart, - MaterialColors.getColor( + val chart = binding.chart + + val serie = LineSerie(chart).apply { + linePaint.color = MaterialColors.getColor( requireView(), com.google.android.material.R.attr.colorPrimary - ), - MaterialColors.getColor( - requireView(), - com.google.android.material.R.attr.colorOnBackground ) - ) + textPaint.color = MaterialColors.getColor( + requireView(), + com.google.android.material.R.attr.colorOnPrimary + ) + } + + chart.apply { + series = arrayListOf(serie) + + yAxis.apply { + textLabel.color = MaterialColors.getColor( + requireView(), + com.google.android.material.R.attr.colorOnPrimaryContainer + ) + linePaint.color = MaterialColors.getColor( + requireView(), + com.google.android.material.R.attr.colorOnPrimaryContainer + ) + + onValueFormat = { value -> "${value.toInt()}" } + } + + xAxis.apply { + // 7 day history + textPaint.color = MaterialColors.getColor( + requireView(), + com.google.android.material.R.attr.colorOnPrimaryContainer + ) + textPaint.textSize = 32f + onValueFormat = onValueFormat@{ + val formatter = DateFormat.getDateTimeInstance( + DateFormat.SHORT, + DateFormat.SHORT, + Locale.getDefault() + ) + return@onValueFormat formatter.format(Date(it.toLong())) + } + } + } + + // Debug button + if (BuildConfig.DEBUG) { + binding.debugRandomValues.visibility = View.VISIBLE + binding.debugRandomValues.setOnClickListener { + viewModel.generateRandomValues() + } + binding.debugRandomValues.setOnLongClickListener { + viewModel.delete(viewModel.weights.value!!) + return@setOnLongClickListener true + } + } + } + + override fun onDestroyView() { + super.onDestroyView() + requireActivity().removeMenuProvider(menuProvider) } private fun updateGraph(list: List) { - WeightChart.setup( - binding.chart, - requireView(), - list, - viewModel.massUnit.value!!, - viewModel.goalWeight.value, - false - ) - } + val chart = binding.chart + val serie = chart.series[0] as LineSerie - @Deprecated("Deprecated in Java") - override fun onPrepareOptionsMenu(menu: Menu) { - menu.findItem(R.id.action_add).isVisible = true + val entries: ArrayList = arrayListOf() - super.onPrepareOptionsMenu(menu) - } - - @Deprecated("Deprecated in Java") - override fun onOptionsItemSelected(item: MenuItem): Boolean { - return when (item.itemId) { - R.id.action_add -> { - findNavController().navigate( - ListWeightFragmentDirections.actionNavListWeightToNavWeightDialog( - WeightDialog.DialogTypes.ADD_WEIGHT.ordinal - ) + list.forEach { + entries.add( + Entry( + it.timestamp.toDouble(), + it.weight ) - true - } - - else -> super.onOptionsItemSelected(item) + ) } + serie.entries = entries + + if (viewModel.goalWeight.value != null) { + chart.yAxis.addLine( + viewModel.goalWeight.value!!, + Line(true, Paint(chart.yAxis.linePaint).apply { strokeWidth = 4f }) + ) + } + + if (list.isEmpty()) { + chart.xAxis.x = 0.0 + } else { + chart.xAxis.x = list[0].timestamp.toDouble() + } + + chart.refresh() } } diff --git a/app/src/main/java/com/dzeio/openhealth/ui/weight/ListWeightViewModel.kt b/app/src/main/java/com/dzeio/openhealth/ui/weight/ListWeightViewModel.kt index 49b2e16..dc47292 100644 --- a/app/src/main/java/com/dzeio/openhealth/ui/weight/ListWeightViewModel.kt +++ b/app/src/main/java/com/dzeio/openhealth/ui/weight/ListWeightViewModel.kt @@ -10,17 +10,17 @@ import com.dzeio.openhealth.data.weight.WeightRepository import com.dzeio.openhealth.units.Units import com.dzeio.openhealth.utils.Configuration import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlin.random.Random import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch -import javax.inject.Inject @HiltViewModel class ListWeightViewModel @Inject internal constructor( private val weightRepository: WeightRepository, - private val settings: Configuration + settings: Configuration ) : BaseViewModel() { - private val _massUnit = MutableLiveData(Units.Mass.KILOGRAM) val massUnit: LiveData = _massUnit @@ -30,7 +30,6 @@ class ListWeightViewModel @Inject internal constructor( private val _weights = MutableLiveData?>(null) val weights: LiveData?> = _weights - init { viewModelScope.launch { weightRepository.getWeights().collectLatest { @@ -48,4 +47,20 @@ class ListWeightViewModel @Inject internal constructor( } } } + + fun generateRandomValues() { + viewModelScope.launch { + weightRepository.addWeight( + Weight( + weight = Random.nextInt(0, 100).toFloat() + ) + ) + } + } + + fun delete(list: List) { + viewModelScope.launch { + weightRepository.deleteWeight(*list.toTypedArray()) + } + } } diff --git a/app/src/main/java/com/dzeio/openhealth/ui/weight/ScanScalesDialog.kt b/app/src/main/java/com/dzeio/openhealth/ui/weight/ScanScalesDialog.kt new file mode 100644 index 0000000..776050d --- /dev/null +++ b/app/src/main/java/com/dzeio/openhealth/ui/weight/ScanScalesDialog.kt @@ -0,0 +1,111 @@ +package com.dzeio.openhealth.ui.weight + +import android.annotation.SuppressLint +import android.app.ProgressDialog +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.widget.Toast +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.LinearLayoutManager +import com.dzeio.openhealth.R +import com.dzeio.openhealth.adapters.ItemAdapter +import com.dzeio.openhealth.core.BaseDialog +import com.dzeio.openhealth.databinding.DialogSearchBinding +import com.dzeio.openhealth.devices.BluetoothLeGattDevice +import com.dzeio.openhealth.devices.Device +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch + +@AndroidEntryPoint +class ScanScalesDialog : + BaseDialog(ScanScalesViewModel::class.java) { + + override val bindingInflater: (LayoutInflater) -> DialogSearchBinding = + DialogSearchBinding::inflate + + override fun onBuilderInit(builder: MaterialAlertDialogBuilder) { + super.onBuilderInit(builder) + + builder.apply { + setTitle(R.string.searching_scales) + setIcon(R.drawable.ic_outline_monitor_weight_24) + setNegativeButton(R.string.cancel) { dialog, _ -> + viewModel.stopScan() + dialog.cancel() + } + } + } + + @SuppressLint("MissingPermission") + override fun onCreated() { + super.onCreated() + binding.search.visibility = View.GONE + binding.loading.visibility = View.VISIBLE + + val adapter = ItemAdapter().apply { + onItemClick = { deviceAdapter -> + dialog!!.dismiss() + val device = deviceAdapter.value + viewModel.stopScan() + val progress = ProgressDialog(requireContext()).apply { + setTitle("Connecting & Fetching...") + setCancelable(false) + setProgressStyle(ProgressDialog.STYLE_HORIZONTAL) + show() + } + device.connect().addObserver { status -> + if (status === Device.ConnectionStatus.CONNECTED) { + device.fetchWeights().addObserver { + progress.progress = it.progress + progress.max = it.progressMax + 1 + + if (it.progress == it.progressMax) { + device.close() + Log.d("YAY", "${it.data}") + lifecycleScope.launch { + viewModel.addWeights(it.data) + progress.dismiss() + Toast.makeText( + requireContext(), + "Data synchonised with the remote device", + Toast.LENGTH_SHORT + ).show() + } + } + } + } else if (status === Device.ConnectionStatus.ERROR) { + progress.dismiss() + Toast.makeText( + requireContext(), + "An error occured while connecting...", + Toast.LENGTH_LONG + ).show() + } + } + } + } + binding.list.apply { + layoutManager = LinearLayoutManager(requireContext()) + this.adapter = adapter + } + + viewModel.devices.observe(this) { + if (it == null) { + adapter.clear() + return@observe + } + adapter.set( + it.map { + ItemAdapter.Item( + it, + it.name, + "${it.item!!.name} (${it.item!!.address})", + icon = R.drawable.ic_baseline_add_24 + ) + } + ) + } + } +} diff --git a/app/src/main/java/com/dzeio/openhealth/ui/weight/ScanScalesViewModel.kt b/app/src/main/java/com/dzeio/openhealth/ui/weight/ScanScalesViewModel.kt new file mode 100644 index 0000000..b658b9b --- /dev/null +++ b/app/src/main/java/com/dzeio/openhealth/ui/weight/ScanScalesViewModel.kt @@ -0,0 +1,42 @@ +package com.dzeio.openhealth.ui.weight + +import android.annotation.SuppressLint +import androidx.annotation.RequiresPermission +import com.dzeio.openhealth.core.BaseViewModel +import com.dzeio.openhealth.data.weight.Weight +import com.dzeio.openhealth.data.weight.WeightRepository +import com.dzeio.openhealth.devices.BluetoothLeGattDevice +import com.dzeio.openhealth.utils.Bluetooth +import com.dzeio.openhealth.utils.Configuration +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.first + +@HiltViewModel +class ScanScalesViewModel @Inject internal constructor( + private val bluetooth: Bluetooth, + private val weightRepository: WeightRepository, + config: Configuration +) : BaseViewModel() { + + @SuppressLint("MissingPermission") + @RequiresPermission(value = "android.permission.BLUETOOTH_SCAN") + val devices = BluetoothLeGattDevice.findDevices(bluetooth, config).toLiveData() + + @RequiresPermission(value = "android.permission.BLUETOOTH_SCAN") + fun stopScan() { + bluetooth.stopScan() + } + + suspend fun addWeights(weights: ArrayList) { + val current = weightRepository.getWeights().first() + val toAdd = arrayListOf() + for (weight in weights) { + if (current.find { it.equals(current) } != null) { + continue + } + toAdd.add(weight) + } + weightRepository.addAll(*toAdd.toTypedArray()) + } +} diff --git a/app/src/main/java/com/dzeio/openhealth/ui/weight/WeightDialog.kt b/app/src/main/java/com/dzeio/openhealth/ui/weight/WeightDialog.kt index fc789f4..4ff45d6 100644 --- a/app/src/main/java/com/dzeio/openhealth/ui/weight/WeightDialog.kt +++ b/app/src/main/java/com/dzeio/openhealth/ui/weight/WeightDialog.kt @@ -66,7 +66,6 @@ class WeightDialog : binding.gram.maxValue = 9 binding.gram.minValue = 0 - } private fun setValue(value: Float) { diff --git a/app/src/main/java/com/dzeio/openhealth/ui/weight/WeightDialogViewModel.kt b/app/src/main/java/com/dzeio/openhealth/ui/weight/WeightDialogViewModel.kt index c96fcbc..6738716 100644 --- a/app/src/main/java/com/dzeio/openhealth/ui/weight/WeightDialogViewModel.kt +++ b/app/src/main/java/com/dzeio/openhealth/ui/weight/WeightDialogViewModel.kt @@ -10,14 +10,14 @@ import com.dzeio.openhealth.data.weight.WeightRepository import com.dzeio.openhealth.units.Units import com.dzeio.openhealth.utils.Configuration import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch -import javax.inject.Inject @HiltViewModel class WeightDialogViewModel @Inject internal constructor( private val weightRepository: WeightRepository, - private val settings: Configuration + settings: Configuration ) : BaseViewModel() { private val _goalWeight = settings.getFloat(Settings.WEIGHT_GOAL) @@ -40,7 +40,6 @@ class WeightDialogViewModel @Inject internal constructor( weightRepository.lastWeight().collectLatest { _weight.postValue(it) } - } } } diff --git a/app/src/main/java/com/dzeio/openhealth/units/UnitFactory.kt b/app/src/main/java/com/dzeio/openhealth/units/UnitFactory.kt deleted file mode 100644 index 07ee51a..0000000 --- a/app/src/main/java/com/dzeio/openhealth/units/UnitFactory.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.dzeio.openhealth.units - -object UnitFactory { - @Deprecated("Move to Units.Mass.find") - fun mass(unit: String): Units.Mass { - return when (unit.lowercase()) { - "kilogram", "kilograms", "kg" -> Units.Mass.KILOGRAM - "pound", "pounds", "lb" -> Units.Mass.POUND - else -> Units.Mass.KILOGRAM - } - } - - @Deprecated("Move to Units.Volume.find") - fun volume(unit: String): Units.Volume { - return when (unit.lowercase()) { - "milliliter", "milliliters", "ml" -> Units.Volume.MILLILITER - "imperial ounce", "imperial ounces", "oz" -> Units.Volume.IMPERIAL_OUNCE - "us ounce", "us ounces" -> Units.Volume.US_OUNCE - else -> Units.Volume.MILLILITER - } - } -} diff --git a/app/src/main/java/com/dzeio/openhealth/units/Units.kt b/app/src/main/java/com/dzeio/openhealth/units/Units.kt index 36ea583..80abbae 100644 --- a/app/src/main/java/com/dzeio/openhealth/units/Units.kt +++ b/app/src/main/java/com/dzeio/openhealth/units/Units.kt @@ -2,7 +2,13 @@ package com.dzeio.openhealth.units import com.dzeio.openhealth.R +/** + * Object containing the differents units and how they are converted + */ object Units { + /** + * the Mass Unit + */ enum class Mass( val id: String, /** @@ -34,18 +40,23 @@ object Units { it.id == value } ?: KILOGRAM } - } fun format(value: Float): Float { return value * modifier } + /** + * Format the value and let the hundred of grams to be outputed + */ fun formatToString(value: Float): String { return String.format("%.1f", value * modifier) } } + /** + * the Volume unit + */ enum class Volume( val id: String, /** @@ -78,6 +89,10 @@ object Units { R.string.unit_volume_ounce_unit ); + fun formatToString(value: Int): String { + return String.format("%.0f", (value * modifier)) + } + companion object { fun find(value: String): Volume { return Volume.values().find { diff --git a/app/src/main/java/com/dzeio/openhealth/units/WaterUnit.kt b/app/src/main/java/com/dzeio/openhealth/units/WaterUnit.kt deleted file mode 100644 index 2d998eb..0000000 --- a/app/src/main/java/com/dzeio/openhealth/units/WaterUnit.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.dzeio.openhealth.units - -@Deprecated("Move to Units.Volume") -enum class WaterUnit( - val unit: String, - val fromML: Float -) { - ML("ml", 1f), - US_OZ("oz", 0.03381413f), - IMP_OZ("oz", 0.03519503f); - - companion object { - fun fromSettings(value: String): WaterUnit { - return when (value.lowercase()) { - "milliliter" -> ML - "us ounce" -> US_OZ - "imperial ounce" -> IMP_OZ - else -> ML - } - } - } -} diff --git a/app/src/main/java/com/dzeio/openhealth/utils/BitmapUtils.kt b/app/src/main/java/com/dzeio/openhealth/utils/BitmapUtils.kt deleted file mode 100644 index fc4cb76..0000000 --- a/app/src/main/java/com/dzeio/openhealth/utils/BitmapUtils.kt +++ /dev/null @@ -1,49 +0,0 @@ -package com.dzeio.openhealth.utils - -import android.content.Context -import android.graphics.Bitmap -import java.io.File -import java.io.RandomAccessFile -import java.nio.channels.FileChannel - -object BitmapUtils { - - /** - * Find source of function lol - */ - fun convertToMutable(context: Context, imgIn: Bitmap): Bitmap? { - val width = imgIn.width - val height = imgIn.height - val type = imgIn.config - var outputFile: File? = null - val outputDir = context.cacheDir - try { - outputFile = File.createTempFile( - System.currentTimeMillis().toString(), - null, - outputDir - ) - outputFile.deleteOnExit() - val randomAccessFile = RandomAccessFile(outputFile, "rw") - val channel = randomAccessFile.channel - val map = channel.map( - FileChannel.MapMode.READ_WRITE, - 0, - (imgIn.rowBytes * height).toLong() - ) - imgIn.copyPixelsToBuffer(map) - imgIn.recycle() - val result = Bitmap.createBitmap(width, height, type) - map.position(0) - result.copyPixelsFromBuffer(map) - channel.close() - randomAccessFile.close() - outputFile.delete() - return result - } catch (e: Exception) { - } finally { - outputFile?.delete() - } - return null - } -} \ No newline at end of file diff --git a/app/src/main/java/com/dzeio/openhealth/utils/Bluetooth.kt b/app/src/main/java/com/dzeio/openhealth/utils/Bluetooth.kt new file mode 100644 index 0000000..d2519a0 --- /dev/null +++ b/app/src/main/java/com/dzeio/openhealth/utils/Bluetooth.kt @@ -0,0 +1,148 @@ +package com.dzeio.openhealth.utils + +import android.annotation.SuppressLint +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothGattCallback +import android.bluetooth.BluetoothManager +import android.bluetooth.le.ScanCallback +import android.bluetooth.le.ScanResult +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageManager +import android.util.Log +import android.widget.Toast +import androidx.annotation.RequiresPermission +import com.dzeio.openhealth.utils.polyfills.getBluetoothDevice + +class Bluetooth( + private val context: Context +) { + companion object { + const val TAG = "Bluetooth" + } + + private lateinit var adapter: BluetoothAdapter + + init { + if (!context.packageManager.hasSystemFeature(PackageManager.FEATURE_BLUETOOTH)) { + throw Exception("Phone missing the Bluetooth feature") + } + try { + adapter = (context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager).adapter + } catch (e: Exception) { + Log.e(TAG, e.message ?: "error getting default adapter") + } + if (!adapter.isEnabled) { + Toast.makeText(context, "Bluetooth is not enabled", Toast.LENGTH_LONG).show() + Log.e(TAG, "Bluetooth is not enabled") + } + } + + @SuppressLint("HardwareIds") + fun getAddress(): String { + return adapter.address + } + + @RequiresPermission(value = "android.permission.BLUETOOTH_CONNECT") + fun getName(): String { + return adapter.name + } + + /** + * Make the current phone discoverable through bluetooth scan + */ +// fun makeDeviceDiscoverable(time: Int = 600) { +// val intent = Intent(BluetoothAdapter.ACTION_REQUEST_DISCOVERABLE).apply { +// putExtra(BluetoothAdapter.EXTRA_DISCOVERABLE_DURATION, time) +// } +// activity.startActivityForResult(intent, 1) +// } + + fun getDiscoverableIntent(time: Int = 600): Intent { + return Intent(BluetoothAdapter.ACTION_REQUEST_DISCOVERABLE).apply { + putExtra(BluetoothAdapter.EXTRA_DISCOVERABLE_DURATION, time) + } + } + + private lateinit var cb: (device: BluetoothDevice) -> Boolean + + private val receiver: BroadcastReceiver = object : BroadcastReceiver() { + @RequiresPermission(allOf = ["android.permission.BLUETOOTH_SCAN", "android.permission.BLUETOOTH_CONNECT"]) + override fun onReceive(context: Context, intent: Intent) { + val action = intent.action + if (BluetoothDevice.ACTION_FOUND == action) { + val device = + intent.getBluetoothDevice() + // val rssi = intent.getShortExtra(BluetoothDevice.EXTRA_RSSI, Short.MIN_VALUE).toInt() + + if (device != null) { + Log.d(TAG, "Device found! (${device.name ?: device.address})") + val end = cb(device) + if (end) { + stopScan() + } + } + } + } + } + + /** + * Callback return boolean if true it stop scan + */ + @RequiresPermission(value = "android.permission.BLUETOOTH_SCAN") + fun scanDevices(callback: (device: BluetoothDevice) -> Boolean): Boolean { + val filter = IntentFilter(BluetoothDevice.ACTION_FOUND) + context.registerReceiver(receiver, filter) + cb = callback + return adapter.startDiscovery() + } + + private var leCallback: ((device: BluetoothDevice) -> Boolean)? = null + private val scanCallback = object : ScanCallback() { + @RequiresPermission(allOf = ["android.permission.BLUETOOTH_SCAN", "android.permission.BLUETOOTH_CONNECT"]) + override fun onScanResult(callbackType: Int, result: ScanResult?) { + super.onScanResult(callbackType, result) + if (result != null) { + val device = result.device + Log.d(TAG, "Device found! (${device.name ?: device.address})") + val doStop = leCallback?.invoke(device) + if (doStop == true) { + stopScan() + } + } + } + } + + /** + * Callback return boolean if true it stop scan + */ + @RequiresPermission(value = "android.permission.BLUETOOTH_SCAN") + fun scanLeDevices(callback: (device: BluetoothDevice) -> Boolean) { + val pouet = adapter.bluetoothLeScanner + leCallback = callback + pouet.startScan(scanCallback) + } + + @RequiresPermission(value = "android.permission.BLUETOOTH_SCAN") + fun stopScan() { + adapter.bluetoothLeScanner.stopScan(scanCallback) + adapter.cancelDiscovery() + try { + context.unregisterReceiver(receiver) + } catch (it: IllegalArgumentException) { + Log.i(TAG, "Seems like it was already unloaded", it) + } + } + + @SuppressLint("MissingPermission") + fun connectGatt( + device: BluetoothDevice, + autoConnect: Boolean, + callback: BluetoothGattCallback + ) { + device.connectGatt(context, autoConnect, callback) + } +} diff --git a/app/src/main/java/com/dzeio/openhealth/utils/ChartUtils.kt b/app/src/main/java/com/dzeio/openhealth/utils/ChartUtils.kt new file mode 100644 index 0000000..a51a4fb --- /dev/null +++ b/app/src/main/java/com/dzeio/openhealth/utils/ChartUtils.kt @@ -0,0 +1,99 @@ +package com.dzeio.openhealth.utils + +import android.view.View +import com.dzeio.charts.ChartView +import com.dzeio.charts.components.Annotation +import com.dzeio.charts.series.BarSerie +import com.dzeio.charts.series.LineSerie +import com.google.android.material.color.MaterialColors +import java.text.DateFormat +import java.util.Date +import java.util.Locale +import kotlin.math.roundToLong + +/** + * Utils object to create Charts + */ +object ChartUtils { + + /** + * Apply Material theme to a DzeioChart [ChartView] + */ + fun materielTheme(chart: ChartView, view: View) { + chart.apply { + annotator.apply { + backgroundPaint.color = MaterialColors.getColor( + view, + com.google.android.material.R.attr.colorBackgroundFloating + ) + titlePaint.color = MaterialColors.getColor( + view, + com.google.android.material.R.attr.colorOnBackground + ) + subTitlePaint.color = MaterialColors.getColor( + view, + com.google.android.material.R.attr.colorOnBackground + ) + + orientation = Annotation.Orientation.VERTICAL + + annotationTitleFormat = { it.y.roundToLong().toString() } + annotationSubTitleFormat = annotationSubTitleFormat@{ + val formatter = DateFormat.getDateTimeInstance( + DateFormat.SHORT, + DateFormat.SHORT, + Locale.getDefault() + ) + return@annotationSubTitleFormat formatter.format(Date(it.x.roundToLong())) + } + } + yAxis.apply { + textLabel.color = MaterialColors.getColor( + view, + com.google.android.material.R.attr.colorOnPrimaryContainer + ) + linePaint.color = MaterialColors.getColor( + view, + com.google.android.material.R.attr.colorOnPrimaryContainer + ) + goalLinePaint.color = MaterialColors.getColor( + view, + com.google.android.material.R.attr.colorError + ) + } + + xAxis.apply { + textPaint.color = MaterialColors.getColor( + view, + com.google.android.material.R.attr.colorOnPrimaryContainer + ) + } + + for (serie in series) { + if (serie is BarSerie) { + serie.apply { + barPaint.color = MaterialColors.getColor( + view, + com.google.android.material.R.attr.colorPrimary + ) + textPaint.color = MaterialColors.getColor( + view, + com.google.android.material.R.attr.colorOnPrimary + ) + } + } else if (serie is LineSerie) { + serie.apply { + linePaint.color = MaterialColors.getColor( + view, + com.google.android.material.R.attr.colorPrimary + ) + textPaint.color = MaterialColors.getColor( + view, + com.google.android.material.R.attr.colorOnPrimary + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/dzeio/openhealth/utils/Configuration.kt b/app/src/main/java/com/dzeio/openhealth/utils/Configuration.kt index 8d3201b..1b2abce 100644 --- a/app/src/main/java/com/dzeio/openhealth/utils/Configuration.kt +++ b/app/src/main/java/com/dzeio/openhealth/utils/Configuration.kt @@ -1,11 +1,15 @@ package com.dzeio.openhealth.utils import android.content.SharedPreferences -import android.util.Log import androidx.core.content.edit import com.dzeio.openhealth.Application import com.dzeio.openhealth.core.Observable +/** + * Class that ease the SharedPreferences works + * + * It allow to use others types simpler and to export them as LiveData for realtime updates + */ class Configuration( private val prefs: SharedPreferences ) : SharedPreferences.OnSharedPreferenceChangeListener { @@ -22,66 +26,66 @@ class Configuration( fun getString(key: String): StringField { if (cache[key] == null) { - Log.d(TAG, "$key does not exist in cache, creating new instance") +// Log.d(TAG, "$key does not exist in cache, creating new instance") cache[key] = StringField(key) } else { - Log.d(TAG, "$key in cache") +// Log.d(TAG, "$key in cache") } return cache[key] as StringField } fun getLong(key: String): LongField { if (cache[key] == null) { - Log.d(TAG, "$key does not exist in cache, creating new instance") +// Log.d(TAG, "$key does not exist in cache, creating new instance") cache[key] = LongField(key) } else { - Log.d(TAG, "$key in cache") +// Log.d(TAG, "$key in cache") } return cache[key] as LongField } fun getBoolean(key: String): BooleanField { if (cache[key] == null) { - Log.d(TAG, "$key does not exist in cache, creating new instance") +// Log.d(TAG, "$key does not exist in cache, creating new instance") cache[key] = BooleanField(key) } else { - Log.d(TAG, "$key in cache") +// Log.d(TAG, "$key in cache") } return cache[key] as BooleanField } fun getInt(key: String): IntField { if (cache[key] == null) { - Log.d(TAG, "$key does not exist in cache, creating new instance") +// Log.d(TAG, "$key is not cache, creating new instance") cache[key] = IntField(key) } else { - Log.d(TAG, "$key in cache") +// Log.d(TAG, "$key in cache") } return cache[key] as IntField } fun getFloat(key: String): FloatField { if (cache[key] == null) { - Log.d(TAG, "$key does not exist in cache, creating new instance") +// Log.d(TAG, "$key does not exist in cache, creating new instance") cache[key] = FloatField(key) } else { - Log.d(TAG, "$key in cache") +// Log.d(TAG, "$key in cache") } return cache[key] as FloatField } fun getStringSet(key: String): StringSetField { if (cache[key] == null) { - Log.d(TAG, "$key does not exist in cache, creating new instance") +// Log.d(TAG, "$key does not exist in cache, creating new instance") cache[key] = StringSetField(key) } else { - Log.d(TAG, "$key in cache") +// Log.d(TAG, "$key in cache") } return cache[key] as StringSetField } override fun onSharedPreferenceChanged(u: SharedPreferences, key: String) { - Log.d(TAG, "configuration update for key: $key") +// Log.d(TAG, "configuration update for key: $key") cache[key]?.needUpdate = true cache[key]?.notifyObservers() } @@ -150,7 +154,14 @@ class Configuration( private val defaultValue: Float = -1f ) : Field(defaultValue) { override fun exists(): Boolean = prefs.contains(key) - override fun internalGet(): Float = prefs.getFloat(key, defaultValue) + override fun internalGet(): Float { + return try { + prefs.getFloat(key, defaultValue) + } catch (e: ClassCastException) { + val it = prefs.getString(key, "") + it?.toFloatOrNull() ?: defaultValue + } + } override fun internalSet(value: Float?) = prefs.edit { if (value == null) remove(key) else putFloat(key, value) } } diff --git a/app/src/main/java/com/dzeio/openhealth/utils/DrawUtils.kt b/app/src/main/java/com/dzeio/openhealth/utils/DrawUtils.kt index 35fcde5..9b490c1 100644 --- a/app/src/main/java/com/dzeio/openhealth/utils/DrawUtils.kt +++ b/app/src/main/java/com/dzeio/openhealth/utils/DrawUtils.kt @@ -4,10 +4,13 @@ import android.graphics.Canvas import android.graphics.Paint import android.graphics.RectF +/** + * Utils class to draw more complexe elements on a canvas + */ object DrawUtils { /** - * Fuck Graphics + * Draw an arc on a canvas or a the previous comment was "Fuck Graphics" */ fun drawArc(canvas: Canvas, percent: Float, rect: RectF, pColor: Int, strokeWidth: Float = 1f) { val r1 = RectF( @@ -26,25 +29,6 @@ object DrawUtils { canvas.drawArc(r1, 180f, 180 * percent / 100f, false, paint) } - /** - * Fuck Graphics - */ - fun drawRect(canvas: Canvas, rect: RectF, pColor: Int, strokeWidth: Float = 1f) { - val r1 = RectF( - canvas.realSize(true, rect.left), - canvas.realSize(false, rect.top), - canvas.realSize(true, rect.right), - canvas.realSize(false, rect.bottom) - ) - val paint = Paint().apply { - this.strokeWidth = canvas.realSize(true, strokeWidth) - style = Paint.Style.STROKE - color = pColor - isAntiAlias = true - } - canvas.drawRect(r1, paint) - } - private fun Canvas.realSize(isWidth: Boolean, value: Float): Float { val it = if (isWidth) this.width else this.height return it * value / 100 @@ -54,5 +38,4 @@ object DrawUtils { val it = (if (isWidth) this.width else this.height) * multiplier return it * value / 100 } - -} \ No newline at end of file +} diff --git a/app/src/main/java/com/dzeio/openhealth/utils/GraphUtils.kt b/app/src/main/java/com/dzeio/openhealth/utils/GraphUtils.kt deleted file mode 100644 index 9af6325..0000000 --- a/app/src/main/java/com/dzeio/openhealth/utils/GraphUtils.kt +++ /dev/null @@ -1,111 +0,0 @@ -package com.dzeio.openhealth.utils - -import com.github.mikephil.charting.charts.BarChart -import com.github.mikephil.charting.charts.BarLineChartBase -import com.github.mikephil.charting.charts.LineChart -import com.github.mikephil.charting.components.AxisBase -import com.github.mikephil.charting.components.Description -import com.github.mikephil.charting.components.XAxis -import com.github.mikephil.charting.data.BarLineScatterCandleBubbleData -import com.github.mikephil.charting.data.Entry -import com.github.mikephil.charting.data.LineDataSet -import com.github.mikephil.charting.formatter.ValueFormatter -import com.github.mikephil.charting.interfaces.datasets.IBarLineScatterCandleBubbleDataSet -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale - -object GraphUtils { - - fun lineChartSetup(chart: LineChart, mainColor: Int, textColor: Int) { - barLineChartSetup(chart, mainColor, textColor) - // chart.isAutoScaleMinMaxEnabled = true - } - - fun lineDataSet(lineDataSet: LineDataSet): LineDataSet { - return lineDataSet.apply { - setDrawCircles(false) - setDrawCircleHole(false) - mode = LineDataSet.Mode.HORIZONTAL_BEZIER - } - } - - fun barChartSetup(chart: BarChart, mainColor: Int, textColor: Int) { - barLineChartSetup(chart, mainColor, textColor) - } - - private fun >?> barLineChartSetup( - chart: BarLineChartBase, - mainColor: Int, - textColor: Int - ) { - chart.apply { - // Setup - legend.isEnabled = true - description = Description().apply { isEnabled = false } - - xAxis.apply { - valueFormatter = DateValueFormatter() - position = XAxis.XAxisPosition.BOTTOM - setDrawGridLines(false) - setLabelCount(3, true) - this.textColor = textColor - // setDrawGridLines(false) - // setDrawZeroLine(false) - setDrawAxisLine(false) - disableGridDashedLine() - invalidateOutline() - } - - axisLeft.apply { - axisMinimum = 0f - isEnabled = false - axisLineColor = mainColor - this.textColor = textColor - setDrawZeroLine(false) - setLabelCount(0, true) - setDrawGridLines(false) - setDrawBorders(false) - } - axisRight.apply { - axisMinimum = 0f - this.textColor = textColor - setLabelCount(4, true) - } - setNoDataTextColor(textColor) - - legend.isEnabled = false - isDragEnabled = true - // isScaleYEnabled = false - description = Description().apply { isEnabled = false } - isScaleXEnabled = true - setPinchZoom(false) - setDrawGridBackground(false) - setDrawBorders(false) - } - } - - class DateValueFormatter( - private val transformer: Int = 1 - ) : ValueFormatter() { - override fun getAxisLabel(value: Float, axis: AxisBase?): String { - return SimpleDateFormat( - "yyyy-MM-dd", - Locale.getDefault() - ).format(Date(value.toLong() * transformer)) - // return super.getAxisLabel(value, axis) - } - } - - class DateTimeValueFormatter( - private val transformer: Int = 1 - ) : ValueFormatter() { - override fun getAxisLabel(value: Float, axis: AxisBase?): String { - return SimpleDateFormat( - "yyyy-MM-dd hh", - Locale.getDefault() - ).format(Date(value.toLong() * transformer)) - // return super.getAxisLabel(value, axis) - } - } -} diff --git a/app/src/main/java/com/dzeio/openhealth/utils/LocaleUtils.kt b/app/src/main/java/com/dzeio/openhealth/utils/LocaleUtils.kt index 70819d5..27aa103 100644 --- a/app/src/main/java/com/dzeio/openhealth/utils/LocaleUtils.kt +++ b/app/src/main/java/com/dzeio/openhealth/utils/LocaleUtils.kt @@ -5,16 +5,14 @@ import android.content.Context import android.content.res.Configuration import android.os.Build import android.os.LocaleList -import android.util.Log import androidx.preference.PreferenceManager import com.dzeio.openhealth.Settings import java.util.Locale - /** * Utils object for [Locale] * - * @see https://github.com/gunhansancar/ChangeLanguageExample/blob/master/app/src/main/java/com/gunhansancar/changelanguageexample/helper/LocaleHelper.java + * https://github.com/gunhansancar/ChangeLanguageExample/blob/master/app/src/main/java/com/gunhansancar/changelanguageexample/helper/LocaleHelper.java */ object LocaleUtils { fun onAttach(context: Context): Context { @@ -44,18 +42,19 @@ object LocaleUtils { } private fun getPersistedData(context: Context): String { + // TODO: use the config class val preferences = PreferenceManager.getDefaultSharedPreferences(context) return preferences.getString(Settings.APP_LANGUAGE, Locale.getDefault().language) ?: Locale.getDefault().language } private fun persist(context: Context, language: String?) { + // TODO: use the config class val preferences = PreferenceManager.getDefaultSharedPreferences(context) preferences.edit().putString(Settings.APP_LANGUAGE, language).apply() } private fun updateResources(context: Context, language: String): Context { - Log.d("LocaleUtils", language) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { val locale = Locale(language) Locale.setDefault(locale) @@ -68,9 +67,11 @@ object LocaleUtils { Locale.setDefault(locale) val resources = context.resources val configuration: Configuration = resources.configuration + @Suppress("DEPRECATION") configuration.locale = locale configuration.setLayoutDirection(locale) + @Suppress("DEPRECATION") resources.updateConfiguration(configuration, resources.displayMetrics) return context } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/dzeio/openhealth/utils/NetworkResult.kt b/app/src/main/java/com/dzeio/openhealth/utils/NetworkResult.kt new file mode 100644 index 0000000..ac1423c --- /dev/null +++ b/app/src/main/java/com/dzeio/openhealth/utils/NetworkResult.kt @@ -0,0 +1,12 @@ +package com.dzeio.openhealth.utils + +data class NetworkResult( + var status: NetworkStatus = NetworkStatus.RUNNING, + var data: T? = null +) { + enum class NetworkStatus { + RUNNING, + FINISHED, + ERRORED + } +} diff --git a/app/src/main/java/com/dzeio/openhealth/utils/NetworkUtils.kt b/app/src/main/java/com/dzeio/openhealth/utils/NetworkUtils.kt new file mode 100644 index 0000000..004d0eb --- /dev/null +++ b/app/src/main/java/com/dzeio/openhealth/utils/NetworkUtils.kt @@ -0,0 +1,39 @@ +package com.dzeio.openhealth.utils + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.os.Handler +import android.os.Looper +import android.util.Log +import android.widget.ImageView +import java.net.URL +import java.util.concurrent.Executors + +object NetworkUtils { + /** + * Fetch an image and apply it to an [image] [ImageView] + * + * Adapted from https://stackoverflow.com/a/10868126/7335674 + * to not be deprecated + * + * @param image the ImageView + * @param url the url to fetch the image from + */ + fun getImageInBackground(image: ImageView, url: String) { + val executor = Executors.newSingleThreadExecutor() + val handler = Handler(Looper.getMainLooper()) + executor.execute { + var bitmap: Bitmap? = null + try { + val `in` = URL(url).openStream() + bitmap = BitmapFactory.decodeStream(`in`) + handler.post { + image.setImageBitmap(bitmap) + } + } catch (e: Exception) { + Log.e("Error", e.message!!) + e.printStackTrace() + } + } + } +} diff --git a/app/src/main/java/com/dzeio/openhealth/utils/PermissionsManager.kt b/app/src/main/java/com/dzeio/openhealth/utils/PermissionsManager.kt index a8c6a76..4c0e838 100644 --- a/app/src/main/java/com/dzeio/openhealth/utils/PermissionsManager.kt +++ b/app/src/main/java/com/dzeio/openhealth/utils/PermissionsManager.kt @@ -5,8 +5,14 @@ import android.content.pm.PackageManager import android.os.Build import androidx.core.content.ContextCompat +/** + * Simple Utils class to check for permissions in multiple android environments + */ object PermissionsManager { + /** + * Allow to check for one permission + */ fun hasPermission(context: Context, permission: String): Boolean = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED @@ -17,6 +23,9 @@ object PermissionsManager { ) == PackageManager.PERMISSION_GRANTED } + /** + * Check for multiple permissions at once + */ fun hasPermission(context: Context, permissions: Array): Boolean { for (permission in permissions) { val res = hasPermission(context, permission) @@ -27,4 +36,3 @@ object PermissionsManager { return true } } - diff --git a/app/src/main/java/com/dzeio/openhealth/utils/ServiceUtils.kt b/app/src/main/java/com/dzeio/openhealth/utils/ServiceUtils.kt index 4413afe..67ac5f6 100644 --- a/app/src/main/java/com/dzeio/openhealth/utils/ServiceUtils.kt +++ b/app/src/main/java/com/dzeio/openhealth/utils/ServiceUtils.kt @@ -4,18 +4,27 @@ import android.app.ActivityManager import android.content.Context import android.content.Intent import android.util.Log -import com.dzeio.openhealth.ui.MainActivity +import com.dzeio.openhealth.Application +/** + * Utils class for services + */ object ServiceUtils { + + /** + * Function that start a service + * + * If I remember correctly I stole this from StackOverflow... TODO: get the original URL + */ fun startService(context: Context, service: Class) { val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager for (runninService in activityManager.getRunningServices(Integer.MAX_VALUE)) { if (service.name.equals(runninService.service.className)) { - Log.w(MainActivity.TAG, "Service already existing, not starting again") + Log.w(Application.TAG, "Service already existing, not starting again") return } } - Log.i(MainActivity.TAG, "Starting service ${service.name}") + Log.i(Application.TAG, "Starting service ${service.name}") Intent(context, service).also { intent -> context.startService(intent) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/dzeio/openhealth/utils/fields/IntEditTextPreference.kt b/app/src/main/java/com/dzeio/openhealth/utils/fields/IntEditTextPreference.kt new file mode 100644 index 0000000..f620e5d --- /dev/null +++ b/app/src/main/java/com/dzeio/openhealth/utils/fields/IntEditTextPreference.kt @@ -0,0 +1,88 @@ +package com.dzeio.openhealth.utils.fields + +import android.content.Context +import android.text.InputType +import android.text.TextUtils +import android.util.AttributeSet +import android.widget.EditText +import androidx.preference.EditTextPreference + +/** + * TOTALLY BASED on stolen code from stackoverflow that I don't remember where... f*ck + * + * Basically it allows to change the way `EditTextPreference` works to allow only numbers + */ +class IntEditTextPreference : EditTextPreference, EditTextPreference.OnBindEditTextListener { + + private var txt: String? = null + + constructor( + context: Context, + attrs: AttributeSet?, + defStyleAttr: Int, + defStyleRes: Int + ) : super(context, attrs, defStyleAttr, defStyleRes) { + setOnBindEditTextListener(this) + } + + constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super( + context, + attrs, + defStyleAttr + ) { + setOnBindEditTextListener(this) + } + + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) { + setOnBindEditTextListener(this) + } + + constructor(context: Context) : super(context) { + setOnBindEditTextListener(this) + } + + /** + * Saves the text to the current data storage. + * + * @param text The text to save + */ + override fun setText(text: String?) { + val wasBlocking = shouldDisableDependents() + val pouet = Integer.parseInt(text.toString()) + this.txt = text + persistInt(pouet) + val isBlocking = shouldDisableDependents() + if (isBlocking != wasBlocking) { + notifyDependencyChange(isBlocking) + } + notifyChanged() + } + + override fun getText(): String? { + return this.txt + } + + override fun onSetInitialValue(defaultValue: Any?) { + var value: Int + if (defaultValue != null) { + val strDefaultValue = defaultValue as String + val defaultIntValue = strDefaultValue.toInt() + value = getPersistedInt(defaultIntValue) + } else { + try { + value = getPersistedInt(0) + } catch (e: ClassCastException) { + value = 0 + } + } + text = value.toString() + } + + override fun shouldDisableDependents(): Boolean { + return TextUtils.isEmpty(text) || super.shouldDisableDependents() + } + + override fun onBindEditText(editText: EditText) { + editText.inputType = InputType.TYPE_CLASS_NUMBER + } +} diff --git a/app/src/main/java/com/dzeio/openhealth/utils/polyfills/GattPolyfills.kt b/app/src/main/java/com/dzeio/openhealth/utils/polyfills/GattPolyfills.kt new file mode 100644 index 0000000..181e4bc --- /dev/null +++ b/app/src/main/java/com/dzeio/openhealth/utils/polyfills/GattPolyfills.kt @@ -0,0 +1,45 @@ +package com.dzeio.openhealth.utils.polyfills + +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGattCharacteristic +import android.bluetooth.BluetoothGattDescriptor +import android.os.Build +import androidx.annotation.RequiresPermission + +@RequiresPermission(value = "android.permission.BLUETOOTH_CONNECT") +fun BluetoothGatt.writeCharacteristicPoly( + characteristic: BluetoothGattCharacteristic, + value: ByteArray, + writeType: Int +) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + this.writeCharacteristic( + characteristic, + value, + writeType + ) + } else { + @Suppress("DEPRECATION") + characteristic.value = value + @Suppress("DEPRECATION") + this.writeCharacteristic(characteristic) + } +} + +@RequiresPermission(value = "android.permission.BLUETOOTH_CONNECT") +fun BluetoothGatt.writeDescriptorPoly( + descriptor: BluetoothGattDescriptor, + value: ByteArray +) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + this.writeDescriptor( + descriptor, + value + ) + } else { + @Suppress("DEPRECATION") + descriptor.value = value + @Suppress("DEPRECATION") + this.writeDescriptor(descriptor) + } +} diff --git a/app/src/main/java/com/dzeio/openhealth/utils/polyfills/IntentPolyfills.kt b/app/src/main/java/com/dzeio/openhealth/utils/polyfills/IntentPolyfills.kt new file mode 100644 index 0000000..6b46218 --- /dev/null +++ b/app/src/main/java/com/dzeio/openhealth/utils/polyfills/IntentPolyfills.kt @@ -0,0 +1,14 @@ +package com.dzeio.openhealth.utils.polyfills + +import android.bluetooth.BluetoothDevice +import android.content.Intent +import android.os.Build + +fun Intent.getBluetoothDevice(): BluetoothDevice? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + this.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE, BluetoothDevice::class.java) + } else { + @Suppress("DEPRECATION") + this.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE) + } +} diff --git a/app/src/main/java/com/dzeio/openhealth/utils/polyfills/ServicePolyfills.kt b/app/src/main/java/com/dzeio/openhealth/utils/polyfills/ServicePolyfills.kt new file mode 100644 index 0000000..d82cb7d --- /dev/null +++ b/app/src/main/java/com/dzeio/openhealth/utils/polyfills/ServicePolyfills.kt @@ -0,0 +1,24 @@ +package com.dzeio.openhealth.utils.polyfills + +import android.app.Service +import android.os.Build + +enum class NotificationBehavior { + REMOVE, + DETACH +} + +fun Service.stopForegroundPoly(notificationBehavior: NotificationBehavior) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + stopForeground( + if (notificationBehavior === NotificationBehavior.REMOVE) { + Service.STOP_FOREGROUND_REMOVE + } else { + Service.STOP_FOREGROUND_DETACH + } + ) + } else { + @Suppress("DEPRECATION") + stopForeground(notificationBehavior == NotificationBehavior.REMOVE) + } +} diff --git a/app/src/main/java/com/dzeio/openhealth/workers/WaterReminderWorker.kt b/app/src/main/java/com/dzeio/openhealth/workers/WaterReminderWorker.kt index 3829842..60f740e 100644 --- a/app/src/main/java/com/dzeio/openhealth/workers/WaterReminderWorker.kt +++ b/app/src/main/java/com/dzeio/openhealth/workers/WaterReminderWorker.kt @@ -17,6 +17,9 @@ import com.dzeio.openhealth.interfaces.NotificationIds import java.util.Date import java.util.concurrent.TimeUnit +/** + * The worker that remind the user to drink water hourly + */ class WaterReminderWorker( private val context: Context, params: WorkerParameters @@ -24,6 +27,10 @@ class WaterReminderWorker( companion object { const val TAG = "${Application.TAG}/WaterWorker" + + /** + * functio nthat setup the worker + */ fun setup(context: Context) { schedule( TAG, @@ -37,6 +44,8 @@ class WaterReminderWorker( override fun doWork(): Result { Log.d(TAG, "Ran! ${Date().toLocaleString()}") + + // send a new notification to the user with(NotificationManagerCompat.from(context)) { val flag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_IMMUTABLE else 0 diff --git a/app/src/main/res/drawable/ic_baseline_chevron_left_24.xml b/app/src/main/res/drawable/ic_baseline_chevron_left_24.xml new file mode 100644 index 0000000..25a728b --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_chevron_left_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_chevron_right_24.xml b/app/src/main/res/drawable/ic_baseline_chevron_right_24.xml new file mode 100644 index 0000000..e7cf886 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_chevron_right_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_bluetooth.xml b/app/src/main/res/drawable/ic_bluetooth.xml new file mode 100644 index 0000000..59d56fd --- /dev/null +++ b/app/src/main/res/drawable/ic_bluetooth.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_zoom_out_map.xml b/app/src/main/res/drawable/ic_zoom_out_map.xml new file mode 100644 index 0000000..1bdc32c --- /dev/null +++ b/app/src/main/res/drawable/ic_zoom_out_map.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/outline_fastfood_24.xml b/app/src/main/res/drawable/outline_fastfood_24.xml new file mode 100644 index 0000000..76a8c97 --- /dev/null +++ b/app/src/main/res/drawable/outline_fastfood_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/layout/activity_error.xml b/app/src/main/res/layout/activity_error.xml index 045108c..3633d72 100644 --- a/app/src/main/res/layout/activity_error.xml +++ b/app/src/main/res/layout/activity_error.xml @@ -1,9 +1,9 @@ @@ -11,6 +11,7 @@ android:id="@+id/textView3" style="?textAppearanceHeadline5" android:layout_width="wrap_content" + android:textAlignment="center" android:layout_height="wrap_content" android:text="@string/error_app_crash" app:layout_constraintEnd_toEndOf="parent" @@ -20,6 +21,7 @@ @@ -53,7 +56,6 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/report_github" - android:layout_marginStart="16dp" app:layout_constraintBaseline_toBaselineOf="@+id/error_submit_email" app:layout_constraintStart_toStartOf="parent" /> @@ -62,7 +64,6 @@ android:layout_marginBottom="16dp" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginEnd="16dp" android:text="@string/report_email" app:layout_constraintBottom_toTopOf="@+id/error_quit" app:layout_constraintEnd_toEndOf="parent" /> diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index be7f15c..c170b91 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -65,6 +65,7 @@ android:id="@+id/bottom_nav" android:layout_width="match_parent" android:fitsSystemWindows="false" + app:labelVisibilityMode="labeled" android:layout_height="wrap_content" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintLeft_toLeftOf="parent" diff --git a/app/src/main/res/layout/dialog_food_product.xml b/app/src/main/res/layout/dialog_food_product.xml new file mode 100644 index 0000000..d68b57c --- /dev/null +++ b/app/src/main/res/layout/dialog_food_product.xml @@ -0,0 +1,154 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_food_search_product.xml b/app/src/main/res/layout/dialog_food_search_product.xml new file mode 100644 index 0000000..4895165 --- /dev/null +++ b/app/src/main/res/layout/dialog_food_search_product.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_search.xml b/app/src/main/res/layout/dialog_search.xml new file mode 100644 index 0000000..5012a47 --- /dev/null +++ b/app/src/main/res/layout/dialog_search.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_extension.xml b/app/src/main/res/layout/fragment_extension.xml deleted file mode 100644 index d5f2a80..0000000 --- a/app/src/main/res/layout/fragment_extension.xml +++ /dev/null @@ -1,39 +0,0 @@ - - - - - -