1
0
mirror of https://github.com/dzeiocom/OpenHealth.git synced 2025-06-07 15:29:55 +00:00

Merge branch 'master' into feat--Extensions

# Conflicts:
#	README.md
This commit is contained in:
Florian Bouillon 2023-02-23 16:48:11 +01:00
commit fd645f7e67
Signed by: Florian Bouillon
GPG Key ID: E05B3A94178D3A7C
178 changed files with 4899 additions and 3671 deletions

15
.editorconfig Normal file
View File

@ -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

View File

@ -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_FINE_LOCATION | Google Fit Extension Requirement (maybe not, still have to test) |
| ACCESS_COARSE_LOCATION | Same as above | | ACCESS_COARSE_LOCATION | Same as above |
| ACTIVITY_RECOGNITION | Device Steps Usage | | 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 ## Build
@ -42,6 +44,12 @@ No other permissions are used (even the internet permission ;)).
- click on the debug icon for debug - click on the debug icon for debug
- it will be running on your emulator/device - 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 ## Contributing
See [CONTRIBUTING.md](https://github.com/dzeiocom/OpenHealth/blob/master/CONTRIBUTING.md) See [CONTRIBUTING.md](https://github.com/dzeiocom/OpenHealth/blob/master/CONTRIBUTING.md)

View File

@ -1,8 +1,13 @@
import java.util.Properties import java.util.Properties
plugins { plugins {
// Android Application?
id("com.android.application") id("com.android.application")
// Support for kotlin in Android
kotlin("android") kotlin("android")
// Data Injection
id("dagger.hilt.android.plugin") id("dagger.hilt.android.plugin")
// Safe Navigation // Safe Navigation
@ -15,14 +20,45 @@ plugins {
kotlin("kapt") 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" val appID = "com.dzeio.openhealth"
// Languages // the application supported languages
val locales = listOf("en", "fr") val locales = listOf("en", "fr")
// minimum application required SDK version to run
val sdkMin = 21 val sdkMin = 21
// target SDK version
val sdkTarget = 33 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 { android {
signingConfigs { signingConfigs {
@ -41,7 +77,6 @@ android {
storeFile = file(keystoreProperties["storeFile"] as String) storeFile = file(keystoreProperties["storeFile"] as String)
} }
} catch (_: Exception) {} } catch (_: Exception) {}
} }
} }
@ -58,7 +93,7 @@ android {
targetSdk = sdkTarget targetSdk = sdkTarget
// Semantic Versioning // Semantic Versioning
versionName = "1.0.0" versionName = "0.1.0"
versionCode = 1 versionCode = 1
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@ -75,6 +110,10 @@ android {
"new String[]{\"" + locales.joinToString("\",\"") + "\"}" "new String[]{\"" + locales.joinToString("\",\"") + "\"}"
) )
resourceConfigurations += locales resourceConfigurations += locales
buildConfigField("String", "BRANCH", "\"$branch\"")
buildConfigField("String", "TAG", "\"$tag\"")
buildConfigField("String", "COMMIT", "\"$commitId\"")
} }
buildTypes { buildTypes {
@ -82,12 +121,18 @@ android {
getByName("release") { getByName("release") {
// Slimmer version // Slimmer version
isMinifyEnabled = true 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") signingConfig = signingConfigs.getByName("release")
} }
getByName("debug") { getByName("debug") {
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
applicationIdSuffix = ".dev" applicationIdSuffix = ".dev"
versionNameSuffix = "-dev" versionNameSuffix = "-dev"
isDebuggable = true isDebuggable = true
@ -118,24 +163,26 @@ android {
namespace = appID namespace = appID
} }
kapt {
correctErrorTypes = true
}
dependencies { dependencies {
// Dzeio Charts // Dzeio Charts
implementation(project(":charts")) implementation("com.dzeio:charts:fe20f90654")
// implementation(project(":CrashHandler"))
// Dzeio Crash Handler
implementation("com.dzeio:crashhandler:1.0.1") implementation("com.dzeio:crashhandler:1.0.1")
// Core dependencies // Core dependencies
implementation("androidx.core:core-ktx:1.9.0") implementation("androidx.core:core-ktx:1.9.0")
implementation("androidx.appcompat:appcompat:1.7.0-alpha01") implementation("androidx.appcompat:appcompat:1.7.0-alpha01")
implementation("javax.inject:javax.inject:1") 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.constraintlayout:constraintlayout:2.1.4")
implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.5.1") implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.5.1")
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1") implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1")
// implementation("com.github.Aviortheking:crashhandler:0.2.3")
// Coroutines // Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4") 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:3.1.1")
implementation("androidx.paging:paging-runtime-ktx:3.1.1") implementation("androidx.paging:paging-runtime-ktx:3.1.1")
// Services // Services
implementation("androidx.work:work-runtime-ktx:2.7.1") implementation("androidx.work:work-runtime-ktx:2.7.1")
implementation("androidx.core:core-ktx:1.9.0")
// Tests // Tests
testImplementation("junit:junit:4.13.2") testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.4") androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0") androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
// Graph
implementation("com.github.PhilJay:MPAndroidChart:v3.1.0")
// Graphs test 2
implementation("com.github.HackPlan:AndroidCharts:1.0.4")
// Hilt // Hilt
implementation("com.google.dagger:hilt-android:2.43.2") implementation("com.google.dagger:hilt-android:2.44.2")
kapt("com.google.dagger:hilt-compiler:2.43.2") kapt("com.google.dagger:hilt-compiler:2.44.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")
// ROOM // ROOM
implementation("androidx.room:room-runtime:2.4.3") implementation("androidx.room:room-runtime:2.5.0")
kapt("androidx.room:room-compiler:2.4.3") kapt("androidx.room:room-compiler:2.5.0")
implementation("androidx.room:room-ktx:2.4.3") implementation("androidx.room:room-ktx:2.5.0")
testImplementation("androidx.room:room-testing:2.4.3") testImplementation("androidx.room:room-testing:2.5.0")
// Futures // Futures
implementation("com.google.guava:guava:31.1-jre") implementation("com.google.guava:guava:31.1-jre")
@ -194,4 +226,9 @@ dependencies {
// OSS Licenses // OSS Licenses
implementation("com.google.android.gms:play-services-oss-licenses:17.0.0") 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")
} }

View File

@ -18,4 +18,9 @@
# If you keep the line number information, uncomment this to # If you keep the line number information, uncomment this to
# hide the original source file name. # hide the original source file name.
#-renamesourcefileattribute SourceFile #-renamesourcefileattribute SourceFile
# do not obfuscate fields with the annotation `@SerializedName`
-keepclassmembers,allowobfuscation class * {
@com.google.gson.annotations.SerializedName <fields>;
}

View File

@ -2,7 +2,7 @@
"formatVersion": 1, "formatVersion": 1,
"database": { "database": {
"version": 1, "version": 1,
"identityHash": "2acd5897bbf15393886259605a1df934", "identityHash": "414712cc283c7f1d14cde8e00da277fb",
"entities": [ "entities": [
{ {
"tableName": "Weight", "tableName": "Weight",
@ -34,10 +34,10 @@
} }
], ],
"primaryKey": { "primaryKey": {
"autoGenerate": true,
"columnNames": [ "columnNames": [
"id" "id"
], ]
"autoGenerate": true
}, },
"indices": [ "indices": [
{ {
@ -82,10 +82,10 @@
} }
], ],
"primaryKey": { "primaryKey": {
"autoGenerate": true,
"columnNames": [ "columnNames": [
"id" "id"
], ]
"autoGenerate": true
}, },
"indices": [ "indices": [
{ {
@ -130,10 +130,10 @@
} }
], ],
"primaryKey": { "primaryKey": {
"autoGenerate": true,
"columnNames": [ "columnNames": [
"id" "id"
], ]
"autoGenerate": true
}, },
"indices": [ "indices": [
{ {
@ -147,12 +147,86 @@
} }
], ],
"foreignKeys": [] "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": [], "views": [],
"setupQueries": [ "setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "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')"
] ]
} }
} }

View File

@ -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')"
]
}
}

View File

@ -1,13 +1,11 @@
package com.dzeio.openhealth package com.dzeio.openhealth
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4 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.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.junit.Assert.*
/** /**
* Instrumented test, which will execute on an Android device. * Instrumented test, which will execute on an Android device.
* *
@ -21,4 +19,4 @@ class ExampleInstrumentedTest {
val appContext = InstrumentationRegistry.getInstrumentation().targetContext val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.dzeio.openhealth", appContext.packageName) assertEquals("com.dzeio.openhealth", appContext.packageName)
} }
} }

View File

@ -0,0 +1,5 @@
<vector android:autoMirrored="true" android:height="24dp"
android:tint="#000000" android:viewportHeight="24"
android:viewportWidth="24" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M15.41,7.41L14,6l-6,6 6,6 1.41,-1.41L10.83,12z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector android:autoMirrored="true" android:height="24dp"
android:tint="#000000" android:viewportHeight="24"
android:viewportWidth="24" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M10,6L8.59,7.41 13.17,12l-4.58,4.59L10,18l6,-6z"/>
</vector>

View File

@ -1,22 +1,21 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android">
xmlns:tools="http://schemas.android.com/tools"> <!-- Internet for OFF -->
<uses-permission android:name="android.permission.INTERNET"/>
<!-- Notifications --> <!-- Notifications for Water and Service -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/> <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<!-- Google Fit --> <!-- Phone Sensors for Steps -->
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />
<!-- Bluetooth Connection -->
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<!-- Phone Sensors -->
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />
<!-- Samsung Health-->
<queries>
<package android:name="com.sec.android.app.shealth" />
</queries>
<uses-sdk tools:overrideLibrary="androidx.health.connect.client" />
<application <application
android:name=".Application" android:name=".Application"
@ -27,16 +26,8 @@
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.OpenHealth"> android:theme="@style/Theme.OpenHealth">
<!-- Samsung Health--> <!-- TODO: Respect what daddy Google wants and try to remove the SplashScreen -->
<meta-data <!-- Main Activity duh -->
android:name="com.samsung.android.health.permission.read"
android:value="com.samsung.health.step_count" />
<!-- Google Fit -->
<meta-data
android:name="com.google.android.gms.version"
android:value="@integer/google_play_services_version" />
<activity <activity
android:name=".ui.MainActivity" android:name=".ui.MainActivity"
android:exported="true" android:exported="true"
@ -48,6 +39,7 @@
</intent-filter> </intent-filter>
</activity> </activity>
<!-- CrashHandler -->
<activity android:name=".ui.ErrorActivity" <activity android:name=".ui.ErrorActivity"
android:theme="@style/Theme.OpenHealth.NoActionBar" android:theme="@style/Theme.OpenHealth.NoActionBar"
android:exported="false" /> android:exported="false" />
@ -60,26 +52,12 @@
android:name="com.google.android.gms.oss.licenses.OssLicensesActivity" android:name="com.google.android.gms.oss.licenses.OssLicensesActivity"
android:theme="@style/Theme.OpenHealth" /> android:theme="@style/Theme.OpenHealth" />
<!-- Activity to show rationale of Health Connect permissions --> <!-- the Service for the application -->
<activity
android:name=".ui.PrivacyPolicyActivity"
android:exported="true"
android:enabled="true">
<intent-filter>
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
</intent-filter>
<!-- List of health data permissions -->
<meta-data
android:name="health_permissions"
android:resource="@array/health_permissions" />
</activity>
<service <service
android:name=".services.OpenHealthService" android:name=".services.OpenHealthService"
android:permission="android.permission.ACTIVITY_RECOGNITION" /> android:permission="android.permission.ACTIVITY_RECOGNITION" />
<!-- Android 13 Locales management if I remember correctly -->
<service <service
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService" android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
android:enabled="false" android:enabled="false"
@ -92,10 +70,4 @@
</application> </application>
<queries>
<package android:name="com.google.android.apps.healthdata" />
</queries>
</manifest> </manifest>

View File

@ -16,24 +16,36 @@ class Application : Application() {
} }
override fun onCreate() { override fun onCreate() {
val prefs = PreferenceManager.getDefaultSharedPreferences(this) val prefs = PreferenceManager.getDefaultSharedPreferences(this)
// setup the CrashHandler
CrashHandler.Builder() CrashHandler.Builder()
.withActivity(ErrorActivity::class.java) .withActivity(ErrorActivity::class.java)
.withPrefs(prefs) .withPrefs(prefs)
.witheErrorReporterCrashKey(R.string.error_reporter_crash) .witheErrorReporterCrashKey(R.string.error_reporter_crash)
.withPrefsKey(Settings.CRASH_LAST_TIME) .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() .build()
.setup(this) .setup(this)
// Android Dynamics Colors // setup for Android Dynamics Colors
DynamicColors.applyToActivitiesIfAvailable(this) DynamicColors.applyToActivitiesIfAvailable(this)
super.onCreate() super.onCreate()
} }
/**
* Change the language of the application if said in the settings
*/
override fun attachBaseContext(base: Context) { override fun attachBaseContext(base: Context) {
super.attachBaseContext(LocaleUtils.onAttach(base)) super.attachBaseContext(LocaleUtils.onAttach(base))
} }

View File

@ -1,7 +1,8 @@
package com.dzeio.openhealth package com.dzeio.openhealth
import com.dzeio.openhealth.extensions.Extension /**
* Object containing every keys for the different settings of the application
*/
object Settings { object Settings {
/** /**
@ -29,8 +30,8 @@ object Settings {
*/ */
const val MASS_UNIT = "com.dzeio.open-health.unit.mass" 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"
} }

View File

@ -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<Extension, LayoutExtensionItemBinding>() {
override val bindingInflater: (LayoutInflater, ViewGroup?, Boolean) ->
LayoutExtensionItemBinding = LayoutExtensionItemBinding::inflate
var onItemClick: ((weight: Extension) -> Unit)? = null
override fun onBindData(
holder: BaseViewHolder<LayoutExtensionItemBinding>,
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)
}
}
}
}

View File

@ -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<Food, ItemFoodBinding>() {
override val bindingInflater: (LayoutInflater, ViewGroup?, Boolean) -> ItemFoodBinding
get() = ItemFoodBinding::inflate
var onItemClick: ((weight: Food) -> Unit)? = null
override fun onBindData(
holder: BaseViewHolder<ItemFoodBinding>,
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)
}
}
}

View File

@ -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<T> : BaseAdapter<ItemAdapter.Item<T>, ItemListBinding>() {
override val bindingInflater: (LayoutInflater, ViewGroup?, Boolean) -> ItemListBinding
get() = ItemListBinding::inflate
var onItemClick: ((weight: Item<T>) -> Unit)? = null
override fun onBindData(
holder: BaseViewHolder<ItemListBinding>,
item: Item<T>,
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<T>(
val value: T,
val title: String? = null,
val subtitle: String? = null,
val image: String? = null,
@DrawableRes
val icon: Int? = null
)
}

View File

@ -1,7 +1,9 @@
package com.dzeio.openhealth.adapters package com.dzeio.openhealth.adapters
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import com.dzeio.openhealth.R
import com.dzeio.openhealth.core.BaseAdapter import com.dzeio.openhealth.core.BaseAdapter
import com.dzeio.openhealth.core.BaseViewHolder import com.dzeio.openhealth.core.BaseViewHolder
import com.dzeio.openhealth.data.step.Step import com.dzeio.openhealth.data.step.Step
@ -14,13 +16,29 @@ class StepsAdapter() : BaseAdapter<Step, LayoutItemListBinding>() {
var onItemClick: ((weight: Step) -> Unit)? = null var onItemClick: ((weight: Step) -> Unit)? = null
var isDay = false
override fun onBindData( override fun onBindData(
holder: BaseViewHolder<LayoutItemListBinding>, holder: BaseViewHolder<LayoutItemListBinding>,
item: Step, item: Step,
position: Int position: Int
) { ) {
holder.binding.value.text = "${item.value}steps" // set the number of steps taken
holder.binding.datetime.text = item.formatTimestamp() 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 { holder.binding.edit.setOnClickListener {
onItemClick?.invoke(item) onItemClick?.invoke(item)
} }

View File

@ -6,8 +6,14 @@ import com.dzeio.openhealth.core.BaseAdapter
import com.dzeio.openhealth.core.BaseViewHolder import com.dzeio.openhealth.core.BaseViewHolder
import com.dzeio.openhealth.data.water.Water import com.dzeio.openhealth.data.water.Water
import com.dzeio.openhealth.databinding.LayoutItemListBinding import com.dzeio.openhealth.databinding.LayoutItemListBinding
import com.dzeio.openhealth.units.Units
class WaterAdapter() : BaseAdapter<Water, LayoutItemListBinding>() { class WaterAdapter : BaseAdapter<Water, LayoutItemListBinding>() {
/**
* The unit the adapter will be using
*/
var unit: Units.Volume = Units.Volume.MILLILITER
override val bindingInflater: (LayoutInflater, ViewGroup?, Boolean) -> LayoutItemListBinding override val bindingInflater: (LayoutInflater, ViewGroup?, Boolean) -> LayoutItemListBinding
get() = LayoutItemListBinding::inflate get() = LayoutItemListBinding::inflate
@ -19,8 +25,14 @@ class WaterAdapter() : BaseAdapter<Water, LayoutItemListBinding>() {
item: Water, item: Water,
position: Int position: Int
) { ) {
holder.binding.value.text = "${item.value}ml" // set the wate intake text
holder.binding.datetime.text = "${item.formatTimestamp()}" 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 { holder.binding.edit.setOnClickListener {
onItemClick?.invoke(item) onItemClick?.invoke(item)
} }

View File

@ -10,6 +10,9 @@ import com.dzeio.openhealth.units.Units
class WeightAdapter : BaseAdapter<Weight, LayoutItemListBinding>() { class WeightAdapter : BaseAdapter<Weight, LayoutItemListBinding>() {
/**
* The unit the adapter will be using
*/
var unit: Units.Mass = Units.Mass.KILOGRAM var unit: Units.Mass = Units.Mass.KILOGRAM
override val bindingInflater: (LayoutInflater, ViewGroup?, Boolean) -> LayoutItemListBinding override val bindingInflater: (LayoutInflater, ViewGroup?, Boolean) -> LayoutItemListBinding
@ -22,12 +25,14 @@ class WeightAdapter : BaseAdapter<Weight, LayoutItemListBinding>() {
item: Weight, item: Weight,
position: Int position: Int
) { ) {
// set the weight text
holder.binding.value.text = holder.binding.value.text =
holder.itemView.context.getString(unit.unit, unit.formatToString(item.weight)) holder.itemView.context.getString(unit.unit, unit.formatToString(item.weight))
// set the datetime
holder.binding.datetime.text = item.formatTimestamp() holder.binding.datetime.text = item.formatTimestamp()
// set the callback
holder.binding.edit.setOnClickListener { holder.binding.edit.setOnClickListener {
onItemClick?.invoke(item) onItemClick?.invoke(item)
} }

View File

@ -5,9 +5,11 @@ import android.view.LayoutInflater
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
/**
* Base around the Activity class to simplify usage
*/
abstract class BaseActivity<VB : ViewBinding>() : AppCompatActivity() { abstract class BaseActivity<VB : ViewBinding>() : AppCompatActivity() {
/** /**
* Function to inflate the Fragment Bindings * Function to inflate the Fragment Bindings
* *
@ -28,4 +30,4 @@ abstract class BaseActivity<VB : ViewBinding>() : AppCompatActivity() {
} }
protected open fun onCreated(savedInstanceState: Bundle?) {} protected open fun onCreated(savedInstanceState: Bundle?) {}
} }

View File

@ -6,9 +6,11 @@ import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
/**
* Base around the adapter to simplify usage
*/
abstract class BaseAdapter<T, VB : ViewBinding> : RecyclerView.Adapter<BaseViewHolder<VB>>() { abstract class BaseAdapter<T, VB : ViewBinding> : RecyclerView.Adapter<BaseViewHolder<VB>>() {
private var items = mutableListOf<T>() private var items = mutableListOf<T>()
// private var lastPosition = -1
@SuppressLint("NotifyDataSetChanged") @SuppressLint("NotifyDataSetChanged")
fun set(items: List<T>) { fun set(items: List<T>) {
@ -22,6 +24,12 @@ abstract class BaseAdapter<T, VB : ViewBinding> : RecyclerView.Adapter<BaseViewH
notifyItemInserted(len) notifyItemInserted(len)
} }
fun clear() {
val len = this.items.size
this.items.clear()
notifyItemRangeRemoved(0, len)
}
/** /**
* Function to inflate the Adapter Bindings * Function to inflate the Adapter Bindings
* *
@ -29,6 +37,9 @@ abstract class BaseAdapter<T, VB : ViewBinding> : RecyclerView.Adapter<BaseViewH
*/ */
abstract val bindingInflater: (LayoutInflater, ViewGroup?, Boolean) -> VB abstract val bindingInflater: (LayoutInflater, ViewGroup?, Boolean) -> VB
/**
* function run when an item is displayed
*/
abstract fun onBindData(holder: BaseViewHolder<VB>, item: T, position: Int) abstract fun onBindData(holder: BaseViewHolder<VB>, item: T, position: Int)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BaseViewHolder<VB> { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BaseViewHolder<VB> {
@ -42,5 +53,4 @@ abstract class BaseAdapter<T, VB : ViewBinding> : RecyclerView.Adapter<BaseViewH
} }
override fun getItemCount(): Int = items.size override fun getItemCount(): Int = items.size
}
}

View File

@ -5,7 +5,9 @@ import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Update import androidx.room.Update
/**
* Base for a DAO interface
*/
interface BaseDao<T> { interface BaseDao<T> {
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(vararg obj: T): List<Long> suspend fun insert(vararg obj: T): List<Long>
@ -13,9 +15,12 @@ interface BaseDao<T> {
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(obj: T): Long suspend fun insert(obj: T): Long
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(vararg obj: T)
@Update @Update
suspend fun update(vararg obj: T) suspend fun update(vararg obj: T)
@Delete @Delete
suspend fun delete(vararg obj: T) suspend fun delete(vararg obj: T)
} }

View File

@ -8,7 +8,9 @@ import androidx.viewbinding.ViewBinding
* *
* note: Dialog crash app with viewmodel error? add @AndroidEntryPoint * note: Dialog crash app with viewmodel error? add @AndroidEntryPoint
*/ */
abstract class BaseDialog<VM : BaseViewModel, VB : ViewBinding>(private val viewModelClass: Class<VM>) : abstract class BaseDialog<VM : BaseViewModel, VB : ViewBinding>(
private val viewModelClass: Class<VM>
) :
BaseSimpleDialog<VB>() { BaseSimpleDialog<VB>() {
val viewModel by lazy { val viewModel by lazy {

View File

@ -3,6 +3,9 @@ package com.dzeio.openhealth.core
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
/**
* Base around the Fragment class to simplify usage
*/
abstract class BaseFragment<VM : BaseViewModel, VB : ViewBinding>( abstract class BaseFragment<VM : BaseViewModel, VB : ViewBinding>(
private val viewModelClass: Class<VM> private val viewModelClass: Class<VM>
) : ) :

View File

@ -13,8 +13,16 @@ import androidx.viewbinding.ViewBinding
import com.dzeio.openhealth.R import com.dzeio.openhealth.R
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
abstract class BaseFullscreenDialog<VM : BaseViewModel, VB : ViewBinding>(private val viewModelClass: Class<VM>) : DialogFragment() { /**
* Base around the DialogFragment class to simplify usage
*/
abstract class BaseFullscreenDialog<VM : BaseViewModel, VB : ViewBinding>(
private val viewModelClass: Class<VM>
) : DialogFragment() {
/**
* Lazyload the viewModel
*/
val viewModel by lazy { val viewModel by lazy {
ViewModelProvider(this)[viewModelClass] ViewModelProvider(this)[viewModelClass]
} }
@ -22,14 +30,14 @@ abstract class BaseFullscreenDialog<VM : BaseViewModel, VB : ViewBinding>(privat
private var _binding: VB? = null private var _binding: VB? = null
val binding get() = _binding!! val binding get() = _binding!!
/** /**
* Function to inflate the Fragment Bindings * Function to inflate the Fragment Bindings
*/ */
abstract val bindingInflater: (LayoutInflater) -> VB abstract val bindingInflater: (LayoutInflater) -> VB
abstract val isFullscreenLayout: Boolean /**
* Function run when the dialog was created
*/
open fun onCreated(savedInstanceState: Bundle?) {} open fun onCreated(savedInstanceState: Bundle?) {}
override fun onCreateView( override fun onCreateView(
@ -37,7 +45,6 @@ abstract class BaseFullscreenDialog<VM : BaseViewModel, VB : ViewBinding>(privat
container: ViewGroup?, container: ViewGroup?,
savedInstanceState: Bundle? savedInstanceState: Bundle?
): View? { ): View? {
_binding = bindingInflater(inflater) _binding = bindingInflater(inflater)
setHasOptionsMenu(true) setHasOptionsMenu(true)
@ -59,14 +66,18 @@ abstract class BaseFullscreenDialog<VM : BaseViewModel, VB : ViewBinding>(privat
onDialogInit(dialog) onDialogInit(dialog)
// onCreated()
dialog dialog
} ?: throw IllegalStateException("Activity cannot be null") } ?: 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) { override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
super.onCreateOptionsMenu(menu, inflater) super.onCreateOptionsMenu(menu, inflater)
@ -75,10 +86,6 @@ abstract class BaseFullscreenDialog<VM : BaseViewModel, VB : ViewBinding>(privat
super.onCreateOptionsMenu(menu, inflater) super.onCreateOptionsMenu(menu, inflater)
} }
override fun onDestroy() {
super.onDestroy()
}
/** /**
* Destroy binding * Destroy binding
*/ */
@ -86,4 +93,4 @@ abstract class BaseFullscreenDialog<VM : BaseViewModel, VB : ViewBinding>(privat
super.onDestroyView() super.onDestroyView()
_binding = null _binding = null
} }
} }

View File

@ -8,8 +8,16 @@ import androidx.fragment.app.DialogFragment
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
/**
* Base around the DialogFragment class to simplify usage
*/
abstract class BaseSimpleDialog<VB : ViewBinding> : DialogFragment() { abstract class BaseSimpleDialog<VB : ViewBinding> : DialogFragment() {
/**
* Function to inflate the Fragment Bindings
*/
abstract val bindingInflater: (LayoutInflater) -> VB
private var _binding: VB? = null private var _binding: VB? = null
val binding get() = _binding!! val binding get() = _binding!!
@ -36,16 +44,20 @@ abstract class BaseSimpleDialog<VB : ViewBinding> : DialogFragment() {
} ?: throw IllegalStateException("Activity cannot be null") } ?: throw IllegalStateException("Activity cannot be null")
} }
/**
* Function to add more customization to the AlertDialogBuilder
*/
open fun onBuilderInit(builder: MaterialAlertDialogBuilder) {} open fun onBuilderInit(builder: MaterialAlertDialogBuilder) {}
/**
* Function that allow to modificate some elements of the final dialog
*/
open fun onDialogInit(dialog: AlertDialog) {} 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 * Destroy binding

View File

@ -1,6 +1,5 @@
package com.dzeio.openhealth.core package com.dzeio.openhealth.core
import android.content.Context
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
@ -8,11 +7,23 @@ import android.view.ViewGroup
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
/**
* Base around the Fragment class to simplify usage
*
* Without ViewModel support (use `BaseFragment` instead)
*/
abstract class BaseStaticFragment<VB : ViewBinding> : Fragment() { abstract class BaseStaticFragment<VB : ViewBinding> : Fragment() {
private var _binding: VB? = null private var _binding: VB? = null
val binding get() = _binding!! val binding get() = _binding!!
/**
* Function to inflate the Fragment Bindings
*
* use like this: `ViewBinding::inflater`
*/
abstract val bindingInflater: (LayoutInflater, ViewGroup?, Boolean) -> VB
/** /**
* Setup everything! * Setup everything!
*/ */
@ -28,13 +39,6 @@ abstract class BaseStaticFragment<VB : ViewBinding> : Fragment() {
return binding.root return binding.root
} }
/**
* Function to inflate the Fragment Bindings
*
* use like this: `ViewBinding::inflater`
*/
abstract val bindingInflater: (LayoutInflater, ViewGroup?, Boolean) -> VB
/** /**
* Destroy binding * Destroy binding
*/ */

View File

@ -3,7 +3,9 @@ package com.dzeio.openhealth.core
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
/**
* Simple implementation of RecyclerView.ViewHolder to limitate usage
*/
class BaseViewHolder<VB : ViewBinding>( class BaseViewHolder<VB : ViewBinding>(
val binding : VB val binding: VB
) : RecyclerView.ViewHolder(binding.root) { ) : RecyclerView.ViewHolder(binding.root)
}

View File

@ -2,4 +2,7 @@ package com.dzeio.openhealth.core
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
abstract class BaseViewModel : ViewModel() /**
* Simple Extension of the base ViewModel
*/
abstract class BaseViewModel : ViewModel()

View File

@ -9,6 +9,9 @@ import androidx.work.Worker
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import com.dzeio.openhealth.Application import com.dzeio.openhealth.Application
/**
* Worker Wrapper to simplify work and usage
*/
abstract class BaseWorker(context: Context, params: WorkerParameters) : Worker(context, params) { abstract class BaseWorker(context: Context, params: WorkerParameters) : Worker(context, params) {
companion object { companion object {
@ -19,4 +22,4 @@ abstract class BaseWorker(context: Context, params: WorkerParameters) : Worker(c
.enqueueUniquePeriodicWork(tag, ExistingPeriodicWorkPolicy.KEEP, request) .enqueueUniquePeriodicWork(tag, ExistingPeriodicWorkPolicy.KEEP, request)
} }
} }
} }

View File

@ -2,12 +2,16 @@ package com.dzeio.openhealth.core
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.callbackFlow
/**
* Simple Observable implementation
*/
open class Observable<T>(baseValue: T) { open class Observable<T>(baseValue: T) {
private val functionObservers: ArrayList<(T) -> Unit> = ArrayList() private val functionObservers: ArrayList<(T) -> Unit> = ArrayList()
fun addObserver(fn: (T) -> Unit) { fun addObserver(fn: (T) -> Unit) {
if (!functionObservers.contains(fn)) { if (!functionObservers.contains(fn)) {
functionObservers.add(fn) functionObservers.add(fn)
@ -35,7 +39,6 @@ open class Observable<T>(baseValue: T) {
} }
fun notifyObservers() { fun notifyObservers() {
// Notify Functions // Notify Functions
for (fn in functionObservers) { for (fn in functionObservers) {
notifyObserver(fn) notifyObserver(fn)
@ -53,4 +56,19 @@ open class Observable<T>(baseValue: T) {
} }
return ld return ld
} }
/**
* Transform the observable to a Kotlin Channel
*/
fun toChannel(): Channel<T> = Channel<T>(Channel.RENDEZVOUS).apply {
addObserver {
trySend(it)
}
}
fun toFlow() = callbackFlow {
addObserver {
trySend(it)
}
}
} }

View File

@ -1,9 +1,12 @@
package com.dzeio.openhealth.data package com.dzeio.openhealth.data
import android.content.Context import android.content.Context
import androidx.room.AutoMigration
import androidx.room.Database import androidx.room.Database
import androidx.room.Room import androidx.room.Room
import androidx.room.RoomDatabase 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.Step
import com.dzeio.openhealth.data.step.StepDao import com.dzeio.openhealth.data.step.StepDao
import com.dzeio.openhealth.data.water.Water 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.Weight
import com.dzeio.openhealth.data.weight.WeightDao 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( @Database(
entities = [ entities = [
Weight::class, Weight::class,
Water::class, Water::class,
Step::class Step::class,
Food::class
],
version = 2,
autoMigrations = [
AutoMigration(1, 2)
], ],
version = 1,
exportSchema = true exportSchema = true
) )
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
// private val PREPOPULATE_DATA = listOf(Thing("1", "val"), Thing("2", "val 2"))
abstract fun weightDao(): WeightDao abstract fun weightDao(): WeightDao
abstract fun waterDao(): WaterDao abstract fun waterDao(): WaterDao
abstract fun stepDao(): StepDao abstract fun stepDao(): StepDao
abstract fun foodDao(): FoodDao
companion object { companion object {
/**
* database name duh
*/
private const val DATABASE_NAME = "open_health" private const val DATABASE_NAME = "open_health"
// For Singleton instantiation // For Singleton instantiation
@Volatile @Volatile
private var instance: AppDatabase? = null private var instance: AppDatabase? = null
// get the Database instance
fun getInstance(context: Context): AppDatabase { fun getInstance(context: Context): AppDatabase {
return instance ?: synchronized(this) { return instance ?: synchronized(this) {
instance ?: buildDatabase(context).also { instance = it } 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 { private fun buildDatabase(context: Context): AppDatabase {
return Room.databaseBuilder(context, AppDatabase::class.java, DATABASE_NAME) return Room.databaseBuilder(context, AppDatabase::class.java, DATABASE_NAME)
// .addCallback(object : Callback() { // .addMigrations(MIGRATION_2_3)
// override fun onCreate(db: SupportSQLiteDatabase) {
// super.onCreate(db)
// // moving to a new thread
// Executors.newSingleThreadExecutor().execute {
// getInstance(context).thingDao()
// .insert(PREPOPULATE_DATA)
// }
// }
// })
.build() .build()
} }
} }

View File

@ -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)
}
}

View File

@ -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
)
}
}
}

View File

@ -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<Food> {
@Query("SELECT * FROM Food ORDER BY timestamp DESC")
fun getAll(): Flow<List<Food>>
@Query("SELECT * FROM Food where id = :weightId")
fun getOne(weightId: Long): Flow<Food?>
@Query("Select count(*) from Food")
fun getCount(): Flow<Int>
@Query("Select * FROM Food ORDER BY timestamp DESC LIMIT 1")
fun last(): Flow<Food?>
}

View File

@ -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<NetworkResult<List<Food>>> = channelFlow {
val result = NetworkResult<List<Food>>()
val items = arrayListOf<Food>()
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)
}

View File

@ -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
)

View File

@ -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?
)

View File

@ -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<OFFProduct>
)

View File

@ -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<OFFResult>
/**
* 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<OFFResult>
}

View File

@ -12,6 +12,10 @@ import java.util.TimeZone
@Entity() @Entity()
data class Step( data class Step(
@PrimaryKey(autoGenerate = true) var id: Long = 0, @PrimaryKey(autoGenerate = true) var id: Long = 0,
/**
* the raw number of step
*/
var value: Int = 0, var value: Int = 0,
/** /**
* Timestamp down to an hour * Timestamp down to an hour
@ -20,6 +24,12 @@ data class Step(
*/ */
@ColumnInfo(index = true) @ColumnInfo(index = true)
var timestamp: Long = 0, var timestamp: Long = 0,
/**
* the source for the Entry
*
* note: Unused currently but kept for future usage
*/
var source: String = "OpenHealth" var source: String = "OpenHealth"
) { ) {
@ -34,19 +44,24 @@ data class Step(
} }
} }
fun formatTimestamp(): String { fun formatTimestamp(removeTime: Boolean = false): String {
val formatter = DateFormat.getDateTimeInstance( val formatter = if (removeTime) {
DateFormat.SHORT, DateFormat.getDateInstance(
DateFormat.SHORT, DateFormat.SHORT,
Locale.getDefault() Locale.getDefault()
) )
} else {
DateFormat.getDateTimeInstance(
DateFormat.SHORT,
DateFormat.SHORT,
Locale.getDefault()
)
}
return formatter.format(Date(this.timestamp)) return formatter.format(Date(this.timestamp))
} }
fun isToday(): Boolean { fun isToday(): Boolean {
val it = Calendar.getInstance(TimeZone.getTimeZone("UTC")) val it = getDay()
it.timeInMillis = timestamp
it.set(Calendar.HOUR, 0)
val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC")) val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
@ -54,7 +69,17 @@ data class Step(
cal.set(Calendar.MINUTE, 0) cal.set(Calendar.MINUTE, 0)
cal.set(Calendar.SECOND, 0) cal.set(Calendar.SECOND, 0)
cal.set(Calendar.MILLISECOND, 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 { fun isCurrent(): Boolean {

View File

@ -11,13 +11,16 @@ interface StepDao : BaseDao<Step> {
@Query("SELECT * FROM Step ORDER BY timestamp DESC") @Query("SELECT * FROM Step ORDER BY timestamp DESC")
fun getAll(): Flow<List<Step>> fun getAll(): Flow<List<Step>>
@Query("SELECT * FROM Step where id = :weightId") @Query("SELECT * FROM Step WHERE timestamp >= :time")
fun getAfter(time: Long): Flow<List<Step>>
@Query("SELECT * FROM Step WHERE id = :weightId")
fun getOne(weightId: Long): Flow<Step?> fun getOne(weightId: Long): Flow<Step?>
@Query("Select count(*) from Step") @Query("SELECT count(*) FROM Step")
fun getCount(): Flow<Int> fun getCount(): Flow<Int>
@Query("Select * FROM Step ORDER BY timestamp DESC LIMIT 1") @Query("SELECT * FROM Step ORDER BY timestamp DESC LIMIT 1")
fun last(): Flow<Step?> fun last(): Flow<Step?>
@Query("DELETE FROM Step where source = :source") @Query("DELETE FROM Step where source = :source")

View File

@ -1,10 +1,11 @@
package com.dzeio.openhealth.data.step package com.dzeio.openhealth.data.step
import kotlinx.coroutines.flow.filter import java.util.Calendar
import kotlinx.coroutines.flow.firstOrNull import java.util.TimeZone
import kotlinx.coroutines.withTimeoutOrNull
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.firstOrNull
@Singleton @Singleton
class StepRepository @Inject constructor( class StepRepository @Inject constructor(
@ -23,7 +24,13 @@ class StepRepository @Inject constructor(
suspend fun deleteFromSource(value: String) = stepDao.deleteFromSource(value) suspend fun deleteFromSource(value: String) = stepDao.deleteFromSource(value)
suspend fun todaySteps(): Int { 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) { if (steps == null) {
return 0 return 0
} }
@ -39,5 +46,4 @@ class StepRepository @Inject constructor(
fun currentStep() = lastStep().filter { fun currentStep() = lastStep().filter {
return@filter it != null && it.isCurrent() return@filter it != null && it.isCurrent()
} }
} }

View File

@ -7,15 +7,21 @@ import android.hardware.SensorEventListener
import android.hardware.SensorManager import android.hardware.SensorManager
import android.os.SystemClock import android.os.SystemClock
import android.util.Log import android.util.Log
import androidx.core.content.edit
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.dzeio.openhealth.Application import com.dzeio.openhealth.Application
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.runBlocking 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( class StepSource(
private val context: Context, context: Context,
private val callback: ((Float) -> Unit)? = null private val callback: ((Float) -> Unit)? = null
): SensorEventListener { ) : SensorEventListener {
companion object { companion object {
const val TAG = "${Application.TAG}/StepSource" const val TAG = "${Application.TAG}/StepSource"
@ -28,27 +34,31 @@ class StepSource(
return prefs.getLong("steps_time_since_last_record", Long.MAX_VALUE) return prefs.getLong("steps_time_since_last_record", Long.MAX_VALUE)
} }
set(value) { set(value) {
val editor = prefs.edit() prefs.edit {
editor.putLong("steps_time_since_last_record", value) putLong("steps_time_since_last_record", value)
editor.commit() }
} }
private var stepsAsOfLastRecord: Float private var stepsAsOfLastRecord: Float
get() { get() {
return prefs.getFloat("steps_as_of_last_record", 0f) return prefs.getFloat("steps_as_of_last_record", 0f)
} }
set(value) { set(value) {
val editor = prefs.edit() prefs.edit {
editor.putFloat("steps_as_of_last_record", value) putFloat("steps_as_of_last_record", value)
editor.commit() }
} }
init { init {
Log.d(TAG, "Setting up") Log.d(TAG, "Setting up")
val sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager val sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
val stepCountSensor = sensorManager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER) val stepCountSensor = sensorManager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER)
stepCountSensor.let { 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") 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 // don't send changes since it wasn't made when the app was running
if (timeSinceLastBoot < timeSinceLastRecord) { 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 timeSinceLastRecord = timeSinceLastBoot
return@let return@let
} }

View File

@ -11,9 +11,23 @@ import java.util.TimeZone
@Entity() @Entity()
data class Water( data class Water(
@PrimaryKey(autoGenerate = true) var id: Long = 0, @PrimaryKey(autoGenerate = true) var id: Long = 0,
/**
* the quantity of water in ML drank by the user
*/
var value: Int = 0, var value: Int = 0,
/**
* when the water was drank precise to the day
*/
@ColumnInfo(index = true) @ColumnInfo(index = true)
var timestamp: Long = 0, var timestamp: Long = 0,
/**
* the source for the Entry
*
* note: Unused currently but kept for future usage
*/
var source: String = "OpenHealth" var source: String = "OpenHealth"
) { ) {
init { init {

View File

@ -1,10 +1,8 @@
package com.dzeio.openhealth.data.water package com.dzeio.openhealth.data.water
import android.util.Log
import kotlinx.coroutines.flow.*
import java.util.*
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlinx.coroutines.flow.filter
@Singleton @Singleton
class WaterRepository @Inject constructor( class WaterRepository @Inject constructor(
@ -23,4 +21,4 @@ class WaterRepository @Inject constructor(
fun todayWater() = lastWater().filter { fun todayWater() = lastWater().filter {
return@filter it != null && it.isToday() return@filter it != null && it.isToday()
} }
} }

View File

@ -14,9 +14,77 @@ data class Weight(
* Store the weight in kilograms * Store the weight in kilograms
*/ */
var weight: Float = 0f, var weight: Float = 0f,
/**
* when the weight was taken precise to the millisecond
*/
@ColumnInfo(index = true) @ColumnInfo(index = true)
var timestamp: Long = System.currentTimeMillis(), 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
}
} }

View File

@ -22,4 +22,4 @@ interface WeightDao : BaseDao<Weight> {
@Query("DELETE FROM Weight where source = :source") @Query("DELETE FROM Weight where source = :source")
suspend fun deleteFromSource(source: String) suspend fun deleteFromSource(source: String)
} }

View File

@ -14,6 +14,7 @@ class WeightRepository @Inject constructor(
fun getWeight(id: Long) = weightDao.getOne(id) fun getWeight(id: Long) = weightDao.getOne(id)
suspend fun addWeight(weight: Weight) = weightDao.insert(weight) 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) suspend fun deleteFromSource(source: String) = weightDao.deleteFromSource(source)
} }

View File

@ -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<BluetoothDevice>(config) {
private val status = Observable(ConnectionStatus.DISCONNECTED)
companion object {
@RequiresPermission(value = "android.permission.BLUETOOTH_SCAN")
fun findDevices(
bluetooth: Bluetooth,
config: Configuration
): Observable<ArrayList<BluetoothLeGattDevice>?> {
val list = arrayListOf<BluetoothLeGattDevice>()
val obs = Observable<ArrayList<BluetoothLeGattDevice>?>(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<BluetoothGattService>()
protected lateinit var gatt: BluetoothGatt
private val gattCallback = ConnectionCallback()
override fun search(): Observable<BluetoothDevice?> {
TODO("Not yet implemented")
}
override fun connect(): Observable<ConnectionStatus> {
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) }
}

View File

@ -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)
}

View File

@ -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<T>(
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<Weight> = arrayListOf()
)
// enum class ActionStatus {}
abstract fun isOfType(item: T): Boolean
abstract fun search(): Observable<T?>
abstract fun connect(): Observable<ConnectionStatus>
abstract fun fetchWeights(): Observable<FetchStatus>
abstract fun reset()
}

View File

@ -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<BluetoothLeGattDevice> {
return arrayListOf(
DeviceMiSmartScale2(bluetooth, configuration)
)
}
}

View File

@ -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> {
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
}
}

View File

@ -0,0 +1 @@
Most code from `./BluetoothGattUuid.kt`, `./DeviceMiSmartScale2.kt` and `./libs/MiScaleLib.java` was taken from [OpenScale](https://github.com/oliexdev/openScale)

View File

@ -0,0 +1,175 @@
/* Copyright (C) 2019 olie.xdev <olie.xdev@googlemail.com>
*
* 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 <http://www.gnu.org/licenses/>
*/
package com.dzeio.openhealth.devices.libs;
/**
* based on <a href="https://github.com/prototux/MIBCS-reverse-engineering">...</a> by prototux
* <p>
* 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;
}
}

View File

@ -2,6 +2,8 @@ package com.dzeio.openhealth.di
import android.content.Context import android.content.Context
import com.dzeio.openhealth.data.AppDatabase 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.step.StepDao
import com.dzeio.openhealth.data.water.WaterDao import com.dzeio.openhealth.data.water.WaterDao
import com.dzeio.openhealth.data.weight.WeightDao import com.dzeio.openhealth.data.weight.WeightDao
@ -12,6 +14,9 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton import javax.inject.Singleton
/**
* Provide to the application the Database/Daos and external services
*/
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
@Module @Module
class DatabaseModule { class DatabaseModule {
@ -36,4 +41,15 @@ class DatabaseModule {
fun provideStepsDao(appDatabase: AppDatabase): StepDao { fun provideStepsDao(appDatabase: AppDatabase): StepDao {
return appDatabase.stepDao() return appDatabase.stepDao()
} }
}
@Provides
fun provideFoodDao(appDatabase: AppDatabase): FoodDao {
return appDatabase.foodDao()
}
@Singleton
@Provides
fun provideOpenFoodFactService(): OpenFoodFactService {
return OpenFoodFactService.getService()
}
}

View File

@ -3,6 +3,7 @@ package com.dzeio.openhealth.di
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.dzeio.openhealth.utils.Bluetooth
import com.dzeio.openhealth.utils.Configuration import com.dzeio.openhealth.utils.Configuration
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
@ -11,6 +12,9 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton import javax.inject.Singleton
/**
* Provide to the application System elements
*/
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
@Module @Module
class SystemModule { class SystemModule {
@ -26,4 +30,8 @@ class SystemModule {
fun provideConfig(sharedPreferences: SharedPreferences): Configuration { fun provideConfig(sharedPreferences: SharedPreferences): Configuration {
return Configuration(sharedPreferences) return Configuration(sharedPreferences)
} }
@Singleton
@Provides
fun provideBluetooth(@ApplicationContext context: Context): Bluetooth = Bluetooth(context)
} }

View File

@ -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<Any> {
data class TaskProgress<T>(
/**
* 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<String>
/**
* 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<Data>
/**
* 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<TaskProgress<ArrayList<Weight>>>
/**
* function run when outgoing sync is enabled and new value is added
* or manual export is launched
*/
suspend fun exportWeights(weight: Array<Weight>): Flow<TaskProgress<Unit>>
// fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) = Unit
suspend fun permissionsGranted(): Boolean
}

View File

@ -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<Extension> {
val extensions: ArrayList<Extension> = arrayListOf(
GoogleFitExtension()
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
extensions.add(HealthConnectExtension())
}
return extensions
}
}
}

View File

@ -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<Extension.Data> {
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<Extension.States> = MutableLiveData(Extension.States.DONE)
override fun connect(): LiveData<Extension.States> = 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<Extension.ImportState<Weight>> {
weightLiveData = MutableLiveData(
Extension.ImportState(
Extension.States.WIP
)
)
startImport(Extension.Data.WEIGHT)
return weightLiveData
}
}

View File

@ -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<Extension.Data> {
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<Extension.States> = MutableLiveData(Extension.States.WIP)
override fun connect(): LiveData<Extension.States> {
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<Extension.ImportState<Weight>>
override fun importWeight(): LiveData<Extension.ImportState<Weight>> {
weightLiveData = MutableLiveData(
Extension.ImportState(
Extension.States.WIP
)
)
startImport(Extension.Data.WEIGHT)
return weightLiveData
}
}

View File

@ -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<Extension.Data> = 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<String, @JvmSuppressWildcards Boolean>>? = 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<Extension.TaskProgress<ArrayList<Weight>>> =
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<Weight> = 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<Weight>): Flow<Extension.TaskProgress<Unit>> {
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
}
}

View File

@ -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<Extension.Data> = 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<Extension.TaskProgress<ArrayList<Weight>>> =
channelFlow {
send(
Extension.TaskProgress(
Extension.TaskState.INITIALIZATING
)
)
val response = client.readRecords(
ReadRecordsRequest(
WeightRecord::class,
timeRangeFilter = TimeRangeFilter.before(Instant.now())
)
)
val weights: ArrayList<Weight> = 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<Weight>): Flow<Extension.TaskProgress<Unit>> {
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<HealthPermission>, Set<HealthPermission>>
get() = PermissionController.createRequestPermissionResultContract()
override suspend fun permissionsGranted(): Boolean {
return this.client.permissionController.getGrantedPermissions(this.PERMISSIONS).containsAll(this.PERMISSIONS)
}
}

View File

@ -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<String>,
grantResult: IntArray
) {
}
fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {}
fun importWeight(callback: (weight: Weight, end: Boolean) -> Unit) {
store.connectService()
}
}

View File

@ -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<HealthData> = 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()
}
}
}
}

View File

@ -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<Weight>,
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<Entry>()
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()
}
}
}

View File

@ -2,6 +2,9 @@ package com.dzeio.openhealth.interfaces
import android.app.NotificationManager import android.app.NotificationManager
/**
* The different notification channels the applicaiton is using
*/
enum class NotificationChannels( enum class NotificationChannels(
val id: String, val id: String,
val channelName: String, val channelName: String,

View File

@ -1,5 +1,8 @@
package com.dzeio.openhealth.interfaces package com.dzeio.openhealth.interfaces
/**
* The different notifications the application can send to the user
*/
enum class NotificationIds { enum class NotificationIds {
WaterIntake, WaterIntake,
@ -7,4 +10,4 @@ enum class NotificationIds {
* Open Health Main Service Notification ID * Open Health Main Service Notification ID
*/ */
Service Service
} }

View File

@ -20,6 +20,8 @@ import com.dzeio.openhealth.data.step.StepRepository_Factory
import com.dzeio.openhealth.data.step.StepSource import com.dzeio.openhealth.data.step.StepSource
import com.dzeio.openhealth.interfaces.NotificationChannels import com.dzeio.openhealth.interfaces.NotificationChannels
import com.dzeio.openhealth.interfaces.NotificationIds 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.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@ -29,12 +31,18 @@ import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
/**
* The Service that allow the application to run in the background
*/
class OpenHealthService : Service() { class OpenHealthService : Service() {
companion object { companion object {
private const val TAG = "${Application.TAG}/Service" private const val TAG = "${Application.TAG}/Service"
} }
/**
* Get the StepRepository without DI because it is unavailable here
*/
private val stepRepository: StepRepository private val stepRepository: StepRepository
get() = StepRepository_Factory.newInstance( get() = StepRepository_Factory.newInstance(
AppDatabase.getInstance(applicationContext).stepDao() AppDatabase.getInstance(applicationContext).stepDao()
@ -64,42 +72,56 @@ class OpenHealthService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
scope.launch { scope.launch {
// start the StepSource
val source = StepSource(this@OpenHealthService) 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) { if (it <= 0f) {
Log.d(TAG, "No new steps registered ($it)") Log.d(TAG, "No new steps registered ($it)")
return@collectLatest 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() stepsTaken += it.toUInt()
stepsBuffer += it.toInt() stepsBuffer += it.toInt()
// show the notification
showNotification() showNotification()
// try to get the current number of steps for the hour from the DB
val step = withTimeoutOrNull(1000) { val step = withTimeoutOrNull(1000) {
return@withTimeoutOrNull stepRepository.currentStep().firstOrNull() 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) { if (step != null) {
step.value += stepsBuffer step.value += stepsBuffer
stepRepository.updateStep(step) stepRepository.updateStep(step)
// create a new steps object and send it
} else { } else {
stepRepository.addStep(Step(value = stepsBuffer)) stepRepository.addStep(Step(value = stepsBuffer))
} }
// reset the internal buffer
stepsBuffer = 0 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. // Display a notification about us starting. We put an icon in the status bar.
startForeground(NotificationIds.Service.ordinal, showNotification()) startForeground(NotificationIds.Service.ordinal, showNotification())
return START_STICKY return START_STICKY
} }
override fun onDestroy() { override fun onDestroy() {
stopForeground(true) stopForegroundPoly(NotificationBehavior.REMOVE)
// Tell the user we stopped. // Tell the user we stopped.
Toast.makeText(this, "Service stopped", Toast.LENGTH_SHORT).show() Toast.makeText(this, "Service stopped", Toast.LENGTH_SHORT).show()

View File

@ -3,13 +3,11 @@ package com.dzeio.openhealth.ui
import android.content.ActivityNotFoundException import android.content.ActivityNotFoundException
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Process import android.os.Process
import android.view.LayoutInflater import android.view.LayoutInflater
import android.widget.Toast import android.widget.Toast
import com.dzeio.openhealth.Application import com.dzeio.openhealth.Application
import com.dzeio.openhealth.BuildConfig
import com.dzeio.openhealth.core.BaseActivity import com.dzeio.openhealth.core.BaseActivity
import com.dzeio.openhealth.databinding.ActivityErrorBinding import com.dzeio.openhealth.databinding.ActivityErrorBinding
import kotlin.system.exitProcess import kotlin.system.exitProcess
@ -28,20 +26,8 @@ class ErrorActivity : BaseActivity<ActivityErrorBinding>() {
val data = intent.getStringExtra("error") 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 // put it in the textView
binding.errorText.text = reportText binding.errorText.text = data
// Handle the Quit button // Handle the Quit button
binding.errorQuit.setOnClickListener { binding.errorQuit.setOnClickListener {
@ -51,14 +37,15 @@ class ErrorActivity : BaseActivity<ActivityErrorBinding>() {
// Handle the Email Button // Handle the Email Button
binding.errorSubmitEmail.setOnClickListener { binding.errorSubmitEmail.setOnClickListener {
// Create Intent // Create Intent
val intent = Intent(Intent.ACTION_SEND) val intent = Intent(Intent.ACTION_VIEW)
intent.data = Uri.parse("mailto:") intent.setDataAndType(
intent.type = "text/plain" Uri.parse("mailto:"),
"text/plain"
)
intent.putExtra(Intent.EXTRA_EMAIL, arrayOf("report.openhealth@dzeio.com")) intent.putExtra(Intent.EXTRA_EMAIL, arrayOf("report.openhealth@dzeio.com"))
intent.putExtra(Intent.EXTRA_SUBJECT, "Error report for application crash") 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 { try {
startActivity(Intent.createChooser(intent, "Send Report Email...")) startActivity(Intent.createChooser(intent, "Send Report Email..."))
@ -69,14 +56,17 @@ class ErrorActivity : BaseActivity<ActivityErrorBinding>() {
// Handle the GitHub Button // Handle the GitHub Button
binding.errorSubmitGithub.setOnClickListener { binding.errorSubmitGithub.setOnClickListener {
// Build URL // 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 { try {
startActivity( val intent = Intent(Intent.ACTION_VIEW).apply {
Intent(Intent.ACTION_VIEW, Uri.parse(url)) setData(Uri.parse(url))
) }
startActivity(intent)
} catch (e: ActivityNotFoundException) { } catch (e: ActivityNotFoundException) {
Toast.makeText(this, "No Web Browser found :(", Toast.LENGTH_LONG).show() Toast.makeText(this, "No Web Browser found :(", Toast.LENGTH_LONG).show()
} }

View File

@ -3,7 +3,6 @@ package com.dzeio.openhealth.ui
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.content.Context import android.content.Context
import android.content.Intent
import android.content.res.Configuration import android.content.res.Configuration
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
@ -11,6 +10,7 @@ import android.util.Log
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.view.WindowInsets
import android.view.WindowManager import android.view.WindowManager
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
@ -21,7 +21,6 @@ import androidx.navigation.ui.NavigationUI
import androidx.navigation.ui.navigateUp import androidx.navigation.ui.navigateUp
import androidx.navigation.ui.setupActionBarWithNavController import androidx.navigation.ui.setupActionBarWithNavController
import androidx.navigation.ui.setupWithNavController import androidx.navigation.ui.setupWithNavController
import com.dzeio.openhealth.Application
import com.dzeio.openhealth.R import com.dzeio.openhealth.R
import com.dzeio.openhealth.core.BaseActivity import com.dzeio.openhealth.core.BaseActivity
import com.dzeio.openhealth.databinding.ActivityMainBinding import com.dzeio.openhealth.databinding.ActivityMainBinding
@ -35,7 +34,7 @@ import dagger.hilt.android.AndroidEntryPoint
class MainActivity : BaseActivity<ActivityMainBinding>() { class MainActivity : BaseActivity<ActivityMainBinding>() {
companion object { companion object {
const val TAG = "${Application.TAG}/MainActivity" val TAG: String = this::class.java.simpleName
} }
private lateinit var appBarConfiguration: AppBarConfiguration private lateinit var appBarConfiguration: AppBarConfiguration
@ -48,7 +47,6 @@ class MainActivity : BaseActivity<ActivityMainBinding>() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
setTheme(R.style.Theme_OpenHealth_NoActionBar) setTheme(R.style.Theme_OpenHealth_NoActionBar)
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
} }
override fun onCreated(savedInstanceState: Bundle?) { override fun onCreated(savedInstanceState: Bundle?) {
@ -58,7 +56,6 @@ class MainActivity : BaseActivity<ActivityMainBinding>() {
// Comportement chelou API 28- // Comportement chelou API 28-
// Comportement normal 31+ // Comportement normal 31+
// do not do the cool status/navigation bars for API 29 & 30 // 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) { 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) // allow to put the content behind the status bar & Navigation bar (one of them at least lul)
@ -66,25 +63,23 @@ class MainActivity : BaseActivity<ActivityMainBinding>() {
window.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION) window.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION)
window.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS) 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 // Update toolbar height with the statusbar size included
// ALSO: make both the status/navigation bars transparent (WHYYYYYYY) // ALSO: make both the status/navigation bars transparent (WHYYYYYYY)
val toolbarHeight = binding.toolbar.layoutParams.height val toolbarHeight = binding.toolbar.layoutParams.height
window.decorView.setOnApplyWindowInsetsListener { _, insets -> 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) // Add padding to the toolbar (YaY I know how something works)
binding.toolbar.updatePadding(top = statusBarSize) binding.toolbar.updatePadding(top = statusBarSize)
binding.toolbar.layoutParams.height = toolbarHeight + statusBarSize binding.toolbar.layoutParams.height = toolbarHeight + statusBarSize
return@setOnApplyWindowInsetsListener insets 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)) { when (this.resources.configuration.uiMode.and(Configuration.UI_MODE_NIGHT_MASK)) {
Configuration.UI_MODE_NIGHT_YES -> { Configuration.UI_MODE_NIGHT_YES -> {
WindowCompat.getInsetsController(window, window.decorView).apply { WindowCompat.getInsetsController(window, window.decorView).apply {
@ -121,25 +116,17 @@ class MainActivity : BaseActivity<ActivityMainBinding>() {
binding.bottomNav.setupWithNavController(navController) binding.bottomNav.setupWithNavController(navController)
// registerForActivityResult(ActivityResultContracts.RequestPermission()) {
//
// }
// .launch(Manifest.permission.ACTIVITY_RECOGNITION)
createNotificationChannel() createNotificationChannel()
// Services // Services
WaterReminderWorker.setup(this) WaterReminderWorker.setup(this)
// StepCountService.setup(this)
ServiceUtils.startService(this, OpenHealthService::class.java) ServiceUtils.startService(this, OpenHealthService::class.java)
} }
override fun onCreateOptionsMenu(menu: Menu): Boolean { override fun onCreateOptionsMenu(menu: Menu): Boolean {
super.onCreateOptionsMenu(menu)
menuInflater.inflate(R.menu.main, menu) menuInflater.inflate(R.menu.main, menu)
return true return super.onCreateOptionsMenu(menu)
} }
override fun onSupportNavigateUp(): Boolean = override fun onSupportNavigateUp(): Boolean =
@ -149,22 +136,13 @@ class MainActivity : BaseActivity<ActivityMainBinding>() {
NavigationUI.onNavDestinationSelected(item, navController) || NavigationUI.onNavDestinationSelected(item, navController) ||
super.onOptionsItemSelected(item) 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() { private fun createNotificationChannel() {
val notificationManager: NotificationManager = val notificationManager: NotificationManager =
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
for (channel in NotificationChannels.values()) { for (channel in NotificationChannels.values()) {
Log.d("MainActivity", channel.channelName) Log.d(TAG, channel.channelName)
try { try {
notificationManager.createNotificationChannel( notificationManager.createNotificationChannel(
NotificationChannel( NotificationChannel(
@ -174,7 +152,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>() {
) )
) )
} catch (e: Exception) { } catch (e: Exception) {
Log.e("MainActivity", "Error Creating Notification Channel", e) Log.e(TAG, "Error Creating Notification Channel", e)
} }
} }
} }

View File

@ -15,6 +15,9 @@ import com.dzeio.openhealth.core.BaseStaticFragment
import com.dzeio.openhealth.databinding.FragmentAboutBinding import com.dzeio.openhealth.databinding.FragmentAboutBinding
import com.google.android.gms.oss.licenses.OssLicensesMenuActivity import com.google.android.gms.oss.licenses.OssLicensesMenuActivity
/**
* Fragment for the About page
*/
class AboutFragment : BaseStaticFragment<FragmentAboutBinding>() { class AboutFragment : BaseStaticFragment<FragmentAboutBinding>() {
override val bindingInflater: (LayoutInflater, ViewGroup?, Boolean) -> FragmentAboutBinding override val bindingInflater: (LayoutInflater, ViewGroup?, Boolean) -> FragmentAboutBinding
get() = FragmentAboutBinding::inflate get() = FragmentAboutBinding::inflate
@ -23,22 +26,29 @@ class AboutFragment : BaseStaticFragment<FragmentAboutBinding>() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
// set the version number
binding.version.text = binding.version.text =
resources.getString(R.string.version_number, BuildConfig.VERSION_NAME) resources.getString(R.string.version_number, BuildConfig.VERSION_NAME)
// handle contact US button
binding.contactUs.setOnClickListener { binding.contactUs.setOnClickListener {
openLink("mailto:context.openhealth@dze.io") openLink("mailto:contact.openhealth@dze.io")
} }
// handle Github button
binding.github.setOnClickListener { binding.github.setOnClickListener {
openLink("https://github.com/dzeiocom/OpenHealth") openLink("https://github.com/dzeiocom/OpenHealth")
} }
// send the user to the Google OSS licenses page when clicked
binding.licenses.setOnClickListener { binding.licenses.setOnClickListener {
startActivity(Intent(requireContext(), OssLicensesMenuActivity::class.java)) startActivity(Intent(requireContext(), OssLicensesMenuActivity::class.java))
} }
} }
/**
* simple function that try to open a link.
*/
private fun openLink(url: String) { private fun openLink(url: String) {
try { try {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))

View File

@ -10,6 +10,9 @@ import com.dzeio.openhealth.core.BaseFragment
import com.dzeio.openhealth.databinding.FragmentActivityBinding import com.dzeio.openhealth.databinding.FragmentActivityBinding
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
/**
* Fragment for the Activity page
*/
@AndroidEntryPoint @AndroidEntryPoint
class ActivityFragment : class ActivityFragment :
BaseFragment<ActivityViewModel, FragmentActivityBinding>(ActivityViewModel::class.java) { BaseFragment<ActivityViewModel, FragmentActivityBinding>(ActivityViewModel::class.java) {

View File

@ -1,7 +1,6 @@
package com.dzeio.openhealth.ui.browse package com.dzeio.openhealth.ui.browse
import android.Manifest import android.Manifest
import android.content.SharedPreferences
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
@ -10,7 +9,6 @@ import android.view.ViewGroup
import android.widget.Toast import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.preference.PreferenceManager
import com.dzeio.openhealth.R import com.dzeio.openhealth.R
import com.dzeio.openhealth.core.BaseFragment import com.dzeio.openhealth.core.BaseFragment
import com.dzeio.openhealth.databinding.FragmentBrowseBinding import com.dzeio.openhealth.databinding.FragmentBrowseBinding
@ -25,10 +23,6 @@ class BrowseFragment :
override val bindingInflater: (LayoutInflater, ViewGroup?, Boolean) -> FragmentBrowseBinding override val bindingInflater: (LayoutInflater, ViewGroup?, Boolean) -> FragmentBrowseBinding
get() = FragmentBrowseBinding::inflate get() = FragmentBrowseBinding::inflate
private val settings: SharedPreferences by lazy {
PreferenceManager.getDefaultSharedPreferences(requireContext())
}
private lateinit var button: MaterialCardView private lateinit var button: MaterialCardView
private val activityResult = registerForActivityResult( private val activityResult = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions() ActivityResultContracts.RequestMultiplePermissions()
@ -45,21 +39,32 @@ class BrowseFragment :
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
// handle clicking on the weight card
binding.weight.setOnClickListener { binding.weight.setOnClickListener {
findNavController().navigate(BrowseFragmentDirections.actionNavBrowseToNavListWeight()) findNavController().navigate(BrowseFragmentDirections.actionNavBrowseToNavListWeight())
} }
// handle clicking on the water intake card
binding.waterIntake.setOnClickListener { binding.waterIntake.setOnClickListener {
findNavController().navigate(BrowseFragmentDirections.actionNavBrowseToNavWaterHome()) 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 { binding.steps.setOnClickListener {
// since Android Q We need additionnal permissions
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// check for activity permission
val activityPermission = PermissionsManager.hasPermission( val activityPermission = PermissionsManager.hasPermission(
requireContext(), requireContext(),
Manifest.permission.ACTIVITY_RECOGNITION Manifest.permission.ACTIVITY_RECOGNITION
) )
// check for notification permission
val notificationPermission = val notificationPermission =
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && PermissionsManager.hasPermission( Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && PermissionsManager.hasPermission(
requireContext(), requireContext(),
@ -68,38 +73,56 @@ class BrowseFragment :
val permissionsToAsk = arrayListOf<String>() val permissionsToAsk = arrayListOf<String>()
// add missing permission to list
if (!activityPermission) { if (!activityPermission) {
permissionsToAsk.add(Manifest.permission.ACTIVITY_RECOGNITION) permissionsToAsk.add(Manifest.permission.ACTIVITY_RECOGNITION)
} }
// add missing permission to list only if necessary
if (!notificationPermission && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (!notificationPermission && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
permissionsToAsk.add(Manifest.permission.POST_NOTIFICATIONS) permissionsToAsk.add(Manifest.permission.POST_NOTIFICATIONS)
} }
// ask for permissions
if (permissionsToAsk.isNotEmpty()) { if (permissionsToAsk.isNotEmpty()) {
button = binding.steps button = binding.steps
activityResult.launch(permissionsToAsk.toTypedArray()) activityResult.launch(permissionsToAsk.toTypedArray())
return@setOnClickListener return@setOnClickListener
} }
} }
// navigate user to the Steps home fragment
findNavController().navigate( findNavController().navigate(
BrowseFragmentDirections.actionNavBrowseToStepsHomeFragment() BrowseFragmentDirections.actionNavBrowseToStepsHomeFragment()
) )
} }
// display the number of steps the user made today
viewModel.steps.observe(viewLifecycleOwner) { 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) { viewModel.weight.observe(viewLifecycleOwner) {
binding.weightText.setText( binding.weightText.setText(
String.format( String.format(
resources.getString(R.string.weight_current), resources.getString(R.string.weight_current),
it, String.format(resources.getString(R.string.unit_mass_kilogram_unit), it)
resources.getString(R.string.unit_mass_kilogram_unit)
) )
) )
} }
}
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)
} }
} }

View File

@ -3,23 +3,28 @@ package com.dzeio.openhealth.ui.browse
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.dzeio.openhealth.Settings
import com.dzeio.openhealth.core.BaseViewModel import com.dzeio.openhealth.core.BaseViewModel
import com.dzeio.openhealth.data.step.StepRepository import com.dzeio.openhealth.data.step.StepRepository
import com.dzeio.openhealth.data.weight.WeightRepository import com.dzeio.openhealth.data.weight.WeightRepository
import com.dzeio.openhealth.utils.Configuration
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel @HiltViewModel
class BrowseViewModel @Inject internal constructor( class BrowseViewModel @Inject internal constructor(
stepRepository: StepRepository, stepRepository: StepRepository,
weightRepository: WeightRepository weightRepository: WeightRepository,
config: Configuration
) : BaseViewModel() { ) : BaseViewModel() {
private val _steps = MutableLiveData(0) private val _steps = MutableLiveData(0)
val steps: LiveData<Int> = _steps val steps: LiveData<Int> = _steps
val stepsGoal = config.getInt(Settings.STEPS_GOAL).toLiveData()
private val _weight = MutableLiveData(0f) private val _weight = MutableLiveData(0f)
val weight: LiveData<Float> = _weight val weight: LiveData<Float> = _weight

View File

@ -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, FragmentExtensionBinding>(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<Any>? = null
override fun onAttach(context: Context) {
if (this.extension.contract != null) {
this.request =
registerForActivityResult<Any, Any>(this.extension.contract!! as ActivityResultContract<Any, Any>) {
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)
}
}
}
}

View File

@ -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<String>().apply {
value = "This is slideshow Fragment"
}
val importProgress = MutableLiveData<Int>().apply {
value = 0
}
// If -1 progress is undetermined
// If 0 no progress bar
// Else progress bar
val importProgressTotal = MutableLiveData<Int>().apply {
value = 0
}
suspend fun importWeight(weight: Weight) = weightRepository.addWeight(weight)
suspend fun deleteFromSource(source: String) = weightRepository.deleteFromSource(source)
}

View File

@ -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, FragmentExtensionsBinding>(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)
)
}
}

View File

@ -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()
}

View File

@ -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, DialogFoodProductBinding>(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()
}
}
}

View File

@ -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<Food> = 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)
}
}
}

View File

@ -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, FragmentFoodHomeBinding>(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)
}
}
}

View File

@ -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<List<Food>> = MutableLiveData()
private val list: MutableLiveData<List<Food>> = MutableLiveData(arrayListOf())
val date: MutableLiveData<Long> = 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<Food>? = 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
}
)
}
}

View File

@ -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, DialogFoodSearchProductBinding>(
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
}
}
}
}

View File

@ -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<NetworkResult<List<Food>>> = 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)
}
}

View File

@ -4,20 +4,25 @@ import android.animation.ValueAnimator
import android.content.SharedPreferences import android.content.SharedPreferences
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.Canvas import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.RectF import android.graphics.RectF
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.preference.PreferenceManager 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.core.BaseFragment
import com.dzeio.openhealth.data.water.Water import com.dzeio.openhealth.data.water.Water
import com.dzeio.openhealth.data.weight.Weight import com.dzeio.openhealth.data.weight.Weight
import com.dzeio.openhealth.databinding.FragmentHomeBinding import com.dzeio.openhealth.databinding.FragmentHomeBinding
import com.dzeio.openhealth.graphs.WeightChart
import com.dzeio.openhealth.ui.weight.WeightDialog import com.dzeio.openhealth.ui.weight.WeightDialog
import com.dzeio.openhealth.units.Units import com.dzeio.openhealth.units.Units
import com.dzeio.openhealth.utils.ChartUtils
import com.dzeio.openhealth.utils.DrawUtils import com.dzeio.openhealth.utils.DrawUtils
import com.dzeio.openhealth.utils.GraphUtils
import com.google.android.material.color.MaterialColors import com.google.android.material.color.MaterialColors
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlin.math.max import kotlin.math.max
@ -44,6 +49,9 @@ class HomeFragment : BaseFragment<HomeViewModel, FragmentHomeBinding>(HomeViewMo
) )
} }
/**
* Water Intake
*/
binding.fragmentHomeWaterAdd.setOnClickListener { binding.fragmentHomeWaterAdd.setOnClickListener {
val water = viewModel.water.value val water = viewModel.water.value
if (water == null || !water.isToday()) { if (water == null || !water.isToday()) {
@ -76,26 +84,34 @@ class HomeFragment : BaseFragment<HomeViewModel, FragmentHomeBinding>(HomeViewMo
} }
} }
// handle button to go to weight home
binding.listWeight.setOnClickListener { binding.listWeight.setOnClickListener {
findNavController().navigate(HomeFragmentDirections.actionNavHomeToNavListWeight()) findNavController().navigate(HomeFragmentDirections.actionNavHomeToNavListWeight())
} }
// handle button to go to water intake home
binding.gotoWaterHome.setOnClickListener { binding.gotoWaterHome.setOnClickListener {
findNavController().navigate(HomeFragmentDirections.actionNavHomeToNavWaterHome()) findNavController().navigate(HomeFragmentDirections.actionNavHomeToNavWaterHome())
} }
GraphUtils.lineChartSetup( binding.weightGraph.apply {
binding.weightGraph, val serie = LineSerie(this)
MaterialColors.getColor( ChartUtils.materielTheme(this, requireView())
requireView(), series = arrayListOf(serie)
com.google.android.material.R.attr.colorPrimary }
),
MaterialColors.getColor(
requireView(),
com.google.android.material.R.attr.colorOnBackground
)
)
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) { viewModel.water.observe(viewLifecycleOwner) {
if (it != null) { if (it != null) {
updateWater(it.value) updateWater(it.value)
@ -104,63 +120,101 @@ class HomeFragment : BaseFragment<HomeViewModel, FragmentHomeBinding>(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) { viewModel.weights.observe(viewLifecycleOwner) {
if (it != null) { if (it != null) {
updateGraph(it) updateGraph(it)
} }
} }
// update the graph when the goal weight change
viewModel.goalWeight.observe(viewLifecycleOwner) { viewModel.goalWeight.observe(viewLifecycleOwner) {
if (viewModel.weights.value != null) updateGraph(viewModel.weights.value!!) if (viewModel.weights.value != null) updateGraph(viewModel.weights.value!!)
} }
// update the graph when the weight unit change
viewModel.massUnit.observe(viewLifecycleOwner) { viewModel.massUnit.observe(viewLifecycleOwner) {
if (viewModel.weights.value != null) updateGraph(viewModel.weights.value!!) if (viewModel.weights.value != null) updateGraph(viewModel.weights.value!!)
} }
} }
/**
* Function that update the graph for the weight
*/
private fun updateGraph(list: List<Weight>) { private fun updateGraph(list: List<Weight>) {
WeightChart.setup( val chart = binding.weightGraph
binding.weightGraph, val serie = chart.series[0] as LineSerie
requireView(),
list,
viewModel.massUnit.value!!,
viewModel.goalWeight.value
)
// legend.apply { val entries: ArrayList<Entry> = arrayListOf()
// isEnabled = true
// form = Legend.LegendForm.LINE list.forEach {
// entries.add(
// if (goal != null) { Entry(
// val legendEntry = LegendEntry().apply { it.timestamp.toDouble(),
// label = "Weight Goal" it.weight
// formColor = Color.RED )
// } )
// setCustom(arrayOf(legendEntry)) }
// } 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 private var oldValue = 0f
/**
* function that update the water count in the home page
*/
private fun updateWater(newValue: Int) { private fun updateWater(newValue: Int) {
// get the current Unit
val waterUnit = val waterUnit =
Units.Volume.find(settings.getString("water_unit", "milliliter") ?: "Milliliter") Units.Volume.find(settings.getString("water_unit", "milliliter") ?: "Milliliter")
// Update the count
binding.fragmentHomeWaterCurrent.text = binding.fragmentHomeWaterCurrent.text =
String.format( String.format(
resources.getString(waterUnit.unit), resources.getString(waterUnit.unit),
(newValue * waterUnit.modifier).toInt() (newValue * waterUnit.modifier).toInt()
) )
// TODO: move it elsewhere
binding.fragmentHomeWaterTotal.text = binding.fragmentHomeWaterTotal.text =
String.format( String.format(
resources.getString(waterUnit.unit), resources.getString(waterUnit.unit),
viewModel.dailyWaterIntake viewModel.dailyWaterIntake
) )
// get the with/height of the ImageView
var width = 1500 var width = 1500
var height = 750 var height = 750
@ -169,55 +223,41 @@ class HomeFragment : BaseFragment<HomeViewModel, FragmentHomeBinding>(HomeViewMo
height = binding.background.height height = binding.background.height
} }
val graph = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) // Prepare the update animation
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
)
val animator = ValueAnimator.ofInt( val animator = ValueAnimator.ofInt(
min(this.oldValue, viewModel.dailyWaterIntake.toFloat()).toInt(), this.oldValue.toInt(),
min(newValue, viewModel.dailyWaterIntake) newValue
) )
animator.duration = 300 // ms animator.duration = 300 // ms
animator.addUpdateListener { 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() val graph = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
// Log.d("Test2", "${this.oldValue}") val canvas = Canvas(graph)
val rect = RectF(
10f,
15f,
90f,
85f
)
// background Arc
DrawUtils.drawArc( DrawUtils.drawArc(
canvas, 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, rect,
MaterialColors.getColor( MaterialColors.getColor(
requireView(), requireView(),
@ -225,9 +265,15 @@ class HomeFragment : BaseFragment<HomeViewModel, FragmentHomeBinding>(HomeViewMo
), ),
6f 6f
) )
// save the canvas
canvas.save() canvas.save()
// send it
binding.background.setImageBitmap(graph) binding.background.setImageBitmap(graph)
} }
// start the animation
animator.start() animator.start()
} }
} }

View File

@ -1,58 +1,94 @@
package com.dzeio.openhealth.ui.home package com.dzeio.openhealth.ui.home
import android.content.SharedPreferences
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.dzeio.openhealth.Settings import com.dzeio.openhealth.Settings
import com.dzeio.openhealth.core.BaseViewModel 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.Water
import com.dzeio.openhealth.data.water.WaterRepository import com.dzeio.openhealth.data.water.WaterRepository
import com.dzeio.openhealth.data.weight.Weight import com.dzeio.openhealth.data.weight.Weight
import com.dzeio.openhealth.data.weight.WeightRepository import com.dzeio.openhealth.data.weight.WeightRepository
import com.dzeio.openhealth.units.UnitFactory
import com.dzeio.openhealth.units.Units import com.dzeio.openhealth.units.Units
import com.dzeio.openhealth.utils.Configuration import com.dzeio.openhealth.utils.Configuration
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel @HiltViewModel
class HomeViewModel @Inject internal constructor( class HomeViewModel @Inject internal constructor(
private val weightRepository: WeightRepository, private val weightRepository: WeightRepository,
private val waterRepository: WaterRepository, private val waterRepository: WaterRepository,
settings: SharedPreferences, stepRepository: StepRepository,
config: Configuration config: Configuration
) : BaseViewModel() { ) : BaseViewModel() {
private val _steps = MutableLiveData(0)
/**
* Steps taken today by the user
*/
val steps: LiveData<Int> = _steps
/**
* Number of steps the use should do today
*/
val stepsGoal: LiveData<Int?> = config.getInt(Settings.STEPS_GOAL).toLiveData()
private val _water = MutableLiveData<Water?>(null) private val _water = MutableLiveData<Water?>(null)
/**
* Quantity of water the user drank today
*/
val water: LiveData<Water?> = _water val water: LiveData<Water?> = _water
private val _weights = MutableLiveData<List<Weight>?>(null) private val _weights = MutableLiveData<List<Weight>?>(null)
/**
* The list of weight of the user
*/
val weights: LiveData<List<Weight>?> = _weights val weights: LiveData<List<Weight>?> = _weights
/**
* The size of a cup for the quick water intake add
*/
var waterCupSize = config.getInt("water_cup_size").toLiveData() 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) private val _massUnit = MutableLiveData(Units.Mass.KILOGRAM)
/**
* The Mass unit used by the user
*/
val massUnit: LiveData<Units.Mass> = _massUnit val massUnit: LiveData<Units.Mass> = _massUnit
/**
* the User weight goal
*/
val goalWeight = config.getFloat(Settings.WEIGHT_GOAL).toLiveData() val goalWeight = config.getFloat(Settings.WEIGHT_GOAL).toLiveData()
val dailyWaterIntake: Int = val dailyWaterIntake: Float = (config.getFloat("water_intake").value ?: 1200f) * waterUnit.modifier
((settings.getString("water_intake", "1200")?.toFloatOrNull() ?: 1200f) * waterUnit.modifier)
.toInt()
init { init {
// Fetch today's water intake
viewModelScope.launch { viewModelScope.launch {
waterRepository.todayWater().collectLatest { waterRepository.todayWater().collectLatest {
_water.postValue(it) _water.postValue(it)
} }
} }
// Fetch the user weights
viewModelScope.launch {
_steps.postValue(stepRepository.todaySteps())
}
// fetch the user weights
viewModelScope.launch { viewModelScope.launch {
weightRepository.getWeights().collectLatest { weightRepository.getWeights().collectLatest {
_weights.postValue(it) _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) { fun updateWater(water: Water) {
viewModelScope.launch { viewModelScope.launch {
waterRepository.addWater(water) waterRepository.addWater(water)

View File

@ -40,7 +40,6 @@ class SettingsFragment : PreferenceFragmentCompat() {
// Force only numbers on Goal // Force only numbers on Goal
val weightGoal = findPreference<EditTextPreference>("tmp_goal_weight") val weightGoal = findPreference<EditTextPreference>("tmp_goal_weight")
weightGoal?.apply { weightGoal?.apply {
setOnBindEditTextListener { setOnBindEditTextListener {
it.setSelectAllOnFocus(true) it.setSelectAllOnFocus(true)
it.inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL it.inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL
@ -60,7 +59,6 @@ class SettingsFragment : PreferenceFragmentCompat() {
return@setOnPreferenceClickListener true return@setOnPreferenceClickListener true
} }
setOnPreferenceChangeListener { _, newValue -> setOnPreferenceChangeListener { _, newValue ->
val unit = config.getString(Settings.MASS_UNIT).value val unit = config.getString(Settings.MASS_UNIT).value
var modifier = Units.Mass.KILOGRAM.modifier var modifier = Units.Mass.KILOGRAM.modifier

View File

@ -1,23 +1,29 @@
package com.dzeio.openhealth.ui.steps package com.dzeio.openhealth.ui.steps
import android.graphics.Paint
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import com.dzeio.charts.Entry import com.dzeio.charts.Entry
import com.dzeio.charts.axis.Line
import com.dzeio.charts.series.BarSerie import com.dzeio.charts.series.BarSerie
import com.dzeio.openhealth.Application import com.dzeio.openhealth.Application
import com.dzeio.openhealth.adapters.StepsAdapter import com.dzeio.openhealth.adapters.StepsAdapter
import com.dzeio.openhealth.core.BaseFragment import com.dzeio.openhealth.core.BaseFragment
import com.dzeio.openhealth.data.step.Step
import com.dzeio.openhealth.databinding.FragmentStepsHomeBinding 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 dagger.hilt.android.AndroidEntryPoint
import java.text.DateFormat import java.text.DateFormat
import java.util.Calendar import java.util.Calendar
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
import java.util.TimeZone import java.util.TimeZone
import kotlin.math.roundToInt
@AndroidEntryPoint @AndroidEntryPoint
class StepsHomeFragment : class StepsHomeFragment :
@ -27,6 +33,8 @@ class StepsHomeFragment :
const val TAG = "${Application.TAG}/SHFragment" const val TAG = "${Application.TAG}/SHFragment"
} }
private val args: StepsHomeFragmentArgs by navArgs()
override val bindingInflater: (LayoutInflater, ViewGroup?, Boolean) -> FragmentStepsHomeBinding = override val bindingInflater: (LayoutInflater, ViewGroup?, Boolean) -> FragmentStepsHomeBinding =
FragmentStepsHomeBinding::inflate FragmentStepsHomeBinding::inflate
@ -35,102 +43,142 @@ class StepsHomeFragment :
viewModel.init() viewModel.init()
val isDay = args.day > 0L
val recycler = binding.list val recycler = binding.list
val manager = LinearLayoutManager(requireContext()) val manager = LinearLayoutManager(requireContext())
recycler.layoutManager = manager recycler.layoutManager = manager
val adapter = StepsAdapter() val adapter = StepsAdapter().apply {
adapter.onItemClick = { this.isDay = isDay
// findNavController().navigate( }
// WaterHomeFragmentDirections.actionNavWaterHomeToNavWaterEdit(
// it.id if (!isDay) {
// ) adapter.onItemClick = {
// ) findNavController().navigate(
StepsHomeFragmentDirections.actionNavStepsHomeSelf().apply {
day = it.timestamp
title = "Steps from " + it.formatTimestamp(true)
}
)
}
} }
recycler.adapter = adapter recycler.adapter = adapter
val chart = binding.chart val chart = binding.chart
// setup serie // setup serie
val serie = BarSerie(chart).apply { val serie = BarSerie(chart)
barPaint.color = MaterialColors.getColor(
requireView(),
com.google.android.material.R.attr.colorPrimary
)
textPaint.color = MaterialColors.getColor(
requireView(),
com.google.android.material.R.attr.colorOnPrimary
)
}
chart.apply { chart.apply {
series = arrayListOf(serie) ChartUtils.materielTheme(chart, requireView())
// debug = true
yAxis.apply { yAxis.apply {
setYMax(500f) setYMin(0f)
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 { xAxis.apply {
increment = 3600000.0 dataWidth = if (isDay) 8.64e+7 else 6.048e+8
// displayCount = 168 scrollEnabled = !isDay
displayCount = 10
textPaint.color = MaterialColors.getColor(
requireView(),
com.google.android.material.R.attr.colorOnPrimaryContainer
)
textPaint.textSize = 32f textPaint.textSize = 32f
onValueFormat = onValueFormat@{ onValueFormat = onValueFormat@{
val formatter = DateFormat.getDateTimeInstance( val formatter = if (isDay) {
DateFormat.SHORT, 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, DateFormat.SHORT,
Locale.getDefault() 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 -> viewModel.items.observe(viewLifecycleOwner) { list ->
adapter.set(list)
if (list.isEmpty()) { if (list.isEmpty()) {
adapter.set(arrayListOf())
return@observe 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<Entry>
} else {
val entries: HashMap<Long, Entry> = HashMap()
cal.set(Calendar.HOUR, 0) list.forEach {
cal.set(Calendar.MINUTE, 0) val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
cal.set(Calendar.SECOND, 0) cal.timeInMillis = it.timestamp
cal.set(Calendar.MILLISECOND, 0)
// chart.animation.enabled = false cal.set(Calendar.HOUR, 0)
// chart.animation.refreshRate = 60 cal.set(Calendar.AM_PM, Calendar.AM)
// chart.animation.duration = 300 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 { adapter.set(
return@map Entry(it.timestamp.toDouble(), it.value.toFloat()) entries.map { Step(value = it.value.y.toInt(), timestamp = it.key) }
} as ArrayList<Entry> .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() chart.refresh()
} }

View File

@ -1,26 +1,37 @@
package com.dzeio.openhealth.ui.steps package com.dzeio.openhealth.ui.steps
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.dzeio.openhealth.Settings
import com.dzeio.openhealth.core.BaseViewModel import com.dzeio.openhealth.core.BaseViewModel
import com.dzeio.openhealth.data.step.Step import com.dzeio.openhealth.data.step.Step
import com.dzeio.openhealth.data.step.StepRepository import com.dzeio.openhealth.data.step.StepRepository
import com.dzeio.openhealth.utils.Configuration
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel @HiltViewModel
class StepsHomeViewModel@Inject internal constructor( class StepsHomeViewModel@Inject internal constructor(
private val stepRepository: StepRepository private val stepRepository: StepRepository,
private val config: Configuration
) : BaseViewModel() { ) : BaseViewModel() {
val items: MutableLiveData<List<Step>> = MutableLiveData() val items: MutableLiveData<List<Step>> = MutableLiveData()
private val _goal: MutableLiveData<Int?> = MutableLiveData()
val goal: LiveData<Int?> = _goal
fun init() { fun init() {
viewModelScope.launch { viewModelScope.launch {
stepRepository.getSteps().collectLatest { stepRepository.getSteps().collectLatest {
items.postValue(it) items.postValue(it)
} }
} }
this._goal.postValue(
config.getInt(Settings.STEPS_GOAL).value
)
} }
} }

View File

@ -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, FragmentTestsBinding>(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()
}
}
}
}

View File

@ -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()

View File

@ -20,7 +20,7 @@ import com.google.android.material.datepicker.DateValidatorPointBackward
import com.google.android.material.datepicker.MaterialDatePicker import com.google.android.material.datepicker.MaterialDatePicker
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import java.util.* import java.util.Date
@AndroidEntryPoint @AndroidEntryPoint
class EditWaterDialog : class EditWaterDialog :
@ -31,8 +31,6 @@ class EditWaterDialog :
private val args: EditWaterDialogArgs by navArgs() private val args: EditWaterDialogArgs by navArgs()
override val isFullscreenLayout = true
var newValue: Int = 0 var newValue: Int = 0
override fun onDialogInit(dialog: Dialog) { override fun onDialogInit(dialog: Dialog) {
@ -50,8 +48,11 @@ class EditWaterDialog :
} }
binding.editTextNumber.doOnTextChanged { text, start, before, count -> binding.editTextNumber.doOnTextChanged { text, start, before, count ->
val value = text.toString() val value = text.toString()
newValue = if (value == "") 0 newValue = if (value == "") {
else text.toString().toInt() 0
} else {
text.toString().toInt()
}
} }
binding.date.setOnClickListener { binding.date.setOnClickListener {
@ -102,6 +103,7 @@ class EditWaterDialog :
findNavController().popBackStack() findNavController().popBackStack()
} }
@Deprecated("Deprecated in Java")
override fun onOptionsItemSelected(item: MenuItem): Boolean { override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) { return when (item.itemId) {
R.id.menu_fullscreen_dialog_save -> { R.id.menu_fullscreen_dialog_save -> {

View File

@ -6,9 +6,9 @@ import com.dzeio.openhealth.core.BaseViewModel
import com.dzeio.openhealth.data.water.Water import com.dzeio.openhealth.data.water.Water
import com.dzeio.openhealth.data.water.WaterRepository import com.dzeio.openhealth.data.water.WaterRepository
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel @HiltViewModel
class EditWaterViewModel @Inject internal constructor( class EditWaterViewModel @Inject internal constructor(
@ -36,4 +36,4 @@ class EditWaterViewModel @Inject internal constructor(
waterRepository.addWater(water) waterRepository.addWater(water)
} }
} }
} }

View File

@ -6,15 +6,16 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.LinearLayoutManager 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.adapters.WaterAdapter
import com.dzeio.openhealth.core.BaseFragment import com.dzeio.openhealth.core.BaseFragment
import com.dzeio.openhealth.databinding.FragmentMainWaterHomeBinding import com.dzeio.openhealth.databinding.FragmentMainWaterHomeBinding
import com.dzeio.openhealth.utils.GraphUtils import com.dzeio.openhealth.utils.ChartUtils
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 dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@AndroidEntryPoint @AndroidEntryPoint
class WaterHomeFragment : class WaterHomeFragment :
@ -45,39 +46,50 @@ class WaterHomeFragment :
val chart = binding.chart val chart = binding.chart
GraphUtils.barChartSetup( val serie = BarSerie(chart)
chart,
MaterialColors.getColor(
requireView(),
com.google.android.material.R.attr.colorPrimary
),
MaterialColors.getColor(
requireView(),
com.google.android.material.R.attr.colorOnBackground
)
)
binding.buttonEditDefaultIntake.setOnClickListener { chart.apply {
findNavController().navigate(WaterHomeFragmentDirections.actionNavWaterHomeToNavWaterSizeDialog()) 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 -> viewModel.items.observe(viewLifecycleOwner) { list ->
adapter.set(list) adapter.set(list)
val dataset = BarDataSet( if (list.isEmpty()) {
list.map { return@observe
return@map BarEntry( }
(it.timestamp / 1000 / 60 / 60 / 24).toFloat(),
it.value.toFloat()
)
},
""
)
chart.data = BarData(dataset) val dataset = list.map {
chart.invalidate() return@map Entry(
it.timestamp.toDouble(),
it.value.toFloat()
)
}
serie.entries = dataset as ArrayList<Entry>
chart.xAxis.x = dataset[0].x
chart.refresh()
} }
} }
} }

View File

@ -6,9 +6,9 @@ import com.dzeio.openhealth.core.BaseViewModel
import com.dzeio.openhealth.data.water.Water import com.dzeio.openhealth.data.water.Water
import com.dzeio.openhealth.data.water.WaterRepository import com.dzeio.openhealth.data.water.WaterRepository
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel @HiltViewModel
class WaterHomeViewModel@Inject internal constructor( class WaterHomeViewModel@Inject internal constructor(
@ -23,4 +23,4 @@ class WaterHomeViewModel@Inject internal constructor(
} }
} }
} }
} }

View File

@ -1,7 +1,6 @@
package com.dzeio.openhealth.ui.water package com.dzeio.openhealth.ui.water
import android.view.LayoutInflater import android.view.LayoutInflater
import androidx.core.view.marginBottom
import com.dzeio.openhealth.R import com.dzeio.openhealth.R
import com.dzeio.openhealth.core.BaseDialog import com.dzeio.openhealth.core.BaseDialog
import com.dzeio.openhealth.databinding.DialogWaterSizeSelectorBinding import com.dzeio.openhealth.databinding.DialogWaterSizeSelectorBinding

View File

@ -15,24 +15,22 @@ import com.dzeio.openhealth.R
import com.dzeio.openhealth.core.BaseFullscreenDialog import com.dzeio.openhealth.core.BaseFullscreenDialog
import com.dzeio.openhealth.data.weight.Weight import com.dzeio.openhealth.data.weight.Weight
import com.dzeio.openhealth.databinding.DialogEditWeightBinding 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.datepicker.MaterialDatePicker
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.timepicker.MaterialTimePicker import com.google.android.material.timepicker.MaterialTimePicker
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.collect import java.util.Date
import java.util.*
@AndroidEntryPoint @AndroidEntryPoint
class EditWeightDialog : class EditWeightDialog :
BaseFullscreenDialog<HomeViewModel, DialogEditWeightBinding>(HomeViewModel::class.java) { BaseFullscreenDialog<EditWeightDialogViewModel, DialogEditWeightBinding>(
EditWeightDialogViewModel::class.java
) {
override val bindingInflater: (LayoutInflater) -> DialogEditWeightBinding = override val bindingInflater: (LayoutInflater) -> DialogEditWeightBinding =
DialogEditWeightBinding::inflate DialogEditWeightBinding::inflate
override val isFullscreenLayout = true private val args: EditWeightDialogArgs by navArgs()
val args: EditWeightDialogArgs by navArgs()
lateinit var weight: Weight lateinit var weight: Weight
@ -101,10 +99,7 @@ class EditWeightDialog :
} else { } else {
TODO("VERSION.SDK_INT < N") TODO("VERSION.SDK_INT < N")
} }
} }
} }
private fun save() { private fun save() {
@ -144,6 +139,5 @@ class EditWeightDialog :
} }
else -> super.onOptionsItemSelected(item) else -> super.onOptionsItemSelected(item)
} }
} }
} }

Some files were not shown because too many files have changed in this diff Show More