1
0
mirror of https://github.com/dzeiocom/OpenHealth.git synced 2025-04-22 10:52:13 +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_COARSE_LOCATION | Same as above |
| ACTIVITY_RECOGNITION | Device Steps Usage |
| INTERNET | Food fetching from OpenFoodFact |
| POST_NOTIFICATIONS | send notifications for water intake and device steps usage |
No other permissions are used (even the internet permission ;)).
No other permissions are used.
## Build
@ -42,6 +44,12 @@ No other permissions are used (even the internet permission ;)).
- click on the debug icon for debug
- it will be running on your emulator/device
## Design
If you want to contribute to the app design you can copy and edit the following Figma file
https://www.figma.com/file/AD63laksP2dvspRpT6MZ67/Open-Health?node-id=50995%3A3212&t=E5IFKMuqg8WDNQqc-1
## Contributing
See [CONTRIBUTING.md](https://github.com/dzeiocom/OpenHealth/blob/master/CONTRIBUTING.md)

View File

@ -1,8 +1,13 @@
import java.util.Properties
plugins {
// Android Application?
id("com.android.application")
// Support for kotlin in Android
kotlin("android")
// Data Injection
id("dagger.hilt.android.plugin")
// Safe Navigation
@ -15,14 +20,45 @@ plugins {
kotlin("kapt")
}
// from: https://discuss.kotlinlang.org/t/use-git-hash-as-version-number-in-build-gradle-kts/19818/8
fun String.runCommand(
workingDir: File = File("."),
timeoutAmount: Long = 60,
timeoutUnit: TimeUnit = TimeUnit.SECONDS
): String = ProcessBuilder(split("\\s(?=(?:[^'\"`]*(['\"`])[^'\"`]*\\1)*[^'\"`]*$)".toRegex()))
.directory(workingDir)
.redirectOutput(ProcessBuilder.Redirect.PIPE)
.redirectError(ProcessBuilder.Redirect.PIPE)
.start()
.apply { waitFor(timeoutAmount, timeoutUnit) }
.run {
val error = errorStream.bufferedReader().readText().trim()
if (error.isNotEmpty()) {
return@run ""
}
inputStream.bufferedReader().readText().trim()
}
// The application ID
val appID = "com.dzeio.openhealth"
// Languages
// the application supported languages
val locales = listOf("en", "fr")
// minimum application required SDK version to run
val sdkMin = 21
// target SDK version
val sdkTarget = 33
val branch = "git rev-parse --abbrev-ref HEAD".runCommand(workingDir = rootDir)
var tag = "git tag -l --points-at HEAD".runCommand(workingDir = rootDir)
if (tag == "") {
tag = "not tagged"
}
val commitId = "git rev-parse HEAD".runCommand(workingDir = rootDir)
.subSequence(0, 7)
android {
signingConfigs {
@ -41,7 +77,6 @@ android {
storeFile = file(keystoreProperties["storeFile"] as String)
}
} catch (_: Exception) {}
}
}
@ -58,7 +93,7 @@ android {
targetSdk = sdkTarget
// Semantic Versioning
versionName = "1.0.0"
versionName = "0.1.0"
versionCode = 1
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@ -75,6 +110,10 @@ android {
"new String[]{\"" + locales.joinToString("\",\"") + "\"}"
)
resourceConfigurations += locales
buildConfigField("String", "BRANCH", "\"$branch\"")
buildConfigField("String", "TAG", "\"$tag\"")
buildConfigField("String", "COMMIT", "\"$commitId\"")
}
buildTypes {
@ -82,12 +121,18 @@ android {
getByName("release") {
// Slimmer version
isMinifyEnabled = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
isShrinkResources = true
isDebuggable = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
signingConfig = signingConfigs.getByName("release")
}
getByName("debug") {
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
applicationIdSuffix = ".dev"
versionNameSuffix = "-dev"
isDebuggable = true
@ -118,24 +163,26 @@ android {
namespace = appID
}
kapt {
correctErrorTypes = true
}
dependencies {
// Dzeio Charts
implementation(project(":charts"))
// implementation(project(":CrashHandler"))
implementation("com.dzeio:charts:fe20f90654")
// Dzeio Crash Handler
implementation("com.dzeio:crashhandler:1.0.1")
// Core dependencies
implementation("androidx.core:core-ktx:1.9.0")
implementation("androidx.appcompat:appcompat:1.7.0-alpha01")
implementation("javax.inject:javax.inject:1")
implementation("com.google.android.material:material:1.8.0-alpha02")
implementation("com.google.android.material:material:1.9.0-alpha01")
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.5.1")
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1")
// implementation("com.github.Aviortheking:crashhandler:0.2.3")
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4")
@ -153,39 +200,24 @@ dependencies {
implementation("androidx.paging:paging-runtime:3.1.1")
implementation("androidx.paging:paging-runtime-ktx:3.1.1")
// Services
implementation("androidx.work:work-runtime-ktx:2.7.1")
implementation("androidx.core:core-ktx:1.9.0")
// Tests
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.4")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0")
// Graph
implementation("com.github.PhilJay:MPAndroidChart:v3.1.0")
// Graphs test 2
implementation("com.github.HackPlan:AndroidCharts:1.0.4")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
// Hilt
implementation("com.google.dagger:hilt-android:2.43.2")
kapt("com.google.dagger:hilt-compiler:2.43.2")
// Google Fit
implementation("com.google.android.gms:play-services-fitness:21.1.0")
implementation("com.google.android.gms:play-services-auth:20.3.0")
implementation("androidx.health.connect:connect-client:1.0.0-alpha07")
// Samsung Health
implementation(files("libs/samsung-health-data-1.5.0.aar"))
implementation("com.google.code.gson:gson:2.9.1")
implementation("com.google.dagger:hilt-android:2.44.2")
kapt("com.google.dagger:hilt-compiler:2.44.2")
// ROOM
implementation("androidx.room:room-runtime:2.4.3")
kapt("androidx.room:room-compiler:2.4.3")
implementation("androidx.room:room-ktx:2.4.3")
testImplementation("androidx.room:room-testing:2.4.3")
implementation("androidx.room:room-runtime:2.5.0")
kapt("androidx.room:room-compiler:2.5.0")
implementation("androidx.room:room-ktx:2.5.0")
testImplementation("androidx.room:room-testing:2.5.0")
// Futures
implementation("com.google.guava:guava:31.1-jre")
@ -194,4 +226,9 @@ dependencies {
// OSS Licenses
implementation("com.google.android.gms:play-services-oss-licenses:17.0.0")
// Retrofit (Open Food Fact)
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.10.0")
}

View File

@ -18,4 +18,9 @@
# If you keep the line number information, uncomment this to
# 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,
"database": {
"version": 1,
"identityHash": "2acd5897bbf15393886259605a1df934",
"identityHash": "414712cc283c7f1d14cde8e00da277fb",
"entities": [
{
"tableName": "Weight",
@ -34,10 +34,10 @@
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
],
"autoGenerate": true
]
},
"indices": [
{
@ -82,10 +82,10 @@
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
],
"autoGenerate": true
]
},
"indices": [
{
@ -130,10 +130,10 @@
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
],
"autoGenerate": true
]
},
"indices": [
{
@ -147,12 +147,86 @@
}
],
"foreignKeys": []
},
{
"tableName": "Food",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `serving` TEXT NOT NULL, `quantity` REAL NOT NULL, `proteins` REAL NOT NULL, `carbohydrates` REAL NOT NULL, `fat` REAL NOT NULL, `energy` REAL NOT NULL, `image` TEXT, `timestamp` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "serving",
"columnName": "serving",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "quantity",
"columnName": "quantity",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "proteins",
"columnName": "proteins",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "carbohydrates",
"columnName": "carbohydrates",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "fat",
"columnName": "fat",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "energy",
"columnName": "energy",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "image",
"columnName": "image",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "timestamp",
"columnName": "timestamp",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '2acd5897bbf15393886259605a1df934')"
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '414712cc283c7f1d14cde8e00da277fb')"
]
}
}

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

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"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Internet for OFF -->
<uses-permission android:name="android.permission.INTERNET"/>
<!-- Notifications -->
<!-- Notifications for Water and Service -->
<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_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
android:name=".Application"
@ -27,16 +26,8 @@
android:supportsRtl="true"
android:theme="@style/Theme.OpenHealth">
<!-- Samsung Health-->
<meta-data
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" />
<!-- TODO: Respect what daddy Google wants and try to remove the SplashScreen -->
<!-- Main Activity duh -->
<activity
android:name=".ui.MainActivity"
android:exported="true"
@ -48,6 +39,7 @@
</intent-filter>
</activity>
<!-- CrashHandler -->
<activity android:name=".ui.ErrorActivity"
android:theme="@style/Theme.OpenHealth.NoActionBar"
android:exported="false" />
@ -60,26 +52,12 @@
android:name="com.google.android.gms.oss.licenses.OssLicensesActivity"
android:theme="@style/Theme.OpenHealth" />
<!-- Activity to show rationale of Health Connect permissions -->
<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>
<!-- the Service for the application -->
<service
android:name=".services.OpenHealthService"
android:permission="android.permission.ACTIVITY_RECOGNITION" />
<!-- Android 13 Locales management if I remember correctly -->
<service
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
android:enabled="false"
@ -92,10 +70,4 @@
</application>
<queries>
<package android:name="com.google.android.apps.healthdata" />
</queries>
</manifest>

View File

@ -16,24 +16,36 @@ class Application : Application() {
}
override fun onCreate() {
val prefs = PreferenceManager.getDefaultSharedPreferences(this)
// setup the CrashHandler
CrashHandler.Builder()
.withActivity(ErrorActivity::class.java)
.withPrefs(prefs)
.witheErrorReporterCrashKey(R.string.error_reporter_crash)
.withPrefsKey(Settings.CRASH_LAST_TIME)
.withPrefix("${BuildConfig.APPLICATION_ID} v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})")
.withPrefix(
"""
${BuildConfig.APPLICATION_ID} v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})
Build informations:
Commit: ${BuildConfig.COMMIT}
Branch: ${BuildConfig.BRANCH}
Tag: ${BuildConfig.TAG}
""".trimIndent()
)
.build()
.setup(this)
// Android Dynamics Colors
// setup for Android Dynamics Colors
DynamicColors.applyToActivitiesIfAvailable(this)
super.onCreate()
}
/**
* Change the language of the application if said in the settings
*/
override fun attachBaseContext(base: Context) {
super.attachBaseContext(LocaleUtils.onAttach(base))
}

View File

@ -1,7 +1,8 @@
package com.dzeio.openhealth
import com.dzeio.openhealth.extensions.Extension
/**
* Object containing every keys for the different settings of the application
*/
object Settings {
/**
@ -29,8 +30,8 @@ object Settings {
*/
const val MASS_UNIT = "com.dzeio.open-health.unit.mass"
fun extensionEnabled(extension: Extension): String {
return "com.dzeio.open-health.extension.${extension.id}.enabled"
}
/**
* Goal number of steps each days
*/
const val STEPS_GOAL = "com.dzeio.open-health.steps.goal-daily"
}

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
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import com.dzeio.openhealth.R
import com.dzeio.openhealth.core.BaseAdapter
import com.dzeio.openhealth.core.BaseViewHolder
import com.dzeio.openhealth.data.step.Step
@ -14,13 +16,29 @@ class StepsAdapter() : BaseAdapter<Step, LayoutItemListBinding>() {
var onItemClick: ((weight: Step) -> Unit)? = null
var isDay = false
override fun onBindData(
holder: BaseViewHolder<LayoutItemListBinding>,
item: Step,
position: Int
) {
holder.binding.value.text = "${item.value}steps"
holder.binding.datetime.text = item.formatTimestamp()
// set the number of steps taken
holder.binding.value.text = holder.itemView.context.getString(
R.string.steps_count,
item.value
)
// set the datetime
holder.binding.datetime.text = item.formatTimestamp(!isDay)
if (isDay) {
holder.binding.iconRight.visibility = View.GONE
} else {
holder.binding.iconRight.setImageResource(R.drawable.ic_zoom_out_map)
}
// set the callback
holder.binding.edit.setOnClickListener {
onItemClick?.invoke(item)
}

View File

@ -6,8 +6,14 @@ import com.dzeio.openhealth.core.BaseAdapter
import com.dzeio.openhealth.core.BaseViewHolder
import com.dzeio.openhealth.data.water.Water
import com.dzeio.openhealth.databinding.LayoutItemListBinding
import com.dzeio.openhealth.units.Units
class WaterAdapter() : BaseAdapter<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
get() = LayoutItemListBinding::inflate
@ -19,8 +25,14 @@ class WaterAdapter() : BaseAdapter<Water, LayoutItemListBinding>() {
item: Water,
position: Int
) {
holder.binding.value.text = "${item.value}ml"
holder.binding.datetime.text = "${item.formatTimestamp()}"
// set the wate intake text
holder.binding.value.text =
holder.itemView.context.getString(unit.unit, unit.formatToString(item.value))
// set the datetime
holder.binding.datetime.text = item.formatTimestamp()
// set the callback
holder.binding.edit.setOnClickListener {
onItemClick?.invoke(item)
}

View File

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

View File

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

View File

@ -6,9 +6,11 @@ import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding
/**
* Base around the adapter to simplify usage
*/
abstract class BaseAdapter<T, VB : ViewBinding> : RecyclerView.Adapter<BaseViewHolder<VB>>() {
private var items = mutableListOf<T>()
// private var lastPosition = -1
@SuppressLint("NotifyDataSetChanged")
fun set(items: List<T>) {
@ -22,6 +24,12 @@ abstract class BaseAdapter<T, VB : ViewBinding> : RecyclerView.Adapter<BaseViewH
notifyItemInserted(len)
}
fun clear() {
val len = this.items.size
this.items.clear()
notifyItemRangeRemoved(0, len)
}
/**
* 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
/**
* function run when an item is displayed
*/
abstract fun onBindData(holder: BaseViewHolder<VB>, item: T, position: Int)
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
}
}

View File

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

View File

@ -8,7 +8,9 @@ import androidx.viewbinding.ViewBinding
*
* 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>() {
val viewModel by lazy {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,4 +2,7 @@ package com.dzeio.openhealth.core
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 com.dzeio.openhealth.Application
/**
* Worker Wrapper to simplify work and usage
*/
abstract class BaseWorker(context: Context, params: WorkerParameters) : Worker(context, params) {
companion object {
@ -19,4 +22,4 @@ abstract class BaseWorker(context: Context, params: WorkerParameters) : Worker(c
.enqueueUniquePeriodicWork(tag, ExistingPeriodicWorkPolicy.KEEP, request)
}
}
}
}

View File

@ -2,12 +2,16 @@ package com.dzeio.openhealth.core
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.callbackFlow
/**
* Simple Observable implementation
*/
open class Observable<T>(baseValue: T) {
private val functionObservers: ArrayList<(T) -> Unit> = ArrayList()
fun addObserver(fn: (T) -> Unit) {
if (!functionObservers.contains(fn)) {
functionObservers.add(fn)
@ -35,7 +39,6 @@ open class Observable<T>(baseValue: T) {
}
fun notifyObservers() {
// Notify Functions
for (fn in functionObservers) {
notifyObserver(fn)
@ -53,4 +56,19 @@ open class Observable<T>(baseValue: T) {
}
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
import android.content.Context
import androidx.room.AutoMigration
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import com.dzeio.openhealth.data.food.Food
import com.dzeio.openhealth.data.food.FoodDao
import com.dzeio.openhealth.data.step.Step
import com.dzeio.openhealth.data.step.StepDao
import com.dzeio.openhealth.data.water.Water
@ -11,50 +14,55 @@ import com.dzeio.openhealth.data.water.WaterDao
import com.dzeio.openhealth.data.weight.Weight
import com.dzeio.openhealth.data.weight.WeightDao
/**
* ROOM SQLite database for the application
*
* It may be replaced if I want to fully encrypt the database
*/
@Database(
entities = [
Weight::class,
Water::class,
Step::class
Step::class,
Food::class
],
version = 2,
autoMigrations = [
AutoMigration(1, 2)
],
version = 1,
exportSchema = true
)
abstract class AppDatabase : RoomDatabase() {
// private val PREPOPULATE_DATA = listOf(Thing("1", "val"), Thing("2", "val 2"))
abstract fun weightDao(): WeightDao
abstract fun waterDao(): WaterDao
abstract fun stepDao(): StepDao
abstract fun foodDao(): FoodDao
companion object {
/**
* database name duh
*/
private const val DATABASE_NAME = "open_health"
// For Singleton instantiation
@Volatile
private var instance: AppDatabase? = null
// get the Database instance
fun getInstance(context: Context): AppDatabase {
return instance ?: synchronized(this) {
instance ?: buildDatabase(context).also { instance = it }
}
}
// Create and pre-populate the database. See this article for more details:
// https://medium.com/google-developers/7-pro-tips-for-room-fbadea4bfbd1#4785
/**
* build teh database
*/
private fun buildDatabase(context: Context): AppDatabase {
return Room.databaseBuilder(context, AppDatabase::class.java, DATABASE_NAME)
// .addCallback(object : Callback() {
// override fun onCreate(db: SupportSQLiteDatabase) {
// super.onCreate(db)
// // moving to a new thread
// Executors.newSingleThreadExecutor().execute {
// getInstance(context).thingDao()
// .insert(PREPOPULATE_DATA)
// }
// }
// })
// .addMigrations(MIGRATION_2_3)
.build()
}
}

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

View File

@ -11,13 +11,16 @@ interface StepDao : BaseDao<Step> {
@Query("SELECT * FROM Step ORDER BY timestamp DESC")
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?>
@Query("Select count(*) from Step")
@Query("SELECT count(*) FROM Step")
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?>
@Query("DELETE FROM Step where source = :source")

View File

@ -1,10 +1,11 @@
package com.dzeio.openhealth.data.step
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.withTimeoutOrNull
import java.util.Calendar
import java.util.TimeZone
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.firstOrNull
@Singleton
class StepRepository @Inject constructor(
@ -23,7 +24,13 @@ class StepRepository @Inject constructor(
suspend fun deleteFromSource(value: String) = stepDao.deleteFromSource(value)
suspend fun todaySteps(): Int {
val steps = getSteps().firstOrNull()
val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
cal.set(Calendar.HOUR, 0)
cal.set(Calendar.MINUTE, 0)
cal.set(Calendar.SECOND, 0)
cal.set(Calendar.MILLISECOND, 0)
val steps = stepDao.getAfter(cal.timeInMillis).firstOrNull()
if (steps == null) {
return 0
}
@ -39,5 +46,4 @@ class StepRepository @Inject constructor(
fun currentStep() = lastStep().filter {
return@filter it != null && it.isCurrent()
}
}

View File

@ -7,15 +7,21 @@ import android.hardware.SensorEventListener
import android.hardware.SensorManager
import android.os.SystemClock
import android.util.Log
import androidx.core.content.edit
import androidx.preference.PreferenceManager
import com.dzeio.openhealth.Application
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.runBlocking
/**
* Class that allows us to get Sensor datas for the internal step counter
*
* TODO: rewrite to use the new libs
*/
class StepSource(
private val context: Context,
context: Context,
private val callback: ((Float) -> Unit)? = null
): SensorEventListener {
) : SensorEventListener {
companion object {
const val TAG = "${Application.TAG}/StepSource"
@ -28,27 +34,31 @@ class StepSource(
return prefs.getLong("steps_time_since_last_record", Long.MAX_VALUE)
}
set(value) {
val editor = prefs.edit()
editor.putLong("steps_time_since_last_record", value)
editor.commit()
prefs.edit {
putLong("steps_time_since_last_record", value)
}
}
private var stepsAsOfLastRecord: Float
get() {
return prefs.getFloat("steps_as_of_last_record", 0f)
}
set(value) {
val editor = prefs.edit()
editor.putFloat("steps_as_of_last_record", value)
editor.commit()
prefs.edit {
putFloat("steps_as_of_last_record", value)
}
}
init {
Log.d(TAG, "Setting up")
val sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
val stepCountSensor = sensorManager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER)
stepCountSensor.let {
sensorManager.registerListener(this, it, SensorManager.SENSOR_DELAY_NORMAL, SensorManager.SENSOR_DELAY_NORMAL)
sensorManager.registerListener(
this,
it,
SensorManager.SENSOR_DELAY_NORMAL,
SensorManager.SENSOR_DELAY_NORMAL
)
Log.d(TAG, "should be setup :D")
}
}
@ -68,7 +78,10 @@ class StepSource(
// don't send changes since it wasn't made when the app was running
if (timeSinceLastBoot < timeSinceLastRecord) {
Log.d(TAG, "Skipping since we don't know when many steps are taken since last boot ($timeSinceLastRecord, $timeSinceLastBoot)")
Log.d(
TAG,
"Skipping since we don't know when many steps are taken since last boot ($timeSinceLastRecord, $timeSinceLastBoot)"
)
timeSinceLastRecord = timeSinceLastBoot
return@let
}

View File

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

View File

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

View File

@ -14,9 +14,77 @@ data class Weight(
* Store the weight in kilograms
*/
var weight: Float = 0f,
/**
* when the weight was taken precise to the millisecond
*/
@ColumnInfo(index = true)
var timestamp: Long = System.currentTimeMillis(),
var source: String = ""
/**
* the source for the Entry
*
* note: Unused currently but kept for future usage
*/
var source: String = "OpenHealth",
/**
* The BMI
*
* calculated from the height and Weight of the user
*
* ex: weight / (height(cm)/100)²
*/
var bmi: Float? = null,
/**
* the total body water (tbw) in percents
*
* can be estimated
* https://www.mdapp.co/total-body-water-tbw-calculator-448/
*/
var totalBodyWater: Float? = null,
/**
* the Muscle weight in percents
*/
var muscles: Float? = null,
/**
* the lean Body Mass in percents
*/
var leanBodyMass: Float? = null,
/**
* the Body Fat in percents
*/
var bodyFat: Float? = null,
/**
* the bone Mass in Percents
*/
var boneMass: Float? = null,
/**
* visceral fat in it's own unit?
*/
var visceralFat: Float? = null
) {
fun formatTimestamp(): String = getDateInstance().format(Date(timestamp));
fun formatTimestamp(): String = getDateInstance().format(Date(timestamp))
override fun equals(other: Any?): Boolean {
if (!(other is Weight)) {
return super.equals(other)
}
return weight == other.weight &&
timestamp == other.timestamp &&
bmi == other.bmi &&
totalBodyWater == other.totalBodyWater &&
muscles == other.muscles &&
leanBodyMass == other.leanBodyMass &&
bodyFat == other.bodyFat &&
boneMass == other.boneMass &&
visceralFat == other.visceralFat
}
}

View File

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

View File

@ -14,6 +14,7 @@ class WeightRepository @Inject constructor(
fun getWeight(id: Long) = weightDao.getOne(id)
suspend fun addWeight(weight: Weight) = weightDao.insert(weight)
suspend fun deleteWeight(weight: Weight) = weightDao.delete(weight)
suspend fun addAll(vararg weight: Weight) = weightDao.insertAll(*weight)
suspend fun deleteWeight(vararg weight: Weight) = weightDao.delete(*weight)
suspend fun deleteFromSource(source: String) = weightDao.deleteFromSource(source)
}
}

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 com.dzeio.openhealth.data.AppDatabase
import com.dzeio.openhealth.data.food.FoodDao
import com.dzeio.openhealth.data.openfoodfact.OpenFoodFactService
import com.dzeio.openhealth.data.step.StepDao
import com.dzeio.openhealth.data.water.WaterDao
import com.dzeio.openhealth.data.weight.WeightDao
@ -12,6 +14,9 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
/**
* Provide to the application the Database/Daos and external services
*/
@InstallIn(SingletonComponent::class)
@Module
class DatabaseModule {
@ -36,4 +41,15 @@ class DatabaseModule {
fun provideStepsDao(appDatabase: AppDatabase): StepDao {
return appDatabase.stepDao()
}
}
@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.SharedPreferences
import androidx.preference.PreferenceManager
import com.dzeio.openhealth.utils.Bluetooth
import com.dzeio.openhealth.utils.Configuration
import dagger.Module
import dagger.Provides
@ -11,6 +12,9 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
/**
* Provide to the application System elements
*/
@InstallIn(SingletonComponent::class)
@Module
class SystemModule {
@ -26,4 +30,8 @@ class SystemModule {
fun provideConfig(sharedPreferences: SharedPreferences): Configuration {
return Configuration(sharedPreferences)
}
@Singleton
@Provides
fun provideBluetooth(@ApplicationContext context: Context): Bluetooth = Bluetooth(context)
}

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
/**
* The different notification channels the applicaiton is using
*/
enum class NotificationChannels(
val id: String,
val channelName: String,

View File

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

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

View File

@ -3,13 +3,11 @@ package com.dzeio.openhealth.ui
import android.content.ActivityNotFoundException
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Process
import android.view.LayoutInflater
import android.widget.Toast
import com.dzeio.openhealth.Application
import com.dzeio.openhealth.BuildConfig
import com.dzeio.openhealth.core.BaseActivity
import com.dzeio.openhealth.databinding.ActivityErrorBinding
import kotlin.system.exitProcess
@ -28,20 +26,8 @@ class ErrorActivity : BaseActivity<ActivityErrorBinding>() {
val data = intent.getStringExtra("error")
// Get Application datas
val deviceToReport = if (Build.DEVICE.contains(Build.MANUFACTURER)) Build.DEVICE else "${Build.MANUFACTURER} ${Build.DEVICE}"
val reportText = """
Crash Report (Thread: ${intent?.getLongExtra("threadId", -1) ?: "unknown"})
${BuildConfig.APPLICATION_ID} v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})
on $deviceToReport (${Build.MODEL}) running Android ${Build.VERSION.RELEASE} (${Build.VERSION.SDK_INT})
backtrace:
""".trimIndent() + data
// put it in the textView
binding.errorText.text = reportText
binding.errorText.text = data
// Handle the Quit button
binding.errorQuit.setOnClickListener {
@ -51,14 +37,15 @@ class ErrorActivity : BaseActivity<ActivityErrorBinding>() {
// Handle the Email Button
binding.errorSubmitEmail.setOnClickListener {
// Create Intent
val intent = Intent(Intent.ACTION_SEND)
intent.data = Uri.parse("mailto:")
intent.type = "text/plain"
val intent = Intent(Intent.ACTION_VIEW)
intent.setDataAndType(
Uri.parse("mailto:"),
"text/plain"
)
intent.putExtra(Intent.EXTRA_EMAIL, arrayOf("report.openhealth@dzeio.com"))
intent.putExtra(Intent.EXTRA_SUBJECT, "Error report for application crash")
intent.putExtra(Intent.EXTRA_TEXT, "Send Report Email\n$reportText")
intent.putExtra(Intent.EXTRA_TEXT, "Send Report Email\n$data")
try {
startActivity(Intent.createChooser(intent, "Send Report Email..."))
@ -69,14 +56,17 @@ class ErrorActivity : BaseActivity<ActivityErrorBinding>() {
// Handle the GitHub Button
binding.errorSubmitGithub.setOnClickListener {
// Build URL
val url = "https://github.com/dzeiocom/OpenHealth/issues/new?title=Application Error&body=$reportText"
val url =
"https://github.com/dzeiocom/OpenHealth/issues/new?" +
"title=Application Error&" +
"body=${data?.replace("\n", "\\n")}"
try {
startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse(url))
)
val intent = Intent(Intent.ACTION_VIEW).apply {
setData(Uri.parse(url))
}
startActivity(intent)
} catch (e: ActivityNotFoundException) {
Toast.makeText(this, "No Web Browser found :(", Toast.LENGTH_LONG).show()
}

View File

@ -3,7 +3,6 @@ package com.dzeio.openhealth.ui
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.content.Intent
import android.content.res.Configuration
import android.os.Build
import android.os.Bundle
@ -11,6 +10,7 @@ import android.util.Log
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuItem
import android.view.WindowInsets
import android.view.WindowManager
import androidx.core.view.WindowCompat
import androidx.core.view.updatePadding
@ -21,7 +21,6 @@ import androidx.navigation.ui.NavigationUI
import androidx.navigation.ui.navigateUp
import androidx.navigation.ui.setupActionBarWithNavController
import androidx.navigation.ui.setupWithNavController
import com.dzeio.openhealth.Application
import com.dzeio.openhealth.R
import com.dzeio.openhealth.core.BaseActivity
import com.dzeio.openhealth.databinding.ActivityMainBinding
@ -35,7 +34,7 @@ import dagger.hilt.android.AndroidEntryPoint
class MainActivity : BaseActivity<ActivityMainBinding>() {
companion object {
const val TAG = "${Application.TAG}/MainActivity"
val TAG: String = this::class.java.simpleName
}
private lateinit var appBarConfiguration: AppBarConfiguration
@ -48,7 +47,6 @@ class MainActivity : BaseActivity<ActivityMainBinding>() {
override fun onCreate(savedInstanceState: Bundle?) {
setTheme(R.style.Theme_OpenHealth_NoActionBar)
super.onCreate(savedInstanceState)
}
override fun onCreated(savedInstanceState: Bundle?) {
@ -58,7 +56,6 @@ class MainActivity : BaseActivity<ActivityMainBinding>() {
// Comportement chelou API 28-
// Comportement normal 31+
// do not do the cool status/navigation bars for API 29 & 30
if (Build.VERSION.SDK_INT != Build.VERSION_CODES.R && Build.VERSION.SDK_INT != Build.VERSION_CODES.Q) {
// allow to put the content behind the status bar & Navigation bar (one of them at least lul)
@ -66,25 +63,23 @@ class MainActivity : BaseActivity<ActivityMainBinding>() {
window.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION)
window.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS)
// Make the color of the navigation bar semi-transparent
// window.navigationBarColor = Color.TRANSPARENT
// Make the color of the status bar transparent
// window.statusBarColor = Color.TRANSPARENT
// Apply the previous changes
// window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS)
// Update toolbar height with the statusbar size included
// ALSO: make both the status/navigation bars transparent (WHYYYYYYY)
val toolbarHeight = binding.toolbar.layoutParams.height
window.decorView.setOnApplyWindowInsetsListener { _, insets ->
val statusBarSize = insets.systemWindowInsetTop
// Use getInsets(int) with WindowInsets.Type.systemBars() instead.
val statusBarSize = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
insets.getInsets(WindowInsets.Type.systemBars()).top
} else {
insets.systemWindowInsetTop
}
// Add padding to the toolbar (YaY I know how something works)
binding.toolbar.updatePadding(top = statusBarSize)
binding.toolbar.layoutParams.height = toolbarHeight + statusBarSize
return@setOnApplyWindowInsetsListener insets
}
// normally makes sure icons are at the correct color but idk if it works
// normally makes sure icons are at the correct color
when (this.resources.configuration.uiMode.and(Configuration.UI_MODE_NIGHT_MASK)) {
Configuration.UI_MODE_NIGHT_YES -> {
WindowCompat.getInsetsController(window, window.decorView).apply {
@ -121,25 +116,17 @@ class MainActivity : BaseActivity<ActivityMainBinding>() {
binding.bottomNav.setupWithNavController(navController)
// registerForActivityResult(ActivityResultContracts.RequestPermission()) {
//
// }
// .launch(Manifest.permission.ACTIVITY_RECOGNITION)
createNotificationChannel()
// Services
WaterReminderWorker.setup(this)
// StepCountService.setup(this)
ServiceUtils.startService(this, OpenHealthService::class.java)
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
super.onCreateOptionsMenu(menu)
menuInflater.inflate(R.menu.main, menu)
return true
return super.onCreateOptionsMenu(menu)
}
override fun onSupportNavigateUp(): Boolean =
@ -149,22 +136,13 @@ class MainActivity : BaseActivity<ActivityMainBinding>() {
NavigationUI.onNavDestinationSelected(item, navController) ||
super.onOptionsItemSelected(item)
@Deprecated("Deprecated in Java")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
Log.d("MainActivity", "onActivityResult $requestCode $resultCode")
for (fragment in supportFragmentManager.primaryNavigationFragment!!.childFragmentManager.fragments) {
fragment.onActivityResult(requestCode, resultCode, data)
}
}
private fun createNotificationChannel() {
val notificationManager: NotificationManager =
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
for (channel in NotificationChannels.values()) {
Log.d("MainActivity", channel.channelName)
Log.d(TAG, channel.channelName)
try {
notificationManager.createNotificationChannel(
NotificationChannel(
@ -174,7 +152,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>() {
)
)
} 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.google.android.gms.oss.licenses.OssLicensesMenuActivity
/**
* Fragment for the About page
*/
class AboutFragment : BaseStaticFragment<FragmentAboutBinding>() {
override val bindingInflater: (LayoutInflater, ViewGroup?, Boolean) -> FragmentAboutBinding
get() = FragmentAboutBinding::inflate
@ -23,22 +26,29 @@ class AboutFragment : BaseStaticFragment<FragmentAboutBinding>() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// set the version number
binding.version.text =
resources.getString(R.string.version_number, BuildConfig.VERSION_NAME)
// handle contact US button
binding.contactUs.setOnClickListener {
openLink("mailto:context.openhealth@dze.io")
openLink("mailto:contact.openhealth@dze.io")
}
// handle Github button
binding.github.setOnClickListener {
openLink("https://github.com/dzeiocom/OpenHealth")
}
// send the user to the Google OSS licenses page when clicked
binding.licenses.setOnClickListener {
startActivity(Intent(requireContext(), OssLicensesMenuActivity::class.java))
}
}
/**
* simple function that try to open a link.
*/
private fun openLink(url: String) {
try {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))

View File

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

View File

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

View File

@ -3,23 +3,28 @@ package com.dzeio.openhealth.ui.browse
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import com.dzeio.openhealth.Settings
import com.dzeio.openhealth.core.BaseViewModel
import com.dzeio.openhealth.data.step.StepRepository
import com.dzeio.openhealth.data.weight.WeightRepository
import com.dzeio.openhealth.utils.Configuration
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class BrowseViewModel @Inject internal constructor(
stepRepository: StepRepository,
weightRepository: WeightRepository
weightRepository: WeightRepository,
config: Configuration
) : BaseViewModel() {
private val _steps = MutableLiveData(0)
val steps: LiveData<Int> = _steps
val stepsGoal = config.getInt(Settings.STEPS_GOAL).toLiveData()
private val _weight = MutableLiveData(0f)
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.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.RectF
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.navigation.fragment.findNavController
import androidx.preference.PreferenceManager
import com.dzeio.charts.Entry
import com.dzeio.charts.axis.Line
import com.dzeio.charts.series.LineSerie
import com.dzeio.openhealth.BuildConfig
import com.dzeio.openhealth.core.BaseFragment
import com.dzeio.openhealth.data.water.Water
import com.dzeio.openhealth.data.weight.Weight
import com.dzeio.openhealth.databinding.FragmentHomeBinding
import com.dzeio.openhealth.graphs.WeightChart
import com.dzeio.openhealth.ui.weight.WeightDialog
import com.dzeio.openhealth.units.Units
import com.dzeio.openhealth.utils.ChartUtils
import com.dzeio.openhealth.utils.DrawUtils
import com.dzeio.openhealth.utils.GraphUtils
import com.google.android.material.color.MaterialColors
import dagger.hilt.android.AndroidEntryPoint
import kotlin.math.max
@ -44,6 +49,9 @@ class HomeFragment : BaseFragment<HomeViewModel, FragmentHomeBinding>(HomeViewMo
)
}
/**
* Water Intake
*/
binding.fragmentHomeWaterAdd.setOnClickListener {
val water = viewModel.water.value
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 {
findNavController().navigate(HomeFragmentDirections.actionNavHomeToNavListWeight())
}
// handle button to go to water intake home
binding.gotoWaterHome.setOnClickListener {
findNavController().navigate(HomeFragmentDirections.actionNavHomeToNavWaterHome())
}
GraphUtils.lineChartSetup(
binding.weightGraph,
MaterialColors.getColor(
requireView(),
com.google.android.material.R.attr.colorPrimary
),
MaterialColors.getColor(
requireView(),
com.google.android.material.R.attr.colorOnBackground
)
)
binding.weightGraph.apply {
val serie = LineSerie(this)
ChartUtils.materielTheme(this, requireView())
series = arrayListOf(serie)
}
if (BuildConfig.DEBUG) {
binding.gotoTests.apply {
visibility = View.VISIBLE
setOnClickListener {
findNavController().navigate(
HomeFragmentDirections.actionNavHomeToTestsFragment()
)
}
}
}
// Update the water intake Graph when the water intake changes
viewModel.water.observe(viewLifecycleOwner) {
if (it != null) {
updateWater(it.value)
@ -104,63 +120,101 @@ class HomeFragment : BaseFragment<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) {
if (it != null) {
updateGraph(it)
}
}
// update the graph when the goal weight change
viewModel.goalWeight.observe(viewLifecycleOwner) {
if (viewModel.weights.value != null) updateGraph(viewModel.weights.value!!)
}
// update the graph when the weight unit change
viewModel.massUnit.observe(viewLifecycleOwner) {
if (viewModel.weights.value != null) updateGraph(viewModel.weights.value!!)
}
}
/**
* Function that update the graph for the weight
*/
private fun updateGraph(list: List<Weight>) {
WeightChart.setup(
binding.weightGraph,
requireView(),
list,
viewModel.massUnit.value!!,
viewModel.goalWeight.value
)
val chart = binding.weightGraph
val serie = chart.series[0] as LineSerie
// legend.apply {
// isEnabled = true
// form = Legend.LegendForm.LINE
//
// if (goal != null) {
// val legendEntry = LegendEntry().apply {
// label = "Weight Goal"
// formColor = Color.RED
// }
// setCustom(arrayOf(legendEntry))
// }
// }
val entries: ArrayList<Entry> = arrayListOf()
list.forEach {
entries.add(
Entry(
it.timestamp.toDouble(),
it.weight
)
)
}
serie.entries = entries
if (viewModel.goalWeight.value != null) {
chart.yAxis.addLine(
viewModel.goalWeight.value!!,
Line(true, Paint(chart.yAxis.linePaint).apply { strokeWidth = 4f })
)
}
if (list.isEmpty()) {
chart.xAxis.x = 0.0
} else {
chart.xAxis.x = list[0].timestamp.toDouble()
}
chart.refresh()
}
/**
* the waterintake old value to keep for value update
*/
private var oldValue = 0f
/**
* function that update the water count in the home page
*/
private fun updateWater(newValue: Int) {
// get the current Unit
val waterUnit =
Units.Volume.find(settings.getString("water_unit", "milliliter") ?: "Milliliter")
// Update the count
binding.fragmentHomeWaterCurrent.text =
String.format(
resources.getString(waterUnit.unit),
(newValue * waterUnit.modifier).toInt()
)
// TODO: move it elsewhere
binding.fragmentHomeWaterTotal.text =
String.format(
resources.getString(waterUnit.unit),
viewModel.dailyWaterIntake
)
// get the with/height of the ImageView
var width = 1500
var height = 750
@ -169,55 +223,41 @@ class HomeFragment : BaseFragment<HomeViewModel, FragmentHomeBinding>(HomeViewMo
height = binding.background.height
}
val graph = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(graph)
val rect = RectF(
10f,
15f,
90f,
85f
)
// DrawUtils.drawRect(
// canvas,
// RectF(
// 0f,
// 0f,
// 100f,
// 100f
// ),
// MaterialColors.getColor(
// requireView(),
// com.google.android.material.R.attr.colorOnPrimary
// ),
// 3f
// )
DrawUtils.drawArc(
canvas,
100f,
rect,
MaterialColors.getColor(
requireView(),
com.google.android.material.R.attr.colorOnPrimary
),
3f
)
// Prepare the update animation
val animator = ValueAnimator.ofInt(
min(this.oldValue, viewModel.dailyWaterIntake.toFloat()).toInt(),
min(newValue, viewModel.dailyWaterIntake)
this.oldValue.toInt(),
newValue
)
animator.duration = 300 // ms
animator.addUpdateListener {
this.oldValue = (it.animatedValue as Int).toFloat()
val value = 100 * it.animatedValue as Int / viewModel.dailyWaterIntake
this.oldValue = 100 * it.animatedValue as Int / viewModel.dailyWaterIntake.toFloat()
// Log.d("Test2", "${this.oldValue}")
val graph = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(graph)
val rect = RectF(
10f,
15f,
90f,
85f
)
// background Arc
DrawUtils.drawArc(
canvas,
max(this.oldValue, 1f),
100f,
rect,
MaterialColors.getColor(
requireView(),
com.google.android.material.R.attr.colorOnPrimary
),
3f
)
// Draw the big Arc
DrawUtils.drawArc(
canvas,
min(max(value, 0.01f), 100f),
rect,
MaterialColors.getColor(
requireView(),
@ -225,9 +265,15 @@ class HomeFragment : BaseFragment<HomeViewModel, FragmentHomeBinding>(HomeViewMo
),
6f
)
// save the canvas
canvas.save()
// send it
binding.background.setImageBitmap(graph)
}
// start the animation
animator.start()
}
}

View File

@ -1,58 +1,94 @@
package com.dzeio.openhealth.ui.home
import android.content.SharedPreferences
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import com.dzeio.openhealth.Settings
import com.dzeio.openhealth.core.BaseViewModel
import com.dzeio.openhealth.data.step.StepRepository
import com.dzeio.openhealth.data.water.Water
import com.dzeio.openhealth.data.water.WaterRepository
import com.dzeio.openhealth.data.weight.Weight
import com.dzeio.openhealth.data.weight.WeightRepository
import com.dzeio.openhealth.units.UnitFactory
import com.dzeio.openhealth.units.Units
import com.dzeio.openhealth.utils.Configuration
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class HomeViewModel @Inject internal constructor(
private val weightRepository: WeightRepository,
private val waterRepository: WaterRepository,
settings: SharedPreferences,
stepRepository: StepRepository,
config: Configuration
) : BaseViewModel() {
private val _steps = MutableLiveData(0)
/**
* Steps taken today by the user
*/
val steps: LiveData<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)
/**
* Quantity of water the user drank today
*/
val water: LiveData<Water?> = _water
private val _weights = MutableLiveData<List<Weight>?>(null)
/**
* The list of weight of the user
*/
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 waterUnit =
UnitFactory.volume(settings.getString("water_unit", "milliliter") ?: "Milliliter")
/**
* The unit used to display the water intake of the user
*/
var waterUnit = Units.Volume.find(config.getString("water_unit").value ?: "ml")
private val _massUnit = MutableLiveData(Units.Mass.KILOGRAM)
/**
* The Mass unit used by the user
*/
val massUnit: LiveData<Units.Mass> = _massUnit
/**
* the User weight goal
*/
val goalWeight = config.getFloat(Settings.WEIGHT_GOAL).toLiveData()
val dailyWaterIntake: Int =
((settings.getString("water_intake", "1200")?.toFloatOrNull() ?: 1200f) * waterUnit.modifier)
.toInt()
val dailyWaterIntake: Float = (config.getFloat("water_intake").value ?: 1200f) * waterUnit.modifier
init {
// Fetch today's water intake
viewModelScope.launch {
waterRepository.todayWater().collectLatest {
_water.postValue(it)
}
}
// Fetch the user weights
viewModelScope.launch {
_steps.postValue(stepRepository.todaySteps())
}
// fetch the user weights
viewModelScope.launch {
weightRepository.getWeights().collectLatest {
_weights.postValue(it)
@ -70,24 +106,6 @@ class HomeViewModel @Inject internal constructor(
}
}
/**
* @deprecated
*/
fun fetchWeights() = weightRepository.getWeights()
/**
* @deprecated
*/
fun lastWeight() = weightRepository.lastWeight()
fun fetchWeight(id: Long) = weightRepository.getWeight(id)
suspend fun deleteWeight(weight: Weight) = weightRepository.deleteWeight(weight)
suspend fun addWeight(weight: Weight) = weightRepository.addWeight(weight)
fun fetchTodayWater() = waterRepository.todayWater()
fun updateWater(water: Water) {
viewModelScope.launch {
waterRepository.addWater(water)

View File

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

View File

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

View File

@ -1,26 +1,37 @@
package com.dzeio.openhealth.ui.steps
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import com.dzeio.openhealth.Settings
import com.dzeio.openhealth.core.BaseViewModel
import com.dzeio.openhealth.data.step.Step
import com.dzeio.openhealth.data.step.StepRepository
import com.dzeio.openhealth.utils.Configuration
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class StepsHomeViewModel@Inject internal constructor(
private val stepRepository: StepRepository
private val stepRepository: StepRepository,
private val config: Configuration
) : BaseViewModel() {
val items: MutableLiveData<List<Step>> = MutableLiveData()
private val _goal: MutableLiveData<Int?> = MutableLiveData()
val goal: LiveData<Int?> = _goal
fun init() {
viewModelScope.launch {
stepRepository.getSteps().collectLatest {
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.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint
import java.util.*
import java.util.Date
@AndroidEntryPoint
class EditWaterDialog :
@ -31,8 +31,6 @@ class EditWaterDialog :
private val args: EditWaterDialogArgs by navArgs()
override val isFullscreenLayout = true
var newValue: Int = 0
override fun onDialogInit(dialog: Dialog) {
@ -50,8 +48,11 @@ class EditWaterDialog :
}
binding.editTextNumber.doOnTextChanged { text, start, before, count ->
val value = text.toString()
newValue = if (value == "") 0
else text.toString().toInt()
newValue = if (value == "") {
0
} else {
text.toString().toInt()
}
}
binding.date.setOnClickListener {
@ -102,6 +103,7 @@ class EditWaterDialog :
findNavController().popBackStack()
}
@Deprecated("Deprecated in Java")
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.menu_fullscreen_dialog_save -> {

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.WaterRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class EditWaterViewModel @Inject internal constructor(
@ -36,4 +36,4 @@ class EditWaterViewModel @Inject internal constructor(
waterRepository.addWater(water)
}
}
}
}

View File

@ -6,15 +6,16 @@ import android.view.View
import android.view.ViewGroup
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.LinearLayoutManager
import com.dzeio.charts.Entry
import com.dzeio.charts.series.BarSerie
import com.dzeio.openhealth.adapters.WaterAdapter
import com.dzeio.openhealth.core.BaseFragment
import com.dzeio.openhealth.databinding.FragmentMainWaterHomeBinding
import com.dzeio.openhealth.utils.GraphUtils
import com.github.mikephil.charting.data.BarData
import com.github.mikephil.charting.data.BarDataSet
import com.github.mikephil.charting.data.BarEntry
import com.google.android.material.color.MaterialColors
import com.dzeio.openhealth.utils.ChartUtils
import dagger.hilt.android.AndroidEntryPoint
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@AndroidEntryPoint
class WaterHomeFragment :
@ -45,39 +46,50 @@ class WaterHomeFragment :
val chart = binding.chart
GraphUtils.barChartSetup(
chart,
MaterialColors.getColor(
requireView(),
com.google.android.material.R.attr.colorPrimary
),
MaterialColors.getColor(
requireView(),
com.google.android.material.R.attr.colorOnBackground
)
)
val serie = BarSerie(chart)
binding.buttonEditDefaultIntake.setOnClickListener {
findNavController().navigate(WaterHomeFragmentDirections.actionNavWaterHomeToNavWaterSizeDialog())
chart.apply {
ChartUtils.materielTheme(chart, requireView())
yAxis.apply {
// onValueFormat
}
xAxis.apply {
dataWidth = 604800000.0
textPaint.textSize = 32f
onValueFormat = onValueFormat@{
return@onValueFormat SimpleDateFormat(
"yyyy-MM-dd",
Locale.getDefault()
).format(Date(it.toLong()))
}
}
}
chart.xAxis.valueFormatter = GraphUtils.DateValueFormatter(1000 * 60 * 60 * 24)
binding.buttonEditDefaultIntake.setOnClickListener {
findNavController().navigate(
WaterHomeFragmentDirections.actionNavWaterHomeToNavWaterSizeDialog()
)
}
viewModel.items.observe(viewLifecycleOwner) { list ->
adapter.set(list)
val dataset = BarDataSet(
list.map {
return@map BarEntry(
(it.timestamp / 1000 / 60 / 60 / 24).toFloat(),
it.value.toFloat()
)
},
""
)
if (list.isEmpty()) {
return@observe
}
chart.data = BarData(dataset)
chart.invalidate()
val dataset = list.map {
return@map Entry(
it.timestamp.toDouble(),
it.value.toFloat()
)
}
serie.entries = dataset as ArrayList<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.WaterRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class WaterHomeViewModel@Inject internal constructor(
@ -23,4 +23,4 @@ class WaterHomeViewModel@Inject internal constructor(
}
}
}
}
}

View File

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

View File

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

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