1
0
mirror of https://github.com/dzeiocom/OpenHealth.git synced 2025-04-22 02:42:15 +00:00

feat: Add bluetooth Scales support (#144)

This commit is contained in:
Florian Bouillon 2023-02-14 14:20:54 +01:00 committed by GitHub
parent e14e3881c1
commit 1f780ae2c4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
77 changed files with 2186 additions and 249 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

@ -163,6 +163,10 @@ android {
namespace = appID
}
kapt {
correctErrorTypes = true
}
dependencies {
// Dzeio Charts
implementation("com.dzeio:charts:fe20f90654")
@ -198,6 +202,7 @@ dependencies {
// 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")

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

@ -1,94 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:orientation="vertical"
android:layout_height="wrap_content">
<com.google.android.material.card.MaterialCardView
style="?attr/materialCardViewFilledStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingHorizontal="12dp"
android:paddingVertical="16dp">
<ImageView
android:layout_width="40dp"
android:layout_height="40dp"
android:paddingVertical="8dp"
android:layout_marginEnd="16dp"
android:src="@drawable/ic_outline_monitor_weight_24"
android:background="@drawable/shape_circle"
app:tint="?colorOnPrimary" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingBottom="4dp"
android:text="History" />
</LinearLayout>
</LinearLayout>
<com.dzeio.charts.ChartView
android:id="@+id/chart"
android:layout_width="match_parent"
android:layout_height="200dp"
android:minHeight="200dp" />
<Button
android:id="@+id/goal_button"
android:text="@string/add_goal"
style="?materialButtonOutlinedStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:layout_marginEnd="16dp"
android:layout_marginVertical="16dp"
/>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<Button
android:id="@+id/debug_random_values"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Generate random values" />
<androidx.recyclerview.widget.RecyclerView
android:clipToPadding="false"
android:id="@+id/list"
android:padding="16dp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:listitem="@layout/layout_item_list"
tools:context=".ui.weight.ListWeightFragment" />
</LinearLayout>

View File

@ -9,6 +9,14 @@
<!-- 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" />
<application
android:name=".Application"
android:allowBackup="true"

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

@ -24,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
*

View File

@ -15,6 +15,9 @@ 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)

View File

@ -2,6 +2,8 @@ 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
@ -54,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,6 +1,7 @@
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
@ -25,8 +26,10 @@ import com.dzeio.openhealth.data.weight.WeightDao
Step::class,
Food::class
],
// TODO: go back to version 1 when the app is published
version = 1,
version = 2,
autoMigrations = [
AutoMigration(1, 2)
],
exportSchema = true
)
abstract class AppDatabase : RoomDatabase() {

View File

@ -69,7 +69,8 @@ data class Food(
if (
food.nutriments == null ||
food.name == null ||
((food.servingSize == null || food.servingSize == "") && (food.quantity == null || food.quantity == "") && food.servingQuantity == null && food.productQuantity == null)) {
((food.servingSize == null || food.servingSize == "") && (food.quantity == null || food.quantity == "") && food.servingQuantity == null && food.productQuantity == null)
) {
return null
}
@ -93,12 +94,12 @@ data class Food(
// 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,
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(),
energy = food.nutriments!!.energy
?: (food.nutriments!!.energyKJ * 0.2390057361).toFloat(),
image = food.image
)
}

View File

@ -18,5 +18,4 @@ interface FoodDao : BaseDao<Food> {
@Query("Select * FROM Food ORDER BY timestamp DESC LIMIT 1")
fun last(): Flow<Food?>
}

View File

@ -2,12 +2,12 @@ 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
import java.io.IOException
import javax.inject.Inject
class FoodRepository @Inject constructor(
private val dao: FoodDao,

View File

@ -43,7 +43,7 @@ data class OFFProduct(
* the product nutriments
*/
@SerializedName("nutriments")
var nutriments: OFFNutriments,
var nutriments: OFFNutriments?,
/**
* the product image

View File

@ -34,15 +34,20 @@ interface OpenFoodFactService {
/**
* 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")
@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")
@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

@ -1,11 +1,11 @@
package com.dzeio.openhealth.data.step
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.firstOrNull
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(
@ -46,5 +46,4 @@ class StepRepository @Inject constructor(
fun currentStep() = lastStep().filter {
return@filter it != null && it.isCurrent()
}
}

View File

@ -7,6 +7,7 @@ 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
@ -18,7 +19,7 @@ import kotlinx.coroutines.runBlocking
* TODO: rewrite to use the new libs
*/
class StepSource(
private val context: Context,
context: Context,
private val callback: ((Float) -> Unit)? = null
) : SensorEventListener {
@ -33,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")
}
}
@ -73,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

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

@ -26,7 +26,65 @@ data class Weight(
*
* note: Unused currently but kept for future usage
*/
var source: String = "OpenHealth"
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

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

@ -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
@ -101,7 +103,7 @@ class OpenHealthService : Service() {
if (step != null) {
step.value += stepsBuffer
stepRepository.updateStep(step)
// create a new steps object and send it
// create a new steps object and send it
} else {
stepRepository.addStep(Step(value = stepsBuffer))
}
@ -119,7 +121,7 @@ class OpenHealthService : Service() {
}
override fun onDestroy() {
stopForeground(true)
stopForegroundPoly(NotificationBehavior.REMOVE)
// Tell the user we stopped.
Toast.makeText(this, "Service stopped", Toast.LENGTH_SHORT).show()

View File

@ -116,7 +116,6 @@ class BrowseFragment :
)
)
}
}
private fun updateStepsText(numberOfSteps: Int?, goal: Int?) {

View File

@ -9,15 +9,15 @@ 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,
private val config: Configuration
config: Configuration
) : BaseViewModel() {
private val _steps = MutableLiveData(0)
@ -41,6 +41,5 @@ class BrowseViewModel @Inject internal constructor(
_weight.postValue(it.weight)
}
}
}
}

View File

@ -22,7 +22,6 @@ class FoodDialog :
private var quantity: Float? = null
override val bindingInflater: (LayoutInflater) -> DialogFoodProductBinding =
DialogFoodProductBinding::inflate
@ -50,7 +49,6 @@ class FoodDialog :
}
}
override fun onCreated() {
super.onCreated()
Log.d("FoodDialog", args.id.toString())

View File

@ -6,9 +6,9 @@ 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
import javax.inject.Inject
@HiltViewModel
class FoodDialogViewModel @Inject internal constructor(
@ -41,7 +41,7 @@ class FoodDialogViewModel @Inject internal constructor(
return
}
val transformer = (quantity ?: it.quantity) / it.quantity
val transformer = quantity / it.quantity
it.energy = it.energy * transformer
it.proteins = it.proteins * transformer
it.carbohydrates = it.carbohydrates * transformer

View File

@ -64,7 +64,6 @@ class FoodHomeFragment :
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()
@ -87,7 +86,9 @@ class FoodHomeFragment :
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)}"
binding.date.text = "${date.get(Calendar.YEAR)}-${date.get(Calendar.MONTH) + 1}-${date.get(
Calendar.DAY_OF_MONTH
)}"
}
}

View File

@ -5,17 +5,15 @@ 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.data.openfoodfact.OpenFoodFactService
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
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,
private val foodFactService: OpenFoodFactService
private val foodRepository: FoodRepository
) : BaseViewModel() {
val items: MutableLiveData<List<Food>> = MutableLiveData()
private val list: MutableLiveData<List<Food>> = MutableLiveData(arrayListOf())
@ -62,8 +60,10 @@ class FoodHomeViewModel @Inject internal constructor(
day.add(Calendar.DAY_OF_YEAR, 1)
val tomorrow = day.timeInMillis
items.postValue((foods ?: list.value!!).filter { food ->
food.timestamp in todayInMillis until tomorrow
})
items.postValue(
(foods ?: list.value!!).filter { food ->
food.timestamp in todayInMillis until tomorrow
}
)
}
}

View File

@ -7,9 +7,9 @@ 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
import javax.inject.Inject
@HiltViewModel
class SearchFoodDialogViewModel @Inject internal constructor(

View File

@ -7,12 +7,14 @@ 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
@ -98,6 +100,17 @@ class HomeFragment : BaseFragment<HomeViewModel, FragmentHomeBinding>(HomeViewMo
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) {
@ -218,7 +231,7 @@ class HomeFragment : BaseFragment<HomeViewModel, FragmentHomeBinding>(HomeViewMo
animator.duration = 300 // ms
animator.addUpdateListener {
this.oldValue = (it.animatedValue as Int).toFloat()
val value = 100 * it.animatedValue as Int / viewModel.dailyWaterIntake.toFloat()
val value = 100 * it.animatedValue as Int / viewModel.dailyWaterIntake
val graph = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(graph)

View File

@ -13,9 +13,9 @@ import com.dzeio.openhealth.data.weight.WeightRepository
import com.dzeio.openhealth.units.Units
import com.dzeio.openhealth.utils.Configuration
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class HomeViewModel @Inject internal constructor(

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
@ -91,10 +89,5 @@ class SettingsFragment : PreferenceFragmentCompat() {
return@setOnPreferenceChangeListener true
}
val stepsGoalPreference = findPreference<EditTextPreference>(Settings.STEPS_GOAL)
stepsGoalPreference.apply {
}
}
}

View File

@ -129,8 +129,12 @@ class StepsHomeFragment :
return@observe
}
val filtered = if (!isDay) list else list.filter {
it.getDay() == args.day
val filtered = if (!isDay) {
list
} else {
list.filter {
it.getDay() == args.day
}
}
if (isDay) {
adapter.set(filtered)

View File

@ -9,9 +9,9 @@ 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(

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

@ -48,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 {
@ -100,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,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

@ -23,7 +23,9 @@ import java.util.Date
@AndroidEntryPoint
class EditWeightDialog :
BaseFullscreenDialog<EditWeightDialogViewModel, DialogEditWeightBinding>(EditWeightDialogViewModel::class.java) {
BaseFullscreenDialog<EditWeightDialogViewModel, DialogEditWeightBinding>(
EditWeightDialogViewModel::class.java
) {
override val bindingInflater: (LayoutInflater) -> DialogEditWeightBinding =
DialogEditWeightBinding::inflate
@ -97,10 +99,7 @@ class EditWeightDialog :
} else {
TODO("VERSION.SDK_INT < N")
}
}
}
private fun save() {
@ -140,6 +139,5 @@ class EditWeightDialog :
}
else -> super.onOptionsItemSelected(item)
}
}
}

View File

@ -11,9 +11,9 @@ import com.dzeio.openhealth.data.weight.WeightRepository
import com.dzeio.openhealth.units.Units
import com.dzeio.openhealth.utils.Configuration
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class EditWeightDialogViewModel @Inject internal constructor(

View File

@ -1,5 +1,6 @@
package com.dzeio.openhealth.ui.weight
import android.Manifest
import android.content.SharedPreferences
import android.graphics.Paint
import android.os.Bundle
@ -9,6 +10,8 @@ import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.view.MenuProvider
import androidx.navigation.fragment.findNavController
import androidx.preference.PreferenceManager
@ -16,11 +19,13 @@ import androidx.recyclerview.widget.LinearLayoutManager
import com.dzeio.charts.Entry
import com.dzeio.charts.axis.Line
import com.dzeio.charts.series.LineSerie
import com.dzeio.openhealth.BuildConfig
import com.dzeio.openhealth.R
import com.dzeio.openhealth.adapters.WeightAdapter
import com.dzeio.openhealth.core.BaseFragment
import com.dzeio.openhealth.data.weight.Weight
import com.dzeio.openhealth.databinding.FragmentListWeightBinding
import com.dzeio.openhealth.utils.PermissionsManager
import com.google.android.material.color.MaterialColors
import dagger.hilt.android.AndroidEntryPoint
import java.text.DateFormat
@ -34,6 +39,40 @@ class ListWeightFragment :
override val bindingInflater: (LayoutInflater, ViewGroup?, Boolean) ->
FragmentListWeightBinding = FragmentListWeightBinding::inflate
private lateinit var button: View
private val activityResult = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) {
if (it.containsValue(false)) {
// TODO: Show a popup with choice to change it
Toast.makeText(requireContext(), R.string.permission_declined, Toast.LENGTH_LONG).show()
return@registerForActivityResult
}
button.callOnClick()
}
private val menuProvider = object : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menu.findItem(R.id.action_add).isVisible = true
}
override fun onMenuItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_add -> {
findNavController().navigate(
ListWeightFragmentDirections.actionNavListWeightToNavWeightDialog(
WeightDialog.DialogTypes.ADD_WEIGHT.ordinal
)
)
true
}
else -> false
}
}
}
val settings: SharedPreferences by lazy {
PreferenceManager.getDefaultSharedPreferences(requireContext())
}
@ -42,26 +81,7 @@ class ListWeightFragment :
super.onViewCreated(view, savedInstanceState)
// Menu
requireActivity().addMenuProvider(object : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menu.findItem(R.id.action_add).isVisible = true
}
override fun onMenuItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_add -> {
findNavController().navigate(
ListWeightFragmentDirections.actionNavListWeightToNavWeightDialog(
WeightDialog.DialogTypes.ADD_WEIGHT.ordinal
)
)
true
}
else -> true
}
}
})
requireActivity().addMenuProvider(menuProvider)
if (viewModel.goalWeight.value != null) {
binding.goalButton.setText(R.string.edit_goal)
@ -75,6 +95,24 @@ class ListWeightFragment :
)
}
binding.bluetoothButton.setOnClickListener {
val permissions = arrayOf(
Manifest.permission.BLUETOOTH,
Manifest.permission.BLUETOOTH_CONNECT,
Manifest.permission.BLUETOOTH_SCAN,
Manifest.permission.ACCESS_FINE_LOCATION
)
val hasPermission = PermissionsManager.hasPermission(requireContext(), permissions)
if (!hasPermission) {
button = binding.bluetoothButton
activityResult.launch(permissions)
return@setOnClickListener
}
findNavController().navigate(
ListWeightFragmentDirections.actionNavListWeightToScanScalesDialog()
)
}
val adapter = WeightAdapter().apply {
onItemClick = {
findNavController().navigate(
@ -152,15 +190,21 @@ class ListWeightFragment :
}
// Debug button
// if (binding.debugRandomValues != null) {
// binding.debugRandomValues.setOnClickListener {
// viewModel.generateRandomValues()
// }
// binding.debugRandomValues.setOnLongClickListener {
// viewModel.delete(viewModel.weights.value!!)
// return@setOnLongClickListener true
// }
// }
if (BuildConfig.DEBUG) {
binding.debugRandomValues.visibility = View.VISIBLE
binding.debugRandomValues.setOnClickListener {
viewModel.generateRandomValues()
}
binding.debugRandomValues.setOnLongClickListener {
viewModel.delete(viewModel.weights.value!!)
return@setOnLongClickListener true
}
}
}
override fun onDestroyView() {
super.onDestroyView()
requireActivity().removeMenuProvider(menuProvider)
}
private fun updateGraph(list: List<Weight>) {

View File

@ -10,15 +10,15 @@ import com.dzeio.openhealth.data.weight.WeightRepository
import com.dzeio.openhealth.units.Units
import com.dzeio.openhealth.utils.Configuration
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import javax.inject.Inject
import kotlin.random.Random
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
@HiltViewModel
class ListWeightViewModel @Inject internal constructor(
private val weightRepository: WeightRepository,
private val settings: Configuration
settings: Configuration
) : BaseViewModel() {
private val _massUnit = MutableLiveData(Units.Mass.KILOGRAM)
@ -60,7 +60,7 @@ class ListWeightViewModel @Inject internal constructor(
fun delete(list: List<Weight>) {
viewModelScope.launch {
for (item in list) weightRepository.deleteWeight(item)
weightRepository.deleteWeight(*list.toTypedArray())
}
}
}

View File

@ -0,0 +1,111 @@
package com.dzeio.openhealth.ui.weight
import android.annotation.SuppressLint
import android.app.ProgressDialog
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.widget.Toast
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import com.dzeio.openhealth.R
import com.dzeio.openhealth.adapters.ItemAdapter
import com.dzeio.openhealth.core.BaseDialog
import com.dzeio.openhealth.databinding.DialogSearchBinding
import com.dzeio.openhealth.devices.BluetoothLeGattDevice
import com.dzeio.openhealth.devices.Device
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
@AndroidEntryPoint
class ScanScalesDialog :
BaseDialog<ScanScalesViewModel, DialogSearchBinding>(ScanScalesViewModel::class.java) {
override val bindingInflater: (LayoutInflater) -> DialogSearchBinding =
DialogSearchBinding::inflate
override fun onBuilderInit(builder: MaterialAlertDialogBuilder) {
super.onBuilderInit(builder)
builder.apply {
setTitle(R.string.searching_scales)
setIcon(R.drawable.ic_outline_monitor_weight_24)
setNegativeButton(R.string.cancel) { dialog, _ ->
viewModel.stopScan()
dialog.cancel()
}
}
}
@SuppressLint("MissingPermission")
override fun onCreated() {
super.onCreated()
binding.search.visibility = View.GONE
binding.loading.visibility = View.VISIBLE
val adapter = ItemAdapter<BluetoothLeGattDevice>().apply {
onItemClick = { deviceAdapter ->
dialog!!.dismiss()
val device = deviceAdapter.value
viewModel.stopScan()
val progress = ProgressDialog(requireContext()).apply {
setTitle("Connecting & Fetching...")
setCancelable(false)
setProgressStyle(ProgressDialog.STYLE_HORIZONTAL)
show()
}
device.connect().addObserver { status ->
if (status === Device.ConnectionStatus.CONNECTED) {
device.fetchWeights().addObserver {
progress.progress = it.progress
progress.max = it.progressMax + 1
if (it.progress == it.progressMax) {
device.close()
Log.d("YAY", "${it.data}")
lifecycleScope.launch {
viewModel.addWeights(it.data)
progress.dismiss()
Toast.makeText(
requireContext(),
"Data synchonised with the remote device",
Toast.LENGTH_SHORT
).show()
}
}
}
} else if (status === Device.ConnectionStatus.ERROR) {
progress.dismiss()
Toast.makeText(
requireContext(),
"An error occured while connecting...",
Toast.LENGTH_LONG
).show()
}
}
}
}
binding.list.apply {
layoutManager = LinearLayoutManager(requireContext())
this.adapter = adapter
}
viewModel.devices.observe(this) {
if (it == null) {
adapter.clear()
return@observe
}
adapter.set(
it.map {
ItemAdapter.Item(
it,
it.name,
"${it.item!!.name} (${it.item!!.address})",
icon = R.drawable.ic_baseline_add_24
)
}
)
}
}
}

View File

@ -0,0 +1,42 @@
package com.dzeio.openhealth.ui.weight
import android.annotation.SuppressLint
import androidx.annotation.RequiresPermission
import com.dzeio.openhealth.core.BaseViewModel
import com.dzeio.openhealth.data.weight.Weight
import com.dzeio.openhealth.data.weight.WeightRepository
import com.dzeio.openhealth.devices.BluetoothLeGattDevice
import com.dzeio.openhealth.utils.Bluetooth
import com.dzeio.openhealth.utils.Configuration
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.first
@HiltViewModel
class ScanScalesViewModel @Inject internal constructor(
private val bluetooth: Bluetooth,
private val weightRepository: WeightRepository,
config: Configuration
) : BaseViewModel() {
@SuppressLint("MissingPermission")
@RequiresPermission(value = "android.permission.BLUETOOTH_SCAN")
val devices = BluetoothLeGattDevice.findDevices(bluetooth, config).toLiveData()
@RequiresPermission(value = "android.permission.BLUETOOTH_SCAN")
fun stopScan() {
bluetooth.stopScan()
}
suspend fun addWeights(weights: ArrayList<Weight>) {
val current = weightRepository.getWeights().first()
val toAdd = arrayListOf<Weight>()
for (weight in weights) {
if (current.find { it.equals(current) } != null) {
continue
}
toAdd.add(weight)
}
weightRepository.addAll(*toAdd.toTypedArray())
}
}

View File

@ -66,7 +66,6 @@ class WeightDialog :
binding.gram.maxValue = 9
binding.gram.minValue = 0
}
private fun setValue(value: Float) {

View File

@ -10,14 +10,14 @@ import com.dzeio.openhealth.data.weight.WeightRepository
import com.dzeio.openhealth.units.Units
import com.dzeio.openhealth.utils.Configuration
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class WeightDialogViewModel @Inject internal constructor(
private val weightRepository: WeightRepository,
private val settings: Configuration
settings: Configuration
) : BaseViewModel() {
private val _goalWeight = settings.getFloat(Settings.WEIGHT_GOAL)
@ -40,7 +40,6 @@ class WeightDialogViewModel @Inject internal constructor(
weightRepository.lastWeight().collectLatest {
_weight.postValue(it)
}
}
}
}

View File

@ -40,7 +40,6 @@ object Units {
it.id == value
} ?: KILOGRAM
}
}
fun format(value: Float): Float {

View File

@ -0,0 +1,148 @@
package com.dzeio.openhealth.utils
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGattCallback
import android.bluetooth.BluetoothManager
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanResult
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.util.Log
import android.widget.Toast
import androidx.annotation.RequiresPermission
import com.dzeio.openhealth.utils.polyfills.getBluetoothDevice
class Bluetooth(
private val context: Context
) {
companion object {
const val TAG = "Bluetooth"
}
private lateinit var adapter: BluetoothAdapter
init {
if (!context.packageManager.hasSystemFeature(PackageManager.FEATURE_BLUETOOTH)) {
throw Exception("Phone missing the Bluetooth feature")
}
try {
adapter = (context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager).adapter
} catch (e: Exception) {
Log.e(TAG, e.message ?: "error getting default adapter")
}
if (!adapter.isEnabled) {
Toast.makeText(context, "Bluetooth is not enabled", Toast.LENGTH_LONG).show()
Log.e(TAG, "Bluetooth is not enabled")
}
}
@SuppressLint("HardwareIds")
fun getAddress(): String {
return adapter.address
}
@RequiresPermission(value = "android.permission.BLUETOOTH_CONNECT")
fun getName(): String {
return adapter.name
}
/**
* Make the current phone discoverable through bluetooth scan
*/
// fun makeDeviceDiscoverable(time: Int = 600) {
// val intent = Intent(BluetoothAdapter.ACTION_REQUEST_DISCOVERABLE).apply {
// putExtra(BluetoothAdapter.EXTRA_DISCOVERABLE_DURATION, time)
// }
// activity.startActivityForResult(intent, 1)
// }
fun getDiscoverableIntent(time: Int = 600): Intent {
return Intent(BluetoothAdapter.ACTION_REQUEST_DISCOVERABLE).apply {
putExtra(BluetoothAdapter.EXTRA_DISCOVERABLE_DURATION, time)
}
}
private lateinit var cb: (device: BluetoothDevice) -> Boolean
private val receiver: BroadcastReceiver = object : BroadcastReceiver() {
@RequiresPermission(allOf = ["android.permission.BLUETOOTH_SCAN", "android.permission.BLUETOOTH_CONNECT"])
override fun onReceive(context: Context, intent: Intent) {
val action = intent.action
if (BluetoothDevice.ACTION_FOUND == action) {
val device =
intent.getBluetoothDevice()
// val rssi = intent.getShortExtra(BluetoothDevice.EXTRA_RSSI, Short.MIN_VALUE).toInt()
if (device != null) {
Log.d(TAG, "Device found! (${device.name ?: device.address})")
val end = cb(device)
if (end) {
stopScan()
}
}
}
}
}
/**
* Callback return boolean if true it stop scan
*/
@RequiresPermission(value = "android.permission.BLUETOOTH_SCAN")
fun scanDevices(callback: (device: BluetoothDevice) -> Boolean): Boolean {
val filter = IntentFilter(BluetoothDevice.ACTION_FOUND)
context.registerReceiver(receiver, filter)
cb = callback
return adapter.startDiscovery()
}
private var leCallback: ((device: BluetoothDevice) -> Boolean)? = null
private val scanCallback = object : ScanCallback() {
@RequiresPermission(allOf = ["android.permission.BLUETOOTH_SCAN", "android.permission.BLUETOOTH_CONNECT"])
override fun onScanResult(callbackType: Int, result: ScanResult?) {
super.onScanResult(callbackType, result)
if (result != null) {
val device = result.device
Log.d(TAG, "Device found! (${device.name ?: device.address})")
val doStop = leCallback?.invoke(device)
if (doStop == true) {
stopScan()
}
}
}
}
/**
* Callback return boolean if true it stop scan
*/
@RequiresPermission(value = "android.permission.BLUETOOTH_SCAN")
fun scanLeDevices(callback: (device: BluetoothDevice) -> Boolean) {
val pouet = adapter.bluetoothLeScanner
leCallback = callback
pouet.startScan(scanCallback)
}
@RequiresPermission(value = "android.permission.BLUETOOTH_SCAN")
fun stopScan() {
adapter.bluetoothLeScanner.stopScan(scanCallback)
adapter.cancelDiscovery()
try {
context.unregisterReceiver(receiver)
} catch (it: IllegalArgumentException) {
Log.i(TAG, "Seems like it was already unloaded", it)
}
}
@SuppressLint("MissingPermission")
fun connectGatt(
device: BluetoothDevice,
autoConnect: Boolean,
callback: BluetoothGattCallback
) {
device.connectGatt(context, autoConnect, callback)
}
}

View File

@ -1,7 +1,6 @@
package com.dzeio.openhealth.utils
import android.content.SharedPreferences
import android.util.Log
import androidx.core.content.edit
import com.dzeio.openhealth.Application
import com.dzeio.openhealth.core.Observable
@ -27,66 +26,66 @@ class Configuration(
fun getString(key: String): StringField {
if (cache[key] == null) {
Log.d(TAG, "$key does not exist in cache, creating new instance")
// Log.d(TAG, "$key does not exist in cache, creating new instance")
cache[key] = StringField(key)
} else {
Log.d(TAG, "$key in cache")
// Log.d(TAG, "$key in cache")
}
return cache[key] as StringField
}
fun getLong(key: String): LongField {
if (cache[key] == null) {
Log.d(TAG, "$key does not exist in cache, creating new instance")
// Log.d(TAG, "$key does not exist in cache, creating new instance")
cache[key] = LongField(key)
} else {
Log.d(TAG, "$key in cache")
// Log.d(TAG, "$key in cache")
}
return cache[key] as LongField
}
fun getBoolean(key: String): BooleanField {
if (cache[key] == null) {
Log.d(TAG, "$key does not exist in cache, creating new instance")
// Log.d(TAG, "$key does not exist in cache, creating new instance")
cache[key] = BooleanField(key)
} else {
Log.d(TAG, "$key in cache")
// Log.d(TAG, "$key in cache")
}
return cache[key] as BooleanField
}
fun getInt(key: String): IntField {
if (cache[key] == null) {
Log.d(TAG, "$key does not exist in cache, creating new instance")
// Log.d(TAG, "$key is not cache, creating new instance")
cache[key] = IntField(key)
} else {
Log.d(TAG, "$key in cache")
// Log.d(TAG, "$key in cache")
}
return cache[key] as IntField
}
fun getFloat(key: String): FloatField {
if (cache[key] == null) {
Log.d(TAG, "$key does not exist in cache, creating new instance")
// Log.d(TAG, "$key does not exist in cache, creating new instance")
cache[key] = FloatField(key)
} else {
Log.d(TAG, "$key in cache")
// Log.d(TAG, "$key in cache")
}
return cache[key] as FloatField
}
fun getStringSet(key: String): StringSetField {
if (cache[key] == null) {
Log.d(TAG, "$key does not exist in cache, creating new instance")
// Log.d(TAG, "$key does not exist in cache, creating new instance")
cache[key] = StringSetField(key)
} else {
Log.d(TAG, "$key in cache")
// Log.d(TAG, "$key in cache")
}
return cache[key] as StringSetField
}
override fun onSharedPreferenceChanged(u: SharedPreferences, key: String) {
Log.d(TAG, "configuration update for key: $key")
// Log.d(TAG, "configuration update for key: $key")
cache[key]?.needUpdate = true
cache[key]?.notifyObservers()
}

View File

@ -38,5 +38,4 @@ object DrawUtils {
val it = (if (isWidth) this.width else this.height) * multiplier
return it * value / 100
}
}

View File

@ -9,11 +9,10 @@ import androidx.preference.PreferenceManager
import com.dzeio.openhealth.Settings
import java.util.Locale
/**
* Utils object for [Locale]
*
* @see https://github.com/gunhansancar/ChangeLanguageExample/blob/master/app/src/main/java/com/gunhansancar/changelanguageexample/helper/LocaleHelper.java
* https://github.com/gunhansancar/ChangeLanguageExample/blob/master/app/src/main/java/com/gunhansancar/changelanguageexample/helper/LocaleHelper.java
*/
object LocaleUtils {
fun onAttach(context: Context): Context {
@ -68,8 +67,10 @@ object LocaleUtils {
Locale.setDefault(locale)
val resources = context.resources
val configuration: Configuration = resources.configuration
@Suppress("DEPRECATION")
configuration.locale = locale
configuration.setLayoutDirection(locale)
@Suppress("DEPRECATION")
resources.updateConfiguration(configuration, resources.displayMetrics)
return context
}

View File

@ -36,4 +36,3 @@ object PermissionsManager {
return true
}
}

View File

@ -4,7 +4,6 @@ import android.content.Context
import android.text.InputType
import android.text.TextUtils
import android.util.AttributeSet
import android.util.Log
import android.widget.EditText
import androidx.preference.EditTextPreference
@ -48,7 +47,6 @@ class IntEditTextPreference : EditTextPreference, EditTextPreference.OnBindEditT
* @param text The text to save
*/
override fun setText(text: String?) {
val wasBlocking = shouldDisableDependents()
val pouet = Integer.parseInt(text.toString())
this.txt = text

View File

@ -0,0 +1,45 @@
package com.dzeio.openhealth.utils.polyfills
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothGattDescriptor
import android.os.Build
import androidx.annotation.RequiresPermission
@RequiresPermission(value = "android.permission.BLUETOOTH_CONNECT")
fun BluetoothGatt.writeCharacteristicPoly(
characteristic: BluetoothGattCharacteristic,
value: ByteArray,
writeType: Int
) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
this.writeCharacteristic(
characteristic,
value,
writeType
)
} else {
@Suppress("DEPRECATION")
characteristic.value = value
@Suppress("DEPRECATION")
this.writeCharacteristic(characteristic)
}
}
@RequiresPermission(value = "android.permission.BLUETOOTH_CONNECT")
fun BluetoothGatt.writeDescriptorPoly(
descriptor: BluetoothGattDescriptor,
value: ByteArray
) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
this.writeDescriptor(
descriptor,
value
)
} else {
@Suppress("DEPRECATION")
descriptor.value = value
@Suppress("DEPRECATION")
this.writeDescriptor(descriptor)
}
}

View File

@ -0,0 +1,14 @@
package com.dzeio.openhealth.utils.polyfills
import android.bluetooth.BluetoothDevice
import android.content.Intent
import android.os.Build
fun Intent.getBluetoothDevice(): BluetoothDevice? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
this.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE, BluetoothDevice::class.java)
} else {
@Suppress("DEPRECATION")
this.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)
}
}

View File

@ -0,0 +1,24 @@
package com.dzeio.openhealth.utils.polyfills
import android.app.Service
import android.os.Build
enum class NotificationBehavior {
REMOVE,
DETACH
}
fun Service.stopForegroundPoly(notificationBehavior: NotificationBehavior) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
stopForeground(
if (notificationBehavior === NotificationBehavior.REMOVE) {
Service.STOP_FOREGROUND_REMOVE
} else {
Service.STOP_FOREGROUND_DETACH
}
)
} else {
@Suppress("DEPRECATION")
stopForeground(notificationBehavior == NotificationBehavior.REMOVE)
}
}

View File

@ -0,0 +1,5 @@
<vector android:autoMirrored="true" android:height="24dp"
android:tint="?attr/colorControlNormal" 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="M17.71,7.71L12,2h-1v7.59L6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 11,14.41L11,22h1l5.71,-5.71 -4.3,-4.29 4.3,-4.29zM13,5.83l1.88,1.88L13,9.59L13,5.83zM14.88,16.29L13,18.17v-3.76l1.88,1.88z"/>
</vector>

View File

@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:paddingHorizontal="16dp"
android:nestedScrollingEnabled="true"
android:orientation="vertical">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/search"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<ProgressBar
android:id="@+id/loading"
android:visibility="gone"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<TextView
android:id="@+id/error_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
android:text="@string/connectivity_error" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/list"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:itemCount="5"
tools:listitem="@layout/item_list">
</androidx.recyclerview.widget.RecyclerView>
</LinearLayout>

View File

@ -7,6 +7,13 @@
android:orientation="vertical"
tools:context=".ui.home.HomeFragment">
<Button
android:id="@+id/goto_tests"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone"
android:text="Goto Tests" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
@ -306,7 +313,7 @@
<com.dzeio.charts.ChartView
android:id="@+id/weight_graph"
android:layout_width="match_parent"
android:layout_height="200dp"
android:layout_height="150dp"
android:layout_margin="8dp"
android:minHeight="200dp" />

View File

@ -6,6 +6,62 @@
android:orientation="vertical"
android:layout_height="wrap_content">
<com.google.android.material.card.MaterialCardView
style="?attr/materialCardViewFilledStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingHorizontal="12dp"
android:paddingVertical="16dp">
<ImageView
android:layout_width="40dp"
android:layout_height="40dp"
android:paddingVertical="8dp"
android:layout_marginEnd="16dp"
android:src="@drawable/ic_bluetooth"
android:background="@drawable/shape_circle"
app:tint="?colorOnPrimary" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_gravity="center"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingBottom="4dp"
android:text="Bluetooth Scale Status" />
</LinearLayout>
<Button
android:id="@+id/bluetooth_button"
android:text="@string/add_goal"
style="?materialButtonOutlinedStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:layout_marginEnd="16dp"
android:layout_marginVertical="16dp"
/>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
style="?attr/materialCardViewFilledStyle"
android:layout_width="match_parent"
@ -74,6 +130,14 @@
</com.google.android.material.card.MaterialCardView>
<Button
android:id="@+id/debug_random_values"
android:layout_width="match_parent"
android:visibility="gone"
android:layout_marginHorizontal="16dp"
android:layout_height="wrap_content"
android:text="Generate random values" />
<androidx.recyclerview.widget.RecyclerView
android:clipToPadding="false"
android:id="@+id/list"

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".ui.home.HomeFragment">
<Button
android:id="@+id/button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Button" />
</LinearLayout>

View File

@ -0,0 +1,62 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
style="?attr/materialCardViewFilledStyle"
android:layout_marginBottom="8dp"
android:clickable="true"
android:focusable="true"
android:layout_width="match_parent"
android:id="@+id/card"
android:layout_height="wrap_content">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:gravity="center_vertical"
android:orientation="horizontal"
android:weightSum="1">
<ImageView
android:id="@+id/image"
android:layout_width="43dp"
android:layout_height="match_parent"
android:layout_marginEnd="16dp"
android:adjustViewBounds="true"
android:scaleType="fitCenter"
tools:srcCompat="@tools:sample/avatars" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
tools:text="Title"
android:text="" />
<TextView
android:id="@+id/sub_value"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
tools:text="Description"
android:text="" />
</LinearLayout>
<ImageView
android:id="@+id/icon_right"
android:layout_width="24dp"
android:layout_height="24dp"
android:src="@drawable/ic_baseline_edit_24" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>

View File

@ -27,6 +27,9 @@
<action
android:id="@+id/action_nav_home_to_nav_weight_dialog"
app:destination="@id/nav_weight_dialog" />
<action
android:id="@+id/action_nav_home_to_testsFragment"
app:destination="@id/testsFragment" />
</fragment>
<fragment
@ -43,6 +46,9 @@
<action
android:id="@+id/action_nav_list_weight_to_nav_weight_dialog"
app:destination="@id/nav_weight_dialog" />
<action
android:id="@+id/action_nav_list_weight_to_scanScalesDialog"
app:destination="@id/scanScalesDialog" />
</fragment>
<fragment
@ -196,4 +202,14 @@
app:argType="boolean"
/>
</dialog>
<fragment
android:id="@+id/testsFragment"
android:name="com.dzeio.openhealth.ui.tests.TestsFragment"
tools:layout="@layout/fragment_tests"
android:label="TestsFragment" />
<dialog
android:id="@+id/scanScalesDialog"
tools:layout="@layout/dialog_search"
android:name="com.dzeio.openhealth.ui.weight.ScanScalesDialog"
android:label="ScanScalesDialog" />
</navigation>

View File

@ -68,4 +68,5 @@
<string name="food_description" translatable="false">%1$s (%2$.0f kcal)</string>
<string name="steps_count">%1$d steps</string>
<string name="connectivity_error">It seems that we can\'t communicate with OpenFoodFact, please retry later</string>
<string name="searching_scales">Searchin Scales</string>
</resources>

View File

@ -1,9 +1,8 @@
package com.dzeio.openhealth
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
@ -14,4 +13,4 @@ class ExampleUnitTest {
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}
}

View File

@ -1,14 +1,15 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
dependencies {
// Data Injection
classpath("com.google.dagger:hilt-android-gradle-plugin:2.44.2")
// Safe Navigation
classpath("androidx.navigation:navigation-safe-args-gradle-plugin:2.5.3")
// OSS licenses
classpath("com.google.android.gms:oss-licenses-plugin:0.10.6")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.10")
}
repositories {
mavenCentral()
}
}
@ -19,7 +20,10 @@ plugins {
id("com.android.library") version "7.4.0" apply false
// add kotlin compatibility :>
id("org.jetbrains.kotlin.android") version "1.8.0" apply false
id("org.jetbrains.kotlin.android") version "1.8.10" apply false
// Hilt
id("com.google.dagger.hilt.android") version "2.44" apply false
}
// Cleanup the build directories